#!/usr/bin/env bun
/**
 * scripts/cleanup-chats.ts
 *
 * Bulk-archive chats from ~/.claude/projects/. Four rules, evaluated
 * in this order (most-specific reason wins per chat):
 *   1. Resolved threads          → archive ALL their linked chats.
 *   2. CLOSE-verdict threads     → archive ALL their linked chats.
 *   3. Active threads w/duplicates → keep most recently touched chat,
 *                                     archive older ones.
 *   4. Older than --age N days   → archive every .jsonl across every
 *                                   project whose mtime is older than
 *                                   N days. Default N = 5.
 *
 * Archives are reversible — entries go to archived_chats.json, the
 * underlying .jsonl files are never deleted.
 *
 * Default: DRY RUN. Prints what would happen, exits without writing.
 * Pass `--apply` to actually update archived_chats.json.
 *
 * Usage:
 *   bun run scripts/cleanup-chats.ts                  # dry run (5d default)
 *   bun run scripts/cleanup-chats.ts --age 14         # 14-day window
 *   bun run scripts/cleanup-chats.ts --apply          # commit
 *   bun run scripts/cleanup-chats.ts --age 5 --apply
 */
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

const APPLY = process.argv.includes("--apply");
const ageIdx = process.argv.indexOf("--age");
const AGE_DAYS = ageIdx >= 0 ? Number(process.argv[ageIdx + 1] || 5) : 5;
if (!Number.isFinite(AGE_DAYS) || AGE_DAYS < 0) {
  console.error("--age must be a non-negative number");
  process.exit(1);
}

const USER_PROJECT_KEY = homedir().replace(/\//g, "-");
const MEMORY_DIR = join(homedir(), ".claude/projects", USER_PROJECT_KEY, "memory");
const THREAD_CHATS_FILE = join(MEMORY_DIR, "thread_chats.json");
const ARCHIVED_CHATS_FILE = join(MEMORY_DIR, "archived_chats.json");
const RESOLUTIONS_FILE = join(MEMORY_DIR, "resolutions.md");
const CHECK_HISTORY_FILE = join(MEMORY_DIR, "now_check_history.json");
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude/projects");
const AGE_CUTOFF_MS = Date.now() - AGE_DAYS * 86_400_000;

type ThreadChat = { sessionId: string; startedAt: string; label?: string };
type ArchivedEntry = { sessionId: string; projectRaw: string; archivedAt: string };
type CachedCheck = { verdict: "CLOSE" | "KEEP" | "ESCALATE" | null; checkedAt: string };
type Reason = "thread resolved" | "verdict: CLOSE" | "duplicate of latest" | `older than ${number}d`;
type Plan = { sessionId: string; projectRaw: string; reason: Reason; threadKey?: string };

// Reason precedence: lower = more specific = wins when the same chat
// would qualify for multiple rules.
const reasonRank: Record<string, number> = {
  "thread resolved": 0,
  "verdict: CLOSE": 1,
  "duplicate of latest": 2,
};
function rankOf(r: Reason): number {
  return reasonRank[r] ?? 3; // age-based always loses to thread rules
}

const itemKey = (file: string, line: number, itemText: string) =>
  `${file}:${line}|${itemText.slice(0, 80)}`;

function loadJSON<T>(path: string, fallback: T): T {
  if (!existsSync(path)) return fallback;
  try { return JSON.parse(readFileSync(path, "utf8")) as T; }
  catch { return fallback; }
}

const threadChats = loadJSON<Record<string, ThreadChat[]>>(THREAD_CHATS_FILE, {});
const archived = loadJSON<ArchivedEntry[]>(ARCHIVED_CHATS_FILE, []);
const archivedSet = new Set(archived.map((e) => e.sessionId));

// ── Build the resolved + CLOSE thread sets ───────────────────
const resolvedKeys = new Set<string>();
if (existsSync(RESOLUTIONS_FILE)) {
  const text = readFileSync(RESOLUTIONS_FILE, "utf8");
  const re = /^### (\d{4}-\d{2}-\d{2})\s+(\S+):(\d+)\s*\n\*\*Source:\*\*\s+(.+?)\s+·\s+\*\*Kind:\*\*\s+(\S+)\s*\n\*\*Item:\*\*\s+(.+?)\n\n([\s\S]*?)\n\n---/gm;
  let m;
  while ((m = re.exec(text)) !== null) {
    resolvedKeys.add(itemKey(m[2], parseInt(m[3], 10), m[6]));
  }
}
const closeKeys = new Set<string>();
const checkHistory = loadJSON<Record<string, CachedCheck[]>>(CHECK_HISTORY_FILE, {});
for (const [threadKey, checks] of Object.entries(checkHistory)) {
  if (checks[0]?.verdict === "CLOSE") closeKeys.add(threadKey);
}

// ── Build per-sessionId proposals, keeping the most specific reason ──
const plans = new Map<string, Plan>();
const orphans: { sessionId: string; threadKey: string }[] = [];

function propose(plan: Plan) {
  if (archivedSet.has(plan.sessionId)) return;
  const existing = plans.get(plan.sessionId);
  if (!existing || rankOf(plan.reason) < rankOf(existing.reason)) {
    plans.set(plan.sessionId, plan);
  }
}

// Find .jsonl file for a sessionId — used by thread-based rules where
// thread_chats.json only stores sessionId, not project.
const projectCache = new Map<string, { project: string; mtime: number } | null>();
function findChat(sessionId: string): { project: string; mtime: number } | null {
  if (projectCache.has(sessionId)) return projectCache.get(sessionId)!;
  let found: { project: string; mtime: number } | null = null;
  try {
    for (const proj of readdirSync(CLAUDE_PROJECTS_DIR)) {
      const file = join(CLAUDE_PROJECTS_DIR, proj, `${sessionId}.jsonl`);
      try {
        const st = statSync(file);
        if (st.isFile()) { found = { project: proj, mtime: st.mtimeMs }; break; }
      } catch { /* not here */ }
    }
  } catch { /* unreadable */ }
  projectCache.set(sessionId, found);
  return found;
}

// Rules 1–3: thread-based.
for (const [threadKey, chats] of Object.entries(threadChats)) {
  if (!chats.length) continue;
  const live: { sessionId: string; project: string; mtime: number }[] = [];
  for (const c of chats) {
    if (archivedSet.has(c.sessionId)) continue;
    const f = findChat(c.sessionId);
    if (!f) { orphans.push({ sessionId: c.sessionId, threadKey }); continue; }
    live.push({ sessionId: c.sessionId, project: f.project, mtime: f.mtime });
  }
  if (!live.length) continue;

  if (resolvedKeys.has(threadKey)) {
    for (const c of live) propose({ sessionId: c.sessionId, projectRaw: c.project, reason: "thread resolved", threadKey });
  } else if (closeKeys.has(threadKey)) {
    for (const c of live) propose({ sessionId: c.sessionId, projectRaw: c.project, reason: "verdict: CLOSE", threadKey });
  } else if (live.length > 1) {
    live.sort((a, b) => b.mtime - a.mtime);
    for (const c of live.slice(1)) propose({ sessionId: c.sessionId, projectRaw: c.project, reason: "duplicate of latest", threadKey });
  }
}

// Rule 4: age sweep across every .jsonl in every project.
let scanned = 0;
try {
  for (const proj of readdirSync(CLAUDE_PROJECTS_DIR)) {
    let files: string[] = [];
    try { files = readdirSync(join(CLAUDE_PROJECTS_DIR, proj)).filter((f) => f.endsWith(".jsonl")); }
    catch { continue; }
    for (const f of files) {
      scanned++;
      const sessionId = f.replace(/\.jsonl$/, "");
      if (archivedSet.has(sessionId)) continue;
      try {
        const st = statSync(join(CLAUDE_PROJECTS_DIR, proj, f));
        if (st.mtimeMs < AGE_CUTOFF_MS) {
          propose({ sessionId, projectRaw: proj, reason: `older than ${AGE_DAYS}d` });
        }
      } catch { /* skip unreadable */ }
    }
  }
} catch { /* projects dir unreadable */ }

// ── Report ───────────────────────────────────────────────────
const toArchive = [...plans.values()];
const byReason = new Map<string, Plan[]>();
for (const p of toArchive) {
  const arr = byReason.get(p.reason) ?? [];
  arr.push(p);
  byReason.set(p.reason, arr);
}

console.log(`\nCleanup plan — ${APPLY ? "APPLY MODE" : "DRY RUN (pass --apply to commit)"}`);
console.log(`Age cutoff: ${AGE_DAYS} days (anything older than ${new Date(AGE_CUTOFF_MS).toISOString().slice(0,10)} is in scope)\n`);
console.log(`Chats scanned across all projects: ${scanned}`);
console.log(`Already archived: ${archived.length}`);
console.log(`Resolved thread keys: ${resolvedKeys.size}`);
console.log(`CLOSE-verdict thread keys: ${closeKeys.size}`);
console.log(`Orphan thread refs (jsonl missing): ${orphans.length}`);
console.log(`\nWill archive ${toArchive.length} chat(s):`);
for (const [reason, list] of [...byReason.entries()].sort()) {
  console.log(`  • ${reason.padEnd(22)}: ${list.length}`);
}

if (toArchive.length === 0) {
  console.log("\nNothing to do.\n");
  process.exit(0);
}

console.log(`\nFirst 20 entries:`);
for (const p of toArchive.slice(0, 20)) {
  console.log(`  ${p.sessionId.slice(0, 8)}  ${p.reason.padEnd(22)}  ${p.projectRaw.slice(0, 50)}`);
}
if (toArchive.length > 20) console.log(`  …and ${toArchive.length - 20} more`);

if (!APPLY) {
  console.log(`\nDry run only. Pass --apply to write archived_chats.json.\n`);
  process.exit(0);
}

// ── Apply ────────────────────────────────────────────────────
const today = new Date().toISOString().slice(0, 10);
const merged = [...archived];
for (const p of toArchive) {
  if (!merged.some((e) => e.sessionId === p.sessionId)) {
    merged.push({ sessionId: p.sessionId, projectRaw: p.projectRaw, archivedAt: today });
  }
}
writeFileSync(ARCHIVED_CHATS_FILE, JSON.stringify(merged, null, 2));
console.log(`\nArchived ${toArchive.length} chats. Total archived now: ${merged.length}`);
console.log(`Wrote ${ARCHIVED_CHATS_FILE}\n`);
