import { readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";

/**
 * THE ONE ADAPTER over the agent-session ledger written by the session recorder.
 * Every other module in overdeck consumes `SessionRecord` — a ledger schema change
 * is absorbed here (key spellings, file layout, state vocabulary) and nowhere else.
 *
 * Ledger contract: docs/agent-session-ledger.md (schemaVersion 1). Entries live at
 * `<root>/sessions/<ledgerId>.json`. Liveness is NOT stored — a record written before
 * a SIGKILL would claim to be alive forever — so it is classified from live evidence
 * on every read by the recorder's own classifier, which is the single owner of the
 * state vocabulary and its thresholds.
 */

export const SESSION_STATES = [
  "ALIVE-WORKING",
  "ALIVE-IDLE",
  "DETACHED-ALIVE",
  "ORPHANED",
  "FINISHED",
] as const;
export type SessionState = (typeof SESSION_STATES)[number];

/** States whose process is still running, so an attach can reach it. */
const ATTACHABLE_STATES = new Set<SessionState>(["ALIVE-WORKING", "ALIVE-IDLE", "DETACHED-ALIVE"]);

export function isAttachableState(state: SessionState): boolean {
  return ATTACHABLE_STATES.has(state);
}

/** The multiplexer hosting a session, as the recorder wrote it. */
export interface SessionMux {
  kind: string | null;
  socket: string | null;
  target: string | null;
}

export interface SessionRecord {
  id: string;
  runtime: string;
  pid: number | null;
  cwd: string;
  repo: string | null;
  /** Absolute repo/worktree root, or the session's cwd when the recorder knows no repo. */
  repoRoot: string;
  branch: string | null;
  worktree: string | null;
  host: string | null;
  startedAt: string | null;
  lastHeartbeatAt: string | null;
  /** Classifier's newest evidence of progress — transcript mtime, else heartbeat. */
  lastProgressAt: string | null;
  lastActiveAt: string | null;
  finishedAt: string | null;
  /** Free text from whoever closed the row — the runtime's own words, not an enum. */
  finishReason: string | null;
  transcriptPath: string | null;
  tmuxSession: string | null;
  tmuxSocket: string | null;
  mux: SessionMux | null;
  /** Classifier verdict: a terminal client is currently on this session. */
  attached: boolean | null;
  /** Classifier's one-line reason for the state it assigned. */
  evidence: string | null;
  /** Ledger id of the session that launched this one; null when nothing launched it. */
  parent: string | null;
  parentLedgerId: string | null;
  state: SessionState;
  /** The runtime's own session id, which is what its resume verb takes. Null until
   * the runtime's hooks report it — the ledger id is not a resume target. */
  resumeId?: string | null;
  /** Recorder-supplied fields the panel renders verbatim or as a gap. Absent until the
   * recorder stamps them; NEVER inferred here, because a guessed launcher or title is
   * indistinguishable from a recorded one once it reaches the screen. */
  launchedBy: string | null;
  title: string | null;
  activity: string | null;
  account: string | null;
  /** Explicit correlation fields written by launchers. They are never inferred. */
  buildKey?: string | null;
  runId?: string | null;
  workloadUid?: string | null;
}

export interface LedgerReadResult {
  sessions: SessionRecord[];
  /** Ledger directory that was read, so the UI can name it in the empty state. */
  dir: string;
  /** True when the ledger does not exist yet (recorder not installed / never ran). */
  missing: boolean;
  /** Files present but unparseable — surfaced as a coverage gap, never silently dropped. */
  unreadable: string[];
}

/** One classified entry as the recorder's classifier returns it. */
export type ClassifiedEntry = Record<string, unknown> & {
  ledgerId: string;
  state: string;
  cwd: string;
};

export type ClassifyImpl = (
  entries: unknown[],
  options: { includeDirty: boolean },
) => Promise<ClassifiedEntry[]>;

/** The recorder installs its classifier here; the collector runs from a deploy clone
 * that does not carry the workstation module, so it is resolved at runtime. */
export const CLASSIFIER_MODULE_PATH = join(
  homedir(),
  ".claude",
  "lib",
  "agent-session-reader.mjs",
);

const EntrySchema = z.object({
  schemaVersion: z.literal(1),
  ledgerId: z.string().min(1),
  runtime: z.string().min(1).optional(),
  cwd: z.string().min(1),
  repoRoot: z.string().min(1).nullable().optional(),
  project: z.string().min(1).nullable().optional(),
  branch: z.string().min(1).nullable().optional(),
  startedAt: z.string().min(1).nullable().optional(),
  lastHeartbeatAt: z.string().min(1).nullable().optional(),
  transcriptPath: z.string().min(1).nullable().optional(),
  sessionId: z.string().min(1).nullable().optional(),
  tmuxSession: z.string().min(1).nullable().optional(),
  tmuxSocket: z.string().min(1).nullable().optional(),
  host: z.string().min(1).nullable().optional(),
  finishedAt: z.string().min(1).nullable().optional(),
  finishReason: z.string().min(1).nullable().optional(),
  parent: z.string().min(1).nullable().optional(),
  parentLedgerId: z.string().min(1).nullable().optional(),
  mux: z.object({
    kind: z.string().min(1).nullable().optional(),
    socket: z.string().min(1).nullable().optional(),
    target: z.string().min(1).nullable().optional(),
  }).nullable().optional(),
  launchedBy: z.string().min(1).nullable().optional(),
  title: z.string().min(1).nullable().optional(),
  activity: z.string().min(1).nullable().optional(),
  account: z.string().min(1).nullable().optional(),
  buildKey: z.string().min(1).nullable().optional(),
  runId: z.string().min(1).nullable().optional(),
  workloadUid: z.string().min(1).nullable().optional(),
}).passthrough();

const ClassifiedSchema = EntrySchema.extend({
  state: z.string().min(1),
  pid: z.number().int().positive().nullable().optional(),
  worktree: z.boolean().optional(),
  attached: z.boolean().optional(),
  lastProgressAt: z.string().min(1).nullable().optional(),
  lastActiveAt: z.string().min(1).nullable().optional(),
  evidence: z.string().nullable().optional(),
});

function asIso(value: string | null | undefined): string | null {
  if (!value) return null;
  const date = new Date(value);
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
}

function asState(value: string): SessionState | null {
  const normalized = value.trim().toUpperCase().replace(/[_\s]+/g, "-");
  return (SESSION_STATES as readonly string[]).includes(normalized)
    ? (normalized as SessionState)
    : null;
}

/**
 * Normalizes one classified entry. Returns null when it carries no usable identity or
 * an unrecognized liveness state — never guessed, because an unknown state would render
 * a dead session as recoverable.
 */
export function normalizeRecord(raw: unknown): SessionRecord | null {
  const parsed = ClassifiedSchema.safeParse(raw);
  if (!parsed.success) return null;
  const value = parsed.data;
  const state = asState(value.state);
  if (!state) return null;
  const mux = value.mux
    ? {
      kind: value.mux.kind ?? null,
      socket: value.mux.socket ?? null,
      target: value.mux.target ?? null,
    }
    : null;
  return {
    id: value.ledgerId,
    runtime: value.runtime ?? "unknown",
    pid: value.pid ?? null,
    cwd: value.cwd,
    repo: value.project ?? null,
    repoRoot: value.repoRoot ?? value.cwd,
    branch: value.branch ?? null,
    worktree: value.worktree === true ? value.cwd : null,
    host: value.host ?? null,
    startedAt: asIso(value.startedAt),
    lastHeartbeatAt: asIso(value.lastHeartbeatAt),
    lastProgressAt: asIso(value.lastProgressAt) ?? asIso(value.lastHeartbeatAt),
    lastActiveAt: asIso(value.lastActiveAt),
    finishedAt: asIso(value.finishedAt),
    finishReason: value.finishReason ?? null,
    transcriptPath: value.transcriptPath ?? null,
    tmuxSession: value.tmuxSession ?? (mux?.kind === "tmux" ? mux.target : null),
    tmuxSocket: value.tmuxSocket ?? (mux?.kind === "tmux" ? mux.socket : null),
    mux,
    attached: value.attached ?? null,
    evidence: value.evidence ?? null,
    parent: value.parent ?? null,
    parentLedgerId: value.parentLedgerId ?? null,
    state,
    resumeId: value.sessionId ?? null,
    launchedBy: value.launchedBy ?? null,
    title: value.title ?? null,
    activity: value.activity ?? null,
    account: value.account ?? null,
    buildKey: value.buildKey ?? null,
    runId: value.runId ?? null,
    workloadUid: value.workloadUid ?? null,
  };
}

export function defaultLedgerDir(): string {
  const configured = process.env.AGENT_SESSIONS_DIR;
  if (configured) return configured;
  const stateHome = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
  return join(stateHome, "agent-sessions");
}

async function loadClassifier(): Promise<ClassifyImpl> {
  const module = (await import(CLASSIFIER_MODULE_PATH)) as { classify: ClassifyImpl };
  return module.classify;
}

export interface ReadLedgerOptions {
  classifyImpl?: ClassifyImpl;
}

/**
 * Reads every ledger entry and classifies it from live evidence. `includeDirty` is off:
 * the sessions adapter already counts dirty files once per directory, and doing it here
 * too would spawn git twice per session on every poll.
 */
const MAX_FINISHED_ENTRIES = 100;

export async function readLedger(
  dir = defaultLedgerDir(),
  options: ReadLedgerOptions = {},
): Promise<LedgerReadResult> {
  const entriesDir = join(dir, "sessions");
  let names: string[];
  try {
    if (!statSync(entriesDir).isDirectory()) {
      return { sessions: [], dir, missing: true, unreadable: [] };
    }
    names = readdirSync(entriesDir);
  } catch {
    return { sessions: [], dir, missing: true, unreadable: [] };
  }

  const unfinishedEntries: unknown[] = [];
  const finishedEntries: Array<{ entry: unknown; finishedAt: number }> = [];
  const unreadable: string[] = [];
  for (const name of names.sort()) {
    if (!name.endsWith(".json") || name.startsWith(".")) continue;
    const path = join(entriesDir, name);
    try {
      const parsed = EntrySchema.parse(JSON.parse(readFileSync(path, "utf8")));
      if (parsed.finishedAt) {
        const finishedAt = Date.parse(parsed.finishedAt);
        if (Number.isFinite(finishedAt)) finishedEntries.push({ entry: parsed, finishedAt });
        else unfinishedEntries.push(parsed);
      } else {
        unfinishedEntries.push(parsed);
      }
    } catch {
      unreadable.push(name);
    }
  }

  const entries = [
    ...unfinishedEntries,
    ...finishedEntries
      .sort((a, b) => b.finishedAt - a.finishedAt)
      .slice(0, MAX_FINISHED_ENTRIES)
      .map(({ entry }) => entry),
  ];
  if (entries.length === 0) return { sessions: [], dir, missing: false, unreadable };

  let classified: ClassifiedEntry[];
  try {
    const classify = options.classifyImpl ?? (await loadClassifier());
    classified = await classify(entries, { includeDirty: false });
  } catch {
    // The classifier is the only authority on liveness; without it every state would be
    // a guess, so the ledger reads as unavailable rather than wrong.
    return { sessions: [], dir, missing: true, unreadable };
  }

  const byId = new Map<string, SessionRecord>();
  for (const entry of classified) {
    const normalized = normalizeRecord(entry);
    if (!normalized) {
      unreadable.push(`${entry?.ledgerId ?? "unknown"}.json`);
      continue;
    }
    byId.set(normalized.id, normalized);
  }

  return { sessions: [...byId.values()], dir, missing: false, unreadable };
}
