// apps/web/lib/integrate/sandbox/build-branch.ts
// Git branch management for isolated builds inside the sandbox container.
//
// Branch model (10,000-client hive):
//   client/<clientId>   — persistent per-install branch, never deleted
//       └── build/<buildId>  — per-feature branch, merges into client/<clientId> on promotion
//
// Git author identity (pseudonymous — see identity-privacy.ts):
//   name:  dpf-agent-<shortId>                    (stable pseudonym per install)
//   email: agent-<shortId>@hive.dpf               (matches the pseudonym)
//
// The shortId is derived from clientId so contributions from one install are
// consistently attributed to the same pseudonym across commits, PRs, and
// issues — the community can recognize repeat contributors without the hash
// revealing anything about the real user or organization.

import { execInSandbox, isSandboxRunning } from "./sandbox";
import { prisma } from "@dpf/db";
import { buildBuildStudioSandboxTargetInput } from "@/lib/runtime-coordination/build-studio-runtime";
import {
  registerRuntimeTarget,
  type RuntimeCoordinationDb,
} from "@/lib/runtime-coordination/runtime-targets";
import {
  buildSandboxSourceCurrencyProbeCommand,
  classifySandboxSourceCurrency,
  formatSandboxSourceCurrencySummary,
  parseSandboxSourceCurrencyProbeOutput,
  type SandboxSourceCurrencySnapshot,
} from "./sandbox-source-currency";

const SANDBOX_CONTAINER = process.env.SANDBOX_CONTAINER_ID ?? "dpf-sandbox-1";
const SANDBOX_PORT = Number(process.env.SANDBOX_PORT ?? "3035");
const WORKSPACE = "/workspace";
const GIT_INDEX_LOCK = `${WORKSPACE}/.git/index.lock`;
const SANDBOX_GIT_STAGE_EXCLUDES = [
  ":!node_modules",
  ":!**/node_modules/**",
  ":!.next",
  ":!**/.next/**",
  ":!.pnpm-store",
  ":!**/.pnpm-store/**",
  ":!*.tsbuildinfo",
  ":!**/*.tsbuildinfo",
  ":!pnpm-lock*",
  // Generated Prisma client: regenerated on every `prisma generate` and must
  // never enter the diff baseline. A stale sandbox image produces wrong types
  // that bloat the patch and cause schema regressions on main.
  ":!**/generated/client/**",
  ":!packages/db/generated/**",
] as const;
const SANDBOX_GIT_CLEAN_EXCLUDES = [
  ":!node_modules",
  ":!**/node_modules/**",
  ":!.pnpm-store",
  ":!**/.pnpm-store/**",
  ":!pnpm-lock*",
] as const;
const SANDBOX_TRACKED_CACHE_FIND_PATHS = [
  "./.pnpm-store",
  "*/.pnpm-store",
  "./node_modules",
  "*/node_modules",
  "*/.next",
] as const;

function quoteGitPathspec(pathspec: string): string {
  return `'${pathspec}'`;
}

export function buildSandboxGitAddCommand(): string {
  const excludes = SANDBOX_GIT_STAGE_EXCLUDES.map(quoteGitPathspec).join(" ");
  return [
    "git add -u",
    `git ls-files -z --others --exclude-standard -- . ${excludes} | xargs -0 -r git add --`,
  ].join(" && ");
}

export function buildSandboxBranchSwitchPrepCommand(workspace: string = WORKSPACE): string {
  return [
    `for _dpf_pid in $(ss -tlnp 2>/dev/null | awk '/:3000 / { if (match($NF, /pid=([0-9]+)/, m)) print m[1]; }' | sort -u); do kill "$_dpf_pid" >/dev/null 2>&1 || true; done`,
    `for _dpf_pid in $(ss -tlnp 2>/dev/null | awk '/:3000 / { if (match($NF, /pid=([0-9]+)/, m)) print m[1]; }' | sort -u); do kill -9 "$_dpf_pid" >/dev/null 2>&1 || true; done`,
    `rm -rf ${workspace}/apps/web/.next`,
    `rm -f ${workspace}/apps/web/tsconfig.tsbuildinfo /tmp/next-dev.log /tmp/dev-server.log /tmp/dev.log`,
  ].join(" && ");
}

export function buildSandboxGitCleanCommand(workspace: string = WORKSPACE): string {
  return `cd ${workspace} && git reset --hard HEAD && git clean -fd -- ${SANDBOX_GIT_CLEAN_EXCLUDES.map(quoteGitPathspec).join(" ")}`;
}

export function buildSandboxGitPruneTrackedArtifactsCommand(): string {
  const cacheFindExpr = SANDBOX_TRACKED_CACHE_FIND_PATHS
    .map((pattern) => `-path '${pattern}'`)
    .join(" -o ");
  return [
    `find . \\( ${cacheFindExpr} \\) -prune -print | while IFS= read -r path; do git rm -r --cached --ignore-unmatch -- "$path" >/dev/null 2>&1 || true; done`,
    `find . -name '*.tsbuildinfo' -print | while IFS= read -r path; do git rm --cached --ignore-unmatch -- "$path" >/dev/null 2>&1 || true; done`,
  ].join(" && ");
}

export function buildSandboxGitCommitPrunedArtifactsCommand(
  message: string,
  workspace: string = WORKSPACE,
): string {
  return [
    `cd ${workspace}`,
    buildSandboxGitPruneTrackedArtifactsCommand(),
    `if ! git diff --cached --quiet --exit-code; then git commit -m ${quoteGitPathspec(message)}; fi`,
  ].join(" && ");
}

function sandboxGitPrelude(): string {
  return [
    `if [ -f "${GIT_INDEX_LOCK}" ]; then for _dpf_git_wait in 1 2 3 4 5; do if ! pgrep -x git >/dev/null 2>&1; then break; fi; sleep 1; done; if [ -f "${GIT_INDEX_LOCK}" ] && ! pgrep -x git >/dev/null 2>&1; then rm -f "${GIT_INDEX_LOCK}"; fi; fi`,
    `git config --global --add safe.directory "${WORKSPACE}" >/dev/null 2>&1 || true`,
  ].join(" && ");
}

export function wrapSandboxGitCommand(command: string): string {
  return [
    sandboxGitPrelude(),
    command,
  ].join(" && ");
}

async function execSandboxGit(command: string): Promise<string> {
  return execInSandbox(SANDBOX_CONTAINER, wrapSandboxGitCommand(command));
}

async function normalizeSandboxBranchArtifacts(branchName: string): Promise<void> {
  await execSandboxGit(
    buildSandboxGitCommitPrunedArtifactsCommand(
      `chore: untrack sandbox generated artifacts on ${branchName}`,
    ),
  ).catch((err) => {
    console.warn(`[build-branch] artifact prune commit skipped on ${JSON.stringify(branchName)}: ${JSON.stringify((err as Error).message?.slice(0, 200))}`);
  });
}

async function configureAndFetchUpstream(identity: ClientIdentity): Promise<boolean> {
  if (!identity.upstreamRemoteUrl) {
    return false;
  }

  const escapedUrl = JSON.stringify(identity.upstreamRemoteUrl);
  await execSandboxGit(
    [
      `cd ${WORKSPACE}`,
      `if git remote get-url origin >/dev/null 2>&1; then git remote set-url origin ${escapedUrl}; else git remote add origin ${escapedUrl}; fi`,
      `git fetch origin main --prune`,
    ].join(" && "),
  );
  return true;
}

async function inspectCurrentSandboxSourceCurrency(
  targetRef: string,
): Promise<SandboxSourceCurrencySnapshot> {
  const output = await execSandboxGit(
    buildSandboxSourceCurrencyProbeCommand({
      workspace: WORKSPACE,
      targetRef,
    }),
  );
  return parseSandboxSourceCurrencyProbeOutput(output, {
    targetRef,
    checkedAt: new Date().toISOString(),
    workspace: WORKSPACE,
  });
}

async function recordBuildSourceCurrency(
  buildId: string,
  snapshot: SandboxSourceCurrencySnapshot,
  summaryPrefix?: string,
): Promise<void> {
  const build = await prisma.featureBuild.findUnique({
    where: { buildId },
    select: { buildExecState: true },
  });
  const existingState = isRecord(build?.buildExecState) ? build.buildExecState : {};
  const summary = [
    summaryPrefix,
    formatSandboxSourceCurrencySummary(snapshot),
  ].filter(Boolean).join(" ");

  await prisma.featureBuild.update({
    where: { buildId },
    data: {
      buildExecState: {
        ...existingState,
        sourceCurrency: snapshot,
      } as unknown as import("@dpf/db").Prisma.InputJsonValue,
    },
  });
  await prisma.buildActivity.create({
    data: {
      buildId,
      tool: "sandbox_source_currency",
      summary,
    },
  }).catch(() => {});
}

async function refreshCurrentBranchFromTarget(args: {
  buildId: string;
  branchName: string;
  targetRef: string;
  blockUnknown: boolean;
}): Promise<SandboxSourceCurrencySnapshot> {
  const before = await inspectCurrentSandboxSourceCurrency(args.targetRef);

  if (before.status === "behind") {
    await recordBuildSourceCurrency(
      args.buildId,
      before,
      `Auto-refreshing ${args.branchName} before Build Studio work starts.`,
    );
    await execSandboxGit(
      `git -C ${WORKSPACE} reset --hard ${quoteGitPathspec(args.targetRef)}`,
    );
    await normalizeSandboxBranchArtifacts(args.branchName);
    const after = await inspectCurrentSandboxSourceCurrency(args.targetRef);
    await recordBuildSourceCurrency(
      args.buildId,
      after,
      `Auto-refreshed ${args.branchName}.`,
    );
    return after;
  }

  if (before.recommendedAction === "pause" || (args.blockUnknown && before.status === "unknown")) {
    await recordBuildSourceCurrency(
      args.buildId,
      before,
      `Paused ${args.branchName} before Build Studio work starts.`,
    );
    throw new Error(formatSandboxSourceCurrencySummary(before));
  }

  await recordBuildSourceCurrency(args.buildId, before);
  return before;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

// ─── Client Identity ─────────────────────────────────────────────────────────

type ClientIdentity = {
  clientId: string;
  gitAgentEmail: string;
  gitAuthorName: string;    // "dpf-agent-<shortId>" — matches identity-privacy.getPlatformIdentity()
  clientBranch: string;     // "client/<clientId>"
  upstreamRemoteUrl: string | null; // Canonical repo URL for fetching origin/main
};

let _cachedIdentity: ClientIdentity | null = null;

/**
 * Returns the stable client identity from PlatformDevConfig.
 * Cached in memory after first read — identity never changes.
 */
export async function getClientIdentity(): Promise<ClientIdentity> {
  if (_cachedIdentity) return _cachedIdentity;

  const config = await prisma.platformDevConfig.findUnique({
    where: { id: "singleton" },
    select: { clientId: true, gitAgentEmail: true, upstreamRemoteUrl: true },
  });

  if (!config?.clientId || !config?.gitAgentEmail) {
    throw new Error(
      "Client identity not initialized. Re-run the seed: docker compose restart portal-init"
    );
  }

  // Author name matches identity-privacy.getPlatformIdentity() — use the
  // 8-char hash prefix from the seeded gitAgentEmail so commits and PRs
  // carry a consistent pseudonym across code paths.
  const emailLocalPart = config.gitAgentEmail.split("@")[0] ?? "";
  const shortId = emailLocalPart.replace(/^agent-/, "").slice(0, 8);

  _cachedIdentity = {
    clientId: config.clientId,
    gitAgentEmail: config.gitAgentEmail,
    gitAuthorName: `dpf-agent-${shortId}`,
    clientBranch: `client/${config.clientId}`,
    upstreamRemoteUrl: config.upstreamRemoteUrl ?? null,
  };

  return _cachedIdentity;
}

// ─── Sandbox Availability ─────────────────────────────────────────────────────

/**
 * Returns true if the sandbox container is running and reachable.
 * 5-second timeout — fails fast instead of hanging the agentic loop.
 */
export async function isSandboxAvailable(): Promise<boolean> {
  return isSandboxRunning(SANDBOX_CONTAINER).catch(() => false);
}

// ─── Git Baseline ────────────────────────────────────────────────────────────

/**
 * Configures git identity and ensures a baseline commit exists.
 *
 * When upstreamRemoteUrl is provided (set during portal OAuth setup), the
 * sandbox fetches origin/main and resets to it before committing the
 * baseline. This guarantees the sandbox always starts from current main,
 * not from the potentially weeks-old portal container image.
 *
 * Without a remote URL (offline / unconfigured installs) the behaviour is
 * unchanged: commit whatever the portal container copied in.
 *
 * Safe to call multiple times — skips if a baseline already exists.
 */
async function ensureGitBaseline(identity: ClientIdentity): Promise<void> {
  // Configure identity first (idempotent)
  await execSandboxGit(
    [
      `git -C ${WORKSPACE} config user.name "${identity.gitAuthorName}"`,
      `git -C ${WORKSPACE} config user.email "${identity.gitAgentEmail}"`,
    ].join(" && "),
  ).catch(() => {});

  const isRepo = await execSandboxGit(
    `git -C ${WORKSPACE} rev-parse --is-inside-work-tree 2>/dev/null && echo yes || echo no`,
  ).catch(() => "no");

  if (isRepo.trim() === "yes") {
    // Repo already exists — ensure at least one commit, then return.
    const commitCount = await execSandboxGit(
      `git -C ${WORKSPACE} rev-list --count HEAD 2>/dev/null || echo 0`,
    ).catch(() => "0");

    if (commitCount.trim() !== "0") return; // Already has a baseline commit.
  }

  // --- Fresh baseline ---

  // Step 1: git init if not already a repo
  if (isRepo.trim() !== "yes") {
    await execSandboxGit(
      [
        `cd ${WORKSPACE}`,
        `git init`,
        `git config user.name "${identity.gitAuthorName}"`,
        `git config user.email "${identity.gitAgentEmail}"`,
      ].join(" && "),
    );
  }

  // Step 2: If we have an upstream remote URL, fetch origin/main and reset to
  // it so the baseline always matches current main — not the stale portal image.
  // This is the key fix: the portal container image may be weeks behind
  // origin/main; by resetting to origin/main, deploy_feature's diff is always
  // a clean delta against what will actually land on the portal.
  if (identity.upstreamRemoteUrl) {
    try {
      await configureAndFetchUpstream(identity);
      await execSandboxGit(`git -C ${WORKSPACE} reset --hard origin/main`);
      console.log(`[build-branch] Sandbox baseline reset to origin/main via ${identity.upstreamRemoteUrl}`);
    } catch (fetchErr) {
      // Non-fatal: if fetch fails (offline, token expired, etc.) fall through
      // to the portal-copy baseline. The schema regression guard in
      // deploy_feature will catch any stale-schema issues downstream.
      console.warn(
        `[build-branch] Could not fetch origin/main for baseline reset (falling back to portal copy): ${
          (fetchErr as Error).message?.slice(0, 200)
        }`,
      );
    }
  }

  // Step 3: Prune generated/cache artifacts and commit the baseline
  await execSandboxGit(
    [
      `cd ${WORKSPACE}`,
      `git config user.name "${identity.gitAuthorName}"`,
      `git config user.email "${identity.gitAgentEmail}"`,
      buildSandboxGitPruneTrackedArtifactsCommand(),
      buildSandboxGitAddCommand(),
      `git commit -m 'sandbox baseline' --allow-empty`,
    ].join(" && "),
  );
}

/**
 * Ensures the persistent client branch exists.
 * Creates it from HEAD if missing.
 */
async function ensureClientBranch(identity: ClientIdentity): Promise<void> {
  const exists = await execSandboxGit(
    `git -C ${WORKSPACE} branch --list "${identity.clientBranch}" | grep -q . && echo yes || echo no`,
  ).catch(() => "no");

  if (exists.trim() !== "yes") {
    await execSandboxGit(
      `git -C ${WORKSPACE} checkout -b "${identity.clientBranch}"`,
    );
    console.log(`[build-branch] Created persistent client branch: ${identity.clientBranch}`);
  }
}

// ─── Build Branch Lifecycle ──────────────────────────────────────────────────

/**
 * Creates (or re-uses) a build branch for this feature.
 * Branch is forked from the client branch: client/<clientId>
 * Updates FeatureBuild with sandboxId/sandboxPort so deploy_feature,
 * the preview proxy, and the promoter all work unchanged.
 */
export async function startBuildBranch(buildId: string): Promise<SandboxSourceCurrencySnapshot | null> {
  const identity = await getClientIdentity();

  await ensureGitBaseline(identity);
  await ensureClientBranch(identity);
  let upstreamVerified = false;
  if (identity.upstreamRemoteUrl) {
    try {
      upstreamVerified = await configureAndFetchUpstream(identity);
    } catch (err) {
      const snapshot = classifySandboxSourceCurrency({
        workspace: WORKSPACE,
        branch: await currentSandboxBranch(),
        targetRef: "origin/main",
        checkedAt: new Date().toISOString(),
      });
      await recordBuildSourceCurrency(
        buildId,
        snapshot,
        `Could not verify origin/main before Build Studio work starts: ${(err as Error).message?.slice(0, 200)}`,
      );
      throw new Error(`Could not verify sandbox source against origin/main: ${(err as Error).message}`);
    }
  }

  // Scrub any uncommitted leakage from a prior build before switching branches.
  //
  // Without this, a previous build's working-tree changes (not yet committed —
  // e.g. because deploy_feature never ran, or the run crashed) bleed into
  // the new build's diff. In the subnet-graph run this caused 34 files of
  // leakage from an earlier HOA build.
  //
  // Preserve the large generated/cached directories so pnpm install stays hot.
  // `git reset --hard HEAD` wipes tracked modifications; `git clean -fd` with
  // exclusions deletes untracked source files without touching node_modules etc.
  await execSandboxGit(
    buildSandboxBranchSwitchPrepCommand(),
  ).catch((err) => {
    console.warn(`[build-branch] preview cleanup failed (non-fatal): ${(err as Error).message?.slice(0, 200)}`);
  });

  await execSandboxGit(
    buildSandboxGitCleanCommand(),
  ).catch((err) => {
    console.warn(`[build-branch] pre-checkout scrub failed (non-fatal): ${(err as Error).message?.slice(0, 200)}`);
  });

  await execSandboxGit(
    `cd ${WORKSPACE} && ${buildSandboxGitPruneTrackedArtifactsCommand()}`,
  ).catch((err) => {
    console.warn(`[build-branch] cache prune failed (non-fatal): ${(err as Error).message?.slice(0, 200)}`);
  });

  // Switch to client branch before forking the build branch
  await execSandboxGit(
    `git -C ${WORKSPACE} checkout "${identity.clientBranch}"`,
  );
  await normalizeSandboxBranchArtifacts(identity.clientBranch);
  if (upstreamVerified) {
    await refreshCurrentBranchFromTarget({
      buildId,
      branchName: identity.clientBranch,
      targetRef: "origin/main",
      blockUnknown: true,
    });
  } else {
    const snapshot = await inspectCurrentSandboxSourceCurrency("origin/main").catch(() =>
      classifySandboxSourceCurrency({
        workspace: WORKSPACE,
        branch: identity.clientBranch,
        targetRef: "origin/main",
        checkedAt: new Date().toISOString(),
      })
    );
    await recordBuildSourceCurrency(
      buildId,
      snapshot,
      "Upstream remote is not configured; sandbox source currency is observe-only.",
    );
  }

  const branchName = `build/${buildId}`;

  const branchExists = await execSandboxGit(
    `git -C ${WORKSPACE} branch --list "${branchName}" | grep -q . && echo yes || echo no`,
  ).catch(() => "no");

  if (branchExists.trim() === "yes") {
    await execSandboxGit(
      `git -C ${WORKSPACE} checkout "${branchName}"`,
    );
    await normalizeSandboxBranchArtifacts(branchName);
    await refreshCurrentBranchFromTarget({
      buildId,
      branchName,
      targetRef: identity.clientBranch,
      blockUnknown: true,
    });
    console.log(`[build-branch] Resumed build branch: ${JSON.stringify(branchName)}`);
  } else {
    await execSandboxGit(
      `git -C ${WORKSPACE} checkout -b "${branchName}"`,
    );
    await normalizeSandboxBranchArtifacts(branchName);
    console.log(`[build-branch] Created build branch: ${JSON.stringify(branchName)} from ${JSON.stringify(identity.clientBranch)}`);
  }
  const finalSourceCurrency = await inspectCurrentSandboxSourceCurrency(
    upstreamVerified ? "origin/main" : identity.clientBranch,
  );
  await recordBuildSourceCurrency(buildId, finalSourceCurrency);

  const updatedBuild = await prisma.featureBuild.update({
    where: { buildId },
    data: {
      sandboxId: SANDBOX_CONTAINER,
      sandboxPort: SANDBOX_PORT,
      buildBranch: branchName,
    },
    select: {
      id: true,
      buildId: true,
      createdById: true,
    },
  });

  const capsule = await prisma.workCapsule.findUnique({
    where: { idempotencyKey: `build-studio:${buildId}` },
    select: { id: true },
  });

  await registerRuntimeTarget({
    db: prisma as unknown as RuntimeCoordinationDb,
    input: buildBuildStudioSandboxTargetInput({
      buildRowId: updatedBuild.id,
      buildId: updatedBuild.buildId,
      capsuleRowId: capsule?.id ?? null,
      containerName: SANDBOX_CONTAINER,
      hostPort: SANDBOX_PORT,
      branchName,
    }),
    actor: {
      userId: updatedBuild.createdById,
      agentId: "build-studio",
      principalId: null,
    },
  });
  return finalSourceCurrency;
}

/**
 * Returns the current git branch inside the sandbox.
 */
export async function currentSandboxBranch(): Promise<string | null> {
  try {
    const out = await execSandboxGit(
      `git -C ${WORKSPACE} rev-parse --abbrev-ref HEAD`,
    );
    return out.trim() || null;
  } catch {
    return null;
  }
}

/**
 * Merges the build branch into the client branch after promotion.
 * The client branch accumulates all promoted features.
 * Upstream contribution (client/<id> → upstream main) is a separate step.
 */
export async function promoteBuildBranch(buildId: string): Promise<void> {
  const identity = await getClientIdentity();
  const branchName = `build/${buildId}`;

  await execSandboxGit(
    [
      `cd ${WORKSPACE}`,
      `git checkout "${identity.clientBranch}"`,
      `git merge --no-ff "${branchName}" -m "feat: promote ${branchName}"`,
    ].join(" && "),
  );

  console.log(`[build-branch] Promoted ${branchName} → ${identity.clientBranch}`);
}

/**
 * Switches the sandbox back to the client branch without deleting the build branch.
 * The build branch is preserved in git for audit / recovery.
 */
export async function abandonBuildBranch(buildId: string): Promise<void> {
  const identity = await getClientIdentity();
  try {
    await execSandboxGit(
      `cd ${WORKSPACE} && git checkout "${identity.clientBranch}"`,
    );
    console.log(`[build-branch] Abandoned build/${buildId} — back on ${identity.clientBranch}`);
  } catch (err) {
    console.warn(`[build-branch] abandon failed (non-fatal): ${(err as Error).message?.slice(0, 100)}`);
  }
}
