import { describe, expect, test } from "bun:test";
import type { IncidentsReadiness } from "./bootstrap";
import type { KanboardRpcClient } from "./kanboard-client";
import { createIncidentStatusWriter, IncidentStatusRevisionError } from "./status-writer";
import { IncidentStatusStore } from "./status-store";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";

const readiness: IncidentsReadiness = {
  status: "ready", drift: [], projectId: 7,
  columnIds: { Filed: 1, Dispatching: 2, Running: 3, "Needs attention": 4, Resolved: 5 },
  swimlaneIds: { "P0 · Critical": 10, "P1 · High": 11, "P2 · Normal": 12, "P3 · Low": 13 },
  agentUserId: 99,
};

function fixture(initial: Record<string, string> = {}, comments: string[] = [], taskState = { column: 2, active: true }) {
  let metadata = {
    "overdeck.incident_id": "inc-42",
    "overdeck.dispatch_id": "d1",
    "overdeck.dispatch_state": "starting",
    "overdeck.status_revision": "1",
    ...initial,
  };
  let column = taskState.column;
  let active = taskState.active;
  const calls: Array<{ method: string; params: any }> = [];
  const client = {
    async call(method: string, params: any) {
      calls.push({ method, params });
      if (method === "getTask") return { id: 42, project_id: 7, column_id: column, swimlane_id: 12, is_active: active };
      if (method === "getTaskMetadata") return { ...metadata };
      if (method === "saveTaskMetadata") { metadata = { ...params.values }; return true; }
      if (method === "moveTaskPosition") { column = params.column_id; return true; }
      if (method === "getAllComments") return comments.map((comment, id) => ({ id, comment }));
      if (method === "createComment") { comments.push(params.content); return comments.length; }
      if (method === "closeTask") { active = false; return true; }
      throw new Error(`unexpected ${method}`);
    },
    async batch() { throw new Error("unused"); },
  } as unknown as KanboardRpcClient;
  const store = new IncidentStatusStore(join(mkdtempSync("/tmp/overdeck-status-writer-"), "status.sqlite"));
  return { writer: createIncidentStatusWriter(client, readiness, store), store, calls, metadata, comments };
}

describe("incident status writer", () => {
  test("writes metadata, column, deduped comment, then closes a resolution", async () => {
    const f = fixture({ "overdeck.status_revision": "1" });
    const update = { incidentId: "inc-42", taskId: 42, dispatchId: "d1", revision: 2, state: "resolved" as const, at: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:00:00.000Z", exitCode: 0, resultSummary: "fixed", comment: "Resolved by agent: fixed" };
    await f.writer(update);
    await f.writer(update);
    const mutations = f.calls.filter(({ method }) => ["saveTaskMetadata", "moveTaskPosition", "createComment", "closeTask"].includes(method));
    expect(mutations.slice(0, 4).map(({ method }) => method)).toEqual(["saveTaskMetadata", "moveTaskPosition", "createComment", "closeTask"]);
    expect(f.calls.filter(({ method }) => method === "saveTaskMetadata")).toHaveLength(1);
    expect(f.calls.filter(({ method }) => method === "createComment")).toHaveLength(1);
    expect(f.calls.filter(({ method }) => method === "closeTask")).toHaveLength(1);
    expect(f.comments[0]).toStartWith("overdeck:d1:2\n");
  });

  test("heartbeat only advances metadata when already in Running", async () => {
    const f = fixture({ "overdeck.status_revision": "2" });
    await f.writer({ incidentId: "inc-42", taskId: 42, dispatchId: "d1", revision: 3, state: "running", at: "2026-01-01T00:00:15.000Z", startedAt: "2026-01-01T00:00:00.000Z", heartbeatAt: "2026-01-01T00:00:15.000Z" });
    expect(f.calls.filter(({ method }) => method === "saveTaskMetadata")).toHaveLength(1);
    expect(f.calls.some(({ method }) => method === "createComment")).toBe(false);
  });

  test("rejects a conflicting authoritative dispatch before mutation", async () => {
    const f = fixture({ "overdeck.dispatch_id": "other" });
    await expect(f.writer({ incidentId: "inc-42", taskId: 42, dispatchId: "d1", revision: 2, state: "running", at: "x" })).rejects.toThrow("different dispatch");
    expect(f.calls.filter(({ method }) => !["getTask", "getTaskMetadata"].includes(method))).toEqual([]);
  });

  test("rejects a foreign authoritative incident before mutation", async () => {
    const f = fixture({ "overdeck.incident_id": "other-incident" });
    await expect(f.writer({ incidentId: "inc-42", taskId: 42, dispatchId: "d1", revision: 2, state: "running", at: "x" })).rejects.toThrow("projection identity");
    expect(f.calls.filter(({ method }) => !["getTask", "getTaskMetadata"].includes(method))).toEqual([]);
  });

  test("treats an exact authoritative terminal projection as idempotent", async () => {
    const update = { incidentId: "inc-42", taskId: 42, dispatchId: "d1", revision: 2, state: "resolved" as const, at: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:00:00.000Z" };
    const f = fixture({
      "overdeck.dispatch_state": "resolved",
      "overdeck.status_revision": "2",
      "overdeck.completed_at": update.completedAt,
    }, [], { column: 5, active: false });
    await f.writer(update);
    expect(f.calls.filter(({ method }) => !["getTask", "getTaskMetadata"].includes(method))).toEqual([]);
  });
  test("rejects lower and conflicting equal revisions", async () => {
    const f = fixture();
    await expect(f.writer({ incidentId: "inc-42", taskId: 42, dispatchId: "d1", revision: 3, expectedRevision: 2, state: "running", at: "x" })).rejects.toThrow();
    await expect(f.writer({ incidentId: "inc-42", taskId: 42, dispatchId: "d1", revision: 4, expectedRevision: 3, state: "running", at: "x" })).rejects.toThrow();
  });

  test("stale projector cannot continue after a mutation loses its lease", async () => {
    const path = join(mkdtempSync("/tmp/overdeck-status-writer-fence-"), "status.sqlite");
    const first = new IncidentStatusStore(path);
    const second = new IncidentStatusStore(path);
    let now = 1_000;
    let moves = 0;
    let replacementClaimed = false;
    const client = {
      async call(method: string) {
        if (method === "getTask") return { id: 42, project_id: 7, column_id: 2, swimlane_id: 12, is_active: true };
        if (method === "getTaskMetadata") return { "overdeck.incident_id": "inc-42", "overdeck.dispatch_id": "d1", "overdeck.dispatch_state": "starting", "overdeck.status_revision": "1" };
        if (method === "saveTaskMetadata") {
          now = 31_001;
          replacementClaimed = second.claim("inc-42", "replacement", now) !== null;
          return true;
        }
        if (method === "moveTaskPosition") { moves += 1; return true; }
        throw new Error(`unexpected ${method}`);
      },
      async batch() { throw new Error("unused"); },
    } as unknown as KanboardRpcClient;
    const writer = createIncidentStatusWriter(client, readiness, first, () => now);

    await expect(writer({ incidentId: "inc-42", taskId: 42, dispatchId: "d1", revision: 2, state: "resolved", at: "x" })).rejects.toThrow("projection lease lost");
    expect(replacementClaimed).toBe(true);
    expect(moves).toBe(0);
  });
  test("continues reconciliation after one incident projection fails", async () => {
    const store = new IncidentStatusStore(join(mkdtempSync("/tmp/overdeck-status-reconcile-"), "status.sqlite"));
    store.commit({ incidentId: "bad", taskId: 41, dispatchId: "bad-d", revision: 2, expectedRevision: 1, state: "running", at: "x" });
    store.commit({ incidentId: "good", taskId: 42, dispatchId: "good-d", revision: 2, expectedRevision: 1, state: "running", at: "x" });
    let goodRevision = "1";
    const client = {
      async call(method: string, params: any) {
        if (method === "getTask") return { id: params.task_id, project_id: 7, column_id: params.task_id === 41 ? 99 : 3, swimlane_id: 12, is_active: true };
        if (method === "getTaskMetadata") return params.task_id === 41
          ? { "overdeck.incident_id": "foreign", "overdeck.dispatch_id": "bad-d", "overdeck.dispatch_state": "starting", "overdeck.status_revision": "1" }
          : { "overdeck.incident_id": "good", "overdeck.dispatch_id": "good-d", "overdeck.dispatch_state": "running", "overdeck.status_revision": goodRevision };
        if (method === "saveTaskMetadata") { goodRevision = params.values["overdeck.status_revision"]; return true; }
        throw new Error(`unexpected ${method}`);
      },
      async batch() { throw new Error("unused"); },
    } as unknown as KanboardRpcClient;
    const writer = createIncidentStatusWriter(client, readiness, store);

    await expect(writer.reconcile()).rejects.toBeInstanceOf(AggregateError);
    expect(goodRevision).toBe("2");
    expect(store.pendingIncidentIds()).toEqual(["bad"]);
  });

  test("reconciles a committed receipt after restart and does not replay projected effects", async () => {
    const f = fixture();
    f.store.commit({ incidentId: "inc-42", taskId: 42, dispatchId: "d1", revision: 2, expectedRevision: 1, state: "resolved", at: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:00:00.000Z", comment: "done" });

    await f.writer.reconcile();
    const mutationCount = f.calls.filter(({ method }) => ["saveTaskMetadata", "moveTaskPosition", "createComment", "closeTask"].includes(method)).length;
    await f.writer.reconcile();

    expect(mutationCount).toBe(4);
    expect(f.calls.filter(({ method }) => ["saveTaskMetadata", "moveTaskPosition", "createComment", "closeTask"].includes(method))).toHaveLength(mutationCount);
  });
});
