import { describe, expect, test } from "bun:test";
import { spawn, type ChildProcess } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer } from "node:net";
import { fileURLToPath } from "node:url";
import { derivePairId, portsForSlot } from "../pair-registry";

const CLI_PATH = fileURLToPath(new URL("../cli.ts", import.meta.url));
const BRIDGE_PATH = fileURLToPath(new URL("../bridge.ts", import.meta.url));

interface HarnessOptions {
  daemonDelayMs?: number;
  configProxyPort?: number;
}

interface RunResult {
  code: number | null;
  stdout: string;
  stderr: string;
}

interface TrackedProcess {
  child: ChildProcess;
  stdout: string[];
  stderr: string[];
}

interface PortReservation {
  port: number;
  release: () => Promise<void>;
}

class CliE2EHarness {
  readonly rootDir: string;
  readonly projectDir: string;
  readonly baseDir: string;
  readonly stateDir: string;
  readonly binDir: string;
  readonly shimLogDir: string;
  readonly fakeDaemonPath: string;
  readonly fakeDaemonLaunchLog: string;
  readonly controlPort: number;
  readonly appPort: number;
  readonly proxyPort: number;
  readonly env: NodeJS.ProcessEnv;

  private readonly trackedProcesses: TrackedProcess[] = [];
  private readonly portReservations: PortReservation[];
  private daemonPortReservations: PortReservation[];
  private daemonPortReleasePromise: Promise<void> | null = null;

  private constructor(
    rootDir: string,
    projectDir: string,
    baseDir: string,
    stateDir: string,
    binDir: string,
    shimLogDir: string,
    fakeDaemonPath: string,
    fakeDaemonLaunchLog: string,
    controlPort: number,
    appPort: number,
    proxyPort: number,
    portReservations: PortReservation[],
    env: NodeJS.ProcessEnv,
  ) {
    this.rootDir = rootDir;
    this.projectDir = projectDir;
    this.baseDir = baseDir;
    this.stateDir = stateDir;
    this.binDir = binDir;
    this.shimLogDir = shimLogDir;
    this.fakeDaemonPath = fakeDaemonPath;
    this.fakeDaemonLaunchLog = fakeDaemonLaunchLog;
    this.controlPort = controlPort;
    this.appPort = appPort;
    this.proxyPort = proxyPort;
    this.portReservations = portReservations;
    this.daemonPortReservations = [
      portReservations[0],
      portReservations[1],
      portReservations[2],
    ].filter((reservation): reservation is PortReservation => !!reservation);
    this.env = env;
  }

  static async create(options: HarnessOptions = {}): Promise<CliE2EHarness> {
    const rootDir = mkdtempSync(join(tmpdir(), "agentbridge-cli-e2e-"));
    const projectDir = join(rootDir, "project");
    const baseDir = join(rootDir, "base");
    const stateDir = join(rootDir, "state");
    const binDir = join(rootDir, "bin");
    const shimLogDir = join(rootDir, "shim-logs");
    const fakeDaemonPath = join(rootDir, "agentbridge-fake-daemon.ts");
    const fakeDaemonLaunchLog = join(rootDir, "fake-daemon-launches.jsonl");
    mkdirSync(projectDir, { recursive: true });
    mkdirSync(baseDir, { recursive: true });
    mkdirSync(stateDir, { recursive: true });
    mkdirSync(binDir, { recursive: true });
    mkdirSync(shimLogDir, { recursive: true });

    const portReservations = await Promise.all([
      reservePort(),
      reservePort(),
      reservePort(),
    ]);
    const [controlPort, appPort, proxyPort] = portReservations.map((reservation) => reservation.port);

    writeFileSync(
      fakeDaemonPath,
      buildFakeDaemonScript(),
      "utf-8",
    );
    writeExecutable(join(binDir, "claude"), buildShimScript("claude"));
    writeExecutable(join(binDir, "codex"), buildShimScript("codex"));

    const configProxyPort = options.configProxyPort ?? 45991;
    mkdirSync(join(projectDir, ".agentbridge"), { recursive: true });
    writeFileSync(
      join(projectDir, ".agentbridge", "config.json"),
      JSON.stringify(
        {
          version: "1.0",
          codex: {
            appPort,
            proxyPort: configProxyPort,
          },
          turnCoordination: {
            attentionWindowSeconds: 15,
          },
          idleShutdownSeconds: 30,
        },
        null,
        2,
      ) + "\n",
      "utf-8",
    );

    const env: NodeJS.ProcessEnv = {
      ...process.env,
      PATH: `${binDir}:${process.env.PATH ?? ""}`,
      // Pair-mode CLI paths (`--pair ...`) use AGENTBRIDGE_BASE_DIR, while
      // manual-mode paths below use AGENTBRIDGE_STATE_DIR. Keep both isolated
      // under this harness root so tests never touch the developer's registry.
      AGENTBRIDGE_BASE_DIR: undefined,
      AGENTBRIDGE_PAIR_ID: undefined,
      AGENTBRIDGE_PAIR_NAME: undefined,
      // The persistent max-permission opt-out must not leak in from the
      // developer's shell — the argv assertions expect the injected defaults.
      AGENTBRIDGE_SAFE: undefined,
      AGENTBRIDGE_MANUAL: "1",
      AGENTBRIDGE_STATE_DIR: stateDir,
      AGENTBRIDGE_CONTROL_PORT: String(controlPort),
      AGENTBRIDGE_DAEMON_ENTRY: fakeDaemonPath,
      AGENTBRIDGE_FAKE_DAEMON_LAUNCH_LOG: fakeDaemonLaunchLog,
      AGENTBRIDGE_FAKE_DAEMON_DELAY_MS: String(options.daemonDelayMs ?? 0),
      AGENTBRIDGE_SHIM_LOG_DIR: shimLogDir,
      CODEX_WS_PORT: String(appPort),
      CODEX_PROXY_PORT: String(proxyPort),
    };

    return new CliE2EHarness(
      rootDir,
      projectDir,
      baseDir,
      stateDir,
      binDir,
      shimLogDir,
      fakeDaemonPath,
      fakeDaemonLaunchLog,
      controlPort,
      appPort,
      proxyPort,
      portReservations,
      env,
    );
  }

  async runCli(args: string[], timeoutMs = 20000): Promise<RunResult> {
    return this.runCliWithEnv(args, {}, timeoutMs);
  }

  async runCliWithEnv(
    args: string[],
    extraEnv: NodeJS.ProcessEnv,
    timeoutMs = 20000,
  ): Promise<RunResult> {
    if (args[0] === "codex") {
      await this.releaseDaemonPortReservations();
    }

    const proc = this.spawnProcess(process.execPath, ["run", CLI_PATH, ...args], {
      cwd: this.projectDir,
      env: this.envForArgs(args, extraEnv),
    });
    return waitForExit(proc, timeoutMs);
  }

  async spawnCli(args: string[], extraEnv: NodeJS.ProcessEnv = {}): Promise<TrackedProcess> {
    if (args[0] === "codex") {
      await this.releaseDaemonPortReservations();
    }

    return this.spawnProcess(process.execPath, ["run", CLI_PATH, ...args], {
      cwd: this.projectDir,
      env: this.envForArgs(args, extraEnv),
    });
  }

  async spawnBridge(): Promise<TrackedProcess> {
    await this.releaseDaemonPortReservations();
    return this.spawnProcess(process.execPath, ["run", BRIDGE_PATH], {
      cwd: this.projectDir,
      env: this.env,
    });
  }

  async startManagedFakeDaemon(): Promise<TrackedProcess> {
    await this.releaseDaemonPortReservations();
    const proc = this.spawnProcess(process.execPath, ["run", this.fakeDaemonPath], {
      cwd: this.projectDir,
      env: this.env,
    });
    await this.waitForHealth();
    return proc;
  }

  async waitForHealth(maxRetries = 80, delayMs = 50): Promise<void> {
    await waitFor(async () => {
      try {
        const response = await fetch(`http://127.0.0.1:${this.controlPort}/healthz`);
        return response.ok;
      } catch {
        return false;
      }
    }, maxRetries, delayMs);
  }

  async waitForOutput(proc: TrackedProcess, needle: string, timeoutMs = 10000): Promise<void> {
    await waitFor(
      () => proc.stdout.join("").includes(needle) || proc.stderr.join("").includes(needle),
      Math.max(1, Math.ceil(timeoutMs / 50)),
      50,
    );
  }

  readShimCalls(command: "claude" | "codex"): Array<{ args: string[]; cwd: string }> {
    const logPath = join(this.shimLogDir, `${command}.jsonl`);
    return readJsonLines(logPath);
  }

  readLaunches(): Array<{ pid: number }> {
    return readJsonLines(this.fakeDaemonLaunchLog);
  }

  readPid(): number | null {
    const pidPath = join(this.stateDir, "daemon.pid");
    if (!existsSync(pidPath)) {
      return null;
    }
    const raw = readFileSync(pidPath, "utf-8").trim();
    const pid = Number.parseInt(raw, 10);
    return Number.isFinite(pid) ? pid : null;
  }

  readStatus(): Record<string, unknown> | null {
    const statusPath = join(this.stateDir, "status.json");
    if (!existsSync(statusPath)) {
      return null;
    }
    return JSON.parse(readFileSync(statusPath, "utf-8"));
  }

  readTuiPid(): number | null {
    const pidPath = join(this.stateDir, "codex-tui.pid");
    if (!existsSync(pidPath)) {
      return null;
    }
    const raw = readFileSync(pidPath, "utf-8").trim();
    const pid = Number.parseInt(raw, 10);
    return Number.isFinite(pid) ? pid : null;
  }

  async cleanup(): Promise<void> {
    for (const proc of this.trackedProcesses) {
      await stopProcess(proc);
    }

    const tuiPid = this.readTuiPid();
    if (tuiPid && isProcessAlive(tuiPid)) {
      try {
        process.kill(tuiPid, "SIGKILL");
      } catch {}
      await sleep(100);
    }

    const pid = this.readPid();
    if (pid && isProcessAlive(pid)) {
      try {
        process.kill(pid, "SIGKILL");
      } catch {}
      await sleep(100);
    }

    for (const pid of this.readAllStatePids()) {
      if (pid && isProcessAlive(pid)) {
        try {
          process.kill(pid, "SIGKILL");
        } catch {}
        await sleep(100);
      }
    }

    for (const reservation of this.portReservations) {
      try {
        await reservation.release();
      } catch {}
    }

    rmSync(this.rootDir, { recursive: true, force: true });
  }

  private readAllStatePids(): number[] {
    const pids: number[] = [];
    const visit = (dir: string) => {
      if (!existsSync(dir)) return;
      for (const entry of readdirSync(dir, { withFileTypes: true })) {
        const path = join(dir, entry.name);
        if (entry.isDirectory()) {
          visit(path);
          continue;
        }
        if (entry.name !== "daemon.pid" && entry.name !== "codex-tui.pid") continue;
        const pid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
        if (Number.isFinite(pid)) pids.push(pid);
      }
    };
    visit(this.rootDir);
    return [...new Set(pids)];
  }

  private async releaseDaemonPortReservations(): Promise<void> {
    if (this.daemonPortReservations.length === 0) {
      if (this.daemonPortReleasePromise) {
        await this.daemonPortReleasePromise;
      }
      return;
    }

    if (!this.daemonPortReleasePromise) {
      const reservations = this.daemonPortReservations;
      this.daemonPortReservations = [];
      this.daemonPortReleasePromise = Promise.all(
        reservations.map((reservation) => reservation.release()),
      ).then(() => {});
    }

    await this.daemonPortReleasePromise;
  }

  private envForArgs(args: string[], extraEnv: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
    return {
      ...this.env,
      ...(usesPairMode(args) ? { AGENTBRIDGE_BASE_DIR: this.baseDir } : {}),
      ...extraEnv,
    };
  }

  private spawnProcess(
    command: string,
    args: string[],
    options: {
      cwd: string;
      env: NodeJS.ProcessEnv;
    },
  ): TrackedProcess {
    const child = spawn(command, args, {
      cwd: options.cwd,
      env: options.env,
      stdio: ["pipe", "pipe", "pipe"],
    });

    const proc: TrackedProcess = {
      child,
      stdout: [],
      stderr: [],
    };

    child.stdout?.on("data", (chunk) => {
      proc.stdout.push(chunk.toString());
    });
    child.stderr?.on("data", (chunk) => {
      proc.stderr.push(chunk.toString());
    });

    this.trackedProcesses.push(proc);
    return proc;
  }
}

describe("E2E: CLI surface", () => {
  test("agentbridge init creates project config", async () => {
    await withHarness(async (harness) => {
      rmSync(join(harness.projectDir, ".agentbridge"), { recursive: true, force: true });

      const result = await harness.runCli(["init"]);

      expect(result.code).toBe(0);
      expect(existsSync(join(harness.projectDir, ".agentbridge", "config.json"))).toBe(true);
      expect(existsSync(join(harness.projectDir, ".agentbridge", "collaboration.md"))).toBe(false);

      const config = JSON.parse(
        readFileSync(join(harness.projectDir, ".agentbridge", "config.json"), "utf-8"),
      ) as { codex: { appPort: number; proxyPort: number } };
      expect(config.codex.appPort).toBe(4500);
      expect(config.codex.proxyPort).toBe(4501);
      expect(result.stdout).toContain("Setup complete!");
      expect(harness.readShimCalls("claude").some((entry) => entry.args[0] === "plugin")).toBe(true);
      expect(harness.readShimCalls("codex").some((entry) => entry.args[0] === "--version")).toBe(true);
    });
  }, 20000);

  test("agentbridge init reports incomplete and exits non-zero when plugin install fails", async () => {
    await withHarness(async (harness) => {
      rmSync(join(harness.projectDir, ".agentbridge"), { recursive: true, force: true });

      const result = await harness.runCliWithEnv(["init"], {
        AGENTBRIDGE_SHIM_CLAUDE_PLUGIN_INSTALL_FAIL: "1",
      });

      // Honest failure: non-zero exit, "incomplete" not "complete".
      expect(result.code).toBe(1);
      expect(result.stdout).toContain("Setup incomplete — plugin not installed");
      expect(result.stdout).not.toContain("Setup complete!");
      // The CLI runs from the repo checkout here, so guidance points at `abg dev`.
      expect(result.stdout).toContain("abg dev");
      // Config still gets generated even though the plugin step failed.
      expect(existsSync(join(harness.projectDir, ".agentbridge", "config.json"))).toBe(true);
    });
  }, 20000);

  test("agentbridge claude injects channel flags", async () => {
    await withHarness(async (harness) => {
      const result = await harness.runCli(["claude", "--resume"]);

      expect(result.code).toBe(0);

      const invocations = harness
        .readShimCalls("claude")
        .filter((entry) => entry.args[0] !== "--version" && entry.args[0] !== "plugin");
      expect(invocations.length).toBe(1);
      expect(invocations[0]?.args).toEqual([
        "--dangerously-load-development-channels",
        "plugin:agentbridge@agentbridge",
        // Max-permission default (opt out: --safe / AGENTBRIDGE_SAFE=1).
        "--dangerously-skip-permissions",
        "--resume",
      ]);
    });
  });

  test("agentbridge claude clears killed sentinel before launching Claude Code", async () => {
    await withHarness(async (harness) => {
      writeFileSync(join(harness.stateDir, "killed"), `${Date.now()}\n`, "utf-8");

      const result = await harness.runCli(["claude", "--resume"]);

      expect(result.code).toBe(0);
      expect(existsSync(join(harness.stateDir, "killed"))).toBe(false);
    });
  });

  test("agentbridge claude rejects owned flags", async () => {
    await withHarness(async (harness) => {
      const result = await harness.runCli(["claude", "--channels", "manual"]);

      expect(result.code).toBe(1);
      expect(result.stderr).toContain("\"--channels\" is automatically set by agentbridge claude");
      expect(harness.readShimCalls("claude")).toHaveLength(0);
    });
  });

  test("agentbridge codex ensures daemon is running and injects remote args", async () => {
    await withHarness(async (harness) => {
      const result = await harness.runCli(["codex", "--model", "o3"]);

      expect(result.code).toBe(0);
      await harness.waitForHealth();

      expect(harness.readLaunches()).toHaveLength(1);
      expect(harness.readPid()).not.toBeNull();
      expect(harness.readStatus()?.proxyUrl).toBe(`ws://127.0.0.1:${harness.proxyPort}`);
      expect(harness.readTuiPid()).toBeNull();

      const invocations = harness
        .readShimCalls("codex")
        .filter((entry) => entry.args[0] !== "--version");
      expect(invocations).toHaveLength(1);
      expect(invocations[0]?.args).toEqual([
        "--enable",
        "tui_app_server",
        "--remote",
        `ws://127.0.0.1:${harness.proxyPort}`,
        // Max-permission default (opt out: --safe / AGENTBRIDGE_SAFE=1).
        "--yolo",
        "--model",
        "o3",
      ]);
    });
  }, 20000);

  test("agentbridge codex rejects owned flags", async () => {
    await withHarness(async (harness) => {
      const remoteConflict = await harness.runCli(["codex", "--remote", "ws://127.0.0.1:7777"]);
      expect(remoteConflict.code).toBe(1);
      expect(remoteConflict.stderr).toContain("\"--remote\" is automatically set by agentbridge codex");

      const enableConflict = await harness.runCli(["codex", "--enable", "tui_app_server"]);
      expect(enableConflict.code).toBe(1);
      expect(enableConflict.stderr).toContain("\"--enable tui_app_server\" is automatically set");

      expect(harness.readShimCalls("codex")).toHaveLength(0);
      expect(harness.readLaunches()).toHaveLength(0);
    });
  });

  test("agentbridge codex reuses healthy daemon and prefers status.json proxyUrl", async () => {
    await withHarness({ configProxyPort: 49991 }, async (harness) => {
      const daemonProc = await harness.startManagedFakeDaemon();
      const pidBefore = harness.readPid();

      expect(pidBefore).not.toBeNull();
      expect(harness.readLaunches()).toHaveLength(1);

      const result = await harness.runCli(["codex", "--profile", "default"]);

      expect(result.code).toBe(0);
      expect(harness.readPid()).toBe(pidBefore);

      const invocations = harness
        .readShimCalls("codex")
        .filter((entry) => entry.args[0] !== "--version");
      expect(invocations).toHaveLength(1);
      expect(invocations[0]?.args).toEqual([
        "--enable",
        "tui_app_server",
        "--remote",
        `ws://127.0.0.1:${harness.proxyPort}`,
        // Max-permission default (opt out: --safe / AGENTBRIDGE_SAFE=1).
        "--yolo",
        "--profile",
        "default",
      ]);

      await stopProcess(daemonProc);
    });
  }, 20000);

  test("concurrent codex starts launch only one daemon", async () => {
    await withHarness({ daemonDelayMs: 1200 }, async (harness) => {
      const [first, second] = await Promise.all([
        harness.runCli(["codex", "--model", "o3-mini"], 25000),
        harness.runCli(["codex", "--model", "o3"], 25000),
      ]);

      expect(first.code).toBe(0);
      expect(second.code).toBe(0);
      expect(harness.readLaunches()).toHaveLength(1);

      const invocations = harness
        .readShimCalls("codex")
        .filter((entry) => entry.args[0] !== "--version");
      expect(invocations).toHaveLength(2);
      for (const invocation of invocations) {
        expect(invocation.args[0]).toBe("--enable");
        expect(invocation.args[2]).toBe("--remote");
        expect(invocation.args[3]).toBe(`ws://127.0.0.1:${harness.proxyPort}`);
      }
    });
  }, 30000);

  test("agentbridge kill stops daemon, cleans state, and writes killed sentinel", async () => {
    await withHarness(async (harness) => {
      const codexProc = await harness.spawnCli(
        ["codex"],
        { AGENTBRIDGE_CODEX_SHIM_HOLD_MS: "30000" },
      );
      await harness.waitForHealth();
      await waitFor(() => harness.readTuiPid() !== null, 80, 50);

      const pid = harness.readPid();
      const tuiPid = harness.readTuiPid();
      expect(pid).not.toBeNull();
      expect(tuiPid).not.toBeNull();
      expect(pid && isProcessAlive(pid)).toBe(true);
      expect(tuiPid && isProcessAlive(tuiPid)).toBe(true);

      const result = await harness.runCli(["kill"]);

      expect(result.code).toBe(0);
      expect(result.stdout).toContain("AgentBridge stopped.");
      // The restart hint echoes the invocation name (cliInvocationName); the
      // harness launches via `bun run cli.ts`, so argv[1]'s basename is neither
      // published name → the "abg" fallback.
      expect(result.stdout).toContain("Please restart Claude Code (`abg claude`), switch to a new conversation, or run `/resume` to fully disconnect.");
      await waitFor(() => (pid ? !isProcessAlive(pid) : true), 60, 50);
      await waitFor(() => (tuiPid ? !isProcessAlive(tuiPid) : true), 60, 50);
      await waitFor(() => codexProc.child.exitCode !== null, 60, 50);
      expect(existsSync(join(harness.stateDir, "daemon.pid"))).toBe(false);
      expect(existsSync(join(harness.stateDir, "codex-tui.pid"))).toBe(false);
      expect(existsSync(join(harness.stateDir, "status.json"))).toBe(false);
      expect(existsSync(join(harness.stateDir, "killed"))).toBe(true);
    });
  }, 20000);

  test("agentbridge kill reaps a managed Codex TUI even when its pid file is missing", async () => {
    await withHarness(async (harness) => {
      const codexProc = await harness.spawnCli(
        ["codex"],
        { AGENTBRIDGE_CODEX_SHIM_HOLD_MS: "30000" },
      );
      await harness.waitForHealth();
      await waitFor(() => harness.readTuiPid() !== null, 80, 50);

      const tuiPid = harness.readTuiPid();
      expect(tuiPid).not.toBeNull();
      expect(tuiPid && isProcessAlive(tuiPid)).toBe(true);

      unlinkSync(join(harness.stateDir, "codex-tui.pid"));

      const result = await harness.runCli(["kill"]);

      expect(result.code).toBe(0);
      await waitFor(() => (tuiPid ? !isProcessAlive(tuiPid) : true), 80, 50);
      await waitFor(() => codexProc.child.exitCode !== null, 80, 50);
      expect(existsSync(join(harness.stateDir, "codex-tui.pid"))).toBe(false);
    });
  }, 20000);

  test("agentbridge codex kills its child before exiting on SIGTERM", async () => {
    await withHarness(async (harness) => {
      const codexProc = await harness.spawnCli(
        ["codex"],
        { AGENTBRIDGE_CODEX_SHIM_HOLD_MS: "30000" },
      );
      await harness.waitForHealth();
      await waitFor(() => harness.readTuiPid() !== null, 80, 50);

      const tuiPid = harness.readTuiPid();
      expect(tuiPid).not.toBeNull();
      expect(tuiPid && isProcessAlive(tuiPid)).toBe(true);

      codexProc.child.kill("SIGTERM");

      await waitFor(() => codexProc.child.exitCode !== null, 80, 50);
      await waitFor(() => (tuiPid ? !isProcessAlive(tuiPid) : true), 80, 50);
      expect(existsSync(join(harness.stateDir, "codex-tui.pid"))).toBe(false);
    });
  }, 20000);

  test("agentbridge kill --pair scopes the restart hint to that pair", async () => {
    await withHarness(async (harness) => {
      const pairName = "work";
      const pairId = derivePairId(harness.projectDir, pairName);
      const pairSlot = await reservePairSlot();
      const pairStateDir = join(harness.baseDir, "pairs", pairId);
      mkdirSync(join(harness.baseDir, "pairs"), { recursive: true });
      mkdirSync(pairStateDir, { recursive: true });
      writeFileSync(
        join(harness.baseDir, "pairs", "registry.json"),
        JSON.stringify(
          {
            version: 1,
            pairs: [
              {
                pairId,
                slot: pairSlot.slot,
                cwd: harness.projectDir,
                name: pairName,
                source: "flag",
                createdAt: new Date().toISOString(),
              },
            ],
          },
          null,
          2,
        ) + "\n",
        "utf-8",
      );

      const { appPort, proxyPort, controlPort } = pairSlot.ports;
      for (const reservation of pairSlot.reservations) {
        await reservation.release();
      }

      const daemon = spawn(process.execPath, ["run", harness.fakeDaemonPath], {
        cwd: harness.projectDir,
        env: {
          ...harness.env,
          AGENTBRIDGE_STATE_DIR: pairStateDir,
          AGENTBRIDGE_CONTROL_PORT: String(controlPort),
          CODEX_WS_PORT: String(appPort),
          CODEX_PROXY_PORT: String(proxyPort),
        },
        stdio: ["ignore", "pipe", "pipe"],
      });
      const trackedDaemon: TrackedProcess = { child: daemon, stdout: [], stderr: [] };
      daemon.stdout?.on("data", (chunk) => trackedDaemon.stdout.push(chunk.toString()));
      daemon.stderr?.on("data", (chunk) => trackedDaemon.stderr.push(chunk.toString()));

      try {
        await waitFor(() => existsSync(join(pairStateDir, "daemon.pid")), 80, 50);

        const result = await harness.runCli(["kill", "--pair", pairName]);

        expect(result.code).toBe(0);
        expect(result.stdout).toContain("AgentBridge stopped.");
        // Invocation name is the "abg" fallback here (launched via `bun run cli.ts`);
        // the --pair scope must still ride along with it.
        expect(result.stdout).toContain("Please restart Claude Code (`abg --pair work claude`)");
        expect(result.stdout).not.toContain("Please restart Claude Code (`abg claude`)");
      } finally {
        await stopProcess(trackedDaemon);
      }
    });
  }, 20000);

  test("agentbridge kill --help prints usage without stopping a running daemon", async () => {
    await withHarness(async (harness) => {
      await harness.startManagedFakeDaemon();
      const pid = harness.readPid();
      expect(pid).not.toBeNull();

      const result = await harness.runCli(["kill", "--help"]);

      expect(result.code).toBe(0);
      expect(result.stdout).toContain("Usage: abg kill");
      expect(result.stdout).toContain("--pair <name|id>");
      expect(pid && isProcessAlive(pid)).toBe(true);
      expect(existsSync(join(harness.stateDir, "killed"))).toBe(false);
    });
  }, 20000);

  test("agentbridge --help documents the prune dry-run-by-default / --apply contract", async () => {
    // Regression guard: #9 inverted the prune contract (bare prune previews,
    // --apply deletes). The help text must match — a destructive command whose
    // safety story is "dry-run default" cannot have help that says bare prune
    // deletes (the old, now-false wording) or omits --apply.
    await withHarness(async (harness) => {
      const result = await harness.runCli(["--help"]);
      expect(result.code).toBe(0);
      expect(result.stdout).toContain("prune [--apply]");
      expect(result.stdout).toContain("--apply");
      expect(result.stdout).not.toContain("delete those orphan state directories");
    });
  }, 20000);

  test("agentbridge kill rejects unknown flags without stopping a running daemon", async () => {
    await withHarness(async (harness) => {
      await harness.startManagedFakeDaemon();
      const pid = harness.readPid();
      expect(pid).not.toBeNull();

      const result = await harness.runCli(["kill", "--badflag"]);

      expect(result.code).toBe(1);
      expect(result.stderr).toContain("Unknown kill argument: --badflag");
      expect(pid && isProcessAlive(pid)).toBe(true);
      expect(existsSync(join(harness.stateDir, "killed"))).toBe(false);
    });
  }, 20000);

  test("bridge stays idle and does not relaunch daemon after kill writes the killed sentinel", async () => {
    await withHarness(async (harness) => {
      const bridge = await harness.spawnBridge();

      await harness.waitForHealth();
      await harness.waitForOutput(bridge, "Daemon status:");
      expect(harness.readLaunches()).toHaveLength(1);

      const killResult = await harness.runCli(["kill"]);
      expect(killResult.code).toBe(0);

      await harness.waitForOutput(bridge, "not reconnecting");
      await sleep(1200);
      expect(bridge.child.exitCode).toBeNull();
      expect(harness.readLaunches()).toHaveLength(1);
      expect(existsSync(join(harness.stateDir, "killed"))).toBe(true);
    });
  }, 30000);

  test("happy path: init -> claude -> codex -> kill", async () => {
    await withHarness(async (harness) => {
      rmSync(join(harness.projectDir, ".agentbridge"), { recursive: true, force: true });

      const initResult = await harness.runCli(["init"]);
      expect(initResult.code).toBe(0);

      const claudeResult = await harness.runCli(["claude", "--resume"]);
      expect(claudeResult.code).toBe(0);

      const codexResult = await harness.runCli(["codex", "--model", "o3"]);
      expect(codexResult.code).toBe(0);
      await harness.waitForHealth();

      const pid = harness.readPid();
      expect(pid).not.toBeNull();

      const killResult = await harness.runCli(["kill"]);
      expect(killResult.code).toBe(0);

      await waitFor(() => (pid ? !isProcessAlive(pid) : true), 60, 50);
      expect(existsSync(join(harness.projectDir, ".agentbridge", "config.json"))).toBe(true);
      expect(existsSync(join(harness.projectDir, ".agentbridge", "collaboration.md"))).toBe(false);
      expect(existsSync(join(harness.stateDir, "daemon.pid"))).toBe(false);
      expect(existsSync(join(harness.stateDir, "status.json"))).toBe(false);
      expect(existsSync(join(harness.stateDir, "killed"))).toBe(true);

      const claudeRun = harness
        .readShimCalls("claude")
        .find((entry) => entry.args[0] === "--dangerously-load-development-channels");
      expect(claudeRun?.args[1]).toBe("plugin:agentbridge@agentbridge");

      const codexRun = harness
        .readShimCalls("codex")
        .find((entry) => entry.args[0] === "--enable");
      expect(codexRun?.args[3]).toBe(`ws://127.0.0.1:${harness.proxyPort}`);
    });
  }, 30000);

  test("agentbridge pairs rm deletes the pair's state directory and registry entry (B1)", async () => {
    await withHarness(async (harness) => {
      const pairName = "work";
      const pairId = derivePairId(harness.projectDir, pairName);
      const pairsRoot = join(harness.baseDir, "pairs");
      const pairStateDir = join(pairsRoot, pairId);
      mkdirSync(pairStateDir, { recursive: true });
      writeFileSync(join(pairStateDir, "agentbridge.log"), "x\n", "utf-8");
      writeFileSync(
        join(pairsRoot, "registry.json"),
        JSON.stringify(
          {
            version: 1,
            pairs: [
              {
                pairId,
                slot: 3,
                cwd: harness.projectDir,
                name: pairName,
                source: "flag",
                createdAt: new Date().toISOString(),
              },
            ],
          },
          null,
          2,
        ) + "\n",
        "utf-8",
      );

      const result = await harness.runCliWithEnv(["pairs", "rm", pairName], {
        AGENTBRIDGE_BASE_DIR: harness.baseDir,
      });

      expect(result.code).toBe(0);
      expect(result.stdout).toContain(`Removed pair ${pairId}`);
      expect(result.stdout).toContain("State directory deleted.");
      expect(existsSync(pairStateDir)).toBe(false);
      const registry = JSON.parse(readFileSync(join(pairsRoot, "registry.json"), "utf-8"));
      expect(registry.pairs.some((p: { pairId: string }) => p.pairId === pairId)).toBe(false);
    });
  }, 20000);

  test("agentbridge pairs prune removes orphan dirs but keeps registered and live ones (B1)", async () => {
    await withHarness(async (harness) => {
      const pairsRoot = join(harness.baseDir, "pairs");
      mkdirSync(pairsRoot, { recursive: true });

      // Orphan with no daemon.pid → prunable.
      const orphanNoPid = join(pairsRoot, "main-aaaa0001");
      mkdirSync(orphanNoPid, { recursive: true });

      // Orphan with a dead daemon.pid → prunable.
      const orphanDead = join(pairsRoot, "main-bbbb0002");
      mkdirSync(orphanDead, { recursive: true });
      writeFileSync(join(orphanDead, "daemon.pid"), "2147483646\n", "utf-8");

      // Orphan with a LIVE daemon.pid → must be kept. Use a throwaway child
      // process, NOT process.pid: harness cleanup SIGKILLs every daemon.pid it
      // finds under rootDir, so writing the test runner's own pid would kill the
      // whole suite.
      const liveChild = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], {
        stdio: "ignore",
      });
      const orphanLive = join(pairsRoot, "main-cccc0003");
      mkdirSync(orphanLive, { recursive: true });
      writeFileSync(join(orphanLive, "daemon.pid"), `${liveChild.pid}\n`, "utf-8");

      // Registered pair → must be kept (use `pairs rm`, not prune).
      const regId = derivePairId(harness.projectDir, "work");
      const regDir = join(pairsRoot, regId);
      mkdirSync(regDir, { recursive: true });
      writeFileSync(
        join(pairsRoot, "registry.json"),
        JSON.stringify(
          {
            version: 1,
            pairs: [
              {
                pairId: regId,
                slot: 2,
                cwd: harness.projectDir,
                name: "work",
                source: "flag",
                createdAt: new Date().toISOString(),
              },
            ],
          },
          null,
          2,
        ) + "\n",
        "utf-8",
      );

      try {
        const baseEnv = { AGENTBRIDGE_BASE_DIR: harness.baseDir };

        // Bare `prune` is now a DRY RUN (the safe default): it lists the orphans
        // but deletes nothing. `--dry-run` stays accepted as an explicit alias.
        const dry = await harness.runCliWithEnv(["pairs", "prune"], baseEnv);
        expect(dry.code).toBe(0);
        expect(dry.stdout).toContain("main-aaaa0001");
        expect(dry.stdout).toContain("main-bbbb0002");
        expect(dry.stdout).toContain("dry run");
        expect(existsSync(orphanNoPid)).toBe(true);
        expect(existsSync(orphanDead)).toBe(true);

        // --apply deletes only the dead orphans; live and registered are kept.
        const run = await harness.runCliWithEnv(["pairs", "prune", "--apply"], baseEnv);
        expect(run.code).toBe(0);
        expect(existsSync(orphanNoPid)).toBe(false);
        expect(existsSync(orphanDead)).toBe(false);
        expect(existsSync(orphanLive)).toBe(true);
        expect(existsSync(regDir)).toBe(true);
      } finally {
        try {
          liveChild.kill("SIGKILL");
        } catch {}
      }
    });
  }, 20000);

  test("agentbridge pairs prune reclaims a stranded entry (cwd-gone + dead + old) only with --apply (P1 #9)", async () => {
    await withHarness(async (harness) => {
      const pairsRoot = join(harness.baseDir, "pairs");
      mkdirSync(pairsRoot, { recursive: true });

      // A stranded entry: its cwd does NOT exist, no daemon, created long ago.
      // This mirrors the real double-hash-bug residue that permanently squats a slot.
      const strandedId = "main-deadbeef";
      const strandedDir = join(pairsRoot, strandedId);
      mkdirSync(strandedDir, { recursive: true });
      writeFileSync(join(strandedDir, "agentbridge.log"), "x", "utf-8");
      const goneCwd = join(harness.rootDir, "vanished-project"); // never created on disk
      const oldCreatedAt = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();

      // A valid current-cwd entry that must NEVER be reclaimed (cwd exists).
      const liveId = derivePairId(harness.projectDir, "work");
      const liveDir = join(pairsRoot, liveId);
      mkdirSync(liveDir, { recursive: true });

      writeFileSync(
        join(pairsRoot, "registry.json"),
        JSON.stringify(
          {
            version: 1,
            pairs: [
              { pairId: strandedId, slot: 1, cwd: goneCwd, name: "main", source: "cwd", createdAt: oldCreatedAt },
              {
                pairId: liveId,
                slot: 2,
                cwd: harness.projectDir,
                name: "work",
                source: "flag",
                createdAt: new Date().toISOString(),
              },
            ],
          },
          null,
          2,
        ) + "\n",
        "utf-8",
      );

      const baseEnv = { AGENTBRIDGE_BASE_DIR: harness.baseDir };

      // Dry run (default): lists the stranded entry, deletes nothing.
      const dry = await harness.runCliWithEnv(["pairs", "prune"], baseEnv);
      expect(dry.code).toBe(0);
      expect(dry.stdout).toContain("Would reclaim registry entries:");
      expect(dry.stdout).toContain(strandedId);
      expect(dry.stdout).toContain("cwd-gone, dead");
      expect(dry.stdout).toContain("dry run");
      // Nothing deleted yet.
      expect(existsSync(strandedDir)).toBe(true);
      // The valid entry is NOT listed for reclamation.
      expect(dry.stdout).not.toContain(`Would reclaim registry entries:\n  ${liveId}`);

      // --apply reclaims the stranded entry + dir; the valid entry stays.
      const apply = await harness.runCliWithEnv(["pairs", "prune", "--apply"], baseEnv);
      expect(apply.code).toBe(0);
      expect(apply.stdout).toContain("Reclaimed registry entries:");
      expect(apply.stdout).toContain(strandedId);
      expect(existsSync(strandedDir)).toBe(false);
      expect(existsSync(liveDir)).toBe(true);

      // The registry now contains only the valid entry.
      const reg = JSON.parse(readFileSync(join(pairsRoot, "registry.json"), "utf-8")) as {
        pairs: Array<{ pairId: string }>;
      };
      expect(reg.pairs.map((p) => p.pairId)).toEqual([liveId]);
    });
  }, 20000);

  test("agentbridge pairs prune combined: one orphan dir + one reclaimable entry, --apply cleans both (P1 #9)", async () => {
    await withHarness(async (harness) => {
      const pairsRoot = join(harness.baseDir, "pairs");
      mkdirSync(pairsRoot, { recursive: true });

      // (1) Orphan dir: a dir with NO registry entry, no daemon → dir-orphan prune.
      const orphanDir = join(pairsRoot, "main-orph0001");
      mkdirSync(orphanDir, { recursive: true });

      // (2) Reclaimable entry: cwd-gone + dead + old → entry-level reclaim.
      const strandedId = "main-strand02";
      const strandedDir = join(pairsRoot, strandedId);
      mkdirSync(strandedDir, { recursive: true });
      const goneCwd = join(harness.rootDir, "no-such-dir");
      const oldCreatedAt = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();

      writeFileSync(
        join(pairsRoot, "registry.json"),
        JSON.stringify(
          {
            version: 1,
            pairs: [
              { pairId: strandedId, slot: 3, cwd: goneCwd, name: "main", source: "cwd", createdAt: oldCreatedAt },
            ],
          },
          null,
          2,
        ) + "\n",
        "utf-8",
      );

      const baseEnv = { AGENTBRIDGE_BASE_DIR: harness.baseDir };

      // Dry run reports BOTH kinds without deleting.
      const dry = await harness.runCliWithEnv(["pairs", "prune"], baseEnv);
      expect(dry.code).toBe(0);
      expect(dry.stdout).toContain("Would remove orphan pair directories:");
      expect(dry.stdout).toContain("main-orph0001");
      expect(dry.stdout).toContain("Would reclaim registry entries:");
      expect(dry.stdout).toContain(strandedId);
      expect(existsSync(orphanDir)).toBe(true);
      expect(existsSync(strandedDir)).toBe(true);

      // --apply cleans BOTH.
      const apply = await harness.runCliWithEnv(["pairs", "prune", "--apply"], baseEnv);
      expect(apply.code).toBe(0);
      expect(existsSync(orphanDir)).toBe(false);
      expect(existsSync(strandedDir)).toBe(false);

      const reg = JSON.parse(readFileSync(join(pairsRoot, "registry.json"), "utf-8")) as {
        pairs: Array<{ pairId: string }>;
      };
      expect(reg.pairs).toHaveLength(0);
    });
  }, 20000);

  test("agentbridge kill (no args) stops only the current directory's pairs (F1)", async () => {
    await withHarness(async (harness) => {
      const pairsRoot = join(harness.baseDir, "pairs");
      mkdirSync(pairsRoot, { recursive: true });
      const otherDir = mkdtempSync(join(tmpdir(), "abg-othercwd-"));

      const idHere = derivePairId(harness.projectDir, "main");
      const idAway = derivePairId(otherDir, "main");
      mkdirSync(join(pairsRoot, idHere), { recursive: true });
      mkdirSync(join(pairsRoot, idAway), { recursive: true });
      writeFileSync(
        join(pairsRoot, "registry.json"),
        JSON.stringify(
          {
            version: 1,
            pairs: [
              { pairId: idHere, slot: 4, cwd: harness.projectDir, name: "main", source: "cwd", createdAt: new Date().toISOString() },
              { pairId: idAway, slot: 5, cwd: otherDir, name: "main", source: "cwd", createdAt: new Date().toISOString() },
            ],
          },
          null,
          2,
        ) + "\n",
        "utf-8",
      );

      try {
        const result = await harness.runCliWithEnv(["kill"], { AGENTBRIDGE_BASE_DIR: harness.baseDir });
        expect(result.code).toBe(0);
        // Current-dir pair stopped (killed sentinel written); the foreign-cwd pair untouched.
        expect(existsSync(join(pairsRoot, idHere, "killed"))).toBe(true);
        expect(existsSync(join(pairsRoot, idAway, "killed"))).toBe(false);
        expect(result.stdout).toContain(idHere);
        expect(result.stdout).not.toContain(idAway);
      } finally {
        rmSync(otherDir, { recursive: true, force: true });
      }
    });
  }, 20000);

  test("agentbridge kill all stops pairs from every directory (F1)", async () => {
    await withHarness(async (harness) => {
      const pairsRoot = join(harness.baseDir, "pairs");
      mkdirSync(pairsRoot, { recursive: true });
      const otherDir = mkdtempSync(join(tmpdir(), "abg-othercwd-"));

      const idHere = derivePairId(harness.projectDir, "main");
      const idAway = derivePairId(otherDir, "main");
      mkdirSync(join(pairsRoot, idHere), { recursive: true });
      mkdirSync(join(pairsRoot, idAway), { recursive: true });
      writeFileSync(
        join(pairsRoot, "registry.json"),
        JSON.stringify(
          {
            version: 1,
            pairs: [
              { pairId: idHere, slot: 4, cwd: harness.projectDir, name: "main", source: "cwd", createdAt: new Date().toISOString() },
              { pairId: idAway, slot: 5, cwd: otherDir, name: "main", source: "cwd", createdAt: new Date().toISOString() },
            ],
          },
          null,
          2,
        ) + "\n",
        "utf-8",
      );

      try {
        const result = await harness.runCliWithEnv(["kill", "all"], { AGENTBRIDGE_BASE_DIR: harness.baseDir });
        expect(result.code).toBe(0);
        expect(existsSync(join(pairsRoot, idHere, "killed"))).toBe(true);
        expect(existsSync(join(pairsRoot, idAway, "killed"))).toBe(true);
      } finally {
        rmSync(otherDir, { recursive: true, force: true });
      }
    });
  }, 20000);

  test("agentbridge kill with no current-directory pairs prints a hint and touches nothing (F1)", async () => {
    await withHarness(async (harness) => {
      const pairsRoot = join(harness.baseDir, "pairs");
      mkdirSync(pairsRoot, { recursive: true });
      const otherDir = mkdtempSync(join(tmpdir(), "abg-othercwd-"));

      const idAway = derivePairId(otherDir, "main");
      mkdirSync(join(pairsRoot, idAway), { recursive: true });
      writeFileSync(
        join(pairsRoot, "registry.json"),
        JSON.stringify(
          {
            version: 1,
            pairs: [
              { pairId: idAway, slot: 5, cwd: otherDir, name: "main", source: "cwd", createdAt: new Date().toISOString() },
            ],
          },
          null,
          2,
        ) + "\n",
        "utf-8",
      );

      try {
        const result = await harness.runCliWithEnv(["kill"], { AGENTBRIDGE_BASE_DIR: harness.baseDir });
        expect(result.code).toBe(0);
        expect(result.stdout).toContain("No AgentBridge pairs registered for current directory");
        expect(result.stdout).toContain("abg kill all");
        expect(existsSync(join(pairsRoot, idAway, "killed"))).toBe(false);
      } finally {
        rmSync(otherDir, { recursive: true, force: true });
      }
    });
  }, 20000);

  test("agentbridge kill rejects --pair together with all/--all (F1)", async () => {
    await withHarness(async (harness) => {
      for (const args of [
        ["kill", "--pair", "x", "--all"],
        ["kill", "--pair", "x", "all"],
      ]) {
        const result = await harness.runCli(args);
        expect(result.code).not.toBe(0);
        expect(result.stderr).toContain("use either");
      }
    });
  }, 20000);

  test("agentbridge pairs prune skips non-canonical (whitespace) directory names (B1)", async () => {
    await withHarness(async (harness) => {
      const pairsRoot = join(harness.baseDir, "pairs");
      mkdirSync(pairsRoot, { recursive: true });
      // Leading space → validatePairId would trim to "main"; prune must NOT act
      // on it via the trimmed alias, and must keep the literal dir.
      const weird = join(pairsRoot, " main");
      mkdirSync(weird, { recursive: true });
      writeFileSync(join(pairsRoot, "registry.json"), JSON.stringify({ version: 1, pairs: [] }) + "\n", "utf-8");

      const result = await harness.runCliWithEnv(["pairs", "prune"], {
        AGENTBRIDGE_BASE_DIR: harness.baseDir,
      });
      expect(result.code).toBe(0);
      expect(existsSync(weird)).toBe(true); // never deleted via a trimmed alias
      expect(result.stdout).toContain("canonical");
    });
  }, 20000);

  test("agentbridge pairs rm aborts (keeps registry + dir) when the pair cannot be stopped (B1)", async () => {
    await withHarness(async (harness) => {
      const pairName = "stuck";
      const pairId = derivePairId(harness.projectDir, pairName);
      const pairsRoot = join(harness.baseDir, "pairs");
      const pairStateDir = join(pairsRoot, pairId);
      mkdirSync(pairStateDir, { recursive: true });
      writeFileSync(
        join(pairsRoot, "registry.json"),
        JSON.stringify(
          {
            version: 1,
            pairs: [
              {
                pairId,
                slot: 3,
                cwd: harness.projectDir,
                name: pairName,
                source: "flag",
                createdAt: new Date().toISOString(),
              },
            ],
          },
          null,
          2,
        ) + "\n",
        "utf-8",
      );
      // Make the pair's state dir read-only so markKilled (writing the `killed`
      // sentinel) throws → stopPairEntry returns {error} → rm must abort without
      // removing the registry entry or the dir.
      chmodSync(pairStateDir, 0o500);
      try {
        const result = await harness.runCliWithEnv(["pairs", "rm", pairName], {
          AGENTBRIDGE_BASE_DIR: harness.baseDir,
        });
        expect(result.code).not.toBe(0);
        expect(result.stderr).toContain("failed to stop");
        const registry = JSON.parse(readFileSync(join(pairsRoot, "registry.json"), "utf-8"));
        expect(registry.pairs.some((p: { pairId: string }) => p.pairId === pairId)).toBe(true);
        expect(existsSync(pairStateDir)).toBe(true);
      } finally {
        chmodSync(pairStateDir, 0o700); // restore so harness cleanup can remove it
      }
    });
  }, 20000);
});

async function withHarness(
  fnOrOptions: HarnessOptions | ((harness: CliE2EHarness) => Promise<void>),
  fnMaybe?: (harness: CliE2EHarness) => Promise<void>,
) {
  const options = typeof fnOrOptions === "function" ? {} : fnOrOptions;
  const fn = typeof fnOrOptions === "function" ? fnOrOptions : fnMaybe;
  if (!fn) {
    throw new Error("Harness callback is required");
  }

  const harness = await CliE2EHarness.create(options);
  try {
    await fn(harness);
  } finally {
    await harness.cleanup();
  }
}

async function waitForExit(proc: TrackedProcess, timeoutMs: number): Promise<RunResult> {
  return new Promise((resolve, reject) => {
    let settled = false;
    let timedOut = false;
    const timer = setTimeout(() => {
      if (settled) {
        return;
      }
      timedOut = true;
      try {
        proc.child.kill("SIGKILL");
      } catch {}
    }, timeoutMs);

    proc.child.once("error", (err) => {
      if (settled) {
        return;
      }
      settled = true;
      clearTimeout(timer);
      reject(err);
    });

    proc.child.once("close", (code) => {
      if (settled) {
        return;
      }
      settled = true;
      clearTimeout(timer);
      if (timedOut) {
        reject(new Error(`Process timed out after ${timeoutMs}ms`));
        return;
      }
      resolve({
        code,
        stdout: proc.stdout.join(""),
        stderr: proc.stderr.join(""),
      });
    });
  });
}

async function stopProcess(proc: TrackedProcess | null): Promise<void> {
  if (!proc || proc.child.exitCode !== null) {
    return;
  }

  await new Promise<void>((resolve) => {
    const timer = setTimeout(() => {
      if (proc.child.exitCode === null) {
        try {
          proc.child.kill("SIGKILL");
        } catch {}
      }
    }, 1000);

    proc.child.once("exit", () => {
      clearTimeout(timer);
      resolve();
    });

    try {
      proc.child.kill("SIGTERM");
    } catch {
      clearTimeout(timer);
      resolve();
    }
  });
}

async function waitFor(
  predicate: () => boolean | Promise<boolean>,
  maxRetries: number,
  delayMs: number,
): Promise<void> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    if (await predicate()) {
      return;
    }
    await sleep(delayMs);
  }

  throw new Error("Timed out waiting for condition");
}

async function reservePort(): Promise<PortReservation> {
  return new Promise((resolve, reject) => {
    const server = createServer();

    server.once("error", reject);
    server.listen(0, "127.0.0.1", () => {
      const address = server.address();
      if (!address || typeof address === "string") {
        server.close();
        reject(new Error("Failed to allocate a free TCP port"));
        return;
      }

      server.unref();

      let released = false;
      resolve({
        port: address.port,
        release: () => new Promise<void>((releaseResolve, releaseReject) => {
          if (released) {
            releaseResolve();
            return;
          }

          released = true;
          server.close((err) => {
            if (err) {
              releaseReject(err);
              return;
            }
            releaseResolve();
          });
        }),
      });
    });
  });
}

function writeExecutable(filePath: string, content: string) {
  writeFileSync(filePath, content, "utf-8");
  chmodSync(filePath, 0o755);
}

function readJsonLines(filePath: string): any[] {
  if (!existsSync(filePath)) {
    return [];
  }

  return readFileSync(filePath, "utf-8")
    .split("\n")
    .map((line) => line.trim())
    .filter(Boolean)
    .map((line) => JSON.parse(line));
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function usesPairMode(args: string[]): boolean {
  return args.some((arg) => arg === "--pair" || arg.startsWith("--pair="));
}

async function reservePairSlot(): Promise<{ slot: number; ports: ReturnType<typeof portsForSlot>; reservations: PortReservation[] }> {
  for (let slot = 20; slot < 200; slot++) {
    const ports = portsForSlot(slot);
    const reservations: PortReservation[] = [];
    try {
      reservations.push(await reserveSpecificPort(ports.appPort));
      reservations.push(await reserveSpecificPort(ports.proxyPort));
      reservations.push(await reserveSpecificPort(ports.controlPort));
      return { slot, ports, reservations };
    } catch {
      for (const reservation of reservations) {
        try {
          await reservation.release();
        } catch {}
      }
    }
  }
  throw new Error("No free AgentBridge pair slot available for test");
}

async function reserveSpecificPort(port: number): Promise<PortReservation> {
  return new Promise((resolve, reject) => {
    const server = createServer();

    server.once("error", reject);
    server.listen(port, "127.0.0.1", () => {
      server.unref();

      let released = false;
      resolve({
        port,
        release: () => new Promise<void>((releaseResolve, releaseReject) => {
          if (released) {
            releaseResolve();
            return;
          }

          released = true;
          server.close((err) => {
            if (err) {
              releaseReject(err);
              return;
            }
            releaseResolve();
          });
        }),
      });
    });
  });
}

function isProcessAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

function buildShimScript(commandName: "claude" | "codex"): string {
  const versionOutput = commandName === "claude" ? "claude v2.1.80" : "codex 0.1.0";
  return `#!/usr/bin/env bun
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";

const logDir = process.env.AGENTBRIDGE_SHIM_LOG_DIR;
if (!logDir) {
  console.error("AGENTBRIDGE_SHIM_LOG_DIR is required");
  process.exit(1);
}

const logPath = join(logDir, "${commandName}.jsonl");
mkdirSync(dirname(logPath), { recursive: true });

const args = process.argv.slice(2);
appendFileSync(
  logPath,
  JSON.stringify({ args, cwd: process.cwd() }) + "\\n",
  "utf-8",
);

if (args[0] === "--version") {
  console.log("${versionOutput}");
  process.exit(0);
}

if ("${commandName}" === "claude" && args[0] === "plugin" && args[1] === "install") {
  // Test hook: force plugin install to fail so init's failure journey can be
  // exercised end-to-end (exit code + context-aware guidance).
  if (process.env.AGENTBRIDGE_SHIM_CLAUDE_PLUGIN_INSTALL_FAIL === "1") {
    console.error("simulated plugin install failure");
    process.exit(1);
  }
  process.exit(0);
}

if ("${commandName}" === "codex" && args[0] === "--enable" && args[1] === "tui_app_server") {
  const holdMs = Number.parseInt(process.env.AGENTBRIDGE_CODEX_SHIM_HOLD_MS ?? "0", 10);
  if (Number.isFinite(holdMs) && holdMs > 0) {
    await Bun.sleep(holdMs);
  }
}

process.exit(0);
`;
}

function buildFakeDaemonScript(): string {
  return `#!/usr/bin/env bun
import { appendFileSync, existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";

const stateDir = process.env.AGENTBRIDGE_STATE_DIR;
const controlPort = Number.parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
const appPort = Number.parseInt(process.env.CODEX_WS_PORT ?? "4500", 10);
const proxyPort = Number.parseInt(process.env.CODEX_PROXY_PORT ?? "4501", 10);
const launchLog = process.env.AGENTBRIDGE_FAKE_DAEMON_LAUNCH_LOG;
const delayMs = Number.parseInt(process.env.AGENTBRIDGE_FAKE_DAEMON_DELAY_MS ?? "0", 10);

if (!stateDir || !launchLog) {
  console.error("Fake daemon requires AGENTBRIDGE_STATE_DIR and AGENTBRIDGE_FAKE_DAEMON_LAUNCH_LOG");
  process.exit(1);
}

const pidFile = join(stateDir, "daemon.pid");
const statusFile = join(stateDir, "status.json");
const killedFile = join(stateDir, "killed");
const proxyUrl = \`ws://127.0.0.1:\${proxyPort}\`;
const appServerUrl = \`ws://127.0.0.1:\${appPort}\`;
const build = {
  version: "0.0.0-source",
  commit: "source",
  bundle: "source",
  contractVersion: 1,
};

if (existsSync(killedFile)) {
  process.exit(0);
}

mkdirSync(stateDir, { recursive: true });
appendFileSync(launchLog, JSON.stringify({ pid: process.pid }) + "\\n", "utf-8");

if (delayMs > 0) {
  await Bun.sleep(delayMs);
}

writeFileSync(pidFile, \`\${process.pid}\\n\`, "utf-8");

function currentStatus() {
  return {
    bridgeReady: false,
    tuiConnected: false,
    threadId: null,
    queuedMessageCount: 0,
    proxyUrl,
    appServerUrl,
    pid: process.pid,
    pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
    cwd: process.cwd(),
    stateDir,
    build,
  };
}

writeFileSync(statusFile, JSON.stringify({
  proxyUrl,
  appServerUrl,
  controlPort,
  pid: process.pid,
  pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
  cwd: process.cwd(),
  stateDir,
  build,
}, null, 2) + "\\n", "utf-8");

let cleanedUp = false;
function cleanupFiles() {
  if (cleanedUp) {
    return;
  }
  cleanedUp = true;
  try { unlinkSync(pidFile); } catch {}
  try { unlinkSync(statusFile); } catch {}
}

const server = Bun.serve({
  port: controlPort,
  hostname: "127.0.0.1",
  fetch(req, serverInstance) {
    const url = new URL(req.url);
    if (url.pathname === "/healthz" || url.pathname === "/readyz") {
      return Response.json(currentStatus());
    }

    if (url.pathname === "/ws" && serverInstance.upgrade(req)) {
      return undefined;
    }

    return new Response("fake agentbridge daemon");
  },
  websocket: {
    message(ws, raw) {
      const text = typeof raw === "string" ? raw : raw.toString();
      let message;
      try {
        message = JSON.parse(text);
      } catch {
        return;
      }

      if (message.type === "claude_connect" || message.type === "status") {
        ws.send(JSON.stringify({ type: "status", status: currentStatus() }));
      }
    },
  },
});

const proxyServer = Bun.serve({
  port: proxyPort,
  hostname: "127.0.0.1",
  fetch(req) {
    const url = new URL(req.url);
    if (url.pathname === "/healthz" || url.pathname === "/readyz") {
      return Response.json({ ok: true, proxyUrl });
    }
    return new Response("fake codex proxy");
  },
});

function shutdown() {
  cleanupFiles();
  server.stop();
  proxyServer.stop();
  process.exit(0);
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.on("exit", cleanupFiles);
`;
}
