import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { normalizeRecord, readLedger, type ClassifiedEntry } from "./ledger";

function ledgerRoot(): string {
  return mkdtempSync(join(tmpdir(), "overdeck-ledger-"));
}

function withSessions(entries: Record<string, unknown>[]): string {
  const root = ledgerRoot();
  mkdirSync(join(root, "sessions"), { recursive: true });
  for (const entry of entries) {
    writeFileSync(join(root, "sessions", `${entry.ledgerId}.json`), JSON.stringify(entry));
  }
  return root;
}

const ENTRY = {
  schemaVersion: 1,
  ledgerId: "claude-20260807T030000Z-482913",
  runtime: "claude",
  startedAt: "2026-08-07T03:00:00Z",
  host: "e14",
  bootId: "boot-1",
  cwd: "/home/user/Projects/overdeck",
  repoRoot: "/home/user/Projects/overdeck",
  project: "overdeck",
  branch: "main",
  worktree: false,
  tty: "/dev/pts/3",
  launcherPid: 4242,
  parentLedgerId: null,
  mux: { kind: "dtach", socket: "/run/sock/x", target: "claude-20260807T030000Z-482913" },
  pid: null,
  pidStartTicks: null,
  sessionId: "c0c245b9-d222-46a3-b6e9-d16f2626c5f9",
  transcriptPath: "/home/user/.claude/projects/x.jsonl",
  lastHeartbeatAt: "2026-08-07T03:30:00Z",
  finishedAt: null,
};

/** Stands in for the recorder's classifier, which reads /proc on the live host. */
function classifierReturning(decorate: (entry: Record<string, unknown>) => Record<string, unknown>) {
  return async (entries: unknown[]) =>
    entries.map((entry) => decorate(entry as Record<string, unknown>)) as ClassifiedEntry[];
}

const aliveClassifier = classifierReturning((entry) => ({
  ...entry,
  state: "DETACHED-ALIVE",
  pid: 4242,
}));

describe("readLedger", () => {
  test("a ledger that was never written reports missing, never invents sessions", async () => {
    const result = await readLedger(join(tmpdir(), "overdeck-ledger-does-not-exist"));
    expect(result.missing).toBe(true);
    expect(result.sessions).toEqual([]);
  });

  test("a ledger root without a sessions directory reports missing", async () => {
    const result = await readLedger(ledgerRoot(), { classifyImpl: aliveClassifier });
    expect(result.missing).toBe(true);
  });

  test("an armed but empty ledger is present and empty", async () => {
    const root = withSessions([]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.missing).toBe(false);
    expect(result.sessions).toEqual([]);
  });

  test("a schemaVersion 1 entry becomes a record carrying its resume id", async () => {
    const root = withSessions([ENTRY]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions).toHaveLength(1);
    expect(result.sessions[0]).toMatchObject({
      id: ENTRY.ledgerId,
      runtime: "claude",
      pid: 4242,
      cwd: ENTRY.cwd,
      repo: "overdeck",
      branch: "main",
      worktree: null,
      startedAt: "2026-08-07T03:00:00.000Z",
      lastHeartbeatAt: "2026-08-07T03:30:00.000Z",
      transcriptPath: ENTRY.transcriptPath,
      state: "DETACHED-ALIVE",
      resumeId: ENTRY.sessionId,
    });
  });

  test("a linked worktree reports its own directory as the worktree", async () => {
    const root = withSessions([{ ...ENTRY, worktree: true }]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions[0]!.worktree).toBe(ENTRY.cwd);
  });

  test("a session with no runtime session id yet carries no resume id", async () => {
    const root = withSessions([{ ...ENTRY, sessionId: null }]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions[0]!.resumeId).toBeNull();
  });

  test("the dtach socket is never reported as a tmux target", async () => {
    const root = withSessions([ENTRY]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions[0]!.tmuxSession).toBeNull();
    expect(result.sessions[0]!.tmuxSocket).toBeNull();
  });

  test("a human session records the tmux target it can be reopened through", async () => {
    const root = withSessions([{
      ...ENTRY,
      mux: null,
      tmuxSession: "overdeck-141207",
      tmuxSocket: "/home/user/.local/state/human-session/tmux.sock",
    }]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions[0]!.tmuxSession).toBe("overdeck-141207");
    expect(result.sessions[0]!.tmuxSocket).toBe("/home/user/.local/state/human-session/tmux.sock");
  });

  test("a tmux host discovered from process ancestry becomes a browser target", async () => {
    const root = withSessions([{
      ...ENTRY,
      mux: { kind: "tmux", socket: "/run/agent-sessions/s1.sock", target: "main" },
    }]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions[0]!.tmuxSession).toBe("main");
    expect(result.sessions[0]!.tmuxSocket).toBe("/run/agent-sessions/s1.sock");
  });

  test("unparseable and wrong-schema files are reported, not silently dropped", async () => {
    const root = withSessions([ENTRY]);
    writeFileSync(join(root, "sessions", "broken.json"), "{not json");
    writeFileSync(join(root, "sessions", "v2.json"), JSON.stringify({ schemaVersion: 2, ledgerId: "x", cwd: "/tmp" }));
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.unreadable.sort()).toEqual(["broken.json", "v2.json"]);
    expect(result.sessions).toHaveLength(1);
  });

  test("a name readdir lists but whose bytes are gone by read time is skipped, not a crash or a fabricated entry", async () => {
    // A dangling symlink reproduces the readdir-then-read race deterministically: the dirent
    // exists (readdirSync lists it, as it would for a file the pruner just unlinked), but
    // readFileSync following it throws ENOENT exactly as it would on the real race.
    const root = withSessions([ENTRY]);
    symlinkSync(join(root, "sessions", "does-not-exist.json"), join(root, "sessions", "pruned-mid-read.json"));
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions.map((s) => s.id)).toEqual([ENTRY.ledgerId]);
    expect(result.unreadable).toContain("pruned-mid-read.json");
  });

  test("an unrecognized liveness state is rejected rather than guessed", async () => {
    const root = withSessions([ENTRY]);
    const result = await readLedger(root, {
      classifyImpl: classifierReturning((entry) => ({ ...entry, state: "PROBABLY-FINE" })),
    });
    expect(result.sessions).toEqual([]);
    expect(result.unreadable).toEqual([`${ENTRY.ledgerId}.json`]);
  });

  test("a classifier that cannot run makes the ledger unavailable, never wrong", async () => {
    const root = withSessions([ENTRY]);
    const result = await readLedger(root, {
      classifyImpl: async () => {
        throw new Error("classifier not installed");
      },
    });
    expect(result.missing).toBe(true);
    expect(result.sessions).toEqual([]);
  });

  test("host, parent, mux and the classifier's own verdict all reach the record", async () => {
    const root = withSessions([{ ...ENTRY, parentLedgerId: "claude-parent-1" }]);
    const result = await readLedger(root, {
      classifyImpl: classifierReturning((entry) => ({
        ...entry,
        state: "DETACHED-ALIVE",
        attached: false,
        lastProgressAt: "2026-08-07T03:45:00Z",
        evidence: "running with no window attached",
      })),
    });
    expect(result.sessions[0]).toMatchObject({
      host: "e14",
      repoRoot: "/home/user/Projects/overdeck",
      parentLedgerId: "claude-parent-1",
      mux: { kind: "dtach", socket: "/run/sock/x", target: ENTRY.ledgerId },
      attached: false,
      lastProgressAt: "2026-08-07T03:45:00.000Z",
      evidence: "running with no window attached",
    });
  });

  test("recorder fields the panel renders are null until the recorder stamps them, never guessed", async () => {
    const root = withSessions([ENTRY]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions[0]).toMatchObject({
      title: null,
      activity: null,
      launchedBy: null,
      account: null,
    });
  });

  test("recorder-stamped title, activity, launcher and account are carried verbatim", async () => {
    const root = withSessions([{
      ...ENTRY,
      title: "close the ledger enrollment gap",
      activity: "editing agent-session-ledger.sh",
      launchedBy: "factory",
      account: "zync2",
    }]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions[0]).toMatchObject({
      title: "close the ledger enrollment gap",
      activity: "editing agent-session-ledger.sh",
      launchedBy: "factory",
      account: "zync2",
    });
  });

  test("classifies every unfinished entry but only the newest 100 finished entries", async () => {
    const entries: Array<Record<string, unknown>> = Array.from({ length: 101 }, (_, index) => ({
      ...ENTRY,
      ledgerId: `finished-${String(index).padStart(3, "0")}`,
      finishedAt: new Date(Date.parse("2026-08-07T03:40:00Z") + index * 1000).toISOString(),
    }));
    entries.push({ ...ENTRY, ledgerId: "live", finishedAt: null });
    const root = withSessions(entries);
    let classifiedIds: string[] = [];

    await readLedger(root, {
      classifyImpl: async (classifiedEntries) => {
        classifiedIds = classifiedEntries.map((entry) => (entry as { ledgerId: string }).ledgerId);
        return classifiedEntries.map((entry) => ({ ...(entry as object), state: "FINISHED" })) as ClassifiedEntry[];
      },
    });

    expect(classifiedIds).toHaveLength(101);
    expect(classifiedIds).toContain("live");
    expect(classifiedIds).not.toContain("finished-000");
  });

  test("the closer's own words for why a session ended reach the record verbatim", async () => {
    const root = withSessions([{
      ...ENTRY,
      finishedAt: "2026-08-07T03:40:00Z",
      finishReason: "process exited, nothing was recorded to recover",
    }]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions[0]!.finishReason).toBe("process exited, nothing was recorded to recover");
  });

  test("a session nobody said anything about carries no finish reason", async () => {
    const root = withSessions([{ ...ENTRY }]);
    const result = await readLedger(root, { classifyImpl: aliveClassifier });
    expect(result.sessions[0]!.finishReason).toBeNull();
  });

  test("no progress timestamp is ever back-filled from the session's start time", async () => {
    const root = withSessions([{ ...ENTRY, lastHeartbeatAt: null }]);
    const result = await readLedger(root, {
      classifyImpl: classifierReturning((entry) => ({ ...entry, state: "ALIVE-IDLE" })),
    });
    expect(result.sessions[0]!.lastProgressAt).toBeNull();
    expect(result.sessions[0]!.startedAt).toBe("2026-08-07T03:00:00.000Z");
  });
});

describe("normalizeRecord", () => {
  test("an entry with no ledger id is rejected", () => {
    expect(normalizeRecord({ schemaVersion: 1, cwd: "/tmp", state: "ORPHANED" })).toBeNull();
  });

  test("an entry with no cwd is rejected", () => {
    expect(normalizeRecord({ schemaVersion: 1, ledgerId: "a", state: "ORPHANED" })).toBeNull();
  });

  test("lowercase and underscored state spellings normalize", () => {
    const record = normalizeRecord({
      schemaVersion: 1,
      ledgerId: "a",
      cwd: "/tmp",
      state: "alive_working",
    });
    expect(record?.state).toBe("ALIVE-WORKING");
  });
});
