import { afterEach, describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import type { Server } from "bun";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
  INCIDENT_RULES,
  IncidentReducer,
  type IncidentNotification,
} from "./incidents";
import { MetricsRegistry } from "./metrics";
import { ClusterScheduler } from "./scheduler";
import { startServer } from "./server";
import { ControllerStore } from "./store";
import type { ControllerEvent } from "./events";

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

const matrixFixtures: Array<{
  id: string;
  reason: string;
  notification: IncidentNotification["severity"] | null;
}> = [
  { id: "transient-transport", reason: "transport-interrupted", notification: null },
  { id: "dead-lease", reason: "dead-lease", notification: "info" },
  { id: "repeated-command-not-found", reason: "capability-missing", notification: "page" },
  { id: "queue-stalled-while-idle", reason: "queue-stalled-while-idle", notification: "page" },
  { id: "fallback-lease-or-drain-stuck", reason: "fallback-lease-expired", notification: "page" },
  { id: "artifact-cas-mismatch", reason: "artifact-publication-blocked", notification: "high" },
  { id: "control-service-down", reason: "controller-down", notification: "page" },
];

describe("incident reducer", () => {
  let dir = "";
  let store: ControllerStore | undefined;

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

  function fixture(): {
    store: ControllerStore;
    reducer: IncidentReducer;
    notifications: IncidentNotification[];
  } {
    dir = mkdtempSync(join(tmpdir(), "controller-incidents-"));
    store = new ControllerStore(join(dir, "state.sqlite"), { now: () => NOW });
    const notifications: IncidentNotification[] = [];
    return {
      store,
      reducer: new IncidentReducer(store, { notify: (notification) => notifications.push(notification) }, () => NOW),
      notifications,
    };
  }

  test("implements one data rule for every page-vs-auto-heal matrix row", () => {
    expect(INCIDENT_RULES.map(({ id }) => id)).toEqual(matrixFixtures.map(({ id }) => id));
  });

  for (const matrix of matrixFixtures) {
    test(`${matrix.id} dedupes durable incident and applies notification policy`, () => {
      const { store, reducer, notifications } = fixture();
      const event = incidentEvent(matrix.reason);

      reducer.reduce(event);
      reducer.reduce(event);

      const incidents = store.listIncidents();
      expect(incidents).toHaveLength(1);
      expect(incidents[0]).toMatchObject({
        count: 2,
        affectedJobs: ["job-1"],
        state: "open",
      });
      expect(incidents[0]?.remediation).not.toBeEmpty();
      expect(incidents[0]?.autoResolveCondition).not.toBeEmpty();
      expect(notifications.map(({ severity }) => severity)).toEqual(
        matrix.notification ? [matrix.notification] : [],
      );
      expect(store.readEvents().filter(({ event }) => event.stage === "incident-opened")).toHaveLength(1);
    });
  }

  test("resolve event closes incident and emits lifecycle event", () => {
    const { store, reducer } = fixture();
    reducer.reduce(incidentEvent("capability-missing"));

    reducer.reduce(incidentEvent("capability-restored"));

    expect(store.listIncidents()[0]?.state).toBe("resolved");
    expect(store.readEvents().map(({ event }) => event.stage)).toEqual([
      "incident-opened",
      "incident-resolved",
    ]);
  });

  test("durable replay cursor prevents recount after restart", () => {
    const first = fixture();
    first.store.appendEvent(incidentEvent("dead-lease"));
    first.reducer.reducePending();
    first.store.close();

    store = new ControllerStore(join(dir, "state.sqlite"), { now: () => NOW });
    const restarted = new IncidentReducer(store, { notify: () => {} }, () => NOW);
    restarted.reducePending();

    expect(store.getIncident("dead-lease:debian1")?.count).toBe(1);
  });

  test("migrates legacy config incidents into durable incident records", () => {
    dir = mkdtempSync(join(tmpdir(), "controller-incidents-legacy-"));
    const dbPath = join(dir, "state.sqlite");
    const legacy = new Database(dbPath);
    legacy.exec(`
      CREATE TABLE incidents (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        kind TEXT NOT NULL,
        detail TEXT NOT NULL,
        created_at INTEGER NOT NULL
      )
    `);
    legacy.prepare(
      "INSERT INTO incidents (kind, detail, created_at) VALUES (?, ?, ?)",
    ).run("config-invalid-toml", "bad config", NOW);
    legacy.close();

    store = new ControllerStore(dbPath, { now: () => NOW });

    expect(store.listIncidents()).toEqual([{
      key: "config-invalid-toml:1",
      firstSeen: "2026-07-19T12:00:00.000Z",
      lastSeen: "2026-07-19T12:00:00.000Z",
      count: 1,
      affectedJobs: [],
      remediation: "bad config",
      cooldownUntil: null,
      autoResolveCondition: "operator resolves legacy incident",
      state: "open",
    }]);
  });

  test("critical package temperature pages and pauses only affected host until clear", () => {
    const { store, reducer, notifications } = fixture();
    store.upsertHost({ hostname: "debian1", slotsTotal: 1 });
    const scheduler = new ClusterScheduler(store, { now: () => NOW, ownerAlive: () => true });
    scheduler.enqueue({
      key: "hot-job",
      repo: "owner/repo",
      command: "build",
      owner: { pid: 1, starttime: 1 },
    });

    reducer.observeTemperature({ host: "debian1", pkg: 94, crit: 95 }, scheduler);
    expect(scheduler.reconcile()).toEqual([{ key: "hot-job", host: "debian1", kind: "builder" }]);
    scheduler.complete("hot-job", "cancelled");
    scheduler.enqueue({
      key: "critical-job",
      repo: "owner/repo",
      command: "build",
      owner: { pid: 2, starttime: 2 },
    });

    reducer.observeTemperature({ host: "debian1", pkg: 95, crit: 95 }, scheduler);
    expect(scheduler.reconcile()).toEqual([]);
    expect(notifications).toHaveLength(1);
    expect(store.listIncidents().find(({ key }) => key === "critical-temperature:debian1")?.state).toBe("open");

    reducer.observeTemperature({ host: "debian1", pkg: 90, crit: 95 }, scheduler);
    expect(scheduler.reconcile()).toEqual([{ key: "critical-job", host: "debian1", kind: "builder" }]);
    expect(store.getIncident("critical-temperature:debian1")?.state).toBe("resolved");
  });

  test("publishes alert-state metrics without adding incidents to status", () => {
    const { store, reducer } = fixture();
    reducer.reduce(incidentEvent("dead-lease"));
    const registry = new MetricsRegistry(store, undefined, () => NOW);

    expect(registry.query('build_offload_incident_alert_state{key!=""}').data.result).toEqual([
      {
        metric: {
          __name__: "build_offload_incident_alert_state",
          key: "dead-lease:debian1",
        },
        value: [NOW / 1000, "1"],
      },
    ]);
  });
});

describe("heartbeat", () => {
  test("is bearer-authenticated and reports revision with last event timestamp", async () => {
    const dir = mkdtempSync(join(tmpdir(), "controller-heartbeat-"));
    const store = new ControllerStore(join(dir, "state.sqlite"), { now: () => NOW });
    store.appendEvent(incidentEvent("box-drain"));
    const server: Server<undefined> = startServer({
      host: "127.0.0.1",
      port: 0,
      token: "heartbeat-token",
      store,
    });
    const endpoint = `http://127.0.0.1:${server.port}/heartbeat`;

    try {
      expect((await fetch(endpoint)).status).toBe(401);
      const response = await fetch(endpoint, {
        headers: { authorization: "Bearer heartbeat-token" },
      });
      expect(response.status).toBe(200);
      expect(await response.json()).toEqual({
        ok: true,
        revision: 1,
        lastEventTs: "2026-07-19T12:00:00.000Z",
      });
    } finally {
      server.stop(true);
      store.close();
      rmSync(dir, { recursive: true, force: true });
    }
  });
});

function incidentEvent(reason: string): ControllerEvent {
  return {
    ts: "2026-07-19T12:00:00.000Z",
    job: "job-1",
    repo: "owner/repo",
    host: "debian1",
    snapshot: "abc123",
    attempt: 1,
    stage: reason === "artifact-publication-blocked" ? "blocked" : "observed",
    reason,
    rc: reason === "capability-missing" ? 127 : null,
    durationSeconds: 0,
  };
}
