#!/usr/bin/env bun
/**
 * # session/cli — the sim Session CLI (RUNTIME §7) + the ledger surface (ADR-0006/0007)
 *
 * A thin, loud command over {@link runSimSession} and the run {@link ../ledger ledger}. It
 * reads a bus and a Kestrel plans document, grades the session under a named fill model, prints the
 * report JSON to stdout, (with `--out`) writes it to a file, and — unless `--no-record` — **auto-
 * records** the graded run into the ledger so it lands in the lineage/leaderboard store the moment
 * it is graded (ADR-0006: names are data). Plus read-only query subcommands over that store.
 *
 * ```
 * # grade a session (auto-records into data/kestrel.db)
 * bun src/session/cli.ts run --bus <path> --plans <path.kestrel|.md> \
 *   --fill strict-cross-v1 --r-usd 100 [--tau-hours 6.5] [--out report.json] \
 *   [--db data/kestrel.db] [--no-record]
 *
 * # query the ledger (human-readable tables)
 * bun src/session/cli.ts runs list [--session-date 2025-04-09] [--fill maker-fair/v1] [--lineage flush-fade] [--db …]
 * bun src/session/cli.ts runs show <run_id> [--db …]
 * bun src/session/cli.ts lineage <name> [--db …]
 * bun src/session/cli.ts leaderboard [--since <ms>] [--db …]
 * ```
 *
 * **Determinism / injected time (RUNTIME §0):** the ledger `recorded_at` is the run's **last bus
 * event `ts`** (`report.session.settle_ts`) — an injected, deterministic clock value, never
 * wall-clock — so a re-grade of the same bus records byte-identically.
 *
 * The plans path parses as a `.kestrel` file (one document) or a Markdown file with
 * ```` ```kestrel ```` fenced blocks (`--plans foo.md`, one document per block, parsed
 * identically — ADR-0001). `--tau-hours <h>` **overrides** the tau with a constant time-to-expiry
 * (hours to the settle, converted to years) so `@fair` — and therefore the `maker-fair-v1`
 * resting-at-fair hazard — is pinned (RUNTIME §4: fair needs an injected tau; a pure valuation
 * reads no clock). **Omitting it uses the dynamic tau by default** (`max(0, 16:00 ET on
 * session_date − now)`), which decays intraday as a same-day expiry does; the constant override is a
 * documented simplification. Either way a silent mid is never used, and time is always supplied,
 * never read. Fails **closed and loud**: any parse error, unknown fill model,
 * missing flag, non-positive number, or bus corruption prints to stderr and exits `1`
 * (RUNTIME §8).
 */

import { readFileSync } from "node:fs";

import { parse, parseMarkdown, type KestrelNode } from "../lang/index.ts";
import { canonicalPlansText } from "../canonical/plans.ts";
import { loadEpisodeSigmoidParams, type EpisodeSigmoidParams } from "../fill/index.ts";
import {
  getRun,
  leaderboard,
  lineage as lineageOf,
  listRuns,
  openLedger,
  record,
  type LeaderboardRow,
  type LineageInstance,
  type PlanInstanceRow,
  type RunRow,
} from "../ledger/index.ts";
import { runSimSession, type FillModelName, type EpisodeReport, type SessionReport } from "./sim.ts";
import { runDaySession, parseWakeTimes, type TimeOfDay } from "./day.ts";

const FILL_MODELS: ReadonlySet<string> = new Set<FillModelName>(["strict-cross-v1", "maker-fair-v1"]);

/** The default ledger path. `data/` is gitignored application space (ADR-0006: strategy content
 * lives in the ledger + gitignored runs, never in committed Kestrel source). */
const DEFAULT_DB = "data/kestrel.db";

// ─────────────────────────────────────────────────────────────────────────────
// Argument parsing (flags + positionals + known booleans)
// ─────────────────────────────────────────────────────────────────────────────

/** A parsed argv: leading positionals, then `--flag value` pairs, with a known set of boolean
 * flags consumed value-lessly. Anything malformed is a loud error (RUNTIME §8). */
interface ParsedArgs {
  readonly positionals: readonly string[];
  readonly flags: Map<string, string>;
  readonly bools: ReadonlySet<string>;
}

function parseArgs(argv: readonly string[], booleanFlags: ReadonlySet<string> = new Set()): ParsedArgs {
  const positionals: string[] = [];
  const flags = new Map<string, string>();
  const bools = new Set<string>();
  let i = 0;
  // Leading positionals (a run_id, a lineage name) precede any `--flag`.
  while (i < argv.length && !argv[i]!.startsWith("--")) {
    positionals.push(argv[i]!);
    i += 1;
  }
  for (; i < argv.length; i += 1) {
    const key = argv[i]!;
    if (!key.startsWith("--")) {
      throw new Error(`bad argument ${JSON.stringify(key)} — expected a --flag here`);
    }
    const name = key.slice(2);
    if (booleanFlags.has(name)) {
      bools.add(name);
      continue;
    }
    const val = argv[i + 1];
    if (val === undefined) throw new Error(`missing value for --${name}`);
    flags.set(name, val);
    i += 1;
  }
  return { positionals, flags, bools };
}

function required(flags: Map<string, string>, name: string): string {
  const v = flags.get(name);
  if (v === undefined) throw new Error(`missing required flag --${name}`);
  return v;
}

/** Parse a plans document path into Kestrel nodes: a `.md` extension takes the Markdown
 * fenced-block path; anything else parses as a single `.kestrel` document. */
function loadDocuments(plansPath: string): readonly KestrelNode[] {
  const text = readFileSync(plansPath, "utf8");
  return plansPath.endsWith(".md") ? parseMarkdown(text) : [parse(text)];
}

/** Hours in a (365-day) year — the constant `--tau-hours` divides by to reach year fractions.
 * Matches {@link import("../fair/index.ts").MS_PER_YEAR}'s 365-day convention. */
const HOURS_PER_YEAR = 365 * 24;

const USAGE = [
  "usage:",
  "  run --bus <path> --plans <path.kestrel|.md> --fill strict-cross-v1|maker-fair-v1 --r-usd <number>",
  "      [--tau-hours <number>] [--hazard-params <calibration.json>] [--out <report.json>]",
  "      [--db <path=data/kestrel.db>] [--no-record]",
  "  day --bus <path> --dir <rundir> --fill strict-cross-v1|maker-fair-v1 --r-usd <number>",
  "      [--wakes HH:MM,HH:MM,…] [--structural] [--max-wait <sec=900>] [--no-author]",
  "      [--tau-hours <number>] [--hazard-params <calibration.json>] [--out <report.json>]",
  "      [--db <path=data/kestrel.db>] [--no-record]",
  "  runs list [--session-date <YYYY-MM-DD>] [--fill <model>] [--lineage <name>] [--db <path>]",
  "  runs show <run_id> [--db <path>]",
  "  lineage <name> [--db <path>]",
  "  leaderboard [--since <ms>] [--db <path>]",
].join("\n");

/** Parse the fill model + r-usd + optional tau + optional hazard params shared by `run` and `day`.
 * Every branch fails closed and loud (RUNTIME §8). */
function parseGradeFlags(flags: Map<string, string>): {
  fillModel: FillModelName;
  rUsd: number;
  fairTauYears?: (now: number) => number | null;
  makerFairParams?: EpisodeSigmoidParams;
  out?: string;
} {
  const fillModel = required(flags, "fill");
  if (!FILL_MODELS.has(fillModel)) {
    throw new Error(`unknown fill model ${JSON.stringify(fillModel)} — one of strict-cross-v1 | maker-fair-v1`);
  }
  const rUsdRaw = required(flags, "r-usd");
  const rUsd = Number(rUsdRaw);
  if (!Number.isFinite(rUsd) || rUsd <= 0) {
    throw new Error(`--r-usd must be a positive number, got ${JSON.stringify(rUsdRaw)}`);
  }

  let fairTauYears: ((now: number) => number | null) | undefined;
  const tauRaw = flags.get("tau-hours");
  if (tauRaw !== undefined) {
    const tauHours = Number(tauRaw);
    if (!Number.isFinite(tauHours) || tauHours <= 0) {
      throw new Error(`--tau-hours must be a positive number, got ${JSON.stringify(tauRaw)}`);
    }
    const tauYears = tauHours / HOURS_PER_YEAR;
    fairTauYears = () => tauYears;
  }

  let makerFairParams: EpisodeSigmoidParams | undefined;
  const hazardPath = flags.get("hazard-params");
  if (hazardPath !== undefined) {
    if (fillModel !== "maker-fair-v1") throw new Error("--hazard-params applies only to --fill maker-fair-v1");
    const raw = JSON.parse(readFileSync(hazardPath, "utf8")) as unknown;
    makerFairParams = loadEpisodeSigmoidParams(raw);
  }

  return {
    fillModel: fillModel as FillModelName,
    rUsd,
    ...(fairTauYears !== undefined ? { fairTauYears } : {}),
    ...(makerFairParams !== undefined ? { makerFairParams } : {}),
    ...(flags.get("out") !== undefined ? { out: flags.get("out")! } : {}),
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// `run` — grade + auto-record
// ─────────────────────────────────────────────────────────────────────────────

function runCommand(argv: readonly string[]): void {
  const { flags, bools } = parseArgs(argv, new Set(["no-record"]));

  const busPath = required(flags, "bus");
  const plansPath = required(flags, "plans");
  // fill model + r-usd + optional tau (RUNTIME §4, a constant is a valid injection) + optional
  // calibrated hazard params (RUNTIME §6) — all shared with `day`, all fail-closed (§8).
  const { fillModel, rUsd, fairTauYears, makerFairParams, out } = parseGradeFlags(flags);

  const documents = loadDocuments(plansPath);
  const report = runSimSession({
    busPath,
    documents,
    fillModel,
    rUsd,
    ...(fairTauYears !== undefined ? { fairTauYears } : {}),
    ...(makerFairParams !== undefined ? { makerFairParams } : {}),
    ...(out !== undefined ? { out } : {}),
  });

  process.stdout.write(JSON.stringify(report, null, 2) + "\n");

  // Auto-record into the ledger (ADR-0006) unless opted out. `now` is the run's LAST bus event
  // ts — an injected, deterministic clock value (RUNTIME §0), never wall-clock — so re-grading the
  // same bus re-records byte-identically. `calibrated` marks a hazard-params (calibrated) fill.
  if (!bools.has("no-record")) {
    recordGraded(flags, report, {
      plansText: canonicalPlansText(documents),
      rUsd,
      calibrated: makerFairParams !== undefined,
      ...(out !== undefined ? { reportPath: out } : {}),
    });
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// `day` — the stepped session (wake handshake + document supersession)
// ─────────────────────────────────────────────────────────────────────────────

async function dayCommand(argv: readonly string[]): Promise<void> {
  const { flags, bools } = parseArgs(argv, new Set(["no-record", "structural", "no-author"]));

  const busPath = required(flags, "bus");
  const dir = required(flags, "dir");
  const { fillModel, rUsd, fairTauYears, makerFairParams, out } = parseGradeFlags(flags);

  const wakes: TimeOfDay[] = flags.get("wakes") !== undefined ? parseWakeTimes(flags.get("wakes")!) : [];

  let maxWaitSec: number | undefined;
  const maxWaitRaw = flags.get("max-wait");
  if (maxWaitRaw !== undefined) {
    maxWaitSec = Number(maxWaitRaw);
    if (!Number.isFinite(maxWaitSec) || maxWaitSec <= 0) {
      throw new Error(`--max-wait must be a positive number of seconds, got ${JSON.stringify(maxWaitRaw)}`);
    }
  }

  const { report, plansText, wakes: delivered } = await runDaySession({
    busPath,
    dir,
    wakes,
    structural: bools.has("structural"),
    fillModel,
    rUsd,
    ...(fairTauYears !== undefined ? { fairTauYears } : {}),
    ...(makerFairParams !== undefined ? { makerFairParams } : {}),
    ...(maxWaitSec !== undefined ? { maxWaitSec } : {}),
    ...(bools.has("no-author") ? { noAuthor: true } : {}),
    ...(out !== undefined ? { out } : {}),
  });

  process.stdout.write(JSON.stringify(report, null, 2) + "\n");
  const revised = delivered.filter((w) => w.revised).length;
  process.stderr.write(`day: ${delivered.length} wake(s), ${revised} revision(s) — settle ${report.session.settle_ts}\n`);

  // Auto-record with the FULL concatenated authored text (plans-0 + every revision) as plansText
  // (ADR-0006). `now` is the injected settle ts, never wall-clock (RUNTIME §0).
  if (!bools.has("no-record")) {
    recordGraded(flags, report, {
      plansText,
      rUsd,
      calibrated: makerFairParams !== undefined,
      ...(out !== undefined ? { reportPath: out } : {}),
    });
  }
}

/** Record a graded report into the ledger, clocked by the injected settle ts (shared by `run`
 * and `day`). `--db` overrides the default path. */
function recordGraded(
  flags: Map<string, string>,
  report: EpisodeReport,
  opts: { plansText: string; rUsd: number; calibrated: boolean; reportPath?: string },
): void {
  const dbPath = flags.get("db") ?? DEFAULT_DB;
  const db = openLedger(dbPath);
  try {
    const { runId } = record(db, report, {
      plansText: opts.plansText,
      now: report.session.settle_ts,
      rUsd: opts.rUsd,
      calibrated: opts.calibrated,
      ...(opts.reportPath !== undefined ? { reportPath: opts.reportPath } : {}),
    });
    process.stderr.write(`session: recorded run ${runId.slice(0, 12)} into ${dbPath}\n`);
  } finally {
    db.close();
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Query subcommands — human-readable tables over the ledger
// ─────────────────────────────────────────────────────────────────────────────

/** Right-pad to width. */
function pad(s: string, w: number): string {
  return s.length >= w ? s : s + " ".repeat(w - s.length);
}

/** Left-pad (numbers align right). */
function padL(s: string, w: number): string {
  return s.length >= w ? s : " ".repeat(w - s.length) + s;
}

/** Signed dollars, 2dp (`+25.00`, `-270.00`) — the human money glyph. */
function usd(n: number): string {
  return (n >= 0 ? "+" : "") + n.toFixed(2);
}

/** Render a header + rows as a fixed-width table, columns sized to content. Numeric columns
 * (flagged by `rightAlign`) pad left. Prints nothing but the table (a note precedes it). */
function table(headers: readonly string[], rows: readonly (readonly string[])[], rightAlign: readonly boolean[]): string {
  const widths = headers.map((h, c) => Math.max(h.length, ...rows.map((r) => (r[c] ?? "").length)));
  const fmt = (cells: readonly string[]): string =>
    cells.map((cell, c) => (rightAlign[c] ? padL(cell, widths[c]!) : pad(cell, widths[c]!))).join("  ");
  const sep = widths.map((w) => "─".repeat(w)).join("  ");
  return [fmt(headers), sep, ...rows.map(fmt)].join("\n");
}

function shortId(id: string): string {
  return id.slice(0, 12);
}

function runsListCommand(argv: readonly string[]): void {
  const { flags } = parseArgs(argv);
  const dbPath = flags.get("db") ?? DEFAULT_DB;
  const db = openLedger(dbPath);
  try {
    const rows = listRuns(db, {
      ...(flags.get("session-date") !== undefined ? { sessionDate: flags.get("session-date")! } : {}),
      ...(flags.get("fill") !== undefined ? { fillModel: flags.get("fill")! } : {}),
      ...(flags.get("lineage") !== undefined ? { lineage: flags.get("lineage")! } : {}),
    });
    printRunsTable(rows);
  } finally {
    db.close();
  }
}

function printRunsTable(rows: readonly RunRow[]): void {
  if (rows.length === 0) {
    process.stdout.write("(no runs)\n");
    return;
  }
  const headers = ["run_id", "date", "mode", "fill_model", "cal", "realized", "expected", "premium", "events"];
  const right = [false, false, false, false, false, true, true, true, true];
  const body = rows.map((r) => [
    shortId(r.run_id),
    r.session_date,
    r.mode,
    r.fill_model,
    r.calibrated ? "✓" : "",
    usd(r.realized_floor_usd),
    usd(r.expected_usd),
    r.premium_spent.toFixed(2),
    String(r.bus_events),
  ]);
  process.stdout.write(table(headers, body, right) + "\n");
  process.stdout.write(`\n${rows.length} run(s)\n`);
}

function runsShowCommand(argv: readonly string[]): void {
  const { positionals, flags } = parseArgs(argv);
  const runId = positionals[0];
  if (runId === undefined) throw new Error("runs show needs a <run_id>");
  const dbPath = flags.get("db") ?? DEFAULT_DB;
  const db = openLedger(dbPath);
  try {
    // Accept a short (12-hex) prefix or the full id.
    const all = listRuns(db);
    const match = all.filter((r) => r.run_id === runId || r.run_id.startsWith(runId));
    if (match.length === 0) throw new Error(`no run matching ${JSON.stringify(runId)}`);
    if (match.length > 1) throw new Error(`ambiguous run prefix ${JSON.stringify(runId)} — ${match.length} matches`);
    const full = match[0]!.run_id;
    const rec = getRun(db, full)!;
    const r = rec.run;
    process.stdout.write(
      [
        `run_id       ${r.run_id}`,
        `session      ${r.session_date}  ${r.mode}  [${r.instruments}]`,
        `fill_model   ${r.fill_model}${r.calibrated ? "  (calibrated)" : ""}`,
        `recorded_at  ${r.recorded_at}  (settle ts, injected)`,
        `r_usd        ${r.r_usd.toFixed(2)}`,
        `totals       realized ${usd(r.realized_floor_usd)}  expected ${usd(r.expected_usd)}  premium ${r.premium_spent.toFixed(2)}`,
        `bus          ${r.bus_events} events  sha ${shortId(r.bus_sha256)}  det ${shortId(r.determinism_hash)}`,
        r.report_path !== null ? `report_path  ${r.report_path}` : "report_path  (none)",
        "",
      ].join("\n"),
    );
    printPlansTable(rec.plans);
  } finally {
    db.close();
  }
}

function printPlansTable(plans: readonly PlanInstanceRow[]): void {
  if (plans.length === 0) {
    process.stdout.write("(no plan instances)\n");
    return;
  }
  const headers = ["plan", "final_state", "outcome", "fired", "orders", "fills", "realized", "expected"];
  const right = [false, false, false, true, true, true, true, true];
  const body = plans.map((p) => [
    p.name,
    p.final_state,
    p.outcome ?? "—",
    p.fired ? "yes" : "no",
    String(p.orders),
    String(p.fills),
    usd(p.realized_usd),
    usd(p.expected_usd),
  ]);
  process.stdout.write(table(headers, body, right) + "\n");
}

function lineageCommand(argv: readonly string[]): void {
  const { positionals, flags } = parseArgs(argv);
  const name = positionals[0];
  if (name === undefined) throw new Error("lineage needs a <name>");
  const dbPath = flags.get("db") ?? DEFAULT_DB;
  const db = openLedger(dbPath);
  try {
    const rows = lineageOf(db, name);
    process.stdout.write(`lineage: ${name}\n\n`);
    printLineageTable(rows);
  } finally {
    db.close();
  }
}

function printLineageTable(rows: readonly LineageInstance[]): void {
  if (rows.length === 0) {
    process.stdout.write("(no instances)\n");
    return;
  }
  const headers = ["date", "mode", "fill_model", "final_state", "outcome", "fired", "fills", "realized", "expected"];
  const right = [false, false, false, false, false, true, true, true, true];
  const body = rows.map((i) => [
    i.session_date,
    i.mode,
    i.fill_model,
    i.final_state,
    i.outcome ?? "—",
    i.fired ? "yes" : "no",
    String(i.fills),
    usd(i.realized_usd),
    usd(i.expected_usd),
  ]);
  process.stdout.write(table(headers, body, right) + "\n");
  const realizedSum = rows.reduce((s, i) => s + i.realized_usd, 0);
  const expectedSum = rows.reduce((s, i) => s + i.expected_usd, 0);
  process.stdout.write(`\n${rows.length} instance(s)  realized ${usd(realizedSum)}  expected ${usd(expectedSum)}\n`);
}

function leaderboardCommand(argv: readonly string[]): void {
  const { flags } = parseArgs(argv);
  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 new Error(`--since must be a number (ms), got ${JSON.stringify(sinceRaw)}`);
  }
  const db = openLedger(dbPath);
  try {
    const rows = leaderboard(db, {
      ...(flags.get("mode") !== undefined ? { mode: flags.get("mode")! } : {}),
      ...(since !== undefined ? { since } : {}),
    });
    printLeaderboardTable(rows);
  } finally {
    db.close();
  }
}

function printLeaderboardTable(rows: readonly LeaderboardRow[]): void {
  if (rows.length === 0) {
    process.stdout.write("(no lineages)\n");
    return;
  }
  const headers = ["lineage", "runs", "fired", "fills", "realized_sum", "expected_sum", "realized_avg", "expected_avg"];
  const right = [false, true, true, true, true, true, true, true];
  const body = rows.map((r) => [
    r.name,
    String(r.runs),
    String(r.fired),
    String(r.fills),
    usd(r.realized_sum),
    usd(r.expected_sum),
    usd(r.realized_avg),
    usd(r.expected_avg),
  ]);
  process.stdout.write(table(headers, body, right) + "\n");
  process.stdout.write(`\n${rows.length} lineage(s)\n`);
}

// ─────────────────────────────────────────────────────────────────────────────
// Dispatch
// ─────────────────────────────────────────────────────────────────────────────

export async function main(argv: readonly string[]): Promise<number> {
  const [command, ...rest] = argv;
  try {
    switch (command) {
      case "run":
        runCommand(rest);
        return 0;
      case "day":
        // `day` delegates to `runDaySession`, which is `async` since it runs OVER the shared
        // `runSimulateSession` driver (kestrel-5zl.3) — so this command (and `main`) awaits it.
        await dayCommand(rest);
        return 0;
      case "runs": {
        const [sub, ...subRest] = rest;
        if (sub === "list") {
          runsListCommand(subRest);
          return 0;
        }
        if (sub === "show") {
          runsShowCommand(subRest);
          return 0;
        }
        throw new Error(`unknown \`runs\` subcommand ${JSON.stringify(sub)} — one of list | show`);
      }
      case "lineage":
        lineageCommand(rest);
        return 0;
      case "leaderboard":
        leaderboardCommand(rest);
        return 0;
      default:
        process.stderr.write(`${USAGE}\n`);
        return 1;
    }
  } catch (err) {
    process.stderr.write(`session: ${err instanceof Error ? err.message : String(err)}\n`);
    return 1;
  }
}

// The report shape re-exported so this module's callers can type a parsed report file.
// `SessionReport` is the deprecated pre-jcn0 alias, kept for API compat.
export type { EpisodeReport, SessionReport };

if (import.meta.main) {
  void main(process.argv.slice(2)).then((code) => process.exit(code));
}
