import { describe, expect, test } from "bun:test";
import { createSessionsAdapter, sessionFromPanel, type SessionsPanelData } from "./sessions";
import { sessionsAttachPosture } from "./index";
import { defaultConfig } from "../config";
import type { HostFacts } from "../sessions/hosts";
import type { LedgerReadResult, SessionRecord } from "../sessions/ledger";
import type { RemoteHostProbe } from "../sessions/remote";

const LOCAL = "e14";

function record(overrides: Partial<SessionRecord> = {}): SessionRecord {
  return {
    id: "s1",
    runtime: "claude",
    pid: 100,
    cwd: "/home/user/Projects/overdeck",
    repo: "overdeck",
    repoRoot: "/home/user/Projects/overdeck",
    branch: "main",
    worktree: "/home/user/Projects/overdeck",
    host: LOCAL,
    startedAt: "2026-08-07T03:00:00.000Z",
    lastHeartbeatAt: "2026-08-07T03:30:00.000Z",
    lastProgressAt: "2026-08-07T03:30:00.000Z",
    finishedAt: null,
    finishReason: null,
    transcriptPath: "/t.jsonl",
    tmuxSession: "agent-s1",
    tmuxSocket: "/run/tmux/sock",
    mux: null,
    attached: false,
    evidence: "running with no window attached",
    lastActiveAt: "2026-08-07T03:30:00.000Z",
    parent: null,
    parentLedgerId: null,
    state: "DETACHED-ALIVE",
    resumeId: "c0c245b9-d222-46a3-b6e9-d16f2626c5f9",
    launchedBy: null,
    title: null,
    activity: null,
    account: null,
    ...overrides,
  };
}

function ledger(sessions: SessionRecord[], overrides: Partial<LedgerReadResult> = {}): LedgerReadResult {
  return { sessions, dir: "/ledger", missing: false, unreadable: [], ...overrides };
}

const HOSTS: HostFacts[] = [
  { name: "debian1", reachability: "declared-enabled", state: "reachable", notes: null },
  { name: "debian3", reachability: "declared-disabled", state: "bricked", notes: "bricked 2026-08-07" },
];

function adapter(options: {
  sessions: SessionRecord[];
  attachAllowed?: boolean;
  attachBlockedReason?: string;
  tmux?: string | null;
  ledgerOverrides?: Partial<LedgerReadResult>;
  hosts?: HostFacts[];
  hostsMissing?: boolean;
  home?: string;
  probeRemoteImpl?: (options: { localHost: string }) => Promise<RemoteHostProbe[]>;
}) {
  return createSessionsAdapter({
    attachAllowed: options.attachAllowed ?? true,
    ...(options.attachBlockedReason ? { attachBlockedReason: options.attachBlockedReason } : {}),
    readLedgerImpl: () => ledger(options.sessions, options.ledgerOverrides ?? {}),
    tmuxBinaryImpl: () => (options.tmux === undefined ? "/usr/bin/tmux" : options.tmux),
    readHostRegistryImpl: () => ({ hosts: options.hosts ?? HOSTS, missing: options.hostsMissing ?? false }),
    hostnameImpl: () => LOCAL,
    homedirImpl: () => options.home ?? "/home/user",
    probeRemoteImpl: options.probeRemoteImpl ?? (async () => []),
  });
}

async function panelOf(instance: ReturnType<typeof createSessionsAdapter>): Promise<SessionsPanelData> {
  const result = await instance.poll();
  return result.panels[0]!.data as SessionsPanelData;
}

describe("sessions adapter", () => {
  test("never groups a session under the home directory just because the repo walk landed there", async () => {
    const data = await panelOf(adapter({
      sessions: [record({ cwd: "/home/user/scratchpad", repoRoot: "/home/user", repo: "user" })],
    }));
    expect(data.sessions[0]!.project).toBe("/home/user/scratchpad");
    expect(data.homeDir).toBe("/home/user");
  });

  test("keeps a real repo root as the grouping key", async () => {
    const data = await panelOf(adapter({ sessions: [record()] }));
    expect(data.sessions[0]!.project).toBe("/home/user/Projects/overdeck");
  });

  test("empty ledger yields an empty panel that names the directory", async () => {
    const data = await panelOf(adapter({ sessions: [], ledgerOverrides: { missing: true } }));
    expect(data.sessions).toEqual([]);
    expect(data.ledgerMissing).toBe(true);
    expect(data.ledgerDir).toBe("/ledger");
    expect(data.localHost).toBe(LOCAL);
  });

  test("sorts most-recently-active first", async () => {
    const data = await panelOf(adapter({
      sessions: [
        record({ id: "old", lastActiveAt: "2026-08-07T01:00:00.000Z" }),
        record({ id: "new", lastActiveAt: "2026-08-07T05:00:00.000Z" }),
      ],
    }));
    expect(data.sessions.map((session) => session.id)).toEqual(["new", "old"]);
  });

  test("sorts by classifier activity, not by the record's own heartbeat", async () => {
    const data = await panelOf(adapter({
      sessions: [
        record({ id: "stale-work", lastActiveAt: "2026-08-07T01:00:00.000Z", lastHeartbeatAt: "2026-08-07T09:00:00.000Z" }),
        record({ id: "recent-work", lastActiveAt: "2026-08-07T05:00:00.000Z", lastHeartbeatAt: "2026-08-07T05:00:00.000Z" }),
      ],
    }));
    expect(data.sessions.map((session) => session.id)).toEqual(["recent-work", "stale-work"]);
  });

  test("groups by the absolute project root, not a repo nickname", async () => {
    const data = await panelOf(adapter({
      sessions: [record({ repoRoot: "/home/user/Projects/overdeck/.worktrees/obs-panel" })],
    }));
    expect(data.sessions[0]!.project).toBe("/home/user/Projects/overdeck/.worktrees/obs-panel");
  });

  test("carries every recorder field the panel renders, absent ones as null", async () => {
    const data = await panelOf(adapter({
      sessions: [record({ title: "land the ledger gap", activity: "running collector tests", launchedBy: "factory", parentLedgerId: "claude-parent", account: "zync2" })],
    }));
    expect(data.sessions[0]).toMatchObject({
      title: "land the ledger gap",
      activity: "running collector tests",
      launchedBy: "factory",
      parentLedgerId: "claude-parent",
      account: "zync2",
      host: LOCAL,
      attached: false,
      evidence: "running with no window attached",
    });
  });

  test("a tmux-hosted live session attaches in the browser and reopens from a terminal", async () => {
    const data = await panelOf(adapter({ sessions: [record()] }));
    expect(data.sessions[0]!.attachable).toBe(true);
    expect(data.sessions[0]!.attach).toEqual({
      kind: "tmux",
      target: "agent-s1",
      command: "agent-sessions attach s1",
    });
  });

  test("a dtach-wrapped live session reopens from a terminal but cannot render a screen", async () => {
    const data = await panelOf(adapter({
      sessions: [record({
        tmuxSession: null,
        tmuxSocket: null,
        mux: { kind: "dtach", socket: "/run/agent-sessions/sock/s1", target: "s1" },
      })],
    }));
    expect(data.sessions[0]!.attach).toEqual({ kind: "tmux", target: "s1", command: "agent-sessions attach s1" });
    expect(data.sessions[0]!.attachable).toBe(false);
    expect(data.sessions[0]!.attachBlockedReason).toBe(
      "this session is not hosted in tmux, so its screen cannot be rendered in the browser",
    );
  });

  test("a live session with no multiplexer says why it cannot be reopened", async () => {
    const data = await panelOf(adapter({
      sessions: [record({ tmuxSession: null, tmuxSocket: null, mux: { kind: null, socket: null, target: null } })],
    }));
    expect(data.sessions[0]!.attach).toEqual({
      kind: "none",
      reason: "this session was started without a multiplexer, so there is no window to reattach to",
    });
  });

  test("a finished claude session resumes by runtime id, with the account when one was recorded", async () => {
    const withAccount = await panelOf(adapter({
      sessions: [record({ state: "FINISHED", account: "zync2" })],
    }));
    expect(withAccount.sessions[0]!.attach).toEqual({
      kind: "resume",
      target: "c0c245b9-d222-46a3-b6e9-d16f2626c5f9",
      command: "cld --account zync2 --resume c0c245b9-d222-46a3-b6e9-d16f2626c5f9",
    });

    const noAccount = await panelOf(adapter({ sessions: [record({ state: "FINISHED" })] }));
    expect(noAccount.sessions[0]!.attach.command).toBe("cld --resume c0c245b9-d222-46a3-b6e9-d16f2626c5f9");
  });

  test("codex and cursor-agent resume with their own verbs, never claude's", async () => {
    const codex = await panelOf(adapter({ sessions: [record({ runtime: "codex", state: "ORPHANED" })] }));
    expect(codex.sessions[0]!.attach.command).toBe("codex resume c0c245b9-d222-46a3-b6e9-d16f2626c5f9");

    const cursor = await panelOf(adapter({ sessions: [record({ runtime: "cursor-agent", state: "FINISHED" })] }));
    expect(cursor.sessions[0]!.attach.command).toBe("cursor-agent --resume c0c245b9-d222-46a3-b6e9-d16f2626c5f9");
  });

  test("an unknown runtime is refused by name rather than given claude's verb", async () => {
    const data = await panelOf(adapter({ sessions: [record({ runtime: "aider", state: "FINISHED" })] }));
    expect(data.sessions[0]!.attach).toEqual({
      kind: "none",
      reason: 'no resume verb is known for runtime "aider"',
    });
  });

  test("a session with no runtime id cannot be resumed and says so", async () => {
    const data = await panelOf(adapter({ sessions: [record({ state: "FINISHED", resumeId: null })] }));
    expect(data.sessions[0]!.attach).toEqual({
      kind: "none",
      reason: "no session id was recorded for this claude session, so it cannot be resumed by id",
    });
  });

  test("a registry-reachable off-host tmux session reconnects and renders over ssh", async () => {
    const data = await panelOf(adapter({ sessions: [record({ host: "debian1" })] }));
    expect(data.sessions[0]!.attach).toEqual({
      kind: "ssh",
      target: "debian1",
      command: "ssh debian1 agent-sessions attach s1",
    });
    expect(data.sessions[0]!.attachable).toBe(true);
    expect(data.sessions[0]!.attachBlockedReason).toBeUndefined();
  });

  test("a registry-disabled host is refused from static data, never probed", async () => {
    const data = await panelOf(adapter({ sessions: [record({ host: "debian3" })] }));
    expect(data.sessions[0]!.attach).toEqual({
      kind: "none",
      reason: "host debian3 is disabled in the buildbox registry (bricked 2026-08-07)",
    });
    expect(data.sessions[0]!.hostFacts).toEqual({
      name: "debian3",
      reachability: "declared-disabled",
      state: "bricked",
      notes: "bricked 2026-08-07",
    });
  });

  test("a host absent from the registry is reported unknown, not assumed reachable", async () => {
    const data = await panelOf(adapter({ sessions: [record({ host: "debian9" })] }));
    expect(data.sessions[0]!.hostFacts).toEqual({ name: "debian9", reachability: "unknown", state: null, notes: null });
    expect(data.sessions[0]!.attachable).toBe(false);
    expect(data.sessions[0]!.attachBlockedReason).toBe("debian9 is not marked reachable in the buildbox registry");
  });

  test("orphaned sessions cannot render a screen and say why", async () => {
    const data = await panelOf(adapter({ sessions: [record({ state: "ORPHANED" })] }));
    expect(data.sessions[0]!.attachable).toBe(false);
    expect(data.sessions[0]!.attachBlockedReason).toBe("session is ORPHANED");
  });

  test("a missing tmux binary blocks the browser screen with a named reason", async () => {
    const data = await panelOf(adapter({ sessions: [record()], tmux: null }));
    expect(data.tmuxAvailable).toBe(false);
    expect(data.sessions[0]!.attachBlockedReason).toBe("tmux is not installed on this host");
  });

  test("a non-loopback bind disables the browser screen for every session", async () => {
    const data = await panelOf(adapter({
      sessions: [record()],
      attachAllowed: false,
      attachBlockedReason: "collector is bound to 100.64.0.2, not loopback — browser attach is refused off-host",
    }));
    expect(data.attachEnabled).toBe(false);
    expect(data.sessions[0]!.attachable).toBe(false);
    expect(data.sessions[0]!.attachBlockedReason).toContain("not loopback");
    expect(data.sessions[0]!.attach.kind).toBe("tmux");
  });

  test("a missing host registry is reported, not silently treated as empty", async () => {
    const data = await panelOf(adapter({ sessions: [record()], hosts: [], hostsMissing: true }));
    expect(data.hostsRegistryMissing).toBe(true);
    expect(data.hostsRegistryPath).toContain("buildbox-hosts.json");
  });

  test("poll does not await probes, then merges cached remote records with remote collisions winning", async () => {
    let finishProbe!: (value: RemoteHostProbe[]) => void;
    const pendingProbe = new Promise<RemoteHostProbe[]>((resolve) => { finishProbe = resolve; });
    const instance = adapter({
      sessions: [record({ id: "collision", host: "debian1", state: "ORPHANED", evidence: "local classifier" })],
      probeRemoteImpl: () => pendingProbe,
    });

    const first = await Promise.race([panelOf(instance), new Promise<never>((_, reject) => setTimeout(() => reject(new Error("poll awaited probe")), 50))]);
    expect(first.hostProbes.find((probe) => probe.host === "debian1")?.status).toBe("pending");

    finishProbe([
      { host: "debian1", status: "ok", error: null, at: "2026-08-08T12:00:00.000Z", sessions: [record({ id: "collision", host: "debian1", state: "ALIVE-WORKING", evidence: "remote classifier" })] },
      { host: "debian3", status: "failed", error: "ssh exit 255", at: "2026-08-08T12:00:00.000Z", sessions: [] },
    ]);
    await pendingProbe;
    await Promise.resolve();

    const second = await panelOf(instance);
    expect(second.sessions.find((session) => session.id === "collision")).toMatchObject({ state: "ALIVE-WORKING", evidence: "remote classifier" });
    expect(second.hostProbes.find((probe) => probe.host === "debian3")).toMatchObject({ status: "failed", error: "ssh exit 255" });
  });

  test("sessionFromPanel resolves only ids present in the snapshot", async () => {
    const data = await panelOf(adapter({ sessions: [record()] }));
    expect(sessionFromPanel(data, "s1")?.id).toBe("s1");
    expect(sessionFromPanel(data, "nope")).toBeNull();
  });
});

describe("resume ids held by a live session", () => {
  const SHARED = "939ddb63-2b81-45e1-bb4b-71c6e8b48533";

  test("a stopped entry sharing a live session's id refuses to resume and names the holder", async () => {
    const data = await panelOf(adapter({
      sessions: [
        record({ id: "live", state: "ALIVE-WORKING", resumeId: SHARED }),
        record({ id: "stopped", state: "FINISHED", tmuxSession: null, tmuxSocket: null, resumeId: SHARED }),
      ],
    }));
    const stopped = sessionFromPanel(data, "stopped")!;
    expect(stopped.attach.kind).toBe("none");
    expect(stopped.attach.reason).toContain(SHARED);
    expect(stopped.attach.reason).toContain("live");
    expect(stopped.attach.command).toBeUndefined();
  });

  test("stopped entries with no live holder stay resumable", async () => {
    const data = await panelOf(adapter({
      sessions: [
        record({ id: "a", state: "FINISHED", tmuxSession: null, tmuxSocket: null, resumeId: SHARED }),
        record({ id: "b", state: "FINISHED", tmuxSession: null, tmuxSocket: null, resumeId: SHARED }),
      ],
    }));
    expect(sessionFromPanel(data, "a")!.attach.kind).toBe("resume");
    expect(sessionFromPanel(data, "b")!.attach.kind).toBe("resume");
  });

  test("the same id under a different runtime is not treated as held", async () => {
    const data = await panelOf(adapter({
      sessions: [
        record({ id: "live", runtime: "claude", state: "ALIVE-WORKING", resumeId: SHARED }),
        record({ id: "stopped", runtime: "codex", state: "FINISHED", tmuxSession: null, tmuxSocket: null, resumeId: SHARED }),
      ],
    }));
    expect(sessionFromPanel(data, "stopped")!.attach.command).toBe(`codex resume ${SHARED}`);
  });
});

describe("attach posture", () => {
  test("default config binds loopback and allows attach", () => {
    expect(sessionsAttachPosture(defaultConfig())).toEqual({ attachAllowed: true });
  });

  test("tailnet bind refuses attach instead of inheriting it", () => {
    const posture = sessionsAttachPosture({ ...defaultConfig(), tailnetBind: true, bindHost: "100.64.0.2" });
    expect(posture.attachAllowed).toBe(false);
    expect(posture.attachBlockedReason).toContain("100.64.0.2");
  });
});
