/**
 * # cli/commands/registry — `runs list`/`runs show`/`lineage`/`leaderboard` (HEAVY, lazy)
 *
 * Data fetch is unchanged from the old `session/cli.ts` (`listRuns`/`getRun`/`lineage`/`leaderboard`,
 * each wrapped in `openRegistry`/`finally db.close()`), but rendering is mode-branched via
 * {@link ../render/tables.ts}: today's glyph tables are the `human` branch; `text` = TSV; `json` =
 * `{schema, rows}`. The registry module (`bun:sqlite`) is dynamic-imported through {@link loadHeavy},
 * so this file is off the load-time graph and faults loud (exit 4) under bun-less node.
 *
 * `runs show <id>` not found → exit 3 (NOT_FOUND).
 */

import { existsSync } from "node:fs";
import { dirname, join } from "node:path";

import type { OutputCtx } from "../context.ts";
import type { RunRow } from "../heavy.ts";
import { parseArgs } from "../args.ts";
import { CliError, EXIT } from "../errors.ts";
import {
  HOSTED_STORE_BASENAME,
  defaultHostedStorePath,
  readHostedRunsMerged,
  type HostedRunRow,
} from "../../ledger/hosted.ts";
import { loadHeavy } from "./_heavy.ts";
import { renderRuns, renderRunShow, renderHostedRunShow, renderRunsCompare, renderLineage, renderLeaderboard } from "../render/tables.ts";

const DEFAULT_DB = "data/kestrel.db";

/** How the reader learns an empty list is a NORMAL fresh-store condition, not a fault, and where runs
 * get recorded. Hosted `sim`/`prove` runs go to the identity-bound HOME ledger (found from any cwd);
 * local `run`/`day` sessions record into the CWD-local sqlite ledger (kestrel-fq78). */
const NO_STORE_HINT =
  "no runs recorded yet — `kestrel sim` / `kestrel prove` record to your identity-bound home ledger (~/.kestrel/hosted-runs.jsonl, found from any directory), and `kestrel run` / `kestrel day` auto-record into ./data/kestrel.db (this folder)";

/**
 * Read the HOSTED lineage as a merged view: the identity-bound HOME store (found from ANY cwd,
 * kestrel-fq78) unioned with the legacy CWD-local receipt store beside the ledger `--db` (read-compat
 * for receipts an older CLI wrote into the originating folder). A corrupt store on either faults LOUDLY
 * inside {@link readHostedRunsMerged} (fail-closed) — never a silently-dropped receipt. */
function readHostedForDb(dbPath: string): HostedRunRow[] {
  return readHostedRunsMerged({
    home: defaultHostedStorePath(),
    fallbacks: [join(dirname(dbPath), HOSTED_STORE_BASENAME)],
  });
}

function usageErr(message: string): CliError {
  return new CliError({ code: "USAGE", exit: EXIT.USAGE, message });
}

/**
 * Open the ledger for a READ verb, distinguishing an **absent** store (a cwd that has recorded no runs
 * yet — a NORMAL empty state) from a **present-but-unreadable** one (a real fault). Returns `db: null`
 * when the store file does not exist — a query NEVER mints a store (that is a recording verb's job), and
 * a missing store must not fault with a raw `unable to open database file`. A store that IS present but
 * fails to open (corrupt bytes, a locked/future-schema db) PROPAGATES — fail-closed, never silently empty.
 */
async function openRegForRead(dbPath: string) {
  const registry = await loadHeavy(() => import("../heavy.ts"));
  const db = existsSync(dbPath) ? registry.openRegistry(dbPath) : null;
  return { registry, db };
}

/** On the human path, tell the reader the empty result is a normal fresh-store state (not a fault) and
 * how a store gets populated. Machine modes (`json`/`text`) stay a pure empty payload — no advisory noise
 * on the channel an agent parses. */
function emitNoStoreHint(ctx: OutputCtx): void {
  if (ctx.mode === "human") process.stdout.write(`hint: ${NO_STORE_HINT}\n`);
}

/** On the human path, tell the reader an empty FILTERED result means no run matched the given filter(s)
 * — NOT that the store is empty (kestrel-f0yr). Machine modes stay a pure empty payload (no advisory
 * noise on the channel an agent parses). */
function emitNoMatchHint(ctx: OutputCtx): void {
  if (ctx.mode === "human")
    process.stdout.write(
      "hint: no recorded run matched the given filter(s) — `kestrel runs list` (no filter) shows the full ledger; `--session-date <YYYY-MM-DD>`/`--lineage <plan|digest>` narrow it (hosted `sim` rows carry no fill model, so `--fill` matches only local `run`/`day` runs)\n",
    );
}

/** The `runs list` query filters, as parsed from the flags. Absent keys mean "no filter on this axis". */
interface RunsListFilters {
  readonly sessionDate?: string;
  readonly fill?: string;
  readonly lineage?: string;
}

/**
 * Apply the documented `runs list` filters to a HOSTED receipt (kestrel-f0yr). The `--session-date` /
 * `--fill` / `--lineage` flags were built for the LOCAL sqlite ledger's columns; a hosted `sim` receipt
 * carries the same query dimensions under its own fields, so the flags must narrow it too instead of
 * being silently inert (a nonsense value was returning the FULL home ledger). The mapping:
 *
 * - **`--session-date <d>`** → the calendar day of the run's signed `issued_at` (its first 10 chars,
 *   `YYYY-MM-DD`) — the server's proof timestamp, never a local wall clock.
 * - **`--lineage <n>`** → the plan lineage the run belongs to: its `strategy` LABEL (the `--plans` path
 *   or bundled default's name) OR its `strategy_digest` CONTENT identity (exact, or a digest prefix).
 * - **`--fill <m>`** → a hosted `sim` receipt records NO fill model (the platform fixes it server-side;
 *   it is not a row field), so a `--fill` filter can never MATCH a hosted row. Fail-CLOSED: it narrows
 *   hosted rows to zero rather than returning the whole ledger. This is the honest "non-matching value →
 *   zero" the bead asks for, not the inert full-ledger bug.
 *
 * A filter with a non-matching value excludes the row; an absent filter never excludes. Pure, no network.
 */
function hostedRowPassesFilters(h: HostedRunRow, f: RunsListFilters): boolean {
  // A hosted receipt has no fill model to match — any `--fill` value narrows it out (fail-closed).
  if (f.fill !== undefined) return false;
  if (f.sessionDate !== undefined && h.issued_at.slice(0, 10) !== f.sessionDate) return false;
  if (f.lineage !== undefined) {
    const inLineage =
      h.strategy === f.lineage ||
      h.strategy_digest === f.lineage ||
      (h.strategy_digest !== undefined && h.strategy_digest.startsWith(f.lineage));
    if (!inLineage) return false;
  }
  return true;
}

/** `runs list [--session-date <d>] [--fill <m>] [--lineage <n>] [--hosted] [--db <p>]`.
 *
 * Merges the local `run`/`day` lineage (the `bun:sqlite` ledger) with the HOSTED `sim` lineage (the
 * sibling `hosted-runs.jsonl` receipt store, kestrel-pvlc). `--hosted` narrows to the hosted lineage
 * ALONE — a pure-node read that never touches `bun:sqlite`, so an agent on plain node can still
 * re-list its hosted attempts. Zero network on either side. */
export async function runsListCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> {
  const { flags, bools } = parseArgs(argv, new Set(["hosted"]), new Set(["session-date", "fill", "lineage", "db"]));
  const dbPath = flags.get("db") ?? DEFAULT_DB;
  const hostedOnly = bools.has("hosted");
  const filters: RunsListFilters = {
    ...(flags.get("session-date") !== undefined ? { sessionDate: flags.get("session-date")! } : {}),
    ...(flags.get("fill") !== undefined ? { fill: flags.get("fill")! } : {}),
    ...(flags.get("lineage") !== undefined ? { lineage: flags.get("lineage")! } : {}),
  };
  const hasFilter = filters.sessionDate !== undefined || filters.fill !== undefined || filters.lineage !== undefined;

  // The hosted receipts live in the identity-bound HOME ledger (found from any cwd), merged with the
  // legacy CWD-local store beside the db (kestrel-fq78). Read first — a corrupt store faults LOUDLY
  // (fail-closed), never a silently-dropped receipt. The SAME documented filters that narrow the local
  // ledger below narrow the hosted rows here (kestrel-f0yr) — a nonsense value returns zero, not the
  // full ledger, on the home lineage a returning user actually lives in.
  const hosted = readHostedForDb(dbPath).filter((h) => hostedRowPassesFilters(h, filters));

  // `--hosted` skips the local sqlite query entirely (no `loadHeavy`, so it runs bun-less).
  let rows: readonly RunRow[] = [];
  if (!hostedOnly) {
    const { registry, db } = await openRegForRead(dbPath);
    if (db !== null) {
      try {
        rows = registry.listRuns(db, {
          ...(filters.sessionDate !== undefined ? { sessionDate: filters.sessionDate } : {}),
          ...(filters.fill !== undefined ? { fillModel: filters.fill } : {}),
          ...(filters.lineage !== undefined ? { lineage: filters.lineage } : {}),
        });
      } finally {
        db.close();
      }
    }
  }

  process.stdout.write(renderRuns(rows, ctx, hosted));
  if (rows.length === 0 && hosted.length === 0) {
    // Distinguish a genuinely FRESH store (no filter, nothing recorded — a normal empty state) from a
    // filter that simply matched nothing. The "no runs recorded yet" hint is misleading in the latter,
    // so a filtered-empty result gets its own honest hint instead (kestrel-f0yr).
    if (hasFilter) emitNoMatchHint(ctx);
    else emitNoStoreHint(ctx);
  }
  return 0;
}

/**
 * Collapse a pasted proof URL (`https://kestrel.markets/proof/art_…`) to its bare `art_` id, so a
 * returning user can `runs show` the exact string they saved. Any non-URL input passes through
 * unchanged. Pure, no network.
 */
export function normalizeRunKey(raw: string): string {
  const m = raw.match(/\/proof\/([A-Za-z0-9_-]+)\/?$/);
  return m !== null ? m[1]! : raw;
}

/** Does a hosted receipt match the lookup key on EITHER of its two shareable handles — the
 * `operation_id` (op_…, the local-lookup id) OR the `grade_artifact_id` (art_…, the proof id the
 * product pushes everywhere and the user actually keeps, kestrel-qsbm)? Exact match or a prefix
 * of either. One receipt matches at most once regardless of which handle hit. */
function hostedRunMatches(h: { operation_id: string; grade_artifact_id: string }, key: string): boolean {
  return (
    h.operation_id === key ||
    h.operation_id.startsWith(key) ||
    h.grade_artifact_id === key ||
    h.grade_artifact_id.startsWith(key)
  );
}

/** `runs show <id> [--db <p>]` — accepts a short (12-char) prefix, resolving against BOTH lineages: a
 * local `run`/`day` run (by `run_id`) OR a hosted `sim` receipt (by `operation_id` OR the `art_` proof id,
 * kestrel-pvlc + kestrel-qsbm). The `art_` id is the shareable handle the product pushes everywhere (the
 * proof URL the user saved), so it MUST be a valid lookup key alongside the `op_` id — a pasted proof URL
 * is collapsed to its `art_` id first. A hosted match re-prints the full receipt incl. the shareable proof
 * URL. The hosted lineage is read FIRST and is node-light: a fresh cwd that ran only `sim` has
 * `hosted-runs.jsonl` but no `bun:sqlite` store, so an agent on plain node can still `runs show` its hosted
 * operation without faulting exit 4 (the local `bun:sqlite` query is skipped entirely when the ledger db
 * file is absent). Not found → exit 3 (a typed hint naming BOTH id forms); an ambiguous prefix (across
 * either lineage) → exit 2. */
export async function runsShowCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> {
  const { positionals, flags } = parseArgs(argv, new Set(), new Set(["db"]));
  const rawId = positionals[0];
  if (rawId === undefined) throw usageErr("runs show needs a <run_id>");
  const runId = normalizeRunKey(rawId);
  const dbPath = flags.get("db") ?? DEFAULT_DB;

  // Hosted lineage first — a pure-node read of the identity-bound home ledger merged with the legacy
  // CWD-local store (never touches `bun:sqlite`), so a hosted-only cwd resolves without the heavy
  // runtime AND the same agent's run resolves from ANY directory (kestrel-fq78). A corrupt store faults
  // LOUDLY inside readHostedRunsMerged.
  const hostedMatch = readHostedForDb(dbPath).filter((h) => hostedRunMatches(h, runId));

  // Local lineage — only opened when the ledger db FILE exists, so `runs show <hosted-op>` on plain node
  // never trips `loadHeavy`'s exit-4 (the db is absent in a hosted-only cwd). openRegForRead never mints.
  const opened = existsSync(dbPath) ? await openRegForRead(dbPath) : null;
  const db = opened?.db ?? null;
  try {
    const localMatch: RunRow[] =
      opened !== null && db !== null
        ? opened.registry.listRuns(db).filter((r) => r.run_id === runId || r.run_id.startsWith(runId))
        : [];
    const total = localMatch.length + hostedMatch.length;
    if (total === 0)
      throw new CliError({
        code: "NOT_FOUND",
        exit: EXIT.NOT_FOUND,
        message: `no run matching ${JSON.stringify(rawId)}`,
        hint: "the id may be an operation id (op_…), a local run id, OR the art_ proof id from your saved proof URL (…/proof/art_…) — `kestrel runs list` shows both",
      });
    if (total > 1) throw usageErr(`ambiguous run prefix ${JSON.stringify(rawId)} — ${total} matches`);
    if (hostedMatch.length === 1) {
      process.stdout.write(renderHostedRunShow(hostedMatch[0]!, ctx));
    } else {
      // A local match implies the db (and its registry) were opened above.
      const rec = opened!.registry.getRun(db!, localMatch[0]!.run_id)!;
      process.stdout.write(renderRunShow(rec, ctx));
    }
  } finally {
    db?.close();
  }
  return 0;
}

/** Resolve ONE hosted receipt using the EXACT id-acceptance `runs show` holds (kestrel-c535): a pasted
 * proof URL is collapsed to its `art_` id first ({@link normalizeRunKey}), then the key is matched on
 * EITHER shareable handle — the `operation_id` (op_…) OR the `grade_artifact_id` (art_…, the proof id the
 * product pushes everywhere and the user actually keeps) — exact or 12-char prefix (via
 * {@link hostedRunMatches}). Compare is the flagship "did-my-tweak-help?" verb, so it MUST accept the very
 * identifier every `sim` run tells the user to keep, not op_ ids alone. Zero match → NOT_FOUND (exit 3)
 * with a typed hint naming BOTH id forms (as `runs show` does — no bare NOT_FOUND); an ambiguous prefix →
 * USAGE (exit 2), never a silent first-match pick (mirrors `runsShowCommand`'s fail-closed ambiguity guard). */
function resolveHostedOne(hosted: readonly HostedRunRow[], rawId: string): HostedRunRow {
  const key = normalizeRunKey(rawId);
  const matches = hosted.filter((h) => hostedRunMatches(h, key));
  if (matches.length === 0)
    throw new CliError({
      code: "NOT_FOUND",
      exit: EXIT.NOT_FOUND,
      message: `no hosted run matching ${JSON.stringify(rawId)}`,
      hint: "the id may be an operation id (op_…) OR the art_ proof id from your saved proof URL (…/proof/art_…) — `kestrel runs list` shows both, and `runs show`/`verify`/`replay` accept the same forms",
    });
  if (matches.length > 1) throw usageErr(`ambiguous run prefix ${JSON.stringify(rawId)} — ${matches.length} matches`);
  return matches[0]!;
}

/** `runs compare <runA> <runB> [--db <p>]` — the returning user's "did my tweak help?" verb (kestrel-etyu).
 *
 * Diffs two HOSTED `sim` receipts (resolved by `operation_id` / 12-char prefix from the sibling
 * `hosted-runs.jsonl`): which PLAN each used + the graded activity deltas (orders, fills, realized P&L),
 * `b − a` (B the tweak, A the baseline). NODE-LIGHT by construction — a pure-node read of the JSONL
 * receipt store, never touching `bun:sqlite`, so an agent iterating a strategy across sims on plain node
 * can compare its attempts. Deterministic (no wall clock, no RNG). A corrupt store faults LOUDLY inside
 * `readHostedRuns` (fail-closed); a missing/ambiguous id fails closed here (exit 3 / exit 2).
 *
 * Scope: the HOSTED lineage (the `sim` iteration loop this bead is about). Local `run`/`day` runs carry a
 * different metric shape and no plan field, so they are out of scope for this verb. */
export async function runsCompareCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> {
  const { positionals, flags } = parseArgs(argv, new Set(), new Set(["db"]));
  const [idA, idB] = positionals;
  if (idA === undefined || idB === undefined)
    throw usageErr("runs compare needs two run ids: `kestrel runs compare <runA> <runB>`");
  const dbPath = flags.get("db") ?? DEFAULT_DB;

  // The hosted receipts live in the identity-bound HOME ledger merged with the legacy CWD-local store
  // (kestrel-fq78), so a returning agent compares runs recorded from ANY directory. A corrupt store
  // faults LOUDLY (fail-closed) inside readHostedRunsMerged — never a silently-dropped receipt.
  const hosted = readHostedForDb(dbPath);
  const rowA = resolveHostedOne(hosted, idA);
  const rowB = resolveHostedOne(hosted, idB);
  process.stdout.write(renderRunsCompare(rowA, rowB, ctx));
  return 0;
}

/** What `lineage` DOES cover — named on an EMPTY result so silence is never the answer (kestrel-qozv).
 * `lineage` tracks only the LOCAL `run`/`day` sqlite ledger (plan-name keyed); hosted `sim` runs are a
 * separate receipt lineage with no plan-name index, so they are reachable via `runs list --hosted`, not here. */
const LINEAGE_COVERAGE =
  "lineage covers LOCAL `run`/`day` sessions (the plan-name ledger in ./data/kestrel.db) — hosted `sim` runs are NOT plan-lineage-tracked; find them with `kestrel runs list --hosted` (or `kestrel runs show <op_…|art_…>`)";

/** Emit the coverage notice on an empty lineage. human/json ride it INSIDE the payload (via
 * renderLineage — human appends it, json carries a `coverage` field); text keeps stdout a pure TSV
 * and mirrors the notice to stderr (the diagnostics channel), so no mode is silent. */
function emitLineageCoverage(ctx: OutputCtx): void {
  if (ctx.mode === "text") process.stderr.write(`notice: ${LINEAGE_COVERAGE}\n`);
}

/** `lineage <name> [--db <p>]`. An empty lineage renders the empty sentinel PLUS a typed coverage
 * notice naming what lineage tracks (kestrel-qozv) — an empty result is never silent. Exit 0. */
export async function lineageCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> {
  const { positionals, flags } = parseArgs(argv, new Set(), new Set(["db"]));
  const name = positionals[0];
  if (name === undefined) throw usageErr("lineage needs a <name>");
  const dbPath = flags.get("db") ?? DEFAULT_DB;
  const { registry, db } = await openRegForRead(dbPath);
  if (db === null) {
    process.stdout.write(renderLineage(name, [], ctx, LINEAGE_COVERAGE));
    emitLineageCoverage(ctx);
    return 0;
  }
  try {
    const rows = registry.lineage(db, name);
    process.stdout.write(renderLineage(name, rows, ctx, rows.length === 0 ? LINEAGE_COVERAGE : undefined));
    if (rows.length === 0) emitLineageCoverage(ctx);
  } finally {
    db.close();
  }
  return 0;
}

/** `leaderboard [--since <ms>] [--mode <m>] [--db <p>]`. */
export async function leaderboardCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> {
  const { flags } = parseArgs(argv, new Set(), new Set(["since", "mode", "db"]));
  const dbPath = flags.get("db") ?? DEFAULT_DB;
  let since: number | undefined;
  const sinceRaw = flags.get("since");
  if (sinceRaw !== undefined) {
    since = Number(sinceRaw);
    if (!Number.isFinite(since)) throw usageErr(`--since must be a number (ms), got ${JSON.stringify(sinceRaw)}`);
  }
  const { registry, db } = await openRegForRead(dbPath);
  if (db === null) {
    process.stdout.write(renderLeaderboard([], ctx));
    emitNoStoreHint(ctx);
    return 0;
  }
  try {
    const rows = registry.leaderboard(db, {
      ...(flags.get("mode") !== undefined ? { mode: flags.get("mode")! } : {}),
      ...(since !== undefined ? { since } : {}),
    });
    process.stdout.write(renderLeaderboard(rows, ctx));
  } finally {
    db.close();
  }
  return 0;
}
