/**
 * # cli/commands/grade — `run` + `day` (HEAVY, lazy bun/chdb)
 *
 * Behavior identical to the old `session/cli.ts`: `parseGradeFlags`, `runSimSession`/`runDaySession`,
 * `recordGraded` with the **injected settle-ts clock** (`record(db, report, { now:
 * report.session.settle_ts })` — never wall-clock, so a re-grade of the same bus records
 * byte-identically). The heavy modules (`session/sim`, `session/day`, `registry` — all pull
 * `bun:sqlite`/`Bun.CryptoHasher`) are dynamic-imported through {@link loadHeavy}, so this file stays
 * off the load-time graph and faults loud (exit 4) under bun-less node, never a raw stack.
 *
 * Only type-only imports of the heavy modules appear statically (erased at build). The parse/print
 * and fill helpers this HEAVY command needs bind to the PUBLISHED engine surface (kestrel-djm.3) —
 * `engine.lang` (parse/print) and `engine.fill` (the hazard-param loader) — rather than deep private
 * `../../lang` / `../../fill` paths; that aggregate is Bun-hosted but still carries NO
 * `bun:sqlite`/`chdb` (those stay behind the lazy {@link ../heavy.ts} edge), so this file's static
 * graph remains free of the native runtime and `run`/`day` still fault loud (exit 4) under bun-less
 * node only when the heavy barrel is dynamically loaded.
 */

import { mkdirSync, readFileSync } from "node:fs";
import { dirname } from "node:path";

import { lang, fill } from "../../engine/index.ts";
import { canonicalPlansText } from "../../canonical/plans.ts";
type KestrelNode = lang.KestrelNode;
type EpisodeSigmoidParams = fill.EpisodeSigmoidParams;
import type { FillModelName, EpisodeReport, TimeOfDay } from "../heavy.ts";
import type { OutputCtx } from "../context.ts";
import { parseArgs, required } from "../args.ts";
import { CliError, EXIT } from "../errors.ts";
import { loadHeavy } from "./_heavy.ts";
import { renderOffer } from "../render/offer.ts";
import { renderEpisodeReport, neverFiredDigestLines } from "../render/sim.ts";
import type { ExecutionBackend, SessionArgs, DayArgs } from "../backend/index.ts";

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

/** The default registry path (gitignored application space, ADR-0006). */
const DEFAULT_DB = "data/kestrel.db";

/** Hours in a (365-day) year — the constant `--tau-hours` divides by to reach year fractions. */
const HOURS_PER_YEAR = 365 * 24;

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

/** The stable codes {@link import("../../session/day.ts").DayHandshakeError} raises. */
const DAY_HANDSHAKE_CODES: ReadonlySet<string> = new Set(["NO_AUTHOR", "HANDSHAKE_ABANDONED"]);

/** Map a `day` handshake refusal onto a CliError with its OWN stable code + a dedicated exit
 * ({@link EXIT.HANDSHAKE}) rather than letting it fall through to GENERIC/exit 1, which the taxonomy
 * reserves for "an unexpected/uncaught error" — an unclassifiable bucket a caller (the web repo's
 * published-command guard) cannot distinguish a refusal from a crash in (kestrel-4fc9).
 *
 * The error is matched STRUCTURALLY (name + known code), not with `instanceof`: the class lives in
 * `session/day.ts`, which this light command module must not statically import (it reaches the runtime
 * only through `loadHeavy`), and an `instanceof` across that dynamic-import boundary is not a contract
 * worth betting a fail-closed path on. Anything else rethrows UNTOUCHED — a read fault or a
 * ReferenceError must never be re-labelled a handshake refusal. */
async function dayHandshakeTyped<T>(run: () => Promise<T>): Promise<T> {
  try {
    return await run();
  } catch (err) {
    if (err instanceof Error && err.name === "DayHandshakeError") {
      const code = (err as { code?: unknown }).code;
      if (typeof code === "string" && DAY_HANDSHAKE_CODES.has(code)) {
        const hint = (err as { hint?: unknown }).hint;
        throw new CliError({
          code,
          exit: EXIT.HANDSHAKE,
          message: err.message,
          ...(typeof hint === "string" ? { hint } : {}),
        });
      }
    }
    throw err;
  }
}

/** `.md` → fenced ```kestrel blocks; else a single `.kestrel` document. */
function loadDocuments(plansPath: string): readonly KestrelNode[] {
  const text = readFileSync(plansPath, "utf8");
  return plansPath.endsWith(".md") ? lang.parseMarkdown(text) : [lang.parse(text)];
}

interface GradeFlags {
  fillModel: FillModelName;
  rUsd: number;
  fairTauYears?: (now: number) => number | null;
  makerFairParams?: EpisodeSigmoidParams;
  out?: string;
}

/** Parse the fill model + r-usd + optional tau + optional hazard params shared by `run`/`day`. */
function parseGradeFlags(flags: Map<string, string>): GradeFlags {
  const fillModel = required(flags, "fill");
  if (!FILL_MODELS.has(fillModel)) {
    throw usageErr(`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 usageErr(`--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 usageErr(`--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 usageErr("--hazard-params applies only to --fill maker-fair-v1");
    const raw = JSON.parse(readFileSync(hazardPath, "utf8")) as unknown;
    makerFairParams = fill.loadEpisodeSigmoidParams(raw);
  }

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

/**
 * The one-line text-mode digest (kestrel-4opv) — the primary feedback loop for an agent iterating on a
 * strategy, so it must tell the near-miss outcomes APART: a plan that never fired (`plans_fired=0`),
 * a plan that quietly expired on its ttl (`plans_expired=1`), a fired plan whose order rests unfilled
 * (`orders_placed=1 orders_filled=0`), and a filled round trip. Read off the report fields the caller
 * already holds — `plans[].lifecycle` (RUNTIME §5) + `orders[]` + `totals` — and kept as the ONE call
 * site so the OSS-ADR-0039 migration (kestrel-c1os) re-points the digest at the Blotter in one place.
 *
 * Counting rules, stated because they are NOT self-evident from the field names:
 * - `plans_armed` / `plans_fired` count plans whose lifecycle EVER REACHED that state, not plans
 *   sitting in it at settle — a plan that armed and then fired counts in both.
 * - `plans_expired` counts the TERMINAL `done(expired)` outcome only (ttl elapsed / deadline passed).
 *   A plan that fired and filled is not "expired", and a plan still armed at settle is not either.
 * - `orders_placed` counts SUBMISSIONS — every order that crossed the Gate, INCLUDING ones later
 *   cancelled (esc-replaced, TTL/INVALIDATE-pulled, CANCEL-IF; `OrderReport.cancelled`). It is
 *   deliberately not a count of orders still resting at settle: the digest reports what the session
 *   DID, and a replaced order was really submitted. `orders_placed − orders_filled` is therefore an
 *   upper bound on what rested, not an exact resting count.
 */
function settleDigest(report: EpisodeReport): string {
  type PlanState = EpisodeReport["plans"][number]["lifecycle"][number]["state"];
  const traces = report.plans;
  const reached = (state: PlanState): number => traces.filter((p) => p.lifecycle.some((s) => s.state === state)).length;
  const armed = reached("armed");
  const fired = reached("fired");
  // A plan EXPIRED is a terminal outcome, not a lifecycle state — read it off `final_state`.
  const expired = traces.filter((p) => p.final_state === "expired").length;
  const placed = report.orders.length;
  const filled = report.orders.filter((o) => o.filled).length;
  return (
    `settle=${report.session.settle_ts}` +
    ` pnl=${report.totals.realized_floor_usd}` +
    ` expected=${report.totals.expected_usd}` +
    ` events=${report.session.bus_events}` +
    ` plans_armed=${armed} plans_fired=${fired} plans_expired=${expired}` +
    ` orders_placed=${placed} orders_filled=${filled}`
  );
}

/** Write the report to stdout in the active mode (diagnostics go to stderr — §5). */
function writeReport(report: EpisodeReport, ctx: OutputCtx): void {
  if (ctx.mode === "text") {
    // The token-cheap agent default: the one-line settle digest — PLUS a per-plan `why` line for every
    // zero-order plan that carries a never-fired reason (kestrel-1zix). The aggregate digest alone made
    // the DEFAULT piped/agent frame silent about WHY a plan never fired — a regime-DOA plan, a
    // ttl-expired plan, and an adoption that bound nothing were byte-identical to a healthy skip. The
    // reason lines the human/json faces already carry now ride this frame too, so nothing is inaudible on
    // the surface the docs tell an agent to pipe. Driven HERE by the real command, so DELETING this call
    // (not editing render/sim.ts) reds the digest fixtures — the decisive wiring probe. A session where
    // every plan traded yields NO extra lines, so a healthy run's digest stays the single aggregate line.
    const lines = [settleDigest(report), ...neverFiredDigestLines(report)];
    process.stdout.write(lines.join("\n") + "\n");
    return;
  }
  if (ctx.mode === "human") {
    // The full session story as pure text — grade channels, plan lifecycle, orders/fills (kestrel-0hx).
    // The site-is-spec third face: the whole report is legible to a human AND an agent without paying
    // the JSON parse cost (the report used to exist in a legible form ONLY via --json).
    renderEpisodeReport(report, ctx);
    return;
  }
  // json emits the raw EpisodeReport object (no wrapper) as canonical two-space JSON. NOTE: a bare pipe
  // resolves to TEXT mode (the one-line settle digest), so `| jq` needs an explicit --json; only json
  // mode is jq-stable.
  process.stdout.write(JSON.stringify(report, null, 2) + "\n");
}

/** Record a graded report, clocked by the injected settle ts (shared by `run` + `day`). */
async function recordGraded(
  flags: Map<string, string>,
  report: EpisodeReport,
  opts: { plansText: string; rUsd: number; calibrated: boolean; reportPath?: string },
): Promise<void> {
  const registry = await loadHeavy(() => import("../heavy.ts"));
  const dbPath = flags.get("db") ?? DEFAULT_DB;
  // First-run DX: a brand-new user's first `run`/`day` records to the default `data/kestrel.db` from a
  // fresh cwd with no `data/` folder yet; create the resolved db's PARENT dir so the open no longer
  // faults with SQLite's raw "unable to open database file". CLI-level filesystem setup, off the
  // deterministic runtime/replay path, and only on this write/record path (reads never create dirs).
  mkdirSync(dirname(dbPath), { recursive: true });
  const db = registry.openRegistry(dbPath);
  try {
    const { runId } = registry.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();
  }
}

/** `run` — grade a session, print the report, auto-record (unless `--no-record`). */
export async function runCommand(argv: readonly string[], ctx: OutputCtx, backend: ExecutionBackend): Promise<number> {
  const { flags, bools } = parseArgs(
    argv,
    new Set(["no-record"]),
    new Set(["bus", "plans", "fill", "r-usd", "tau-hours", "hazard-params", "out", "db"]),
  );

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

  const documents = loadDocuments(plansPath);
  const sessionArgs: SessionArgs = {
    busPath,
    documents,
    fillModel,
    rUsd,
    ...(fairTauYears !== undefined ? { fairTauYears } : {}),
    ...(makerFairParams !== undefined ? { makerFairParams } : {}),
    ...(out !== undefined ? { out } : {}),
  };
  const g = await backend.openSession("sim", sessionArgs);
  if (g.gated) {
    renderOffer(g.payment, ctx);
    return EXIT.PAYMENT_REQUIRED;
  }
  const { report } = g.value;

  writeReport(report, ctx);

  if (!bools.has("no-record")) {
    await recordGraded(flags, report, {
      plansText: canonicalPlansText(documents),
      rUsd,
      calibrated: makerFairParams !== undefined,
      ...(out !== undefined ? { reportPath: out } : {}),
    });
  }
  return 0;
}

/** `day` — the stepped/wake session (wake handshake + document supersession), auto-record. */
export async function dayCommand(argv: readonly string[], ctx: OutputCtx, backend: ExecutionBackend): Promise<number> {
  const { flags, bools } = parseArgs(
    argv,
    new Set(["no-record", "structural", "no-author", "await-author"]),
    new Set(["bus", "dir", "fill", "r-usd", "tau-hours", "hazard-params", "wakes", "max-wait", "out", "db"]),
  );

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

  // `--wakes HH:MM,…` parses through the heavy barrel's pure helper (behavior-identical to before).
  let wakes: TimeOfDay[] = [];
  const wakesRaw = flags.get("wakes");
  if (wakesRaw !== undefined) {
    const day = await loadHeavy(() => import("../heavy.ts"));
    wakes = day.parseWakeTimes(wakesRaw);
  }

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

  // --await-author (block for an out-of-band author) and --no-author (nobody will reply) are contradictory
  // declarations — refuse the combination loudly rather than silently letting one win (kestrel-4fc9).
  if (bools.has("await-author") && bools.has("no-author")) {
    throw usageErr("--await-author and --no-author are contradictory: declare at most one (default: refuse promptly on a missing document).");
  }

  // The stepped day authors its plan document in-band via the handshake dir (`plans-0.kestrel`),
  // so `documents` is empty here — runDaySession reads plans from `dir`, not from this field.
  const dayArgs: DayArgs = {
    dir,
    documents: [],
    busPath,
    wakes,
    structural: bools.has("structural"),
    fillModel,
    rUsd,
    ...(fairTauYears !== undefined ? { fairTauYears } : {}),
    ...(makerFairParams !== undefined ? { makerFairParams } : {}),
    ...(maxWaitSec !== undefined ? { maxWaitSec } : {}),
    ...(bools.has("await-author") ? { awaitAuthor: true } : {}),
    ...(bools.has("no-author") ? { noAuthor: true } : {}),
    ...(out !== undefined ? { out } : {}),
  };
  const g = await dayHandshakeTyped(() => backend.openDaySession("sim", dayArgs));
  if (g.gated) {
    renderOffer(g.payment, ctx);
    return EXIT.PAYMENT_REQUIRED;
  }
  const { report, plansText, wakes: delivered } = g.value;

  writeReport(report, ctx);
  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`);

  if (!bools.has("no-record")) {
    await recordGraded(flags, report, {
      plansText,
      rUsd,
      calibrated: makerFairParams !== undefined,
      ...(out !== undefined ? { reportPath: out } : {}),
    });
  }
  return 0;
}
