import { describe, expect, test } from "bun:test";
import { parseIncidentAgentResult, recordIncidentInitializationFailure, runIncident, runIncidentProcess, type IncidentProcessResult, type IncidentRunnerRequest } from "./incident-runner";
import { IncidentStatusProjectionError, type IncidentStatusUpdate } from "./status-writer";
import type { IncidentStatusState } from "./status-writer";
import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";

const request: IncidentRunnerRequest = {
  incidentId: "inc-1", taskId: 42, dispatchId: "dispatch-1", brief: "Persisted incident brief",
  workspace: "/tmp/workspace", wrapper: "/opt/harness/wrappers/codex.sh", wrapperModel: "gpt-5.6-sol-high",
  account: "work", accountMode: "profile", permissionMode: "safe", timeoutSeconds: 600,
};

async function scenario(result: IncidentProcessResult | Error) {
  const workspace = await mkdtemp("/tmp/overdeck-incident-runner-");
  const scenarioRequest = { ...request, workspace };
  const updates: IncidentStatusUpdate[] = [];
  const argv: string[][] = [];
  const intervals: Array<{ callback: () => void; milliseconds: number }> = [];
  await runIncident(scenarioRequest, {
    recordStatus: async (update) => { updates.push(update); },
    runProcess: async (next) => {
      argv.push(next);
      if (result instanceof Error) throw result;
      if (result.stdout) {
        await mkdir(join(workspace, ".overdeck"), { recursive: true });
        await writeFile(join(workspace, ".overdeck", "inc-1.json"), result.stdout);
      }
      return result;
    },
    now: () => new Date("2026-08-09T12:00:00.000Z"),
    setInterval: (callback, milliseconds) => { intervals.push({ callback, milliseconds }); return 1; },
    clearInterval: () => {},
  });
  return { updates, argv, intervals };
}

describe("incident runner", () => {
  test("caps retained stdout and stderr while draining the process", async () => {
    const result = await runIncidentProcess([
      process.execPath,
      "-e",
      "process.stdout.write('o'.repeat(200000)); process.stderr.write('e'.repeat(200000))",
    ]);
    expect(result.exitCode).toBe(0);
    expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(65_536);
    expect(Buffer.byteLength(result.stderr)).toBeLessThanOrEqual(65_536);
  });

  test("parses only a valid final result object", () => {
    expect(parseIncidentAgentResult('noise\n{"kind":"incident.result","disposition":"resolved","summary":" done "}\n')).toEqual({ kind: "incident.result", disposition: "resolved", summary: "done" });
    expect(parseIncidentAgentResult('{"kind":"incident.result","disposition":"resolved","summary":""}')).toBeNull();
    expect(parseIncidentAgentResult('{"kind":"other","disposition":"resolved","summary":"done"}')).toBeNull();
  });

  test("runs one foreground wrapper with the canonical argv and never resolves without evidence verification", async () => {
    const out = await scenario({ exitCode: 0, stdout: '{"kind":"incident.result","disposition":"resolved","summary":"Fixed it"}\n', stderr: "" });
    expect(out.argv).toHaveLength(1);
    expect(out.argv[0]).toContain("--permission-mode");
    expect(out.argv[0]).toContain("--profile");
    expect(out.updates.map((item) => item.state)).toEqual(["running", "invalid-result"]);
    expect(out.intervals[0]?.milliseconds).toBe(15_000);
    expect(out.argv[0]?.[out.argv[0]!.indexOf("--permission-mode") + 1]).toBe("safe");
    expect(out.argv[0]!.indexOf("--profile")).toBeLessThan(out.argv[0]!.indexOf("--permission-mode"));
  });

  test("refuses a bare resolved result when the evidence transaction is unavailable", async () => {
    const out = await scenario({ exitCode: 0, stdout: '{"kind":"incident.result","disposition":"resolved","summary":"Fixed it"}\n', stderr: "" });

    expect(out.updates.map((item) => item.state)).toEqual(["running", "invalid-result"]);
    expect(out.updates.at(-1)?.failureClass).toBe("resolution-verification-unavailable");
  });

  test("routes a valid automatic resolution through the shared evidence transaction", async () => {
    const workspace = await mkdtemp("/tmp/overdeck-incident-runner-");
    const updates: IncidentStatusUpdate[] = [];
    const resolutions: Array<{ incidentId: string; artifact: string; summary: string }> = [];
    await runIncident({ ...request, workspace }, {
      recordStatus: async (update) => { updates.push(update); },
      resolveIncident: async (incidentId, artifact, summary) => { resolutions.push({ incidentId, artifact, summary }); },
      runProcess: async () => {
        const artifact = join(workspace, ".overdeck", "inc-1.json");
        await mkdir(join(workspace, ".overdeck"), { recursive: true });
        await writeFile(artifact, '{"kind":"incident.result","disposition":"resolved","summary":"Fixed it"}');
        return { exitCode: 0, stdout: "", stderr: "" };
      },
      now: () => new Date("2026-08-09T12:00:00.000Z"),
      setInterval: () => 1,
      clearInterval: () => {},
    });

    expect(resolutions).toEqual([{ incidentId: "inc-1", artifact: join(workspace, ".overdeck", "inc-1.json"), summary: "Fixed it" }]);
    expect(updates.map((item) => item.state)).toEqual(["running"]);
  });

  test("commits initialization refusal before any remote projection is available", () => {
    const updates: IncidentStatusUpdate[] = [];
    recordIncidentInitializationFailure({ ...request, initialRevision: 7 }, (update) => { updates.push(update); }, () => new Date("2026-08-09T12:00:00.000Z"));
    expect(updates).toEqual([{
      incidentId: "inc-1", taskId: 42, dispatchId: "dispatch-1", revision: 8, expectedRevision: 7,
      state: "needs-attention", at: "2026-08-09T12:00:00.000Z", completedAt: "2026-08-09T12:00:00.000Z",
      failureClass: "runner-initialization-refused",
      comment: "Incident runner initialization failed; operator attention is required. No retry was started.",
    }]);
  });

  test("refuses tampered unsafe input before status or process launch", async () => {
    const updates: IncidentStatusUpdate[] = []; const argv: string[][] = [];
    await expect(runIncident({ ...request, permissionMode: "unsafe" } as unknown as IncidentRunnerRequest, {
      recordStatus: async (update) => { updates.push(update); },
      runProcess: async (next) => { argv.push(next); return { exitCode: 0, stdout: "", stderr: "" }; },
    })).rejects.toThrow("unsafe incident permission mode refused");
    expect(updates).toEqual([]); expect(argv).toEqual([]);
  });

  test("rejects symlink and oversized durable result files", async () => {
    for (const kind of ["symlink", "oversized"] as const) {
      const workspace = await mkdtemp(`/tmp/overdeck-incident-${kind}-`);
      const resultDir = join(workspace, ".overdeck");
      const resultFile = join(resultDir, "incident-result.json");
      const updates: IncidentStatusUpdate[] = [];
      await runIncident({ ...request, workspace }, {
        recordStatus: async (update) => { updates.push(update); },
        runProcess: async () => {
          await mkdir(resultDir, { recursive: true });
          if (kind === "symlink") {
            const target = join(workspace, "attacker-result.json");
            await writeFile(target, '{"kind":"incident.result","disposition":"resolved","summary":"forged"}');
            await symlink(target, resultFile);
          } else {
            await writeFile(resultFile, `${" ".repeat(65_536)}{"kind":"incident.result","disposition":"resolved","summary":"forged"}`);
          }
          return { exitCode: 0, stdout: "", stderr: "" };
        },
        setInterval: () => 1,
        clearInterval: () => {},
      });
      expect(updates.at(-1)?.state).toBe("invalid-result");
    }
  });

  test("never treats a valid final stdout line as the durable result", async () => {
    const workspace = await mkdtemp("/tmp/overdeck-incident-stdout-");
    const updates: IncidentStatusUpdate[] = [];
    await runIncident({ ...request, workspace }, {
      recordStatus: async (update) => { updates.push(update); },
      runProcess: async () => ({ exitCode: 0, stdout: '{"kind":"incident.result","disposition":"resolved","summary":"stdout only"}', stderr: "" }),
      setInterval: () => 1,
      clearInterval: () => {},
    });
    expect(updates.at(-1)?.state).toBe("invalid-result");
  });

  const terminalCases = [
    [0, "", "invalid-result"],
    [0, "malformed", "invalid-result"],
    [0, '{"kind":"incident.result","disposition":"needs-attention","summary":"Review me"}', "needs-attention"],
    [75, '{"kind":"incident.result","disposition":"resolved","summary":"Ignore me"}', "rate-limited"],
    [124, "", "timed-out"],
    [1, '{"kind":"incident.result","disposition":"resolved","summary":"Ignore me"}', "engine-down"],
  ] satisfies Array<[number, string, IncidentStatusState]>;

  test.each(terminalCases)("classifies exit %i as %s", async (exitCode, stdout, expected) => {
    const out = await scenario({ exitCode, stdout, stderr: "" });
    expect(out.updates.at(-1)?.state).toBe(expected);
    expect(out.argv).toHaveLength(1);
  });

  test("classifies process admission failure as engine-down without relaunch", async () => {
    const out = await scenario(new Error("spawn failed"));
    expect(out.updates.at(-1)?.state).toBe("engine-down");
    expect(out.argv).toHaveLength(1);
  });

  test("persists terminal status at the next durable revision before surfacing a failed heartbeat", async () => {
    const workspace = await mkdtemp("/tmp/overdeck-incident-heartbeat-");
    const attempts: IncidentStatusUpdate[] = [];
    const accepted: IncidentStatusUpdate[] = [];
    let tick: (() => void) | undefined;
    let tickReady: (() => void) | undefined;
    const scheduled = new Promise<void>((resolve) => { tickReady = resolve; });
    let finish: ((result: IncidentProcessResult) => void) | undefined;
    const processResult = new Promise<IncidentProcessResult>((resolve) => { finish = resolve; });
    const running = runIncident({ ...request, workspace }, {
      recordStatus: async (update) => {
        attempts.push(update);
        if (update.state === "running" && update.revision > 2) throw new Error("heartbeat transport failed");
        accepted.push(update);
      },
      runProcess: async () => processResult,
      setInterval: (callback) => { tick = callback; tickReady?.(); return 1; },
      clearInterval: () => {},
      wait: async () => {},
    });
    await scheduled;
    tick?.();
    await Promise.resolve();
    finish?.({ exitCode: 0, stdout: "", stderr: "" });
    await expect(running).rejects.toThrow("heartbeat transport failed");
    const failedHeartbeats = attempts.filter((update) => update.state === "running" && update.revision === 3);
    expect(failedHeartbeats).toHaveLength(3);
    expect(accepted.map((update) => [update.revision, update.state])).toEqual([[2, "running"], [3, "invalid-result"]]);
  });

  test("advances past a durably committed heartbeat whose projection failed", async () => {
    const workspace = await mkdtemp("/tmp/overdeck-incident-durable-heartbeat-");
    const accepted: IncidentStatusUpdate[] = [];
    let tick: (() => void) | undefined;
    let tickReady: (() => void) | undefined;
    const scheduled = new Promise<void>((resolve) => { tickReady = resolve; });
    let finish: ((result: IncidentProcessResult) => void) | undefined;
    const processResult = new Promise<IncidentProcessResult>((resolve) => { finish = resolve; });
    const running = runIncident({ ...request, workspace }, {
      recordStatus: async (update) => {
        if (update.state === "running" && update.revision === 3) throw new IncidentStatusProjectionError(3, new Error("projection failed"));
        accepted.push(update);
      },
      runProcess: async () => processResult,
      setInterval: (callback) => { tick = callback; tickReady?.(); return 1; },
      clearInterval: () => {},
      wait: async () => {},
    });
    await scheduled;
    tick?.();
    await Promise.resolve();
    finish?.({ exitCode: 0, stdout: "", stderr: "" });
    await expect(running).rejects.toBeInstanceOf(IncidentStatusProjectionError);
    expect(accepted.map((update) => [update.revision, update.state])).toEqual([[2, "running"], [4, "invalid-result"]]);
  });

  test("retries only the identical status revision with bounded backoff", async () => {
    const attempts: IncidentStatusUpdate[] = [];
    const waits: number[] = [];
    await runIncident(request, {
      recordStatus: async (update) => {
        attempts.push(update);
        if (attempts.length < 3) throw new Error("transport");
      },
      runProcess: async () => ({ exitCode: 0, stdout: '{"kind":"incident.result","disposition":"needs-attention","summary":"review"}', stderr: "" }),
      setInterval: () => 1,
      clearInterval: () => {},
      wait: async (milliseconds) => { waits.push(milliseconds); },
    });
    expect(attempts.slice(0, 3)).toEqual([attempts[0]!, attempts[0]!, attempts[0]!]);
    expect(waits).toEqual([100, 250]);
  });
});
