import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { CapabilityService } from "./capability";
import { ControllerStore } from "./store";
import {
  TransitionEngine,
  coerceExpectedRevision,
  grantFallbackLease,
} from "./transitions";

setDefaultTimeout(20_000);

describe("TransitionEngine", () => {
  let dir: string;
  let dbPath: string;
  let store: ControllerStore;
  let engine: TransitionEngine;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), "controller-transitions-"));
    dbPath = join(dir, "state.sqlite");
    store = new ControllerStore(dbPath);
    store.upsertHost({ hostname: "debian1", state: "available", slotsTotal: 4 });
    store.upsertJob({
      id: "job-1",
      repo: "owner/repo",
      host: "debian1",
      snapshot: "abc",
      stage: "failed",
      attempt: 1,
      rc: 1,
      infraFailure: true,
    });
    engine = new TransitionEngine(store);
  });

  afterEach(() => {
    store.close();
    rmSync(dir, { recursive: true, force: true });
  });

  test("coerces expectedRevision from string", async () => {
    expect(coerceExpectedRevision("12")).toBe(12);
    expect(coerceExpectedRevision(12)).toBe(12);
    expect(coerceExpectedRevision("nope")).toBeNull();
  });

  test("stale expectedRevision returns 409 without mutation", async () => {
    const before = store.getRevision();
    const response = await engine.handle("box-drain", {
      expectedRevision: before - 1,
      idempotencyKey: "stale-1",
      args: { host: "debian1" },
    });
    expect(response.status).toBe(409);
    expect(response.body).toEqual({
      error: "stale-revision",
      currentRevision: before,
    });
    expect(store.getHost("debian1")?.state).toBe("available");
  });

  test("idempotency replay returns prior result without re-exec", async () => {
    const first = await engine.handle("box-drain", {
      expectedRevision: 0,
      idempotencyKey: "replay-1",
      args: { host: "debian1" },
    });
    expect(first.status).toBe(200);
    expect(store.getHost("debian1")?.state).toBe("draining");

    const replay = await engine.handle("box-drain", {
      expectedRevision: 999,
      idempotencyKey: "replay-1",
      args: { host: "debian1" },
    });
    expect(replay.status).toBe(200);
    expect(replay.body).toEqual(first.body);
    expect(store.getRevision()).toBe(1);
    expect(store.getHost("debian1")?.state).toBe("draining");
  });

  test("idempotency replay wins over stale revision", async () => {
    const first = await engine.handle("box-drain", {
      expectedRevision: 0,
      idempotencyKey: "replay-stale",
      args: { host: "debian1" },
    });
    expect(first.status).toBe(200);

    const replay = await engine.handle("box-drain", {
      expectedRevision: 0,
      idempotencyKey: "replay-stale",
      args: { host: "debian1" },
    });
    expect(replay.status).toBe(200);
    expect(replay.body).toEqual(first.body);
  });

  test("audit write failure returns 403", async () => {
    store.close();
    const failing = new ControllerStore(dbPath, { auditWriteFails: true });
    const failingEngine = new TransitionEngine(failing);
    failing.upsertHost({ hostname: "debian1", state: "available" });

    const response = await failingEngine.handle("box-drain", {
      expectedRevision: 0,
      idempotencyKey: "audit-403",
      args: { host: "debian1" },
    });
    expect(response.status).toBe(403);
    expect(response.body).toEqual({ error: "audit-write-failed" });
    failing.close();
  });

  test("box-drain moves host to draining", async () => {
    const response = await engine.handle("box-drain", {
      expectedRevision: 0,
      idempotencyKey: "drain-1",
      args: { host: "debian1" },
    });
    expect(response.status).toBe(200);
    expect(store.getHost("debian1")?.state).toBe("draining");
  });

  test("admission-reconcile does not enter maintenance while jobs run", async () => {
    store.upsertHost({
      hostname: "debian1",
      state: "draining",
      runningJobs: 1,
    });
    const response = await engine.handle("admission-reconcile", {
      expectedRevision: 0,
      idempotencyKey: "adm-1",
      args: {},
    });
    expect(response.status).toBe(200);
    expect(store.getHost("debian1")?.state).toBe("draining");
  });

  test("admission-reconcile advances draining to maintenance when idle", async () => {
    store.setHostState("debian1", "draining");
    const response = await engine.handle("admission-reconcile", {
      expectedRevision: 0,
      idempotencyKey: "adm-2",
      args: {},
    });
    expect(response.status).toBe(200);
    expect(store.getHost("debian1")?.state).toBe("maintenance");
  });

  test("admission-reconcile probes enrolling hosts concurrently and promotes only green", async () => {
    store.upsertHost({ hostname: "green", state: "maintenance", enrolling: true, capabilityOk: false });
    store.upsertHost({ hostname: "red", state: "maintenance", enrolling: true, capabilityOk: false });
    const calls: string[] = [];
    const release = Promise.withResolvers<void>();
    const capability = new CapabilityService(store, {
      probe: async (host) => {
        calls.push(host);
        await release.promise;
        return {
          commandPresent: host === "green",
          version: host === "green" ? "ssh-exit-0" : null,
          writablePaths: [], diskFreeBytes: 0, systemd: false,
        };
      },
    });
    engine = new TransitionEngine(store, () => Date.now(), capability);
    const pending = engine.handle("admission-reconcile", {
      expectedRevision: 0,
      idempotencyKey: "adm-concurrent",
      args: { reason: "enrollment" },
    });
    await Bun.sleep(0);
    expect(calls.sort()).toEqual(["green", "red"]);
    release.resolve();
    expect((await pending).status).toBe(200);
    expect(store.getHost("green")).toMatchObject({ state: "available", enrolling: false, capabilityOk: true });
    expect(store.getHost("red")).toMatchObject({ state: "maintenance", enrolling: true, capabilityOk: false });
  });

  test("admission-reconcile rejects host args and loses post-probe CAS", async () => {
    expect((await engine.handle("admission-reconcile", {
      expectedRevision: 0,
      idempotencyKey: "adm-host-rejected",
      args: { host: "debian1" },
    })).status).toBe(422);

    store.upsertHost({ hostname: "enrolling", state: "maintenance", enrolling: true, capabilityOk: false });
    const started = Promise.withResolvers<void>();
    const release = Promise.withResolvers<void>();
    const capability = new CapabilityService(store, {
      probe: async () => {
        started.resolve();
        await release.promise;
        return {
          commandPresent: true, version: "ssh-exit-0", writablePaths: [],
          diskFreeBytes: 0, systemd: false,
        };
      },
    });
    engine = new TransitionEngine(store, () => Date.now(), capability);
    const pending = engine.handle("admission-reconcile", {
      expectedRevision: 0,
      idempotencyKey: "adm-cas",
      args: {},
    });
    await started.promise;
    const other = await engine.handle("box-drain", {
      expectedRevision: 0,
      idempotencyKey: "adm-cas-other",
      args: { host: "debian1" },
    });
    expect(other.status).toBe(200);
    release.resolve();
    expect(await pending).toEqual({
      status: 409,
      body: { error: "stale-revision", currentRevision: 1 },
    });
    expect(store.getHost("enrolling")).toMatchObject({
      state: "maintenance", enrolling: true, capabilityOk: false,
    });
    expect(store.getIdempotencyResult("adm-cas")).toBeNull();
  });

  test("box-restore requires green health to reach available", async () => {
    store.upsertHost({
      hostname: "debian1",
      state: "maintenance",
      healthRunner: false,
    });
    const toRestoring = await engine.handle("box-restore", {
      expectedRevision: 0,
      idempotencyKey: "restore-1",
      args: { host: "debian1" },
    });
    expect(toRestoring.status).toBe(200);
    expect(store.getHost("debian1")?.state).toBe("restoring");

    const blocked = await engine.handle("box-restore", {
      expectedRevision: 1,
      idempotencyKey: "restore-2",
      args: { host: "debian1" },
    });
    expect(blocked.status).toBe(422);

    store.upsertHost({
      hostname: "debian1",
      state: "restoring",
      healthRunner: true,
      healthStorage: true,
      healthOffload: true,
      capabilityOk: true,
    });
    const available = await engine.handle("box-restore", {
      expectedRevision: 1,
      idempotencyKey: "restore-3",
      args: { host: "debian1" },
    });
    expect(available.status).toBe(200);
    expect(store.getHost("debian1")).toMatchObject({ state: "available", enrolling: false });
  });

  test("host quarantine and unquarantine route through persisted breaker states", async () => {
    const capability = new CapabilityService(store, {
      probe: async () => ({
        commandPresent: true,
        version: "1",
        writablePaths: [],
        diskFreeBytes: 1,
        systemd: true,
      }),
    });
    engine = new TransitionEngine(store, () => Date.now(), capability);
    const q = await engine.handle("host-quarantine", {
      expectedRevision: 0,
      idempotencyKey: "q-1",
      args: { host: "debian1", command: "playwright" },
    });
    expect(q.status).toBe(200);
    expect(store.getHost("debian1")?.quarantinedCommands).toContain("playwright");

    const u = await engine.handle("host-unquarantine", {
      expectedRevision: 1,
      idempotencyKey: "u-1",
      args: { host: "debian1", command: "playwright" },
    });
    expect(u.status).toBe(200);
    expect(store.getCapabilityBreaker("debian1", "playwright")?.state).toBe("half-open");
    expect(store.getHost("debian1")?.quarantinedCommands).toEqual([]);
  });

  test("job-retry only for infra failures", async () => {
    const ok = await engine.handle("job-retry", {
      expectedRevision: 0,
      idempotencyKey: "jr-1",
      args: { jobId: "job-1" },
    });
    expect(ok.status).toBe(200);
    expect(store.getJob("job-1")?.attempt).toBe(2);

    store.upsertJob({
      id: "job-2",
      repo: "owner/repo",
      host: "debian1",
      snapshot: "abc",
      stage: "failed",
      attempt: 1,
      rc: 1,
      infraFailure: false,
    });
    const bad = await engine.handle("job-retry", {
      expectedRevision: 1,
      idempotencyKey: "jr-2",
      args: { jobId: "job-2" },
    });
    expect(bad.status).toBe(422);
  });

  test("ci-reconcile clears ci jobs", async () => {
    store.upsertHost({
      hostname: "debian1",
      state: "available",
      ciJobsRunning: 2,
    });
    const response = await engine.handle("ci-reconcile", {
      expectedRevision: 0,
      idempotencyKey: "ci-1",
      args: { host: "debian1" },
    });
    expect(response.status).toBe(200);
    expect(store.getHost("debian1")?.ciJobsRunning).toBe(0);
  });

  test("recall-spill clears fallback lease", async () => {
    grantFallbackLease(store, { host: "laptop", reason: "spill" });
    expect(store.getLease().active).toBe(true);
    const response = await engine.handle("recall-spill", {
      expectedRevision: 0,
      idempotencyKey: "spill-1",
      args: {},
    });
    expect(response.status).toBe(200);
    expect(store.getLease().active).toBe(false);
  });

  const verbs = [
    ["box-drain", { host: "debian1" }],
    ["box-restore", { host: "debian1" }],
    ["host-quarantine", { host: "debian1", command: "node" }],
    ["host-unquarantine", { host: "debian1", command: "node" }],
    ["admission-reconcile", {}],
    ["job-retry", { jobId: "job-1" }],
    ["ci-reconcile", { host: "debian1" }],
    ["recall-spill", {}],
  ] as const;

  for (const [verb, args] of verbs) {
    test(`kill-mid-transition for ${verb} converges on restart`, async () => {
      const crashDir = mkdtempSync(join(tmpdir(), `crash-${verb}-`));
      const crashDb = join(crashDir, "state.sqlite");
      const seeded = new ControllerStore(crashDb);
      seeded.upsertHost({ hostname: "debian1", state: "available", slotsTotal: 4 });
      seeded.upsertJob({
        id: "job-1",
        repo: "owner/repo",
        host: "debian1",
        snapshot: "abc",
        stage: "failed",
        attempt: 1,
        rc: 1,
        infraFailure: true,
      });
      grantFallbackLease(seeded, { host: "laptop", reason: "spill" });
      if (verb === "box-drain") {
        // keep available
      } else if (verb === "box-restore") {
        seeded.setHostState("debian1", "maintenance");
      } else if (verb === "admission-reconcile") {
        seeded.setHostState("debian1", "draining");
      }
      const revision = seeded.getRevision();
      seeded.close();

      const crashing = new ControllerStore(crashDb, { crashAfterJournal: true });
      const crashEngine = new TransitionEngine(crashing);
      expect(() => crashEngine.handle(verb, {
        expectedRevision: revision,
        idempotencyKey: `crash-${verb}`,
        args,
      })).toThrow(/simulated crash/);
      crashing.close();

      const recovered = new ControllerStore(crashDb);
      const recoveredEngine = new TransitionEngine(recovered);
      const resume = await recoveredEngine.resumePending();
      expect(resume.length).toBe(1);
      expect(resume[0]?.status).toBe(200);

      const replay = await recoveredEngine.handle(verb, {
        expectedRevision: revision,
        idempotencyKey: `crash-${verb}`,
        args,
      });
      expect(replay.status).toBe(200);
      expect(recovered.getRevision()).toBe(revision + 1);
      if (verb === "recall-spill") {
        expect(recovered.getLease().active).toBe(false);
      }
      if (verb === "admission-reconcile") {
        expect(recovered.getHost("debian1")?.state).toBe("maintenance");
      }
      recovered.close();
      rmSync(crashDir, { recursive: true, force: true });
    });
  }
});
