import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
  EventSchema,
  buildJobReportFinishedEvent,
  buildSpineConfigInvalidEvent,
  projectEventLog,
} from "./events";
import { ControllerStore } from "./store";
import { TransitionEngine } from "./transitions";

const NOW = Date.parse("2026-07-19T12:00:00.000Z");

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

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), "controller-events-"));
    dbPath = join(dir, "state.sqlite");
    store = new ControllerStore(dbPath, { now: () => NOW });
    store.upsertHost({ hostname: "debian1", state: "available" });
    store.upsertJob({
      id: "job-1",
      repo: "owner/repo",
      host: "debian1",
      snapshot: "abc123",
      stage: "failed",
      attempt: 1,
      rc: 127,
      infraFailure: true,
    });
  });

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

  test("enforces the exact event shape and non-job sentinels", async () => {
    const hostEvent = {
      ts: "2026-07-19T12:00:00.000Z",
      job: "",
      repo: "",
      host: "debian1",
      snapshot: "",
      attempt: 0,
      stage: "box-drain",
      reason: "box-drain",
      rc: null,
      durationSeconds: 0,
    };

    expect(EventSchema.parse(hostEvent)).toEqual(hostEvent);
    expect(EventSchema.safeParse({ ...hostEvent, repo: null }).success).toBe(false);
    expect(EventSchema.safeParse({ ...hostEvent, attempt: null }).success).toBe(false);
    expect(EventSchema.safeParse({ ...hostEvent, snapshot: "unexpected" }).success).toBe(false);
    expect(EventSchema.safeParse({ ...hostEvent, extra: true }).success).toBe(false);
  });

  test("builds exact report events", () => {
    expect(buildJobReportFinishedEvent({
      finishedAt: "2026-07-20T00:00:03.500Z",
      startedAt: "2026-07-20T00:00:00.000Z",
      jobId: "job-id",
      repo: "owner/repo",
      host: "debian1",
      snapshot: "42",
      attempt: 2,
      rc: 0,
    })).toEqual({
      ts: "2026-07-20T00:00:03.500Z",
      job: "job-id",
      repo: "owner/repo",
      host: "debian1",
      snapshot: "42",
      attempt: 2,
      stage: "finished",
      reason: "report-finished",
      rc: 0,
      durationSeconds: 3.5,
    });
    expect(buildSpineConfigInvalidEvent({
      observedAt: "2026-07-20T00:00:00.000Z",
      kind: "config-invalid-shape",
      override: true,
    })).toMatchObject({
      host: "local",
      stage: "config-invalid",
      reason: "authorized-local-fallback:config-invalid-shape",
    });
  });

  test("replays lifecycle events in strict revision order from a cursor", async () => {
    const engine = new TransitionEngine(store, () => NOW);
    expect((await engine.handle("job-retry", {
      expectedRevision: 0,
      idempotencyKey: "retry-1",
      args: { jobId: "job-1" },
    })).status).toBe(200);
    expect((await engine.handle("box-drain", {
      expectedRevision: 1,
      idempotencyKey: "drain-1",
      args: { host: "debian1" },
    })).status).toBe(200);

    expect(store.readEvents(0)).toEqual([
      {
        revision: 1,
        event: {
          ts: "2026-07-19T12:00:00.000Z",
          job: "job-1",
          repo: "owner/repo",
          host: "debian1",
          snapshot: "abc123",
          attempt: 2,
          stage: "queued",
          reason: "job-retry",
          rc: 127,
          durationSeconds: 0,
        },
      },
      {
        revision: 2,
        event: {
          ts: "2026-07-19T12:00:00.000Z",
          job: "",
          repo: "",
          host: "debian1",
          snapshot: "",
          attempt: 0,
          stage: "box-drain",
          reason: "box-drain",
          rc: null,
          durationSeconds: 0,
        },
      },
    ]);
    expect(store.readEvents(1).map(({ revision }) => revision)).toEqual([2]);

    store.close();
    store = new ControllerStore(dbPath, { now: () => NOW });
    expect(store.readEvents(0).map(({ revision }) => revision)).toEqual([1, 2]);
  });

  test("rolls back state and event when event append fails", () => {
    store.close();
    store = new ControllerStore(dbPath, { now: () => NOW, eventWriteFails: true });
    const engine = new TransitionEngine(store, () => NOW);

    expect(() => engine.handle("box-drain", {
      expectedRevision: 0,
      idempotencyKey: "rollback-1",
      args: { host: "debian1" },
    })).toThrow(/event write failed/);
    expect(store.getHost("debian1")?.state).toBe("available");
    expect(store.getRevision()).toBe(0);
    expect(store.readEvents(0)).toEqual([]);
  });

  test("projects the authoritative SQLite log to exact JSONL events", async () => {
    const engine = new TransitionEngine(store, () => NOW);
    await engine.handle("box-drain", {
      expectedRevision: 0,
      idempotencyKey: "projection-1",
      args: { host: "debian1" },
    });
    const path = join(dir, "events.jsonl");

    projectEventLog(store, path);
    const inode = statSync(path).ino;
    await engine.handle("host-quarantine", {
      expectedRevision: 1,
      idempotencyKey: "projection-2",
      args: { host: "debian1", command: "playwright" },
    });
    projectEventLog(store, path);

    const lines = readFileSync(path, "utf8")
      .trimEnd()
      .split("\n")
      .map((line) => JSON.parse(line) as unknown);
    expect(lines).toEqual(store.readEvents(0).map(({ event }) => event));
    expect(statSync(path).ino).toBe(inode);
    expect(Object.keys(lines[0] ?? {})).toEqual([
      "ts", "job", "repo", "host", "snapshot", "attempt", "stage", "reason", "rc", "durationSeconds",
    ]);
  });
});
