import { describe, expect, test } from "bun:test";
import {
  createAgentSessionsAdapter,
  type AgentSessionEntry,
  type AgentSessionState,
  type ClassifiedAgentSession,
} from "./agent-sessions";

const NOW_ISO = "2026-08-07T01:00:00.000Z";
const NOW_MS = Date.parse(NOW_ISO);

function entry(overrides: Partial<AgentSessionEntry> = {}): AgentSessionEntry {
  return {
    schemaVersion: 1,
    ledgerId: "ledger-1",
    runtime: "claude",
    startedAt: "2026-08-07T00:00:00.000Z",
    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: 1234,
    parentLedgerId: null,
    mux: { kind: null, socket: null, target: null },
    pid: 1234,
    pidStartTicks: 100,
    sessionId: "session-1",
    transcriptPath: "/home/user/.claude/projects/x/transcript.jsonl",
    lastHeartbeatAt: "2026-08-07T00:50:00.000Z",
    finishedAt: null,
    finishReason: null,
    ...overrides,
  };
}

function classified(
  state: AgentSessionState,
  overrides: Partial<ClassifiedAgentSession> = {},
): ClassifiedAgentSession {
  return {
    ...entry(),
    state,
    attached: state === "ALIVE-WORKING" || state === "ALIVE-IDLE",
    lastProgressAt: "2026-08-07T00:50:00.000Z",
    idleMs: 60_000,
    dirtyCount: 0,
    evidence: "test-evidence",
    ...overrides,
  };
}

function adapterWith(
  sessions: ClassifiedAgentSession[],
  overrides: Record<string, unknown> = {},
) {
  return createAgentSessionsAdapter({
    dir: "/fake/agent-sessions",
    now: () => NOW_MS,
    existsImpl: () => true,
    readdirImpl: () => [],
    readFileImpl: () => "{}",
    classifyImpl: async () => sessions,
    ...overrides,
  });
}

describe("createAgentSessionsAdapter", () => {
  test("ORPHANED with dirty files -> act/alert item", async () => {
    const result = await adapterWith([
      classified("ORPHANED", { ledgerId: "a", dirtyCount: 3 }),
    ]).poll();
    const item = result.items.find((i) => i.id === "agent-session:a")!;
    expect(item).toBeDefined();
    expect(item.severity).toBe("act");
    expect(item.kind).toBe("alert");
    expect(item.title).toBe("Abandoned claude session in overdeck (3 uncommitted files)");
    expect(item.actions).toEqual([{ verb: "resume", args: { ledgerId: "a" }, label: "Resume session" }]);
  });

  test("ORPHANED with no dirty files -> warn/alert item", async () => {
    const result = await adapterWith([
      classified("ORPHANED", { ledgerId: "b", dirtyCount: 0 }),
    ]).poll();
    const item = result.items.find((i) => i.id === "agent-session:b")!;
    expect(item.severity).toBe("warn");
    expect(item.kind).toBe("alert");
    expect(item.title).toBe("Abandoned claude session in overdeck");
  });

  test("DETACHED-ALIVE -> warn/progress item with attach action", async () => {
    const result = await adapterWith([
      classified("DETACHED-ALIVE", { ledgerId: "c" }),
    ]).poll();
    const item = result.items.find((i) => i.id === "agent-session:c")!;
    expect(item.severity).toBe("warn");
    expect(item.kind).toBe("progress");
    expect(item.title).toBe("Detached claude session in overdeck");
    expect(item.actions).toEqual([
      { verb: "attach", args: { ledgerId: "c" }, label: "Reopen session", recommended: true },
    ]);
    expect(item.title).not.toMatch(/dtach|tmux|mux/i);
  });

  test("ALIVE-WORKING and ALIVE-IDLE -> info/progress items", async () => {
    const result = await adapterWith([
      classified("ALIVE-WORKING", { ledgerId: "d" }),
      classified("ALIVE-IDLE", { ledgerId: "e" }),
    ]).poll();
    const working = result.items.find((i) => i.id === "agent-session:d")!;
    const idle = result.items.find((i) => i.id === "agent-session:e")!;
    expect(working.severity).toBe("info");
    expect(working.kind).toBe("progress");
    expect(idle.severity).toBe("info");
    expect(idle.kind).toBe("progress");
    expect(working.actions[0]!.recommended).toBe(true);
  });

  test("FINISHED session excluded from items but present in panel within 24h", async () => {
    const result = await adapterWith([
      classified("FINISHED", { ledgerId: "f", finishedAt: "2026-08-07T00:30:00.000Z" }),
    ]).poll();
    expect(result.items.find((i) => i.id === "agent-session:f")).toBeUndefined();
    const panel = result.panels.find((p) => p.id === "agent-sessions")!;
    const data = panel.data as { sessions: Array<{ project: string | null; sessions: ClassifiedAgentSession[] }> };
    const found = data.sessions.flatMap((g) => g.sessions).find((s) => s.ledgerId === "f");
    expect(found).toBeDefined();
  });

  test("reconciles only canonically rescued dead sessions with an exact request identity", async () => {
    const calls: Array<{ sessionId: string; dirtyCount: number }> = [];
    const result = await adapterWith([
      classified("FINISHED", {
        ledgerId: "dead-dirty",
        sessionId: "session-exact",
        dirtyCount: null,
        finishedAt: NOW_ISO,
        finishReason: "process exited",
        rescuedPaths: 3,
      }),
      classified("ORPHANED", { ledgerId: "birth-window", sessionId: "session-birth", dirtyCount: 4 }),
      classified("FINISHED", {
        ledgerId: "dead-clean",
        sessionId: "session-clean",
        finishedAt: NOW_ISO,
        finishReason: "process exited",
        rescuedPaths: 0,
      }),
      classified("FINISHED", {
        ledgerId: "ended-cleanly",
        sessionId: "session-ended",
        finishedAt: NOW_ISO,
        finishReason: "SessionEnd",
        rescuedPaths: 2,
      }),
      classified("FINISHED", {
        ledgerId: "missing-session",
        sessionId: null,
        finishedAt: NOW_ISO,
        finishReason: "process exited",
        rescuedPaths: 5,
      }),
    ], {
      requests: {
        orphanSession(sessionId: string, dirtyCount: number) {
          calls.push({ sessionId, dirtyCount });
          return [];
        },
      },
    }).poll();

    expect(calls).toEqual([{ sessionId: "session-exact", dirtyCount: 3 }]);
    expect(result.items.find((item) => item.id === "agent-session:dead-dirty")).toBeUndefined();
    expect(result.items.find((item) => item.id === "agent-session:birth-window")).toBeDefined();
  });

  test("request reconciliation failure does not hide session telemetry", async () => {
    const result = await adapterWith([
      classified("FINISHED", {
        ledgerId: "dead-dirty",
        sessionId: "session-exact",
        dirtyCount: null,
        finishedAt: NOW_ISO,
        finishReason: "process exited",
        rescuedPaths: 2,
      }),
    ], {
      requests: {
        orphanSession() {
          throw new Error("database unavailable");
        },
      },
    }).poll();

    const panel = result.panels.find((candidate) => candidate.id === "agent-sessions")!;
    const data = panel.data as { sessions: Array<{ sessions: ClassifiedAgentSession[] }> };
    expect(data.sessions.flatMap((group) => group.sessions).some((session) => session.ledgerId === "dead-dirty")).toBe(true);
  });

  test("FINISHED session older than retention window excluded from panel too", async () => {
    const result = await adapterWith([
      classified("FINISHED", { ledgerId: "g", finishedAt: "2026-08-05T00:00:00.000Z" }),
    ]).poll();
    const panel = result.panels.find((p) => p.id === "agent-sessions")!;
    const data = panel.data as { sessions: Array<{ sessions: ClassifiedAgentSession[] }> };
    const found = data.sessions.flatMap((g) => g.sessions).find((s) => s.ledgerId === "g");
    expect(found).toBeUndefined();
  });

  test("completeScopes always includes agent-sessions on a successful poll", async () => {
    const result = await adapterWith([]).poll();
    expect(result.completeScopes).toEqual(["agent-sessions"]);
  });

  test("skips corrupt JSON and wrong-schemaVersion files, keeps parsable ones (warn once)", async () => {
    let capturedEntries: AgentSessionEntry[] = [];
    const adapter = createAgentSessionsAdapter({
      dir: "/fake/agent-sessions",
      now: () => NOW_MS,
      existsImpl: () => true,
      readdirImpl: () => ["bad-json.json", "wrong-version.json", "not-json.txt", "good.json"],
      readFileImpl: (path) => {
        if (path.endsWith("bad-json.json")) return "{not valid json";
        if (path.endsWith("wrong-version.json")) return JSON.stringify(entry({ schemaVersion: 2 as unknown as 1 }));
        return JSON.stringify(entry({ ledgerId: "good" }));
      },
      classifyImpl: async (entries) => {
        capturedEntries = entries;
        return [];
      },
    });

    await adapter.poll();
    expect(capturedEntries).toHaveLength(1);
    expect(capturedEntries[0]!.ledgerId).toBe("good");
  });

  test("classify import/impl failure -> poll throws (adapter.ts: retains previously known state)", async () => {
    const adapter = createAgentSessionsAdapter({
      dir: "/fake/agent-sessions",
      now: () => NOW_MS,
      existsImpl: () => true,
      readdirImpl: () => [],
      readFileImpl: () => "{}",
      classifyImpl: async () => {
        throw new Error("Cannot find module 'agent-session-reader.mjs'");
      },
    });

    await expect(adapter.poll()).rejects.toThrow();
  });

  test("groups panel sessions per project, newest lastProgressAt first", async () => {
    const result = await adapterWith([
      classified("ALIVE-WORKING", {
        ledgerId: "p1-old",
        project: "overdeck",
        lastProgressAt: "2026-08-07T00:10:00.000Z",
      }),
      classified("DETACHED-ALIVE", {
        ledgerId: "p2-newest",
        project: "multideal",
        lastProgressAt: "2026-08-07T00:55:00.000Z",
      }),
      classified("ALIVE-IDLE", {
        ledgerId: "p1-new",
        project: "overdeck",
        lastProgressAt: "2026-08-07T00:40:00.000Z",
      }),
    ]).poll();

    const panel = result.panels.find((p) => p.id === "agent-sessions")!;
    const data = panel.data as {
      sessions: Array<{ project: string | null; sessions: ClassifiedAgentSession[] }>;
    };

    expect(data.sessions.map((g) => g.project)).toEqual(["multideal", "overdeck"]);
    const overdeckGroup = data.sessions.find((g) => g.project === "overdeck")!;
    expect(overdeckGroup.sessions.map((s) => s.ledgerId)).toEqual(["p1-new", "p1-old"]);
  });
});
