import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import {
  createIncidentService,
  IncidentMutationError,
  type BriefConfig,
} from "./incident-service";
import type { LoadedIncidentOptions } from "./dispatch-options";
import type {
  KanboardParams,
  KanboardProcedure,
  KanboardResult,
  KanboardRpcClient,
} from "./kanboard-client";
import { IncidentMutationLeaseError, IncidentMutationStore } from "./incident-mutation-store";

type TaskRecord = KanboardResult<"getTask">;
type TaskMetadata = KanboardResult<"getTaskMetadata">;

function mutationStoreAt(path: string): IncidentMutationStore {
  return new IncidentMutationStore(path);
}

function sharedStorePath(): string {
  return join(mkdtempSync("/tmp/overdeck-incident-launch-recovery-"), "mutations.sqlite");
}

function loadIncidentOptionsFixture(): () => Promise<LoadedIncidentOptions> {
  const internal = new Map([
    ["codex\0gpt-5.6-sol\0high\0work\0safe", { wrapperModel: "gpt-5.6-sol-high", permissionMode: "safe" as const, fixed: false, wrapper: "wrappers/codex.sh", timeoutSeconds: 3600 }],
  ]);
  return async () => ({
    types: [],
    clis: [{
      id: "codex",
      label: "Codex",
      models: [{ id: "gpt-5.6-sol", efforts: ["high"] }],
      accounts: [{ slug: "work", label: "Work", ready: true, fixed: false }],
      permissionModes: ["safe"],
    }],
    sourcePaths: [],
    internal,
  });
}

function briefConfigFixture(): BriefConfig {
  return {
    deps: {
      readAsset: (name) => (name === "taxonomy.json" ? "[]" : name === "dispatch-template.md" ? "Incident {{incident_id}}" : name === "placement-map.md" ? "map" : name === "never-touch.md" ? "never" : null),
      listKb: () => [],
      skillExists: () => true,
    },
    provenance: () => '{"sha":"fixture-sha"}',
  };
}

function baseReadiness() {
  return {
    status: "ready" as const,
    drift: [] as string[],
    projectId: 77,
    columnIds: { Filed: 101, Dispatching: 102, Running: 103, "Needs attention": 104, Resolved: 105 },
    swimlaneIds: { "P0 · Critical": 201, "P1 · High": 202, "P2 · Normal": 203, "P3 · Low": 204 },
    agentUserId: 801,
  };
}

function taskFixture(overrides: Partial<TaskRecord> = {}): TaskRecord {
  return {
    id: 200,
    title: "Recovery case",
    description: "Recover launch",
    date_creation: 1710000000,
    color_id: "blue",
    project_id: 77,
    column_id: 101,
    owner_id: 7,
    position: 1,
    is_active: true,
    date_completed: null,
    score: 10,
    date_due: 0,
    category_id: 0,
    creator_id: 11,
    date_modification: 1710000300,
    reference: "recover-me",
    date_started: 1710000000,
    time_spent: null,
    time_estimated: null,
    swimlane_id: 203,
    date_moved: 1710000000,
    recurrence_status: null,
    recurrence_counter: null,
    recurrence_parent: 0,
    priority: 2,
    external_provider: null,
    external_uri: null,
    url: "https://example.test/task/200",
    color: { name: "blue", background: "#123456", border: "#654321" },
    ...overrides,
  } as TaskRecord;
}

function filedDispatchMetadata(overrides: Partial<TaskMetadata> = {}): TaskMetadata {
  return {
    "overdeck.incident_id": "recover-me",
    "overdeck.cli": "codex",
    "overdeck.model": "gpt-5.6-sol",
    "overdeck.reasoning_effort": "high",
    "overdeck.account": "work",
    "overdeck.unsafe": "0",
    "overdeck.wrapper_model": "gpt-5.6-sol-high",
    "overdeck.request_sha256": "abc",
    ...overrides,
  } as TaskMetadata;
}

type CrashPoint = "after-claim" | "after-metadata" | "after-board" | "during-launch";

function createRecoveryHarness(seed: {
  incidentId?: string;
  task: TaskRecord;
  metadata: TaskMetadata;
  mutationStore?: IncidentMutationStore;
  now?: () => number;
  slowMetadataMs?: number;
  slowBoardMs?: number;
  slowLaunchMs?: number;
}) {
  const incidentId = seed.incidentId ?? "recover-me";
  const mutationStore = seed.mutationStore ?? mutationStoreAt(sharedStorePath());
  const calls: { method: string; params: Record<string, unknown> }[] = [];
  const launches: string[] = [];
  let crashPoint: CrashPoint | null = null;
  let task = seed.task;
  let metadata = seed.metadata;

  const client: KanboardRpcClient = {
    async call<M extends KanboardProcedure>(method: M, params: KanboardParams<M>): Promise<KanboardResult<M>> {
      calls.push({ method, params: params as Record<string, unknown> });

      if (method === "getTaskByReference") {
        const { reference } = params as KanboardParams<"getTaskByReference">;
        if (reference === incidentId) return task as KanboardResult<M>;
        throw new Error(`missing reference ${reference}`);
      }
      if (method === "getTask") {
        return task as KanboardResult<M>;
      }
      if (method === "getTaskMetadata") {
        return metadata as KanboardResult<M>;
      }
      if (method === "saveTaskMetadata") {
        metadata = (params as KanboardParams<"saveTaskMetadata">).values as TaskMetadata;
        if (seed.slowMetadataMs) await new Promise((resolve) => setTimeout(resolve, seed.slowMetadataMs));
        if (crashPoint === "after-metadata") throw new Error("crash after metadata");
        return true as KanboardResult<M>;
      }
      if (method === "moveTaskPosition") {
        const move = params as KanboardParams<"moveTaskPosition">;
        task = { ...task, column_id: move.column_id };
        if (seed.slowBoardMs) await new Promise((resolve) => setTimeout(resolve, seed.slowBoardMs));
        if (crashPoint === "after-board") throw new Error("crash after board");
        return true as KanboardResult<M>;
      }
      if (method === "createComment" || method === "getAllComments") {
        return (method === "getAllComments" ? [] : true) as KanboardResult<M>;
      }
      throw new Error(`unexpected method ${method}`);
    },
    async batch(callsArg) {
      const output: unknown[] = [];
      for (const item of callsArg) output.push(await client.call(item.method as never, item.params as never));
      return output as never;
    },
  };

  const originalClaimLaunch = mutationStore.claimLaunch.bind(mutationStore);
  mutationStore.claimLaunch = (...args) => {
    const result = originalClaimLaunch(...args);
    if (result.result === "claimed" && crashPoint === "after-claim") {
      throw new Error("crash after claim");
    }
    return result;
  };

  const service = createIncidentService({
    client,
    readiness: baseReadiness(),
    brief: briefConfigFixture(),
    loadIncidentOptions: loadIncidentOptionsFixture(),
    mutationStore,
    now: seed.now,
    launcher: {
      launch: async ({ dispatchId }) => {
        launches.push(dispatchId);
        if (seed.slowLaunchMs) await new Promise((resolve) => setTimeout(resolve, seed.slowLaunchMs));
        if (crashPoint === "during-launch") throw new Error("crash during launch");
        return { dispatchId, unit: `overdeck-incident-${incidentId}.service`, acceptedAt: new Date().toISOString() };
      },
    },
    workspaceFor: () => `/tmp/${incidentId}`,
    wrapperFor: () => "/opt/harness/wrappers/codex.sh",
    recordStatus: async () => undefined,
  });

  return {
    service,
    mutationStore,
    calls,
    launches,
    get task() { return task; },
    get metadata() { return metadata; },
    armCrash(point: CrashPoint) { crashPoint = point; },
    clearCrash() { crashPoint = null; },
  };
}

describe("incident launch recovery", () => {
  test("recovers after claim before metadata with the same dispatch id", async () => {
    const harness = createRecoveryHarness({
      task: taskFixture(),
      metadata: filedDispatchMetadata(),
    });
    harness.armCrash("after-claim");

    await expect(harness.service.dispatchIncident("recover-me")).rejects.toThrow("crash after claim");
    expect(harness.mutationStore.getLaunchClaim("recover-me")?.phase).toBe("claimed");
    const canonicalId = harness.mutationStore.getLaunchClaim("recover-me")!.dispatchId;

    harness.clearCrash();
    const result = await harness.service.dispatchIncident("recover-me");

    expect(result.state).toBe("dispatching");
    expect(harness.launches).toEqual([canonicalId]);
    expect(harness.mutationStore.getLaunchClaim("recover-me")?.phase).toBe("started");
    expect(harness.calls.filter((call) => call.method === "saveTaskMetadata")).toHaveLength(1);
  });

  test("recovers after metadata before board move", async () => {
    const harness = createRecoveryHarness({
      task: taskFixture(),
      metadata: filedDispatchMetadata(),
    });
    harness.armCrash("after-metadata");

    await expect(harness.service.dispatchIncident("recover-me")).rejects.toThrow("crash after metadata");
    const canonicalId = harness.mutationStore.getLaunchClaim("recover-me")!.dispatchId;
    expect(harness.mutationStore.getLaunchClaim("recover-me")?.phase).toBe("claimed");
    expect(harness.task.column_id).toBe(101);

    harness.clearCrash();
    await harness.service.dispatchIncident("recover-me");

    expect(harness.task.column_id).toBe(102);
    expect(harness.launches).toEqual([canonicalId]);
    expect(harness.mutationStore.getLaunchClaim("recover-me")?.phase).toBe("started");
  });

  test("recovers when stranded in Dispatching before launcher.launch", async () => {
    const harness = createRecoveryHarness({
      task: taskFixture(),
      metadata: filedDispatchMetadata(),
    });
    harness.armCrash("after-board");

    await expect(harness.service.dispatchIncident("recover-me")).rejects.toThrow("crash after board");
    const canonicalId = harness.mutationStore.getLaunchClaim("recover-me")!.dispatchId;
    expect(harness.task.column_id).toBe(102);
    expect(harness.mutationStore.getLaunchClaim("recover-me")?.phase).toBe("metadata");

    harness.clearCrash();
    const result = await harness.service.dispatchIncident("recover-me");

    expect(result.state).toBe("dispatching");
    expect(harness.launches).toEqual([canonicalId]);
    expect(harness.calls.filter((call) => call.method === "moveTaskPosition")).toHaveLength(1);
  });

  test("recovers after launcher crash without creating a new dispatch id", async () => {
    const harness = createRecoveryHarness({
      task: taskFixture(),
      metadata: filedDispatchMetadata(),
    });
    harness.armCrash("during-launch");

    await expect(harness.service.dispatchIncident("recover-me")).rejects.toThrow("crash during launch");
    const canonicalId = harness.mutationStore.getLaunchClaim("recover-me")!.dispatchId;
    expect(harness.mutationStore.getLaunchClaim("recover-me")?.phase).toBe("board");

    harness.clearCrash();
    await harness.service.dispatchIncident("recover-me");

    expect(harness.launches).toEqual([canonicalId, canonicalId]);
  });

  test("resumes a pre-stranded Dispatching task using the durable claim", async () => {
    const storePath = sharedStorePath();
    const mutationStore = mutationStoreAt(storePath);
    const incidentId = "stranded-dispatching";
    const dispatchId = "dispatch-stranded";
    const task = taskFixture({ id: 203, reference: incidentId, column_id: 102 });
    const metadata = filedDispatchMetadata({
      "overdeck.incident_id": incidentId,
      "overdeck.dispatch_id": dispatchId,
      "overdeck.dispatch_state": "starting",
      "overdeck.status_revision": "1",
    });
    const lockKey = `incident:${incidentId}`;
    const holder = "phase-seed";
    mutationStore.acquire(lockKey, holder, 1_000);
    mutationStore.claimLaunch(incidentId, task.id, dispatchId, 1_000);
    mutationStore.advanceLaunchPhase(incidentId, "claimed", "metadata", lockKey, holder, 1_000);
    mutationStore.advanceLaunchPhase(incidentId, "metadata", "board", lockKey, holder, 1_000);
    mutationStore.release(lockKey, holder);

    const harness = createRecoveryHarness({
      incidentId,
      task,
      metadata,
      mutationStore,
    });

    const result = await harness.service.dispatchIncident(incidentId);

    expect(result.state).toBe("dispatching");
    expect(harness.launches).toEqual([dispatchId]);
    expect(harness.calls.some((call) => call.method === "saveTaskMetadata")).toBe(false);
    expect(harness.calls.some((call) => call.method === "moveTaskPosition")).toBe(false);
  });

  test("stops before remote metadata when lease renewal is lost", async () => {
    const harness = createRecoveryHarness({
      task: taskFixture(),
      metadata: filedDispatchMetadata(),
    });
    harness.mutationStore.renewLease = () => false;

    await expect(harness.service.dispatchIncident("recover-me")).rejects.toBeInstanceOf(IncidentMutationLeaseError);
    expect(harness.calls.some((call) => call.method === "saveTaskMetadata")).toBe(false);
    expect(harness.calls.some((call) => call.method === "moveTaskPosition")).toBe(false);
    expect(harness.launches).toEqual([]);
  });

  test("holder-bound lease renewal survives slow side effects", async () => {
    const store = mutationStoreAt(sharedStorePath());
    let nowMs = 1_000;
    const holder = "holder-a";
    expect(store.acquire("incident:recover-me", holder, nowMs, 40)).toBe(true);
    nowMs += 30;
    expect(store.renewLease("incident:recover-me", holder, nowMs, 40)).toBe(true);
    nowMs += 20;
    expect(store.acquire("incident:recover-me", "rival", nowMs, 40)).toBe(false);
    expect(store.renewLease("incident:recover-me", holder, nowMs, 40)).toBe(true);
    nowMs += 50;
    store.release("incident:recover-me", holder);
    expect(store.acquire("incident:recover-me", "rival", nowMs, 40)).toBe(true);
  });

  test("rejects duplicate dispatch after launch reaches started phase", async () => {
    const harness = createRecoveryHarness({
      incidentId: "already-started",
      task: taskFixture({ id: 202, reference: "already-started" }),
      metadata: filedDispatchMetadata({ "overdeck.incident_id": "already-started" }),
    });

    await harness.service.dispatchIncident("already-started");
    expect(harness.mutationStore.getLaunchClaim("already-started")?.phase).toBe("started");

    await expect(harness.service.dispatchIncident("already-started")).rejects.toMatchObject({
      code: "not-filed",
    } satisfies Partial<IncidentMutationError>);
  });
});
