import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Server } from "bun";
import { Database } from "bun:sqlite";
import { ALLOWED_ACTION_VERBS, createActionGateway, type ActionGateway, type ActionJournalEntry } from "./actions";
import { IncidentMutationError, IncidentResolutionConflictError } from "./incidents/incident-service";
import { Journal } from "./journal";
import { HarnessApiError, type HarnessAdapter, type HarnessConfigField } from "./adapters/harness";
import { readAbandoned } from "./abandoned-store";
import { resolveDeploymentSha, startServer } from "./server";
import type { Item, Panel } from "./schema";
import { CollectorState } from "./state";

const TOKEN = "test-token-actions";
const CONTROLLER_TOKEN = "test-offload-controller-token";
const CONTROLLER_URL = "http://127.0.0.1:8787";
const PORT = 14983;
const DEPLOYED_SHA = "1234567890abcdef1234567890abcdef12345678";

const ORPHAN_PID = 88_231;
const CI_RUN_ID = 4_821;
const ACTIVE_CI_RUN_ID = 4_822;
const CI_REPO = "alexcodeplace/multideal";

function ciPanel(
  repoOverrides: Record<string, unknown> = {},
  trains: Array<Record<string, unknown>> = [
    {
      repo: CI_REPO,
      branch: "integration/batch-train-12",
      state: "failed",
      gateRun: { id: CI_RUN_ID, status: "completed", conclusion: "failure" },
    },
    {
      repo: CI_REPO,
      branch: "integration/batch-train-13",
      state: "gating",
      gateRun: { id: ACTIVE_CI_RUN_ID, status: "in_progress", conclusion: null },
    },
  ],
): Panel {
  const repo = typeof repoOverrides.repo === "string" ? repoOverrides.repo : CI_REPO;
  return {
    id: "ci",
    ts: "2026-07-17T12:00:00.000Z",
    data: {
      repos: [
        {
          repo,
          queueDepth: 0,
          runs: [{ id: CI_RUN_ID, name: "e2e", headBranch: "main", status: "completed", conclusion: "failure" }],
          runners: [],
          refsComplete: true,
          historyComplete: false,
          trainRunsComplete: true,
          prsComplete: true,
          trainsComplete: true,
          trains,
          ...repoOverrides,
        },
      ],
      runners: [],
      runnersComplete: true,
      oldestQueuedAgeH: null,
    },
  };
}

const CI_PANEL = ciPanel();

const ORPHAN_ITEM: Item = {
  id: `cluster:orphan:${ORPHAN_PID}`,
  source: "cluster",
  severity: "act",
  kind: "alert",
  title: "Orphan agent",
  detail: "cursor-agent",
  ts: "2026-07-17T12:00:00.000Z",
  actions: [{ verb: "reap", args: { pid: String(ORPHAN_PID) }, label: "Reap", recommended: true }],
};

const FLEET_PANEL: Panel = {
  id: "fleet",
  ts: "2026-07-17T12:00:00.000Z",
  data: {
    stale: false,
    hosts: [
      {
        host: "debian1",
        registryState: "reachable",
        role: "builder",
        state: "available",
        primary: true,
        enrolling: false,
      },
    ],
  },
};

const REMOTE_JOBS_PANEL: Panel = {
  id: "remote-jobs",
  ts: "2026-07-17T12:00:00.000Z",
  data: {
    stale: false,
    jobs: [{ id: "rb-abc123", repo: "alexcodeplace/multideal", snapshot: "snap-1", stage: "done", host: "debian1" }],
  },
};

let server: Server<undefined>;
let state: CollectorState;
let journalPath: string;
let spawnCalls: string[][];
let decisionCalls: Array<{ runId: string; decisionId: string; choice: string }>;
let transitionCalls: Array<{ url: string; body: Record<string, unknown> }>;
let mintedIdempotencyKeys: string[];
let controllerFetchShouldFail: boolean;
let previousConfigDir: string | undefined;
let harnessTaskCalls: Array<{ runId: string; taskId: string; verb: string; attemptId?: string; requestId: string }>;
let harnessRunCalls: Array<{ runId: string; verb: string; requestId?: string }>;
let harnessConfigCalls: Array<{ runId: string; patch: unknown; revision: string }>;
let harnessConfigFields: Record<string, HarnessConfigField>;
let incidentResolveCalls: Array<[string, string, string | undefined]>;

function url(path: string): string {
  return `http://127.0.0.1:${PORT}${path}`;
}

function authHeaders(): Record<string, string> {
  return { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" };
}

async function postAction(
  verb: string,
  body: { args: Record<string, string>; requestedBy?: string },
): Promise<Response> {
  return postRawAction(verb, body);
}

async function postRawAction(verb: string, body: unknown): Promise<Response> {
  return fetch(url(`/actions/${verb}`), { method: "POST", headers: authHeaders(), body: JSON.stringify(body) });
}

function recordCiPanel(panel: Panel = CI_PANEL, atTs = Date.now()): void {
  state.recordSuccess("ghci", atTs, [], [panel]);
}

beforeAll(() => {
  const dir = mkdtempSync(join(tmpdir(), "overdeck-actions-"));
  previousConfigDir = process.env.OVERDECK_CONFIG_DIR;
  process.env.OVERDECK_CONFIG_DIR = dir;
  journalPath = join(dir, "actions.jsonl");
  state = new CollectorState(new Journal(join(dir, "items.jsonl")));
  state.registerAdapter("cluster", 30_000);
  state.registerAdapter("ghci", 60_000);
  state.registerAdapter("offload", 30_000);
  state.recordSuccess("cluster", Date.now(), [ORPHAN_ITEM], []);
  state.recordSuccess("ghci", Date.now(), [], [CI_PANEL]);
  state.recordSuccess("offload", Date.now(), [], [FLEET_PANEL, REMOTE_JOBS_PANEL]);

  spawnCalls = [];
  decisionCalls = [];
  transitionCalls = [];
  harnessTaskCalls = [];
  harnessRunCalls = [];
  harnessConfigCalls = [];
  incidentResolveCalls = [];
  harnessConfigFields = {
    watchdog: { value: { idleTimeoutMs: 50 }, source: "run-override", immutable: false, mutationClass: "mid-run" },
  };
  mintedIdempotencyKeys = [];
  controllerFetchShouldFail = false;
  const steerCalls: Array<{ runId: string; taskId: string; body: { text?: string; restart?: boolean } }> = [];
  const harness: Pick<HarnessAdapter, "steerTask" | "answerDecision" | "controlTask" | "controlRun" | "getRunConfig" | "patchRunConfig"> = {
    steerTask: async (runId, taskId, body) => {
      steerCalls.push({ runId, taskId, body });
      return { ok: true, id: "steer-1", queued: true, restart: false };
    },
    answerDecision: async (runId, decisionId, choice) => {
      decisionCalls.push({ runId, decisionId, choice });
      return { statusCode: 200, applied: true };
    },
    controlTask: async (runId, taskId, verb, body) => {
      harnessTaskCalls.push({ runId, taskId, verb, ...body });
      if (body.attemptId === "stale") throw new HarnessApiError(409, { error: "stale-attempt" }, "test");
      return { ok: true, requestId: body.requestId };
    },
    controlRun: async (runId, verb, body) => {
      harnessRunCalls.push({ runId, verb, requestId: body?.requestId });
      return { ok: true };
    },
    getRunConfig: async () => ({ revision: "rev-1", fields: harnessConfigFields }),
    patchRunConfig: async (runId, patch, revision) => {
      harnessConfigCalls.push({ runId, patch, revision });
      if (revision === "stale") throw new HarnessApiError(409, { error: "stale-revision" }, "test");
      return { revision: "next", fields: {} };
    },
  };

  const gateway = createActionGateway({
    state,
    harness: harness as HarnessAdapter,
    journalPath,
    spawn: async (argv) => {
      spawnCalls.push(argv);
      return { rc: 0, stdout: "ok", stderr: "" };
    },
    now: () => Date.parse("2026-07-17T12:00:00.000Z"),
    controllerUrl: CONTROLLER_URL,
    controllerToken: CONTROLLER_TOKEN,
    incidents: {
      async resolveIncident(incidentId, artifact, summary) {
        incidentResolveCalls.push([incidentId, artifact, summary]);
        return {};
      },
    },
    fetcher: async (input, init) => {
      if (controllerFetchShouldFail) {
        throw new Error("connect ECONNREFUSED 127.0.0.1:8787");
      }
      const url = String(input);
      const body = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
      transitionCalls.push({ url, body });
      const headers = new Headers(init?.headers);
      expect(headers.get("authorization")).toBe(`Bearer ${CONTROLLER_TOKEN}`);
      return new Response(JSON.stringify({ revision: 4200, result: { ok: true } }), {
        status: 200,
        headers: { "content-type": "application/json" },
      });
    },
    randomUUID: () => {
      const key = `gateway-key-${mintedIdempotencyKeys.length + 1}`;
      mintedIdempotencyKeys.push(key);
      return key;
    },
  });

  server = startServer({
    host: "127.0.0.1",
    port: PORT,
    token: TOKEN,
    state,
    actionGateway: gateway,
    deploymentSha: DEPLOYED_SHA,
  });
});

beforeEach(() => {
  spawnCalls.length = 0;
  harnessTaskCalls.length = 0;
  harnessRunCalls.length = 0;
  harnessConfigCalls.length = 0;
  incidentResolveCalls.length = 0;
  recordCiPanel();
});

afterAll(() => {
  server.stop(true);
  if (previousConfigDir === undefined) delete process.env.OVERDECK_CONFIG_DIR;
  else process.env.OVERDECK_CONFIG_DIR = previousConfigDir;
});

describe("collector deployment identity", () => {
  test("authenticated health names the exact deployed revision", async () => {
    const response = await fetch(url("/health"), { headers: authHeaders() });
    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({ ok: true, deployedSha: DEPLOYED_SHA });
  });

  test("accepts only a full lowercase deployment revision", () => {
    expect(resolveDeploymentSha(DEPLOYED_SHA)).toBe(DEPLOYED_SHA);
    expect(resolveDeploymentSha(undefined)).toBeNull();
    expect(resolveDeploymentSha("")).toBeNull();
    expect(() => resolveDeploymentSha("1234")).toThrow("full lowercase 40-character git SHA");
    expect(() => resolveDeploymentSha(DEPLOYED_SHA.toUpperCase())).toThrow(
      "full lowercase 40-character git SHA",
    );
  });
});

describe("action gateway deny paths", () => {
  test("non-allowlisted verb returns 404", async () => {
    const res = await postAction("kill", { args: { pid: String(ORPHAN_PID) }, requestedBy: ORPHAN_ITEM.id });
    expect(res.status).toBe(404);
    expect(harnessTaskCalls).toEqual([]);
    expect(harnessRunCalls).toEqual([]);
    expect(harnessConfigCalls).toEqual([]);
  });

  test("reap with pid absent from orphan candidate set returns 400", async () => {
    const res = await postAction("reap", { args: { pid: "99999" }, requestedBy: "cluster:orphan:99999" });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error?: string };
    expect(body.error).toContain("pid");
  });

  test("ci-rerun with run id absent from ci panel returns 400", async () => {
    const res = await postAction("ci-rerun", { args: { id: "99999" }, requestedBy: "ghci:missing" });
    expect(res.status).toBe(400);
  });
});

describe("harness action gateway", () => {
  test("rejects task controls with missing or extra args before harness call", async () => {
    const cases: Record<string, string>[] = [
      { runId: "run", taskId: "task", attemptId: "attempt" },
      { runId: "run", taskId: "task", attemptId: "attempt", requestId: "request", extra: "no" },
    ];
    for (const args of cases) {
      const response = await postAction("harness.task.pause", { args, requestedBy: "overdeck-web" });
      expect(response.status).toBe(400);
    }
    expect(harnessTaskCalls).toEqual([]);
  });

  test("forwards exact observed task IDs and leaves duplicate request IDs idempotent upstream", async () => {
    const args = { runId: "run-1", taskId: "task-1", attemptId: "attempt-1", requestId: "request-1" };
    for (const verb of ["harness.task.pause", "harness.task.pause"] as const) {
      expect((await postAction(verb, { args, requestedBy: "overdeck-web" })).status).toBe(200);
    }
    expect(harnessTaskCalls).toEqual([
      { ...args, verb: "pause" },
      { ...args, verb: "pause" },
    ]);
  });

  test("preserves stale attempt conflicts and journals config patch atomically", async () => {
    const stale = await postAction("harness.task.kill", {
      args: { runId: "run-1", taskId: "task-1", attemptId: "stale", requestId: "request-stale" },
      requestedBy: "overdeck-web",
    });
    expect(stale.status).toBe(409);
    expect(await stale.json()).toEqual({ error: "stale-attempt" });

    const patch = JSON.stringify({ watchdog: { idleTimeoutMs: 50 } });
    expect((await postAction("harness.config.patch", {
      args: { runId: "run-1", revision: "rev-1", patch }, requestedBy: "overdeck-web",
    })).status).toBe(200);
    expect(harnessConfigCalls).toEqual([{ runId: "run-1", revision: "rev-1", patch: { watchdog: { idleTimeoutMs: 50 } } }]);

    const malformed = await postAction("harness.config.patch", {
      args: { runId: "run-1", revision: "rev-1", patch: "[]" }, requestedBy: "overdeck-web",
    });
    expect(malformed.status).toBe(400);
    expect(harnessConfigCalls).toHaveLength(1);
  });

  test("authorizes every config patch key against authoritative mutability metadata", async () => {
    const patch = async (body: Record<string, unknown>) => postAction("harness.config.patch", {
      args: { runId: "run-1", revision: "rev-1", patch: JSON.stringify(body) }, requestedBy: "overdeck-web",
    });

    for (const [key, field] of Object.entries({
      unknown: undefined,
      locked: { value: true, source: "run-override", immutable: true, mutationClass: "mid-run" as const },
      launchOnly: { value: true, source: "run-override", immutable: false, mutationClass: "launch" as const },
    })) {
      harnessConfigFields = field === undefined ? {} : { [key]: field };
      expect((await patch({ [key]: true })).status).toBe(403);
      expect(harnessConfigCalls).toEqual([]);
    }

    harnessConfigFields = {
      allowed: { value: true, source: "run-override", immutable: false, mutationClass: "mid-run" },
      locked: { value: true, source: "run-override", immutable: true, mutationClass: "mid-run" },
    };
    expect((await patch({ allowed: false, locked: false })).status).toBe(403);
    expect(harnessConfigCalls).toEqual([]);

    harnessConfigFields = { allowed: { value: true, source: "run-override", immutable: false, mutationClass: "mid-run" } };
    expect((await patch({ allowed: false })).status).toBe(200);
    expect(harnessConfigCalls).toEqual([{ runId: "run-1", revision: "rev-1", patch: { allowed: false } }]);
  });

  test("bounds and redacts harness config patches before parsing", async () => {
    harnessConfigFields = {
      credentials: { value: {}, source: "run-override", immutable: false, mutationClass: "mid-run" },
    };
    const secretPatch = JSON.stringify({ credentials: { token: "super-secret" } });
    expect((await postAction("harness.config.patch", {
      args: { runId: "run-1", revision: "rev-1", patch: secretPatch }, requestedBy: "overdeck-web",
    })).status).toBe(200);

    const journal = readFileSync(journalPath, "utf8");
    expect(journal).not.toContain("super-secret");

    const tooDeep = JSON.stringify({ a: { b: { c: { d: { e: { f: { g: { h: { i: 1 } } } } } } } } });
    const response = await postAction("harness.config.patch", {
      args: { runId: "run-1", revision: "rev-1", patch: tooDeep }, requestedBy: "overdeck-web",
    });
    expect(response.status).toBe(400);

    const tooManyKeys = JSON.stringify(Object.fromEntries(Array.from({ length: 101 }, (_, index) => [`key${index}`, index])));
    expect((await postAction("harness.config.patch", {
      args: { runId: "run-1", revision: "rev-1", patch: tooManyKeys }, requestedBy: "overdeck-web",
    })).status).toBe(400);

    const oversized = JSON.stringify({ value: "x".repeat(32 * 1024) });
    expect((await postAction("harness.config.patch", {
      args: { runId: "run-1", revision: "rev-1", patch: oversized }, requestedBy: "overdeck-web",
    })).status).toBe(400);
  });

  test("forwards exact run controls", async () => {
    const response = await postAction("harness.run.resume", {
      args: { runId: "run-1", requestId: "request-1" }, requestedBy: "overdeck-web",
    });
    expect(response.status).toBe(200);
    expect(harnessRunCalls).toEqual([{ runId: "run-1", verb: "resume", requestId: "request-1" }]);
  });
});

describe("mediated train actions", () => {
  test.each([
    ["ci.rerunFailed", CI_RUN_ID, ["gh", "run", "rerun", String(CI_RUN_ID), "--failed", "-R", CI_REPO]],
    ["ci.cancelRun", ACTIVE_CI_RUN_ID, ["gh", "run", "cancel", String(ACTIVE_CI_RUN_ID), "-R", CI_REPO]],
  ])("spawns and journals exact %s argv", async (verb, runId, argv) => {
    const before = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean).length;
    const args = { repo: CI_REPO, runId: String(runId) };

    const response = await postAction(verb, { args, requestedBy: "overdeck-web" });

    expect(response.status).toBe(200);
    expect(spawnCalls).toEqual([argv]);
    const entries = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean).slice(before);
    expect(entries).toHaveLength(1);
    expect(JSON.parse(entries[0]!) as ActionJournalEntry).toEqual({
      ts: "2026-07-17T12:00:00.000Z",
      verb,
      args,
      requestedBy: "overdeck-web",
      result: "ok",
      rc: 0,
    });
  });

  test("accepts exact repository regex boundaries", async () => {
    const repo = `${"a".repeat(39)}/${"r".repeat(100)}`;
    recordCiPanel(ciPanel({ repo }, [{ repo, state: "failed", gateRun: { id: CI_RUN_ID, status: "completed" } }]));

    const response = await postAction("ci.rerunFailed", {
      args: { repo, runId: String(CI_RUN_ID) },
      requestedBy: "overdeck-web",
    });

    expect(response.status).toBe(200);
    expect(spawnCalls).toEqual([["gh", "run", "rerun", String(CI_RUN_ID), "--failed", "-R", repo]]);
  });

  test.each([
    `-${CI_REPO}`,
    `${"a".repeat(40)}/repo`,
    `owner/${"r".repeat(101)}`,
    "owner/repo;rm",
    "owner/repo$(id)",
  ])("rejects invalid or shell-bearing repo %s before spawn", async (repo) => {
    recordCiPanel(ciPanel({ repo }, [{ repo, state: "failed", gateRun: { id: CI_RUN_ID, status: "completed" } }]));

    const response = await postAction("ci.rerunFailed", {
      args: { repo, runId: String(CI_RUN_ID) },
      requestedBy: "overdeck-web",
    });

    expect(response.status).toBe(400);
    expect(spawnCalls).toHaveLength(0);
  });

  test("rejects non-string args, extra keys, and malformed bodies", async () => {
    const requests = [
      { args: { repo: CI_REPO, runId: CI_RUN_ID }, requestedBy: "overdeck-web" },
      { args: { repo: CI_REPO, runId: String(CI_RUN_ID), extra: "value" }, requestedBy: "overdeck-web" },
      { args: [CI_REPO, String(CI_RUN_ID)], requestedBy: "overdeck-web" },
      { requestedBy: "overdeck-web" },
    ];

    for (const body of requests) {
      const response = await postRawAction("ci.rerunFailed", body);
      expect(response.status).toBe(400);
    }
    expect(spawnCalls).toHaveLength(0);
  });

  test.each([
    ["cross-repo run", "other/repo", CI_RUN_ID],
    ["stale run", CI_REPO, 99_999],
  ])("rejects %s", async (_case, repo, runId) => {
    const response = await postAction("ci.rerunFailed", {
      args: { repo, runId: String(runId) },
      requestedBy: "overdeck-web",
    });

    expect(response.status).toBe(400);
    expect(spawnCalls).toHaveLength(0);
  });

  test("rejects an obsolete run from a reused train branch", async () => {
    const currentRunId = CI_RUN_ID + 100;
    recordCiPanel(ciPanel({}, [{
      repo: CI_REPO,
      branch: "integration/batch-train-12",
      state: "failed",
      gateRun: { id: currentRunId, status: "completed", conclusion: "failure" },
    }]));

    const response = await postAction("ci.rerunFailed", {
      args: { repo: CI_REPO, runId: String(CI_RUN_ID) },
      requestedBy: "overdeck-web",
    });

    expect(response.status).toBe(400);
    expect(spawnCalls).toHaveLength(0);
  });

  test.each(["refsComplete", "trainRunsComplete", "prsComplete", "trainsComplete"])(
    "rejects %s false",
    async (field) => {
      recordCiPanel(ciPanel({ [field]: false }));

      const response = await postAction("ci.rerunFailed", {
        args: { repo: CI_REPO, runId: String(CI_RUN_ID) },
        requestedBy: "overdeck-web",
      });

      expect(response.status).toBe(400);
      expect(spawnCalls).toHaveLength(0);
    },
  );

  test("does not gate eligible actions on history completeness", async () => {
    recordCiPanel(ciPanel({ historyComplete: false }));

    const response = await postAction("ci.rerunFailed", {
      args: { repo: CI_REPO, runId: String(CI_RUN_ID) },
      requestedBy: "overdeck-web",
    });

    expect(response.status).toBe(200);
    expect(spawnCalls).toHaveLength(1);
  });

  test.each([
    ["ci.rerunFailed", "green", "completed"],
    ["ci.cancelRun", "failed", "completed"],
    ["ci.cancelRun", "gating", "completed"],
  ])("rejects ineligible %s state %s/%s", async (verb, trainState, runStatus) => {
    const runId = verb === "ci.cancelRun" ? ACTIVE_CI_RUN_ID : CI_RUN_ID;
    recordCiPanel(ciPanel({}, [{ repo: CI_REPO, state: trainState, gateRun: { id: runId, status: runStatus } }]));

    const response = await postAction(verb, {
      args: { repo: CI_REPO, runId: String(runId) },
      requestedBy: "overdeck-web",
    });

    expect(response.status).toBe(400);
    expect(spawnCalls).toHaveLength(0);
  });

  test("rejects a stale adapter snapshot", async () => {
    recordCiPanel(CI_PANEL, 0);

    const response = await postAction("ci.rerunFailed", {
      args: { repo: CI_REPO, runId: String(CI_RUN_ID) },
      requestedBy: "overdeck-web",
    });

    expect(response.status).toBe(400);
    expect(spawnCalls).toHaveLength(0);
  });

  test("rejects after a successful poll is followed by a failed poll", async () => {
    const successAt = Date.now();
    recordCiPanel(CI_PANEL, successAt);
    state.recordFailure("ghci", successAt + 1, new Error("poll failed"));

    const response = await postAction("ci.rerunFailed", {
      args: { repo: CI_REPO, runId: String(CI_RUN_ID) },
      requestedBy: "overdeck-web",
    });

    expect(response.status).toBe(400);
    expect(spawnCalls).toHaveLength(0);
  });
});

describe("action gateway journal", () => {
  test("abandon and restore are idempotent local actions with journal entries", async () => {
    const before = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean).length;

    for (const verb of ["abandon", "abandon"] as const) {
      const response = await postAction(verb, { args: { runId: "run-1" }, requestedBy: "overdeck-web" });
      expect(response.status).toBe(200);
      expect(await response.json()).toEqual({ ok: true, result: "ok" });
    }
    expect(readAbandoned()["run-1"]).toBeDefined();

    for (const verb of ["restore", "restore"] as const) {
      const response = await postAction(verb, { args: { runId: "run-1" }, requestedBy: "overdeck-web" });
      expect(response.status).toBe(200);
    }
    expect(readAbandoned()["run-1"]).toBeUndefined();

    const entries = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean).slice(before);
    expect(entries.map((line) => (JSON.parse(line) as ActionJournalEntry).verb)).toEqual([
      "abandon",
      "abandon",
      "restore",
      "restore",
    ]);
  });

  test("abandon rejects invalid args with the steer validation shape", async () => {
    const response = await postAction("abandon", { args: { runId: "" }, requestedBy: "overdeck-web" });

    expect(response.status).toBe(400);
    expect(await response.json()).toEqual({ ok: false, error: "invalid abandon args" });
  });

  test("forwards a decision answer through the harness adapter", async () => {
    const res = await postAction("decision", {
      args: { runId: "run-1", decisionId: "d-4", choice: "abort" },
      requestedBy: "decision-d-4",
    });

    expect(res.status).toBe(200);
    expect(decisionCalls.at(-1)).toEqual({ runId: "run-1", decisionId: "d-4", choice: "abort" });
  });

  test("appends a journal line with exact shape on failed attempt", async () => {
    const before = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean).length;
    await postAction("reap", { args: { pid: "99999" }, requestedBy: "cluster:orphan:99999" });
    const lines = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean);
    expect(lines.length).toBe(before + 1);
    const entry = JSON.parse(lines.at(-1)!) as ActionJournalEntry;
    expect(entry).toEqual({
      ts: "2026-07-17T12:00:00.000Z",
      verb: "reap",
      args: { pid: "99999" },
      requestedBy: "cluster:orphan:99999",
      result: expect.any(String),
      rc: 400,
    });
  });

  test("appends a journal line with exact shape on successful reap", async () => {
    const before = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean).length;
    const res = await postAction("reap", {
      args: { pid: String(ORPHAN_PID) },
      requestedBy: ORPHAN_ITEM.id,
    });
    expect(res.status).toBe(200);
    const lines = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean);
    expect(lines.length).toBe(before + 1);
    const entry = JSON.parse(lines.at(-1)!) as ActionJournalEntry;
    expect(entry).toEqual({
      ts: "2026-07-17T12:00:00.000Z",
      verb: "reap",
      args: { pid: String(ORPHAN_PID) },
      requestedBy: ORPHAN_ITEM.id,
      result: "ok",
      rc: 0,
    });
    expect(spawnCalls.at(-1)).toEqual(["reaper-ctl", "kill", String(ORPHAN_PID), "--escalate"]);
  });
});

describe("offload action gateway", () => {
  const offloadVerbs = [
    "box-drain",
    "box-restore",
    "host-quarantine",
    "host-unquarantine",
    "admission-reconcile",
    "job-retry",
    "ci-reconcile",
    "recall-spill",
  ] as const;

  test("all 8 offload verbs proxy to controller /transition with gateway-owned idempotency key", async () => {
    const argsByVerb: Record<(typeof offloadVerbs)[number], Record<string, string>> = {
      "box-drain": { host: "debian1", expectedRevision: "4192" },
      "box-restore": { host: "debian1", expectedRevision: "4192" },
      "host-quarantine": { host: "debian1", command: "playwright browsers", expectedRevision: "4192" },
      "host-unquarantine": { host: "debian1", command: "playwright browsers", expectedRevision: "4192" },
      "admission-reconcile": { expectedRevision: "4192", reason: "fleet wedge" },
      "job-retry": { jobId: "rb-abc123", expectedRevision: "4192" },
      "ci-reconcile": { host: "debian1", expectedRevision: "4192" },
      "recall-spill": { host: "debian1", expectedRevision: "4192" },
    };

    for (const verb of offloadVerbs) {
      const before = transitionCalls.length;
      const res = await postAction(verb, { args: argsByVerb[verb], requestedBy: `offload:${verb}` });
      expect(res.status).toBe(200);

      const call = transitionCalls.at(-1);
      expect(call?.url).toBe(`${CONTROLLER_URL}/transition/${verb}`);
      expect(call?.body.expectedRevision).toBe(4192);
      expect(call?.body.idempotencyKey).toBe(`gateway-key-${before + 1}`);
      expect(call?.body.args).not.toHaveProperty("expectedRevision");
      expect(transitionCalls.length).toBe(before + 1);
    }
  });

  test("rejects caller-supplied idempotencyKey in args", async () => {
    const res = await postAction("box-drain", {
      args: { host: "debian1", expectedRevision: "4192", idempotencyKey: "caller-key" },
      requestedBy: "offload:box-drain",
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error?: string };
    expect(body.error).toBe("idempotencyKey is not accepted");
  });

  test("box-drain with host absent from fleet panel returns 400", async () => {
    const res = await postAction("box-drain", {
      args: { host: "debian2", expectedRevision: "4192" },
      requestedBy: "offload:box-drain",
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error?: string };
    expect(body.error).toContain("fleet panel");
  });

  test("job-retry with job absent from remote-jobs panel returns 400", async () => {
    const res = await postAction("job-retry", {
      args: { jobId: "rb-missing", expectedRevision: "4192" },
      requestedBy: "offload:job-retry",
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error?: string };
    expect(body.error).toContain("remote-jobs panel");
  });

  test("job-retry forwards jobId to controller args", async () => {
    await postAction("job-retry", {
      args: { jobId: "rb-abc123", expectedRevision: "4192" },
      requestedBy: "offload:job-retry",
    });
    const call = transitionCalls.at(-1);
    expect(call?.body.args).toEqual({ jobId: "rb-abc123" });
  });

  test("controller unreachable returns typed 502", async () => {
    controllerFetchShouldFail = true;
    try {
      const res = await postAction("admission-reconcile", {
        args: { expectedRevision: "4192" },
        requestedBy: "offload:admission-reconcile",
      });
      expect(res.status).toBe(502);
      expect(await res.json()).toEqual({ ok: false, error: "controller-unreachable" });
    } finally {
      controllerFetchShouldFail = false;
    }
  });

  test("appends journal line with exact shape on offload proxy success", async () => {
    const before = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean).length;
    const res = await postAction("admission-reconcile", {
      args: { expectedRevision: "4192" },
      requestedBy: "offload:admission-reconcile",
    });
    expect(res.status).toBe(200);
    const lines = readFileSync(journalPath, "utf8").trim().split("\n").filter(Boolean);
    expect(lines.length).toBe(before + 1);
    const entry = JSON.parse(lines.at(-1)!) as ActionJournalEntry;
    expect(entry).toEqual({
      ts: "2026-07-17T12:00:00.000Z",
      verb: "admission-reconcile",
      args: { expectedRevision: "4192" },
      requestedBy: "offload:admission-reconcile",
      result: expect.any(String),
      rc: 0,
    });
    expect(entry).not.toHaveProperty("idempotencyKey");
    expect(entry).not.toHaveProperty("outcome");
  });
});

const FACTORY_ACTION_SCHEMA = `
CREATE TABLE sessions (
  adw_id TEXT PRIMARY KEY,
  adw_name TEXT,
  repo TEXT,
  request TEXT,
  status TEXT,
  engineer TEXT,
  started_at TEXT,
  ended_at TEXT,
  total_tokens INTEGER DEFAULT 0,
  total_cost REAL DEFAULT 0,
  archived INTEGER DEFAULT 0
);
CREATE TABLE decisions (
  decision_id  TEXT PRIMARY KEY,
  adw_id       TEXT NOT NULL,
  phase        TEXT,
  question     TEXT NOT NULL,
  options      TEXT NOT NULL DEFAULT '[]',
  free_text    INTEGER NOT NULL DEFAULT 0,
  context      TEXT NOT NULL DEFAULT '',
  status       TEXT NOT NULL DEFAULT 'pending',
  answer_value TEXT,
  answer_text  TEXT,
  answered_by  TEXT,
  created_at   TEXT NOT NULL,
  answered_at  TEXT
);
CREATE TABLE events (
  event_id      TEXT PRIMARY KEY,
  adw_id        TEXT,
  phase_id      TEXT,
  parent_id     TEXT,
  type          TEXT,
  name          TEXT,
  payload_json  TEXT,
  tokens        INTEGER,
  started_at    TEXT,
  ended_at      TEXT
);
`;

function createFactoryActionDb(
  decision: {
    decisionId: string;
    adwId: string;
    options?: string;
    freeText?: number;
    status?: string;
    phase?: string | null;
  },
): string {
  const dir = mkdtempSync(join(tmpdir(), "factory-actions-"));
  const path = join(dir, "sssf.db");
  const db = new Database(path);
  db.exec("PRAGMA journal_mode = WAL");
  db.exec("PRAGMA busy_timeout = 5000");
  db.exec(FACTORY_ACTION_SCHEMA);
  db.query(
    "INSERT INTO sessions (adw_id, repo, status, started_at) VALUES (?, ?, ?, ?)",
  ).run(decision.adwId, "/home/user/Projects/overdeck", "running", "2026-08-05T10:00:00Z");
  db.query(
    `INSERT INTO decisions
       (decision_id, adw_id, phase, question, options, free_text, context, status, created_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  ).run(
    decision.decisionId,
    decision.adwId,
    decision.phase ?? "phase-1",
    "Choose?",
    decision.options ?? JSON.stringify([{ value: "go", label: "Go", recommended: true }]),
    decision.freeText ?? 0,
    "",
    decision.status ?? "pending",
    "2026-08-05T10:05:00Z",
  );
  db.close();
  return path;
}

async function postFactoryDecision(
  gateway: ActionGateway,
  body: { args: Record<string, string>; requestedBy?: string },
): Promise<Response> {
  return gateway.handle(
    new Request("http://127.0.0.1/actions/factory.decision.answer", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body),
    }),
    "factory.decision.answer",
  );
}

describe("factory decision action handler", () => {
  test("factory.decision.answer is allowlisted", () => {
    expect(ALLOWED_ACTION_VERBS).toContain("factory.decision.answer");
  });

  test("valid option answer returns 200, marks decision answered, and writes decision_answered event", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-valid", adwId: "adw-1" });
    const gateway = createActionGateway({
      state,
      journalPath,
      now: () => Date.parse("2026-07-17T12:00:00.000Z"),
      factoryDbPath,
    });
    const response = await postFactoryDecision(gateway, {
      args: { decisionId: "dec-valid", choice: "go" },
      requestedBy: "factory-decision-dec-valid",
    });
    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({ ok: true });

    const db = new Database(factoryDbPath);
    const row = db.query<{ status: string; answer_value: string | null; answered_by: string }, [string]>(
      "SELECT status, answer_value, answered_by FROM decisions WHERE decision_id=?",
    ).get("dec-valid");
    expect(row).toEqual({ status: "answered", answer_value: "go", answered_by: "factory-decision-dec-valid" });
    const event = db.query<{ type: string; name: string; payload_json: string }, []>(
      "SELECT type, name, payload_json FROM events WHERE adw_id='adw-1'",
    ).get();
    expect(event?.type).toBe("decision_answered");
    expect(event?.name).toBe("decision");
    expect(JSON.parse(event!.payload_json)).toEqual({
      decision_id: "dec-valid",
      answer_value: "go",
      answer_text: null,
    });
    db.close();
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });

  test("second answer returns 409", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-twice", adwId: "adw-2" });
    const gateway = createActionGateway({
      state,
      journalPath,
      now: () => Date.parse("2026-07-17T12:00:00.000Z"),
      factoryDbPath,
    });
    const first = await postFactoryDecision(gateway, {
      args: { decisionId: "dec-twice", choice: "go" },
      requestedBy: "user-1",
    });
    expect(first.status).toBe(200);
    const second = await postFactoryDecision(gateway, {
      args: { decisionId: "dec-twice", choice: "go" },
      requestedBy: "user-2",
    });
    expect(second.status).toBe(409);
    expect(await second.json()).toEqual({ ok: false, error: "decision not pending" });
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });

  test("unknown decisionId returns 404", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-known", adwId: "adw-3" });
    const gateway = createActionGateway({
      state,
      journalPath,
      now: () => Date.parse("2026-07-17T12:00:00.000Z"),
      factoryDbPath,
    });
    const response = await postFactoryDecision(gateway, {
      args: { decisionId: "dec-missing", choice: "go" },
      requestedBy: "user",
    });
    expect(response.status).toBe(404);
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });

  test("invalid choice returns 400 when options are fixed and free text is disabled", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-fixed", adwId: "adw-4" });
    const gateway = createActionGateway({
      state,
      journalPath,
      now: () => Date.parse("2026-07-17T12:00:00.000Z"),
      factoryDbPath,
    });
    const response = await postFactoryDecision(gateway, {
      args: { decisionId: "dec-fixed", choice: "not-an-option" },
      requestedBy: "user",
    });
    expect(response.status).toBe(400);
    expect(await response.json()).toEqual({ ok: false, error: "invalid choice" });
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });

  test("free-text decision accepts arbitrary choice text", async () => {
    const factoryDbPath = createFactoryActionDb({
      decisionId: "dec-free",
      adwId: "adw-5",
      options: "[]",
      freeText: 1,
    });
    const gateway = createActionGateway({
      state,
      journalPath,
      now: () => Date.parse("2026-07-17T12:00:00.000Z"),
      factoryDbPath,
    });
    const response = await postFactoryDecision(gateway, {
      args: { decisionId: "dec-free", choice: "custom operator note" },
      requestedBy: "operator",
    });
    expect(response.status).toBe(200);
    const db = new Database(factoryDbPath);
    const row = db.query<{ answer_value: string | null; answer_text: string | null }, [string]>(
      "SELECT answer_value, answer_text FROM decisions WHERE decision_id=?",
    ).get("dec-free");
    expect(row).toEqual({ answer_value: null, answer_text: "custom operator note" });
    db.close();
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });

  test("event insert failure rolls back the decision update and returns 500", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-atomic", adwId: "adw-6" });
    const setupDb = new Database(factoryDbPath);
    setupDb.exec("DROP TABLE events");
    setupDb.close();

    const gateway = createActionGateway({
      state,
      journalPath,
      now: () => Date.parse("2026-07-17T12:00:00.000Z"),
      factoryDbPath,
    });
    const response = await postFactoryDecision(gateway, {
      args: { decisionId: "dec-atomic", choice: "go" },
      requestedBy: "user-1",
    });
    expect(response.status).toBe(500);
    expect((await response.json()) as { ok: boolean }).toEqual(
      expect.objectContaining({ ok: false }),
    );

    const db = new Database(factoryDbPath);
    const row = db.query<{ status: string; answer_value: string | null }, [string]>(
      "SELECT status, answer_value FROM decisions WHERE decision_id=?",
    ).get("dec-atomic");
    expect(row).toEqual({ status: "pending", answer_value: null });
    db.close();
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });
});

async function postFactoryStop(
  gateway: ActionGateway,
  body: { args: Record<string, string>; requestedBy?: string },
): Promise<Response> {
  return gateway.handle(
    new Request("http://127.0.0.1/actions/factory.run.stop", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body),
    }),
    "factory.run.stop",
  );
}

describe("factory stop action handler", () => {
  function createStopGateway(
    factoryDbPath: string,
    spawnResult: { rc: number; stdout: string; stderr: string } = { rc: 0, stdout: "stopped", stderr: "" },
  ): { gateway: ActionGateway; stopSpawnCalls: string[][] } {
    const stopSpawnCalls: string[][] = [];
    const gateway = createActionGateway({
      state,
      journalPath,
      now: () => Date.parse("2026-07-17T12:00:00.000Z"),
      factoryDbPath,
      spawn: async (argv) => {
        stopSpawnCalls.push(argv);
        return spawnResult;
      },
    });
    return { gateway, stopSpawnCalls };
  }

  test("factory.run.stop is allowlisted", () => {
    expect(ALLOWED_ACTION_VERBS).toContain("factory.run.stop");
  });

  test("running session returns 200 and spawns factory stop with the session repo", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-s1", adwId: "adw-stop-1" });
    const { gateway, stopSpawnCalls } = createStopGateway(factoryDbPath);
    const response = await postFactoryStop(gateway, {
      args: { adwId: "adw-stop-1" },
      requestedBy: "deck-web",
    });
    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({ ok: true, result: "stopped" });
    expect(stopSpawnCalls).toEqual([
      ["factory", "stop", "adw-stop-1", "--repo", "/home/user/Projects/overdeck"],
    ]);
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });

  test("unknown adwId returns 404 without spawning", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-s2", adwId: "adw-stop-2" });
    const { gateway, stopSpawnCalls } = createStopGateway(factoryDbPath);
    const response = await postFactoryStop(gateway, {
      args: { adwId: "adw-missing" },
      requestedBy: "deck-web",
    });
    expect(response.status).toBe(404);
    expect(await response.json()).toEqual({ ok: false, error: "session not found" });
    expect(stopSpawnCalls).toHaveLength(0);
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });

  test("non-running session returns 409 without spawning", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-s3", adwId: "adw-stop-3" });
    const db = new Database(factoryDbPath);
    db.query("UPDATE sessions SET status='done' WHERE adw_id=?").run("adw-stop-3");
    db.close();
    const { gateway, stopSpawnCalls } = createStopGateway(factoryDbPath);
    const response = await postFactoryStop(gateway, {
      args: { adwId: "adw-stop-3" },
      requestedBy: "deck-web",
    });
    expect(response.status).toBe(409);
    expect(await response.json()).toEqual({ ok: false, error: "session not running" });
    expect(stopSpawnCalls).toHaveLength(0);
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });

  test("invalid adwId returns 400 without touching the db", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-s4", adwId: "adw-stop-4" });
    const { gateway, stopSpawnCalls } = createStopGateway(factoryDbPath);
    const response = await postFactoryStop(gateway, {
      args: { adwId: "../evil" },
      requestedBy: "deck-web",
    });
    expect(response.status).toBe(400);
    expect(await response.json()).toEqual({ ok: false, error: "invalid factory.run.stop args" });
    expect(stopSpawnCalls).toHaveLength(0);
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });

  test("failed factory stop spawn returns 500 with stderr", async () => {
    const factoryDbPath = createFactoryActionDb({ decisionId: "dec-s5", adwId: "adw-stop-5" });
    const { gateway, stopSpawnCalls } = createStopGateway(factoryDbPath, {
      rc: 1,
      stdout: "",
      stderr: "no live processes",
    });
    const response = await postFactoryStop(gateway, {
      args: { adwId: "adw-stop-5" },
      requestedBy: "deck-web",
    });
    expect(response.status).toBe(500);
    expect(await response.json()).toEqual({ ok: false, error: "no live processes" });
    expect(stopSpawnCalls).toHaveLength(1);
    rmSync(join(factoryDbPath, ".."), { recursive: true, force: true });
  });
});

describe("incident.resolve action handler", () => {
  function postIncidentResolve(
    gateway: ActionGateway,
    body: { args: Record<string, string>; requestedBy?: string },
  ): Promise<Response> {
    return gateway.handle(
      new Request("http://127.0.0.1/actions/incident.resolve", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(body),
      }),
      "incident.resolve",
    );
  }

  function resolveGateway(incidents?: { resolveIncident(incidentId: string, artifact: string, summary?: string): Promise<unknown> }) {
    return createActionGateway({
      state,
      journalPath,
      now: () => Date.parse("2026-08-08T12:00:00.000Z"),
      incidents,
    });
  }

  test("incident.resolve is allowlisted", () => {
    expect(ALLOWED_ACTION_VERBS).toContain("incident.resolve");
  });

  test("the generic /actions/:verb route resolves through the configured incident provider", async () => {
    const response = await postAction("incident.resolve", {
      args: { incidentId: "INC-route", artifact: "overdeck@abc1234", summary: "Fixed through route." },
      requestedBy: "od-incidents",
    });

    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({ ok: true });
    expect(incidentResolveCalls).toEqual([["INC-route", "overdeck@abc1234", "Fixed through route."]]);
  });

  test("missing artifact returns 400 naming the artifact-required rule, provider untouched", async () => {
    const calls: unknown[] = [];
    const gateway = resolveGateway({ async resolveIncident(...args) { calls.push(args); return {}; } });
    const missing = await postIncidentResolve(gateway, { args: { incidentId: "INC-1" } });
    expect(missing.status).toBe(400);
    const missingBody = await missing.json() as { error: string; detail: string };
    expect(missingBody.detail).toContain("artifact is required");

    const empty = await postIncidentResolve(gateway, { args: { incidentId: "INC-1", artifact: "   " } });
    expect(empty.status).toBe(400);
    const emptyBody = await empty.json() as { detail: string };
    expect(emptyBody.detail).toContain("artifact is required");
    expect(calls).toEqual([]);
  });

  test("happy path resolves through the provider and returns ok", async () => {
    const calls: unknown[] = [];
    const gateway = resolveGateway({ async resolveIncident(...args) { calls.push(args); return {}; } });
    const res = await postIncidentResolve(gateway, {
      args: { incidentId: "INC-1", artifact: "overdeck@abc1234", summary: "Fixed." },
      requestedBy: "od-incidents",
    });
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ ok: true });
    expect(calls).toEqual([["INC-1", "overdeck@abc1234", "Fixed."]]);
  });

  test("resolution conflict maps to 409 with the stored-artifact detail", async () => {
    const gateway = resolveGateway({
      async resolveIncident() {
        throw new IncidentResolutionConflictError('incident is already resolved with artifact "overdeck@abc1234" — refusing to overwrite it with "overdeck@deadbeef"');
      },
    });
    const res = await postIncidentResolve(gateway, { args: { incidentId: "INC-1", artifact: "overdeck@deadbeef" } });
    expect(res.status).toBe(409);
    const body = await res.json() as { error: string; detail: string };
    expect(body.error).toBe("resolution-conflict");
    expect(body.detail).toContain("overdeck@abc1234");
  });

  test("unknown incident maps to 404", async () => {
    const gateway = resolveGateway({ async resolveIncident() { throw new IncidentMutationError("not-found"); } });
    const res = await postIncidentResolve(gateway, { args: { incidentId: "nope", artifact: "overdeck@abc1234" } });
    expect(res.status).toBe(404);
  });

  test("collector without an incidents provider answers 503, never a bare 500", async () => {
    const gateway = resolveGateway(undefined);
    const res = await postIncidentResolve(gateway, { args: { incidentId: "INC-1", artifact: "overdeck@abc1234" } });
    expect(res.status).toBe(503);
    expect(await res.json()).toEqual({ ok: false, error: "incidents-unavailable" });
  });
});
