/**
 * cache-store.ts — Phase 6: metadata index + stale-cache cleanup.
 *
 * The checkpoint files themselves (`oc-prefix-<fp16>.bin` + `.ckpt`
 * sidecar) are written by llama-server under its `--slot-save-path`;
 * the extension never controls that directory directly. Here we keep:
 *
 *   1. A central metadata index at
 *      `~/.cache/opencode/llama-prefix/index.json` (override with
 *      OC_PREFIX_CACHE_DIR), keyed by full fingerprint:
 *        { fingerprint, fileName, model, baseUrl, slot, opencodeVersion,
 *          extVersion, tokenCount, createdAt, lastUsed }
 *
 *   2. Stale-cache cleanup: when OC_PREFIX_CACHE_SLOT_DIR points at
 *      the server's `--slot-save-path`, delete checkpoint files whose
 *      fingerprint is no longer active. Only files matching
 *      `oc-prefix-[0-9a-f]{16}.bin` (and their `.ckpt`) are ever
 *      touched — manually named files (e.g. qwen27b_prefix.bin) are
 *      never deleted.
 *
 * Keep set = fingerprints explicitly kept by the caller (the current
 * session) ∪ index entries with lastUsed within TTL (default 30 days)
 * ∪ the N most recently used (default 3).
 *
 * Everything here is fail-open: callers wrap in try/catch; a broken
 * index or unreadable directory degrades to "no cleanup".
 */
import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

export interface MetaEntry {
  fingerprint: string;
  fileName: string;
  model: string;
  baseUrl: string;
  slot: number;
  opencodeVersion: string;
  extVersion: string;
  tokenCount: number;
  createdAt: string; // ISO
  lastUsed: string; // ISO
}

type Index = Record<string, MetaEntry>;

const INDEX_NAME = "index.json";

export function storeDir(): string {
  return process.env.OC_PREFIX_CACHE_DIR ?? join(homedir(), ".cache", "opencode", "llama-prefix");
}

export function loadIndex(): Index {
  let raw: string;
  try {
    raw = readFileSync(join(storeDir(), INDEX_NAME), "utf8");
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; // missing → empty is fine
    throw new IndexCorruptError(`index unreadable: ${String(err)}`);
  }
  try {
    return JSON.parse(raw) as Index;
  } catch (err) {
    throw new IndexCorruptError(`index malformed: ${String(err)}`);
  }
}

export function saveIndex(idx: Index): void {
  const dir = storeDir();
  mkdirSync(dir, { recursive: true });
  const tmp = join(dir, INDEX_NAME + ".tmp");
  writeFileSync(tmp, JSON.stringify(idx, null, 2));
  renameSync(tmp, join(dir, INDEX_NAME));
}

/** Create/update the index entry for a fingerprint; bumps lastUsed. */
export function recordUse(fp: string, fields: Partial<Omit<MetaEntry, "fingerprint">>): MetaEntry {
  const idx = loadIndex();
  const now = new Date().toISOString();
  const prev = idx[fp];
  const entry: MetaEntry = {
    fingerprint: fp,
    fileName: fields.fileName ?? prev?.fileName ?? `oc-prefix-${fp.slice(0, 16)}.bin`,
    model: fields.model ?? prev?.model ?? "",
    baseUrl: fields.baseUrl ?? prev?.baseUrl ?? "",
    slot: fields.slot ?? prev?.slot ?? 0,
    opencodeVersion: fields.opencodeVersion ?? prev?.opencodeVersion ?? "",
    extVersion: fields.extVersion ?? prev?.extVersion ?? "",
    tokenCount: fields.tokenCount ?? prev?.tokenCount ?? 0,
    createdAt: prev?.createdAt ?? now,
    lastUsed: now,
  };
  idx[fp] = entry;
  saveIndex(idx);
  return entry;
}

export interface CleanupResult {
  deleted: Array<{ fingerprint: string; bytes: number }>;
  kept: string[];
  totalBytesDeleted: number;
  /** Set when cleanup was refused (untrusted index); nothing deleted. */
  skipped?: string;
}

/** Thrown when index.json exists but cannot be read/parsed. A corrupt
 *  index is NOT the same as a missing one: cleaning against an empty
 *  view of a corrupt index would delete valid checkpoints. */
export class IndexCorruptError extends Error {}

const FP_FILE_RE = /^oc-prefix-([0-9a-f]{16})\.bin$/;

export function cleanupStale(
  slotDir: string,
  keepFps: string[],
  opts: { ttlDays?: number; keepRecent?: number; maxBytes?: number } = {},
): CleanupResult {
  const ttlDays = opts.ttlDays ?? 30;
  const keepRecent = opts.keepRecent ?? 3;
  const maxBytes = opts.maxBytes ?? 0; // 0 = no cap

  let files: string[];
  try {
    files = readdirSync(slotDir);
  } catch {
    return { deleted: [], kept: keepFps, totalBytesDeleted: 0 };
  }

  // Fingerprints present as checkpoint files (full fp recovered from
  // the 16-hex prefix + index; files without an index entry are keyed
  // by their 16-char prefix below).
  const byPrefix = new Map<string, string>();
  for (const f of files) {
    const m = f.match(FP_FILE_RE);
    if (m) byPrefix.set(m[1], f);
  }

  let idx: Index;
  try {
    idx = loadIndex();
  } catch (err) {
    // Never destructively clean against an index we cannot trust.
    return { deleted: [], kept: [], totalBytesDeleted: 0, skipped: String(err) };
  }
  const now = Date.now();
  const ttlMs = ttlDays * 86400_000;

  // Keep set, expressed as 16-char prefixes.
  const keep = new Set<string>();
  const currentPrefixes = new Set<string>();
  for (const fp of keepFps) currentPrefixes.add(fp.slice(0, 16));
  for (const p of currentPrefixes) keep.add(p);
  const recent: Array<{ prefix: string; ts: number }> = [];
  for (const e of Object.values(idx)) {
    const prefix = e.fingerprint.slice(0, 16);
    if (!byPrefix.has(prefix)) continue; // file gone; drop entry lazily
    const ts = Date.parse(e.lastUsed);
    if (!Number.isFinite(ts)) continue; // can't rank; don't let it crowd out keepRecent
    if (now - ts <= ttlMs) keep.add(prefix);
    recent.push({ prefix, ts });
  }
  recent.sort((a, b) => b.ts - a.ts);
  for (const r of recent.slice(0, keepRecent)) keep.add(r.prefix);

  const sizeOf = (prefix: string): number => {
    const bin = join(slotDir, byPrefix.get(prefix)!);
    let bytes = 0;
    try {
      bytes += statSync(bin).size;
    } catch {
      return 0;
    }
    try {
      bytes += statSync(bin + ".ckpt").size;
    } catch {
      /* no sidecar */
    }
    return bytes;
  };

  const del = (prefix: string): number => {
    const bin = join(slotDir, byPrefix.get(prefix)!);
    const bytes = sizeOf(prefix);
    try {
      unlinkSync(bin);
      try {
        unlinkSync(bin + ".ckpt");
      } catch {
        /* no sidecar */
      }
    } catch {
      return 0;
    }
    byPrefix.delete(prefix);
    keep.delete(prefix);
    return bytes;
  };

  const deleted: Array<{ fingerprint: string; bytes: number }> = [];
  let totalBytesDeleted = 0;
  const sweep = (prefix: string): number => {
    const bytes = del(prefix);
    if (bytes > 0) {
      deleted.push({ fingerprint: prefix, bytes });
      totalBytesDeleted += bytes;
    }
    return bytes;
  };

  // Pass 1: TTL / keep-recent stale files.
  for (const prefix of [...byPrefix.keys()]) {
    if (!keep.has(prefix)) sweep(prefix);
  }

  // Pass 2: total-size cap — delete oldest first (never the current
  // session's checkpoint) until under maxBytes.
  if (maxBytes > 0) {
    let total = 0;
    const survivors: Array<{ prefix: string; ts: number; size: number }> = [];
    for (const prefix of [...byPrefix.keys()]) {
      const size = sizeOf(prefix);
      total += size;
      const ts =
        Object.values(idx)
          .filter((e) => e.fingerprint.slice(0, 16) === prefix)
          .map((e) => Date.parse(e.lastUsed))
          .find(Number.isFinite) ?? 0;
      survivors.push({ prefix, ts, size });
    }
    if (total > maxBytes) {
      survivors.sort((a, b) => a.ts - b.ts); // oldest first
      for (const s of survivors) {
        if (total <= maxBytes) break;
        if (currentPrefixes.has(s.prefix)) continue; // never delete current
        total -= sweep(s.prefix); // only count bytes actually deleted
      }
    }
  }

  // Drop index entries whose files are gone (and were not kept).
  let changed = false;
  for (const fp of Object.keys(idx)) {
    const prefix = fp.slice(0, 16);
    if (!byPrefix.has(prefix) || !keep.has(prefix)) {
      delete idx[fp];
      changed = true;
    }
  }
  if (changed) {
    try {
      saveIndex(idx);
    } catch {
      /* non-fatal */
    }
  }

  return { deleted, kept: [...keep], totalBytesDeleted };
}
