/**
 * worktree-isolation-e2e.test.ts — `isolation: "worktree"` through the real
 * stack: a real git repo, a real pi-mono session, the real extension, real
 * `createWorktree`/`cleanupWorktree`, on a faux model.
 *
 * Coverage sat on either side of this seam and never joined it.
 * test/worktree.test.ts drives real git but calls createWorktree/cleanupWorktree
 * directly, with no agent anywhere. test/agent-manager.test.ts (and -gc, and
 * agent-startup-error) mock ../src/worktree.js entirely and assert only the gate
 * and the fail-loud throw. So the chain the feature actually promises was never
 * pinned end to end: spawn → the child's cwd IS the copy → its edits stay out of
 * the main checkout → cleanup commits them to a branch the result names → the
 * copy is gone. Every link was tested; the chain was not.
 *
 * Deliberately faux, not live: a live model may decline to spawn at all, which
 * would look like a pass. Each run pins `live: false` rather than trusting the
 * env var to leave it alone — the pre-publish smoke sets PI_E2E_LIVE globally.
 */
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Context } from "@earendil-works/pi-ai";
import { fauxToolCall } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setWorktreeIsolationEnabled } from "../src/worktree.js";
import { agentCall, type FauxReply, type PrintModeRun, runPrintMode } from "./helpers/print-mode-runner.js";

vi.setConfig({ testTimeout: 30_000 });

/** The child creates this in whatever cwd it was actually given. */
const MARKER_FILE = "agent-work.txt";
/** Emitted by the child once its edit has landed. */
const CHILD_MARKER = "CHILD-EDITED-ITS-TREE";
const CHILD_PROMPT = "Create the marker file.";

function git(cwd: string, ...args: string[]): string {
  return execFileSync("git", args, { cwd, stdio: "pipe", timeout: 10_000 }).toString().trim();
}

/** A real repo with one commit — `git worktree add` needs a HEAD to branch from. */
function initGitRepo(): string {
  const dir = mkdtempSync(join(tmpdir(), "wt-iso-e2e-"));
  git(dir, "init");
  git(dir, "config", "user.email", "test@test.com");
  git(dir, "config", "user.name", "Test");
  writeFileSync(join(dir, "README.md"), "# Test repo");
  git(dir, "add", "README.md");
  git(dir, "commit", "-m", "initial");
  return dir;
}

function firstUserText(context: Context): string {
  const first = context.messages.find((m) => m.role === "user");
  const content = first?.content;
  if (typeof content === "string") return content;
  return ((content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join("");
}

function toolResultNames(context: Context): string[] {
  return context.messages
    .filter((m) => m.role === "toolResult")
    .map((m) => (m as { toolName?: string }).toolName ?? "");
}

/** Every Agent tool result the parent session received, concatenated. */
function agentResultText(session: Context): string {
  return session.messages
    .filter((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent")
    .flatMap((m) => ((m.content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? ""))
    .join("\n");
}

/**
 * One responder for both sessions, split on the first user message.
 *
 * The child writes through the REAL bash tool rather than having the test touch
 * the filesystem for it: the whole question is which directory the agent's own
 * tools resolve against, and writing the file from the test would answer it by
 * assumption.
 */
function respondSpawning(isolation: "worktree" | undefined): (context: Context) => FauxReply {
  return (context: Context): FauxReply => {
    if (firstUserText(context).includes(CHILD_PROMPT)) {
      if (!toolResultNames(context).includes("bash")) {
        return fauxToolCall("bash", { command: `echo isolated > ${MARKER_FILE}` });
      }
      return CHILD_MARKER;
    }
    // Parent: spawn once, then echo the tool result so a lost one fails loudly.
    if (toolResultNames(context).includes("Agent")) {
      return `parent saw: ${agentResultText(context)}`;
    }
    return agentCall({
      // Foreground: this test reads the child's marker out of the parent's
      // inline Agent tool result, which a background spawn replaces with a
      // "started in background" receipt.
      run_in_background: false,
      description: "worktree work",
      prompt: CHILD_PROMPT,
      ...(isolation ? { isolation } : {}),
    });
  };
}

describe("worktree isolation e2e (real git, real pi-mono, faux model)", () => {
  let run: PrintModeRun | undefined;
  const repos: string[] = [];

  afterEach(async () => {
    await run?.dispose();
    run = undefined;
    // Module-level switch: applySettings only applies keys that are PRESENT, so
    // a later run without the key would otherwise inherit whatever this one set.
    setWorktreeIsolationEnabled(true);
    for (const dir of repos.splice(0)) {
      try { git(dir, "worktree", "prune"); } catch { /* repo may be gone */ }
      rmSync(dir, { recursive: true, force: true });
    }
  });

  it("runs the child in the copy and lands its changes on a branch, not the main checkout", async () => {
    const repo = initGitRepo();
    repos.push(repo);

    run = await runPrintMode({
      prompt: "Delegate the work.",
      cwd: repo,
      respond: respondSpawning("worktree"),
      live: false,
    });

    // The child's own tools resolved against the copy — the main checkout never
    // saw the file. This is the guarantee; everything below is its bookkeeping.
    expect(existsSync(join(repo, MARKER_FILE))).toBe(false);

    const result = agentResultText(run.parentSession);
    expect(result).toContain(CHILD_MARKER);

    // The result names a branch and the command to merge it — the only artifact,
    // since the worktree directory does not survive.
    const branch = /Changes saved to branch `(pi-agent-[^`]+)`/.exec(result)?.[1];
    expect(branch).toBeTruthy();
    expect(result).toContain(`git merge ${branch}`);

    // That branch exists in the MAIN repo and carries the child's file.
    expect(git(repo, "branch", "--list", branch!)).toContain(branch!);
    expect(git(repo, "ls-tree", "--name-only", branch!)).toContain(MARKER_FILE);

    // And the copy is gone: `git worktree list` is down to the main checkout.
    expect(git(repo, "worktree", "list").split("\n")).toHaveLength(1);
  });

  it("downgrades to the main checkout when the project set worktreeIsolation: false", async () => {
    const repo = initGitRepo();
    repos.push(repo);
    mkdirSync(join(repo, ".pi"), { recursive: true });
    writeFileSync(join(repo, ".pi", "subagents.json"), JSON.stringify({ worktreeIsolation: false }));

    // The caller passes `isolation: "worktree"` even though the setting drops
    // the parameter from the schema — exactly what a model holding a cached tool
    // spec does, and the case the downgrade (rather than a throw) exists for.
    //
    // Mutation note: the resolver gate (invocation-config) and the manager gate
    // (agent-manager) are redundant on THIS path, so removing either one alone
    // leaves this test green — verified, not assumed. That is the point of the
    // second gate, which exists for cross-extension RPC, where options skip the
    // resolver entirely. This test pins the behaviour and goes red when both are
    // gone; each gate is pinned individually by its own unit test.
    run = await runPrintMode({
      prompt: "Delegate the work.",
      cwd: repo,
      respond: respondSpawning("worktree"),
      live: false,
    });

    const result = agentResultText(run.parentSession);
    expect(result).toContain(CHILD_MARKER);

    // Ran in the main checkout: the file is right there, and no branch was made.
    expect(existsSync(join(repo, MARKER_FILE))).toBe(true);
    expect(git(repo, "branch", "--list", "pi-agent-*")).toBe("");
    expect(git(repo, "worktree", "list").split("\n")).toHaveLength(1);

    // Silent by design — no per-result note, which is why the tool description
    // drops the isolation bullet alongside the parameter (see index.ts).
    expect(result).not.toContain("Changes saved to branch");
  });
});
