// session-durability — post-hoc truth about how a Claude Code session ended.
//
// A cgroup OOM kill is SIGKILL: no hook runs, so nothing can be written at death.
// Every verdict here is therefore computed at READ time from two crash-proof inputs:
//   1. the session transcript (append-only, always complete up to the last flushed line)
//   2. an append-only session ledger of immutable facts (session X opened / closed cleanly)
// Neither is a snapshot, so neither can go stale. A clean close is only ever ASSERTED
// by a close record; its absence never reads as clean.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync } from 'node:child_process';

export const LEDGER_NAME = 'sessions.jsonl';

export const handoffSlug = (cwd) =>
  String(cwd).replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(-80) || 'root';

// Claude Code's own encoding of cwd -> ~/.claude/projects/<dir>: every non-alphanumeric
// run becomes a single '-', leading separator kept.
export const transcriptDirName = (cwd) => String(cwd).replace(/[^a-zA-Z0-9]/g, '-');

export function transcriptDirFor(cwd, home = os.homedir()) {
  return path.join(home, '.claude', 'projects', transcriptDirName(cwd));
}

export function ledgerPathFor(cwd, home = os.homedir()) {
  return path.join(home, '.claude', 'handoffs', handoffSlug(cwd), LEDGER_NAME);
}

function readBootId() {
  try {
    return fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() || null;
  } catch {
    return null;
  }
}

function readProcStart(pid) {
  try {
    const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
    const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
    return fields[19] || null;
  } catch {
    return null;
  }
}

function statCgroupIno(cgroup) {
  try {
    return String(fs.statSync(path.join('/sys/fs/cgroup', cgroup)).ino);
  } catch {
    return null;
  }
}

function oomEventsSince(sinceIso) {
  try {
    const out = execFileSync(
      'journalctl',
      ['-k', '--since', sinceIso, '--grep', 'Memory cgroup out of memory', '-o', 'short-iso', '--no-pager'],
      { timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] }
    ).toString();
    return out.split('\n').filter((l) => l.includes('Memory cgroup out of memory'));
  } catch {
    return null;
  }
}

export const realDeps = {
  now: () => new Date(),
  home: () => os.homedir(),
  bootId: readBootId,
  procStart: readProcStart,
  cgroupIno: statCgroupIno,
  oomEventsSince
};

// Facts the SessionStart hook captures so a later reader can decide whether the
// session is still running. The cgroup inode defeats transient-scope name reuse;
// the pid start time defeats pid reuse.
export function scopeFromCgroupFile(text) {
  const rel = String(text).trim().split('\n').pop()?.split(':').pop();
  return rel && rel.includes('/agent.slice/') && rel.endsWith('.scope') ? rel : null;
}

export function openFacts(deps = realDeps) {
  let cgroup = null;
  try {
    cgroup = scopeFromCgroupFile(fs.readFileSync('/proc/self/cgroup', 'utf8'));
  } catch {}
  const pid = resolveSessionPid(process.ppid, readProcArgv0, readProcPpid);
  return {
    boot: deps.bootId(),
    cgroup,
    cgroupIno: cgroup ? deps.cgroupIno(cgroup) : null,
    pid,
    pidStart: pid ? deps.procStart(pid) : null
  };
}

function readProcArgv0(pid) {
  try {
    return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0')[0] || null;
  } catch {
    return null;
  }
}

function readProcPpid(pid) {
  try {
    const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
    return Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]) || null;
  } catch {
    return null;
  }
}

// argv0 basename only: any later argument can carry a `.claude` path and would
// otherwise match an unrelated ancestor such as the shell running a tool call.
export function resolveSessionPid(startPid, argv0Of, ppidOf) {
  let pid = startPid;
  for (let hop = 0; hop < 8 && pid > 1; hop++) {
    const argv0 = argv0Of(pid);
    if (!argv0) return null;
    const name = path.basename(argv0);
    if (name === 'claude' || name === 'claudex') return pid;
    pid = ppidOf(pid);
    if (!pid) return null;
  }
  return null;
}

export function appendLedger(file, record) {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.appendFileSync(file, `${JSON.stringify(record)}\n`);
}

export function readLedger(file) {
  let raw;
  try {
    raw = fs.readFileSync(file, 'utf8');
  } catch {
    return [];
  }
  const out = [];
  for (const line of raw.split('\n')) {
    if (!line) continue;
    try {
      out.push(JSON.parse(line));
    } catch {}
  }
  return out;
}

// A close only counts as the session's final word. `/clear` fires SessionEnd and the
// same session id can be reopened after it, so an earlier close must not stamp a
// session that later died as clean.
export function ledgerFor(entries, sessionId) {
  const mine = entries.filter((e) => e && e.session === sessionId);
  const last = mine[mine.length - 1] || null;
  const openAt = mine.map((e, i) => (e.k === 'open' ? i : -1)).filter((i) => i >= 0);
  // Lives of this id that were reopened (`--resume`, `/clear`, `/compact`) without a
  // close in between. Each is a candidate death the newest open would otherwise mask.
  const priorLives = openAt.slice(0, -1).flatMap((at, n) => {
    const next = openAt[n + 1];
    const closed = mine.slice(at + 1, next).some((e) => e.k === 'close');
    return closed ? [] : [{ open: mine[at], reopenedAt: mine[next].ts || null }];
  });
  return {
    open: openAt.length ? mine[openAt[openAt.length - 1]] : null,
    close: last && last.k === 'close' ? last : null,
    priorLives
  };
}

export function scanTranscript(file) {
  let raw;
  try {
    raw = fs.readFileSync(file, 'utf8');
  } catch (err) {
    return { ok: false, reason: `transcript unreadable: ${err.code || err.message}` };
  }
  const lines = raw.split('\n');
  const records = [];
  let badLines = 0;
  for (const line of lines) {
    if (!line) continue;
    try {
      records.push(JSON.parse(line));
    } catch {
      badLines++;
    }
  }
  if (!records.length) return { ok: false, reason: 'transcript contains no parsable records' };

  const byUuid = new Map();
  for (const r of records) if (r.uuid && !r.isSidechain) byUuid.set(r.uuid, r);
  let leaf = null;
  for (let i = records.length - 1; i >= 0; i--) {
    const r = records[i];
    if (r.uuid && !r.isSidechain) {
      leaf = r;
      break;
    }
  }
  const chain = [];
  const seen = new Set();
  for (let cur = leaf; cur && !seen.has(cur.uuid); cur = cur.parentUuid ? byUuid.get(cur.parentUuid) : null) {
    seen.add(cur.uuid);
    chain.push(cur);
  }
  chain.reverse();

  const pending = new Map();
  let userTurns = 0;
  let lastUserPrompt = '';
  let lastAssistantText = '';
  for (const r of chain) {
    const content = r.message?.content;
    if (r.type === 'user') {
      const text = typeof content === 'string'
        ? content
        : Array.isArray(content)
          ? content.filter((b) => b?.type === 'text').map((b) => b.text).join('\n')
          : '';
      if (text.trim() && !/^\[Request interrupted/.test(text.trim())) {
        userTurns++;
        lastUserPrompt = text.trim().replace(/\s+/g, ' ').slice(0, 300);
      }
    }
    if (!Array.isArray(content)) continue;
    if (r.type === 'assistant') {
      for (const b of content) {
        if (b?.type === 'tool_use') pending.set(b.id, b.name);
        if (b?.type === 'text' && b.text?.trim()) lastAssistantText = b.text.trim().slice(0, 1500);
      }
    } else if (r.type === 'user') {
      for (const b of content) if (b?.type === 'tool_result') pending.delete(b.tool_use_id);
    }
  }

  const timestamps = records.map((r) => r.timestamp).filter(Boolean);
  return {
    ok: true,
    sessionId: records.find((r) => r.sessionId)?.sessionId || records.find((r) => r.session_id)?.session_id || null,
    cwd: records.find((r) => r.cwd)?.cwd || null,
    records: records.length,
    badLines,
    truncatedTail: !raw.endsWith('\n') || badLines > 0,
    lastTimestamp: timestamps.length ? timestamps[timestamps.length - 1] : null,
    leafType: leaf?.type || null,
    userInterrupt: Boolean(leaf?.interruptedByShutdown),
    abortedMidStream: chain.some((r) => r.isAbortedMidStream),
    pendingTools: [...pending.values()],
    userTurns,
    lastUserPrompt,
    lastAssistantText
  };
}

export function livenessOf(open, deps = realDeps) {
  if (!open) return 'undetermined';
  const boot = deps.bootId();
  if (open.boot && boot && open.boot !== boot) return 'dead';
  // The pid+starttime pair IS the session. The scope cgroup outlives it whenever any
  // other member (background command, stray child) remains, so cgroup is only a
  // fallback oracle for when the pid facts were never captured.
  if (open.pid && open.pidStart) {
    return deps.procStart(open.pid) === open.pidStart ? 'alive' : 'dead';
  }
  if (open.cgroup && open.cgroupIno) {
    return deps.cgroupIno(open.cgroup) === open.cgroupIno ? 'alive' : 'dead';
  }
  return 'undetermined';
}

function interruptionPoint(scan) {
  if (scan.truncatedTail) return 'mid-record-write (transcript tail truncated)';
  if (scan.pendingTools.length) {
    const names = [...new Set(scan.pendingTools)].join(', ');
    return `mid-tool-call (${names}) — ${scan.pendingTools.length} tool call(s) never returned`;
  }
  if (scan.userInterrupt) return 'turn boundary, after a user interrupt';
  if (scan.abortedMidStream) return 'mid assistant stream';
  return 'turn boundary';
}

export function classifySession({ sessionId, transcript, scan, ledger, deps = realDeps }) {
  const base = { sessionId, transcript, evidence: [] };
  if (!scan.ok) {
    return { ...base, state: 'undetermined', detail: scan.reason, evidence: ['transcript could not be read'] };
  }
  base.lastTimestamp = scan.lastTimestamp;
  base.lastUserPrompt = scan.lastUserPrompt;
  base.lastAssistantText = scan.lastAssistantText;
  base.userTurns = scan.userTurns;

  if (ledger.close) {
    return {
      ...base,
      state: 'clean',
      detail: `closed by SessionEnd at ${ledger.close.ts}${ledger.close.reason ? ` (${ledger.close.reason})` : ''}`,
      evidence: ['ledger close record present']
    };
  }
  if (!ledger.open) {
    return {
      ...base,
      state: 'undetermined',
      detail: 'no ledger open record — session predates the durability ledger, or the SessionStart hook did not run',
      evidence: ['no ledger open record', 'no ledger close record']
    };
  }

  const liveness = livenessOf(ledger.open, deps);
  const deadPriorLife = [...(ledger.priorLives || [])].reverse().find((l) => livenessOf(l.open, deps) === 'dead');
  if (liveness === 'alive' && !deadPriorLife) {
    return { ...base, state: 'active', detail: 'session process is still running', evidence: ['liveness oracle: alive'] };
  }
  if (liveness === 'undetermined' && !deadPriorLife) {
    return {
      ...base,
      state: 'undetermined',
      detail: 'no usable liveness oracle recorded for this session; cannot tell running from killed',
      evidence: ['ledger open record carries no cgroup or pid facts']
    };
  }

  // The newest life wins when it is itself dead: an older death is already superseded.
  const life = liveness === 'dead' ? { open: ledger.open, reopenedAt: null } : deadPriorLife;
  const died = life.open;
  const evidence = ['no ledger close record', 'liveness oracle: dead'];
  let cause = 'died without a clean close (SIGKILL, crash, or host reset)';
  const resumedAt = life.reopenedAt;
  // A resumed session appends to the same transcript, so the tail is only the death
  // point while nothing has been written since the reopen.
  const tailIsDeathPoint = !resumedAt || !scan.lastTimestamp || scan.lastTimestamp <= resumedAt;
  const since = (tailIsDeathPoint ? scan.lastTimestamp : null) || died.ts;
  const oom = since ? deps.oomEventsSince(since) : null;
  if (oom === null) evidence.push('kernel OOM log unavailable');
  else if (oom.length) {
    cause = 'OOM-killed (cgroup out of memory)';
    evidence.push(`${oom.length} kernel cgroup-OOM kill(s) logged since ${since}`);
  } else evidence.push('no kernel cgroup-OOM kill logged in the window');

  if (resumedAt) evidence.push(`session was reopened at ${resumedAt} after that death`);
  return {
    ...base,
    state: 'unclean',
    cause,
    interruptedAt: tailIsDeathPoint ? interruptionPoint(scan) : `unknown — the transcript has been appended to since the reopen at ${resumedAt}`,
    detail: cause,
    evidence
  };
}

const RESUME_SCAN_LIMIT = 25;

function transcriptsIn(dir) {
  try {
    return fs
      .readdirSync(dir)
      .filter((f) => f.endsWith('.jsonl'))
      .map((f) => {
        const file = path.join(dir, f);
        return { file, mtime: fs.statSync(file).mtimeMs };
      });
  } catch {
    return [];
  }
}

function classifyTranscript(file, deps, knownCwd) {
  const scan = scanTranscript(file);
  const cwd = knownCwd || (scan.ok ? scan.cwd : null);
  const sessionId = path.basename(file, '.jsonl');
  const ledger = cwd
    ? ledgerFor(readLedger(ledgerPathFor(cwd, deps.home())), sessionId)
    : { open: null, close: null, priorLives: [] };
  return { ...classifySession({ sessionId, transcript: file, scan, ledger, deps }), cwd };
}

// Resuming a running session would give two processes one append-only transcript.
const notRunningAndHasTurns = (s) => s.state !== 'active' && s.userTurns > 0;

const dirExists = (p) => {
  try {
    return fs.statSync(p).isDirectory();
  } catch {
    return false;
  }
};

export function resumeTargetForCwd({ cwd, deps = realDeps, limit = RESUME_SCAN_LIMIT }) {
  const files = transcriptsIn(transcriptDirFor(cwd, deps.home()))
    .sort((a, b) => b.mtime - a.mtime)
    .slice(0, limit);
  for (const { file } of files) {
    const session = classifyTranscript(file, deps, cwd);
    if (notRunningAndHasTurns(session)) return session;
  }
  return null;
}

export function resumeTargetAnywhere({ deps = realDeps, limit = RESUME_SCAN_LIMIT }) {
  const root = path.join(deps.home(), '.claude', 'projects');
  let dirs;
  try {
    dirs = fs.readdirSync(root).map((d) => path.join(root, d));
  } catch {
    return null;
  }
  const files = dirs
    .flatMap(transcriptsIn)
    .sort((a, b) => b.mtime - a.mtime)
    .slice(0, limit);
  for (const { file } of files) {
    const session = classifyTranscript(file, deps, null);
    const provenDead = session.state === 'unclean' && session.userTurns > 0;
    if (provenDead && session.cwd && dirExists(session.cwd)) {
      return session;
    }
  }
  return null;
}

function parseHandoff(file) {
  let text;
  try {
    text = fs.readFileSync(file, 'utf8');
  } catch {
    return null;
  }
  return {
    file,
    session: (text.match(/^- \*\*session:\*\* (\S+)/m) || [])[1] || null,
    written: (text.match(/^- \*\*written:\*\* (\S+)/m) || [])[1] || null
  };
}

export function reportForCwd({ cwd, excludeSessionId = null, deps = realDeps }) {
  const home = deps.home();
  const dir = transcriptDirFor(cwd, home);
  const handoffDir = path.join(home, '.claude', 'handoffs', handoffSlug(cwd));
  const report = { cwd, transcriptDir: dir, handoffDir, session: null, handoff: parseHandoff(path.join(handoffDir, 'latest.md')) };

  let files;
  try {
    files = fs
      .readdirSync(dir)
      .filter((f) => f.endsWith('.jsonl'))
      .map((f) => ({ f, m: fs.statSync(path.join(dir, f)).mtimeMs }))
      .sort((a, b) => b.m - a.m);
  } catch {
    report.session = {
      state: 'undetermined',
      detail: `no transcript directory for this cwd (${dir})`,
      evidence: ['transcript directory missing'],
      sessionId: null
    };
    return report;
  }

  const candidate = files.find((x) => path.basename(x.f, '.jsonl') !== excludeSessionId);
  if (!candidate) {
    report.session = {
      state: 'undetermined',
      detail: 'no prior session transcript for this cwd',
      evidence: ['transcript directory holds no other session'],
      sessionId: null
    };
    return report;
  }

  const sessionId = path.basename(candidate.f, '.jsonl');
  const transcript = path.join(dir, candidate.f);
  const entries = readLedger(path.join(handoffDir, LEDGER_NAME));
  report.session = classifySession({
    sessionId,
    transcript,
    scan: scanTranscript(transcript),
    ledger: ledgerFor(entries, sessionId),
    deps
  });

  if (report.handoff) {
    report.handoff.matchesSession = report.handoff.session === sessionId;
    report.handoff.rich = path.join(handoffDir, `${sessionId}-rich.md`);
    report.handoff.richExists = fs.existsSync(report.handoff.rich);
  }
  return report;
}

const BANNER = {
  clean: 'CLEAN — previous session closed itself',
  active: 'ACTIVE — a session for this directory is still running',
  unclean: 'UNCLEAN — previous session was killed and never closed',
  undetermined: 'UNDETERMINED — cannot prove how the previous session ended'
};

export function renderReport(report) {
  const s = report.session;
  const lines = ['=== session durability ==='];
  lines.push(`state: ${BANNER[s.state] || s.state}`);
  if (s.sessionId) lines.push(`session: ${s.sessionId}${s.lastTimestamp ? ` (last record ${s.lastTimestamp})` : ''}`);
  lines.push(`why: ${s.detail}`);
  if (s.interruptedAt) lines.push(`interrupted: ${s.interruptedAt}`);
  if (s.userTurns !== undefined) lines.push(`user turns recorded: ${s.userTurns}`);
  if (s.evidence?.length) lines.push(`evidence: ${s.evidence.join('; ')}`);
  if (s.transcript) lines.push(`transcript (ground truth, survives SIGKILL): ${s.transcript}`);
  if (s.lastUserPrompt) lines.push(`last user prompt: ${s.lastUserPrompt}`);

  const h = report.handoff;
  if (!h) {
    lines.push('handoff latest.md: ABSENT — no written handoff for this directory.');
  } else if (!s.sessionId) {
    lines.push(
      `handoff latest.md: UNVERIFIABLE — no session could be identified for this directory, so nothing confirms ` +
        `it is current. It claims session ${h.session || 'unknown'}, written ${h.written || 'unknown'} — ${h.file}`
    );
  } else if (h.matchesSession) {
    const stale = h.written && s.lastTimestamp && h.written < s.lastTimestamp;
    lines.push(
      stale
        ? `handoff latest.md: INCOMPLETE — right session (${s.sessionId}) but written ${h.written}, before the last ` +
            `recorded turn at ${s.lastTimestamp}. Everything after it is missing; use the transcript above. — ${h.file}`
        : `handoff latest.md: matches session ${s.sessionId} (written ${h.written}) — ${h.file}`
    );
    if (h.richExists) lines.push(`rich handoff for the same session: ${h.rich}`);
  } else {
    lines.push(
      `handoff latest.md: STALE — it describes session ${h.session || 'unknown'}, NOT ${s.sessionId}. ` +
        'DO NOT read it as the current state; use the transcript above.'
    );
  }

  if (s.state === 'unclean' || s.state === 'undetermined') {
    lines.push(
      'action: reconstruct from the transcript before continuing (pickup skill step 4). ' +
        'Re-run any tool call listed as never returned — its effect is unknown.'
    );
    if (s.sessionId) lines.push('resume that conversation: session-recovery --resume');
  }
  return lines.join('\n');
}
