/**
 * # cli/commands/paper — `kestrel paper` (HEAVY, lazy)
 *
 * The verb that STARTS a paper session (kestrel-7o2.12) — the last link: `sim | run | day | runs` all
 * drive RECORDED tape, and nothing on the CLI could put a live feed and a real venue gate under the
 * SAME engine until this. It mirrors `run`/`day` (`./grade.ts`) flag-for-flag in shape: required flags
 * via {@link required}, unknown flags loud (exit 2), the heavy runtime behind {@link loadHeavy} so a
 * bun-less host faults clean (exit 4) rather than dumping a stack, and its own `--help` table
 * (authored in `./meta.ts`, LIGHT — no heavy import).
 *
 * ## The IB Gateway channel is a SOCKET, not a secret (kestrel-7o2.12, scope)
 * `--host/--port/--client-id/--account` (and their `KESTREL_IBKR_*` env equivalents, resolved by
 * `adapters/broker/ibkr/config.ts`) address a **locally-running, human-logged-in IB Gateway**. The TWS
 * socket API has no in-band password, so NO credential store is invented here — BYO-broker credential
 * hardening is a separate bead (kestrel-7o2.23) and `PaperVenueSource` is the seam if one is needed.
 * `--account` is a SENSITIVE identifier: it is never echoed unredacted (the config layer redacts it in
 * every surfaced diagnostic).
 *
 * ## PAPER ONLY — there is no `--live`, and there never will be from here
 * The verb is named `paper` and its driver can only build `makeGate("paper", …)`. There is deliberately
 * no `--mode` flag: `KESTREL_IBKR_MODE=live` in the environment does NOT make this verb route live —
 * the driver REFUSES loudly (exit 7 / `PAPER_REFUSED`). Live real-money routing is owner-gated
 * (kestrel-7o2.10).
 *
 * State what ENFORCES that, precisely — not a blanket claim (ADR-0034 §3). This header used to say
 * live "is not reachable from this command by any flag, env var, or config value", which was FALSE:
 * `--port`/`KESTREL_IBKR_PORT` IS such a flag, and 7496/4001 point at a LIVE gateway while every mode
 * string still reads `paper`. Four things enforce it now, and none is a blanket:
 *  - the driver's **mode gate** (a `live` resolution refuses, never a silent downgrade);
 *  - the single literal **`makeGate("paper")`** call in the driver — no variable, no branch;
 *  - the **handshake account assertion** (ADR-0034 §3's second barrier): the gateway's own
 *    `managedAccounts` reply must name a PAPER account (`DU`/`DF`) before any tape/gate/order exists.
 *    A live `U…` account — or one that cannot be classified — refuses and the socket is CLOSED. This
 *    is what makes a fat-fingered `--port` safe, and it holds on ANY port;
 *  - the **live-port refusal**: 4001 / 7496 are refused up front, with no proceed-anyway flag.
 *
 * ## The ceilings are REQUIRED
 * `--max-order-qty`, `--max-position-qty`, `--max-notional-usd` have NO defaults. A defaulted risk
 * ceiling is the silent default AGENTS.md forbids, and an ABSENT one makes the gate seam's L0 clamp a
 * benign no-op (`UNBOUNDED_PAPER_LIMITS`). Missing ⇒ exit 2.
 */

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

import { lang } from "../../engine/index.ts";
type KestrelNode = lang.KestrelNode;
import type { OutputCtx } from "../context.ts";
import { parseArgs, required } from "../args.ts";
import { CliError, EXIT } from "../errors.ts";
import { loadHeavy } from "./_heavy.ts";

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

/** A required positive number flag — a ceiling or size that is absent, non-numeric, or ≤ 0 is a loud
 * USAGE error, never a silent fallback. */
function positive(flags: Map<string, string>, name: string): number {
  const raw = required(flags, name);
  const n = Number(raw);
  if (!Number.isFinite(n) || n <= 0) {
    throw usageErr(`--${name} must be a positive, finite number, got ${JSON.stringify(raw)}`);
  }
  return n;
}

/** An OPTIONAL positive number flag. Present-but-malformed is loud; absent is `undefined`. */
function optionalPositive(flags: Map<string, string>, name: string): number | undefined {
  const raw = flags.get(name);
  if (raw === undefined) return undefined;
  const n = Number(raw);
  if (!Number.isFinite(n) || n <= 0) {
    throw usageErr(`--${name} must be a positive, finite number, got ${JSON.stringify(raw)}`);
  }
  return n;
}

/** An OPTIONAL integer flag (port / client-id / cycles). */
function optionalInt(flags: Map<string, string>, name: string): number | undefined {
  const raw = flags.get(name);
  if (raw === undefined) return undefined;
  const n = Number(raw);
  if (!Number.isInteger(n)) throw usageErr(`--${name} must be an integer, got ${JSON.stringify(raw)}`);
  return n;
}

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

/** The stable codes {@link import("../../session/paper.ts").PaperSessionRefused} raises. Matched
 * STRUCTURALLY (name + known failure), never with `instanceof`: the class lives behind the dynamic
 * `loadHeavy` boundary, and an `instanceof` across it is not a contract worth betting a fail-closed
 * path on (the `day` handshake mapping's own discipline, kestrel-4fc9). Anything else rethrows
 * UNTOUCHED — a read fault must never be re-labelled a session refusal. */
const PAPER_FAILURES: ReadonlySet<string> = new Set([
  "mode-gate",
  "live-port",
  "live-account",
  "limits-absent",
  "documents-absent",
  "connect-failed",
  "contract-unresolved",
  "feed-absent",
  "feed-stale",
  "transport-degraded",
  "killed",
  "order-refused",
]);

/** Map a typed paper refusal onto a CliError with its OWN stable code + dedicated exit, rather than
 * letting it fall through to GENERIC/exit 1 — the taxonomy reserves exit 1 for "an unexpected/uncaught
 * error", an unclassifiable bucket in which a caller cannot tell a REFUSAL from a CRASH. A refused
 * paper session is the fail-closed path working, and it must say so. */
async function paperTyped<T>(run: () => Promise<T>): Promise<T> {
  try {
    return await run();
  } catch (err) {
    if (err instanceof Error && err.name === "PaperSessionRefused") {
      const failure = (err as { failure?: unknown }).failure;
      if (typeof failure === "string" && PAPER_FAILURES.has(failure)) {
        throw new CliError({
          code: "PAPER_REFUSED",
          exit: EXIT.PAPER_REFUSED,
          message: err.message,
          hint: `paper session refusal kind: ${failure} — fail-closed; nothing was transmitted after this point`,
        });
      }
    }
    throw err;
  }
}

/**
 * `kestrel paper --instrument <sym> --plans <p> --session-date <d> --r-usd <n>
 *   --max-order-qty <n> --max-position-qty <n> --max-notional-usd <n> [flags]`
 */
export async function paperCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> {
  const { flags, bools } = parseArgs(
    argv,
    new Set(["live-market-data"]),
    new Set([
      "instrument",
      "plans",
      "session-date",
      "r-usd",
      "max-order-qty",
      "max-position-qty",
      "max-notional-usd",
      "budget-usd",
      "expiry",
      "spot",
      "half-width",
      "tau-hours",
      "tolerance",
      "cycles",
      "pump-ms",
      "host",
      "port",
      "client-id",
      "account",
      "out",
    ]),
  );

  const instrument = required(flags, "instrument");
  const plansPath = required(flags, "plans");
  const sessionDate = required(flags, "session-date");
  const rUsd = positive(flags, "r-usd");

  // THE CEILINGS — all three REQUIRED, no defaults (see the header). An absent ceiling would fall
  // through to UNBOUNDED_PAPER_LIMITS and make the seam's L0 clamp a no-op.
  const limits = {
    maxOrderQty: positive(flags, "max-order-qty"),
    maxPositionQty: positive(flags, "max-position-qty"),
    maxNotionalUsd: positive(flags, "max-notional-usd"),
  };

  const expiry = flags.get("expiry");
  const spot = optionalPositive(flags, "spot");
  if (expiry !== undefined && spot === undefined) {
    // FAIL CLOSED (kestrel-7o2.19): a chain slice picked with no idea where the money is, is a slice of
    // the GRID, not of the market — the 741–745-at-751.94 defect. The centre is an INJECTED observation.
    throw usageErr(
      "--expiry needs --spot: a strike window must be centred on an OBSERVED underlier price, and a centre is never guessed (kestrel-7o2.19)",
    );
  }

  const tauHours = optionalPositive(flags, "tau-hours");

  const paper = await loadHeavy(() => import("../heavy.ts"));
  const result = await paperTyped(() =>
    paper.runPaperSession({
      instrument,
      sessionDate,
      documents: loadDocuments(plansPath),
      limits,
      rUsd,
      cycles: optionalInt(flags, "cycles") ?? 60,
      ...(optionalInt(flags, "pump-ms") !== undefined ? { pumpMs: optionalInt(flags, "pump-ms")! } : {}),
      ...(optionalPositive(flags, "budget-usd") !== undefined ? { budgetUsd: optionalPositive(flags, "budget-usd")! } : {}),
      ...(expiry !== undefined ? { expiry } : {}),
      ...(spot !== undefined ? { spot } : {}),
      ...(optionalPositive(flags, "half-width") !== undefined ? { halfWidth: optionalPositive(flags, "half-width")! } : {}),
      ...(tauHours !== undefined ? { tauYears: tauHours / (365 * 24) } : {}),
      ...(optionalPositive(flags, "tolerance") !== undefined ? { tolerance: optionalPositive(flags, "tolerance")! } : {}),
      ...(bools.has("live-market-data") ? { liveMarketData: true } : {}),
      // The IB Gateway CHANNEL (a socket to a locally-running gateway — never a credential). Omitted
      // fields fall through to env then the publication-safe defaults (127.0.0.1:4002 = IB PAPER).
      gateway: {
        ...(flags.get("host") !== undefined ? { host: flags.get("host")! } : {}),
        ...(optionalInt(flags, "port") !== undefined ? { port: optionalInt(flags, "port")! } : {}),
        ...(optionalInt(flags, "client-id") !== undefined ? { clientId: optionalInt(flags, "client-id")! } : {}),
        ...(flags.get("account") !== undefined ? { account: flags.get("account")! } : {}),
      },
    }),
  );

  const out = flags.get("out");
  if (out !== undefined) {
    mkdirSync(dirname(out), { recursive: true });
    writeFileSync(out, JSON.stringify({ events: result.events, ledger: result.ledger, positions: result.positions }, null, 2));
  }

  // The Bus is the truth (ADR-0010); this line is a HOST status summary of it, never a second record.
  const fills = result.events.filter((e) => e.stream === "ORDER" && (e as { type?: string }).type === "fill").length;
  const summary = {
    schema: "paper-session/v1",
    mode: "paper",
    instrument,
    cycles: result.cycles,
    events: result.events.length,
    orders: result.ledger.length,
    fills,
    positions: result.positions,
    feed_health: result.watermark.health,
  };
  if (ctx.mode === "json") process.stdout.write(JSON.stringify(summary) + "\n");
  else process.stdout.write(`paper\t${instrument}\tcycles=${result.cycles}\tevents=${result.events.length}\torders=${result.ledger.length}\tfills=${fills}\tfeed=${result.watermark.health}\n`);
  return 0;
}
