import { afterEach, describe, expect, test } from "bun:test";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { consumeOnce, matchingPlaybook, signatureMatches, type FireRow, type FireStore, type Notifier, type PlaybookResult } from "./fire-consumer";

const entries = [
  { pattern: "gh-runner:*:offline", script: "runner" },
  { pattern: "deploy-clone:*-dirty", script: "clone" },
];
let roots: string[] = [];
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
function edge(): string { const root = mkdtempSync(join(tmpdir(), "overdeck-fire-consumer-")); roots.push(root); return join(root, "edge.json"); }
function row(title: string, asked_at = "2026-08-16T12:00:00.000Z"): FireRow { return { id: "fire-a", title, origin: "agent-incident", state: "asked", asked_at }; }
function lifecycleFakes(exitCode = 0): { root: string; botmaster: string; events: string; transcript: string } {
  const root = mkdtempSync(join(tmpdir(), "overdeck-fire-lifecycle-")); roots.push(root);
  const bin = join(root, "bin"), events = join(root, "events"), botmaster = join(bin, "botmaster"), agent = join(bin, "agent"), scope = join(bin, "systemd-run");
  mkdirSync(bin);
  writeFileSync(botmaster, `#!/usr/bin/env bash\nprintf 'botmaster:' >>\"$FAKE_EVENTS\"\nprintf '%s|' \"$@\" >>\"$FAKE_EVENTS\"\nprintf '\\n' >>\"$FAKE_EVENTS\"\nexit \"\${FAKE_BOTMASTER_EXIT:-0}\"\n`);
  writeFileSync(agent, `#!/usr/bin/env bash\nprintf 'agent-start\\n' >>\"$FAKE_EVENTS\"\nprintf 'agent transcript\\n'\nexit \"\${FAKE_AGENT_EXIT:-${exitCode}}\"\n`);
  writeFileSync(scope, "#!/usr/bin/env bash\nwhile [[ $# -gt 0 ]]; do\n  if [[ $1 == -- ]]; then shift; break; fi\n  shift\ndone\nexec \"$@\"\n");
  chmodSync(botmaster, 0o755); chmodSync(agent, 0o755); chmodSync(scope, 0o755);
  process.env.BOTMASTER_BIN = botmaster;
  process.env.OVERDECK_FIRE_AGENT_CMD = agent;
  process.env.XDG_STATE_HOME = join(root, "state");
  process.env.FAKE_EVENTS = events;
  process.env.FAKE_AGENT_EXIT = String(exitCode);
  process.env.PATH = `${bin}:${process.env.PATH}`;
  return { root, botmaster, events, transcript: join(root, "state", "overdeck", "fire-agent", "fire-a.log") };
}
const originalEnvironment = { ...process.env };
afterEach(() => {
  for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key];
  Object.assign(process.env, originalEnvironment);
});

describe("fire consumer", () => {
  test("matches only complete colon-delimited playbook keys", () => {
    expect(signatureMatches("gh-runner:*:offline", "gh-runner:runner-a:offline")).toBe(true);
    expect(signatureMatches("gh-runner:*:offline", "gh-runner:runner-a:offline:again")).toBe(false);
    expect(signatureMatches("deploy-clone:*-dirty", "deploy-clone:overdeck-dirty")).toBe(true);
    expect(matchingPlaybook(entries, "unknown:failure")).toBeUndefined();
  });

  test("ships a matched playbook with its proof", async () => {
    const notes: string[] = [], ships: Array<[string, string, string]> = [], messages: string[] = [];
    const store: FireStore = { askedIncidents: async () => [row("gh-runner:runner-a:offline")], note: async (_id, detail) => { notes.push(detail); }, ship: async (...args) => { ships.push(args); } };
    const notifier: Notifier = { send: async (message) => { messages.push(message); } };
    await consumeOnce({ store, entries, edgeFile: edge(), notifier, playbooks: new Map([["runner", { run: async (): Promise<PlaybookResult> => ({ outcome: "shipped", detail: "runner online", proof: "gh online" }) }]]) });
    expect(ships).toEqual([["fire-a", "runner online", "gh online"]]);
    expect(notes).toEqual([]); expect(messages).toEqual([]);
  });

  test("leaves unmatched and failed incidents asked while notifying once per crossing", async () => {
    const notes: string[] = [], messages: string[] = [];
    const current = row("unknown:failure");
    const store: FireStore = { askedIncidents: async () => [current], note: async (_id, detail) => { notes.push(detail); }, ship: async () => { throw new Error("must not ship"); } };
    const notifier: Notifier = { send: async (message) => { messages.push(message); } };
    const state = edge();
    await consumeOnce({ store, entries, edgeFile: state, notifier, playbooks: new Map() });
    await consumeOnce({ store, entries, edgeFile: state, notifier, playbooks: new Map() });
    expect(notes).toEqual([]); expect(messages).toHaveLength(1);
    current.title = "gh-runner:runner-a:offline";
    await consumeOnce({ store, entries, edgeFile: state, notifier, playbooks: new Map([["runner", { run: async () => ({ outcome: "escalate", detail: "missing unit" } as PlaybookResult) }]]) });
    expect(notes).toEqual(["missing unit"]); expect(messages).toHaveLength(2);
  });

  test("deploy agent announces before it starts and closes with its transcript", async () => {
    const fake = lifecycleFakes();
    const notes: string[] = [], ships: unknown[] = [];
    const store: FireStore = { askedIncidents: async () => [row("deploy-local:reset-failed")], note: async (_id, detail) => { notes.push(detail); }, ship: async (...args) => { ships.push(args); } };
    await consumeOnce({ store, entries: [], edgeFile: edge(), notifier: (await import("./fire-consumer")).botmasterNotifier(), playbooks: new Map() });
    const events = readFileSync(fake.events, "utf8").trim().split("\n");
    expect(events[0]).toContain("botmaster:--fyi|");
    expect(events[1]).toBe("agent-start");
    expect(events[2]).toContain(`botmaster:--attachment|${fake.transcript}|`);
    expect(readFileSync(fake.transcript, "utf8")).toContain("agent transcript");
    // An exit code never proves remediation: the consumer must not ship the row itself.
    // Shipping with proof is the agent's own board write; the consumer records a note.
    expect(ships).toEqual([]);
    expect(notes).toHaveLength(1);
    expect(notes[0]).toContain(fake.transcript);
  });

  test("a spawn failure leaves the row asked and reports it needs review", async () => {
    lifecycleFakes();
    const notes: string[] = [], ships: unknown[] = [];
    const store: FireStore = { askedIncidents: async () => [row("deploy-local:reset-failed")], note: async (_id, detail) => { notes.push(detail); }, ship: async (...args) => { ships.push(args); } };
    const messages: string[] = [];
    const notifier: Notifier = { send: async (message) => { messages.push(message); } };
    await consumeOnce({ store, entries: [], edgeFile: edge(), notifier, playbooks: new Map(), emergencyAgent: { run: async () => { throw new Error("launcher missing"); } } });
    expect(ships).toEqual([]);
    expect(notes[0]).toContain("could not be launched");
    expect(messages.at(-1)).toContain("needs review");
  });

  test("a dead deploy agent leaves the row asked and reports an attached partial transcript", async () => {
    const fake = lifecycleFakes(23);
    const notes: string[] = [], ships: unknown[] = [];
    const store: FireStore = { askedIncidents: async () => [row("deploy-clone:new-failure")], note: async (_id, detail) => { notes.push(detail); }, ship: async (...args) => { ships.push(args); } };
    await consumeOnce({ store, entries: [], edgeFile: edge(), notifier: (await import("./fire-consumer")).botmasterNotifier(), playbooks: new Map() });
    expect(ships).toEqual([]);
    expect(notes[0]).toContain("died mid-run");
    const close = readFileSync(fake.events, "utf8").trim().split("\n").at(-1)!;
    expect(close).toContain("--attachment"); expect(close).toContain("died mid-run");
  });

  test("a repeated deploy signature does not spawn a second emergency agent", async () => {
    const fake = lifecycleFakes();
    const current = row("deploy-local:reset-failed");
    const notes: string[] = [];
    const store: FireStore = { askedIncidents: async () => [current], note: async (_id, detail) => { notes.push(detail); }, ship: async () => {} };
    const state = edge();
    const notifier = (await import("./fire-consumer")).botmasterNotifier();
    await consumeOnce({ store, entries: [], edgeFile: state, notifier, playbooks: new Map() });
    current.id = "fire-b"; current.asked_at = "2026-08-16T12:01:00.000Z";
    await consumeOnce({ store, entries: [], edgeFile: state, notifier, playbooks: new Map() });
    expect(readFileSync(fake.events, "utf8").match(/agent-start/g)).toHaveLength(1);
    expect(notes.at(-1)).toContain("already ran");
  });

  test("an unmatched non-deploy signature remains escalation-only", async () => {
    const fake = lifecycleFakes();
    const notes: unknown[] = [], ships: unknown[] = [];
    const store: FireStore = { askedIncidents: async () => [row("runner-image:drift")], note: async (...args) => { notes.push(args); }, ship: async (...args) => { ships.push(args); } };
    await consumeOnce({ store, entries: [], edgeFile: edge(), notifier: (await import("./fire-consumer")).botmasterNotifier(), playbooks: new Map() });
    expect(notes).toEqual([]); expect(ships).toEqual([]);
    expect(readFileSync(fake.events, "utf8")).not.toContain("agent-start");
  });

  test("a failed or missing botmaster cannot change deploy remediation", async () => {
    const fake = lifecycleFakes();
    process.env.FAKE_BOTMASTER_EXIT = "1";
    const notes: string[] = [], ships: unknown[] = [];
    const store: FireStore = { askedIncidents: async () => [row("deploy-local:tool-shims-stale")], note: async (_id, detail) => { notes.push(detail); }, ship: async (...args) => { ships.push(args); } };
    await consumeOnce({ store, entries: [], edgeFile: edge(), notifier: (await import("./fire-consumer")).botmasterNotifier(), playbooks: new Map() });
    expect(ships).toEqual([]);
    expect(notes).toHaveLength(1);
    expect(readFileSync(fake.transcript, "utf8")).toContain("agent transcript");
  });

  test("a missing botmaster cannot change deploy remediation", async () => {
    const fake = lifecycleFakes();
    process.env.BOTMASTER_BIN = join(fake.root, "missing-botmaster");
    const notes: string[] = [], ships: unknown[] = [];
    const store: FireStore = { askedIncidents: async () => [row("deploy-local:sandbox-image-drift")], note: async (_id, detail) => { notes.push(detail); }, ship: async (...args) => { ships.push(args); } };
    await consumeOnce({ store, entries: [], edgeFile: edge(), notifier: (await import("./fire-consumer")).botmasterNotifier(), playbooks: new Map() });
    expect(ships).toEqual([]);
    expect(notes).toHaveLength(1);
    expect(readFileSync(fake.transcript, "utf8")).toContain("agent transcript");
  });

  test("emergency launcher keeps the agent outside the consumer cgroup without memory limits", () => {
    const launcher = readFileSync(join(import.meta.dir, "spawn-emergency-agent.sh"), "utf8");
    expect(launcher).toContain("systemd-run --user --scope --slice=agent.slice");
    expect(launcher).not.toContain("--property=Memory");
  });
});
