/**
 * # cli/render/sim — the SIM story + menu as PURE TEXT (kestrel-585 / kestrel-vcn).
 *
 * The hosted `/simulate` path is one-shot today (the whole Operation arrives completed —
 * no interactive increments yet; that streaming face is kestrel-markets-dog). So this
 * renderer tells the COMPLETED Operation's story rather than faking a stream — but it is
 * structured as a list of independent lines built from a {@link SimStory} domain object,
 * so a streaming transport can later emit the SAME lines incrementally without changing a
 * character of the copy.
 *
 * Pure text on stdout in every non-json mode (the default human path is intentionally the
 * same plain text — the sim verb is a light, node-runnable funnel, not a TTY cockpit).
 * Diagnostics belong on stderr (the command writes those); this module only builds the
 * stdout payload lines.
 */

import type { OutputCtx } from "../context.ts";
import type { GradedMetrics, HostedEvidenceBundle, ReceiptsSummary } from "../backend/hosted.ts";
import type { EpisodeReport } from "../heavy.ts";

/** The domain object a sim run renders from — everything the story needs, transport-agnostic. */
export interface SimStory {
  /** The canonical catalog slug (printed in the header even when an alias was typed). */
  readonly canonicalSlug: string;
  /** What the caller typed (may be a marketing alias). */
  readonly requestedSlug: string;
  /** True when an alias resolved to the canonical slug. */
  readonly viaAlias: boolean;
  readonly title: string;
  readonly instrument: string;
  readonly period: { readonly start: string; readonly end: string };
  readonly frameCount: number;
  /** How the strategy was supplied: the stand-down demo label, or the --plans path. */
  readonly strategyLabel: string;
  readonly operation: { readonly id: string; readonly status: string };
  readonly receipts: ReceiptsSummary;
  /** Verified private R2 references for replay, evaluation, and watcher training. */
  readonly evidence: HostedEvidenceBundle;
  /**
   * The graded metrics from the signed proof, or `null` when the proof is still SETTLING — a
   * transient R2 write-lag 404 on `GET /proof`. The Operation completed and was metered (the
   * user's paid-for result), but the shareable grade is not yet readable, so the grade + proof
   * lines annotate "settling — retry shortly" rather than discard the run (kestrel-fzum).
   */
  readonly metrics: GradedMetrics | null;
  /** The shareable web proof URL — always the LAST line. */
  readonly proofUrl: string;
}

/** Format a dollar figure compactly (budget/pnl), trimming noise floats deterministically. */
function money(n: number): string {
  const r = Math.round(n * 1e6) / 1e6;
  return `$${r}`;
}

/**
 * Build the ordered story lines. ONE line per beat so a future streaming face can emit
 * them as they arrive (header first, proof last). No color, no width-wrapping — the funnel
 * output is byte-identical across a TTY and a pipe.
 */
export function simStoryLines(story: SimStory): string[] {
  const lines: string[] = [];
  lines.push(`kestrel sim · ${story.canonicalSlug}`);
  if (story.viaAlias) lines.push(`alias      ${story.requestedSlug} → ${story.canonicalSlug}`);
  lines.push(
    `scenario   ${story.title} — ${story.instrument} · ${story.period.start}..${story.period.end} · ${story.frameCount} frames`,
  );
  lines.push(`briefing   ${story.instrument} percept context available (${story.frameCount} frames)`);
  lines.push(`strategy   ${story.strategyLabel}`);
  lines.push(`operation  ${story.operation.id} · ${story.operation.status}`);
  const budget = story.receipts.budgetRemaining === null ? "—" : money(story.receipts.budgetRemaining);
  lines.push(`receipts   ${story.receipts.meteredCount} metered · budget remaining ${budget}`);
  lines.push(
    `evidence   manifest=${story.evidence.manifestRef.artifact_id} bus=${story.evidence.busRef.artifact_id} grade=${story.evidence.gradeRef.artifact_id} calls=${story.evidence.callRefs.length}`,
  );
  if (story.metrics === null) {
    // The Operation completed and was metered, but the signed grade proof is not yet readable
    // (transient R2 write lag). Annotate the pending lines "settling — retry shortly" rather than
    // fabricate a zero-trade grade or discard the completed run (kestrel-fzum).
    lines.push(`grade      settling — retry shortly`);
    lines.push(`proof      ${story.proofUrl} — settling — retry shortly`);
  } else {
    lines.push(
      `grade      order_count=${story.metrics.orderCount} fill_count=${story.metrics.fillCount} realized_pnl=${story.metrics.realizedPnl}`,
    );
    lines.push(`proof      ${story.proofUrl}`);
  }
  return lines;
}

/** Write the sim story to stdout as pure text (human + text modes share the plain form). */
export function renderSimStory(story: SimStory, _ctx: OutputCtx): void {
  process.stdout.write(simStoryLines(story).join("\n") + "\n");
}

/**
 * The one-line "why did nothing trade?" explanation for a 0-order run (kestrel-kglw). A run that
 * placed no orders is the single highest-value teaching moment in the funnel — and it used to be
 * SILENT on the default face (the grader's reason lived only under `--json`). This surfaces the
 * grader-stamped reason VERBATIM (gate unsatisfied / trigger never crossed / budget absent / series
 * unavailable, …) as one legible line beside the `grade order_count=0` line. Pure + transport-
 * agnostic; the caller decides WHEN to emit it (only a 0-order run carrying a reason).
 */
export function neverFiredLine(reason: string): string {
  return `why        no orders fired — ${reason}`;
}

/**
 * The one-line never-fired reason for ONE plan in the LOCAL `run`/`day` `--format human` report
 * (kestrel-kglw / kestrel-y6vo) — the twin of the hosted {@link neverFiredLine}, but per-plan and
 * sourced from the plan's own lifecycle trace rather than the grader's run-level diagnostic.
 *
 * A plan that placed ZERO orders and never fired is the highest-value teaching moment of a 0-order run,
 * and the plan-lifecycle ARC alone could not tell WHY: the arc renders a reason only when it rides a
 * TERMINAL (outcome-bearing) step (`done(expired:…)`), so the armed-expiry classes surface there
 * (kestrel-y6vo engine work) but a regime-DOA plan — whose reason rides a NON-terminal `authored` reject
 * (engine/plans.ts `#emitReject`, the kestrel-ocyf/hk9u arm-block notice) — printed a bare
 * `authored → authored` with the reason dropped. This surfaces the LAST reason-bearing lifecycle step of
 * a never-fired plan UNIFORMLY, whether that reason sits on a terminal or an intermediate step, so every
 * never-fired class (plan-budget clamp, unwritten-series trigger, trigger-never-crossed, regime-gate DOA,
 * manages-nothing) self-explains on the default face.
 *
 * Returns `undefined` for a plan that traded (reached `fired`/`managing`, or placed any order — a resting
 * unfilled order still FIRED) or that carries no reason, so a healthy run stays quiet. Pure: reads only
 * report fields. A leading `ttl — ` from the armed-expiry classifier is trimmed so the line reads as one
 * clean cause, not a doubled `ttl` token.
 */
export function planNeverFiredReason(
  plan: EpisodeReport["plans"][number],
  orders: EpisodeReport["orders"],
): string | undefined {
  const fired = plan.lifecycle.some((s) => s.state === "fired" || s.state === "managing");
  if (fired) return undefined;
  const placed = orders.some((o) => o.plan_instance === plan.plan_instance);
  if (placed) return undefined;
  for (let i = plan.lifecycle.length - 1; i >= 0; i--) {
    const reason = plan.lifecycle[i]!.reason;
    if (reason !== undefined) return reason.startsWith("ttl — ") ? reason.slice("ttl — ".length) : reason;
  }
  return undefined;
}

/**
 * The per-plan never-fired reason lines for the TEXT-mode `run`/`day` settle DIGEST (kestrel-1zix) — the
 * token-cheap agent/piped default face. That digest collapses a session to ONE aggregate line
 * (`plans_fired=0 … orders_placed=0`); before this it dropped every per-plan reason the report carries,
 * so the DEFAULT frame a user pipes was byte-for-byte silent about WHY a plan never fired — the exact
 * "learn it from an empty blotter" failure the human/json faces already close, one surface downstream.
 *
 * This surfaces the SAME projection the human report uses ({@link planNeverFiredReason}) — regime-DOA,
 * unwritten-series, trigger-never-crossed, plan-budget, ttl-expired, adoption-bound-nothing — one line
 * per zero-order plan that carries a reason, so the text digest self-explains for EVERY reason-bearing
 * class instead of an aggregate that looks the same for a healthy skip and a dead-on-arrival plan.
 *
 * Returns `[]` for a session where every plan traded (or none carries a reason), so a healthy run's
 * digest stays the SINGLE aggregate line it always was — the token-cheap default is preserved; the extra
 * lines appear ONLY when there is something to explain. Pure: reads only report fields. The plan NAME is
 * echoed (the digest has no per-plan section like the human face) so each reason is attributable.
 */
export function neverFiredDigestLines(report: EpisodeReport): string[] {
  const lines: string[] = [];
  for (const p of report.plans) {
    const why = planNeverFiredReason(p, report.orders);
    if (why !== undefined) lines.push(`why        ${p.plan} placed no orders — ${why}`);
  }
  return lines;
}

/* ─────────────────────── the bundled-demo one-frame lesson ─────────────────── */

/**
 * The one-frame lesson a bare `prove` prints when it ran the BUNDLED example plan (no `--plans`).
 * It teaches in a single frame: WHAT the example is (a labeled, replaceable starter — never the
 * caller's strategy) and HOW to swap in your own. Pure + transport-agnostic; the `grade` line of
 * the story above is what the example actually did, so this block never restates the metrics
 * (and never dresses a naive buy-and-hold up as alpha).
 */
export interface DemoLesson {
  /** The bundled example's label (self-describing as a replaceable example). */
  readonly planLabel: string;
  /** The example's canonical multi-line source, echoed verbatim so the reader sees what ran. */
  readonly planSource: string;
  /** The zero-credential re-entry that authors your OWN plan (`prove --plans <file>`). */
  readonly swapInCli: string;
}

/** Build the structured demo-lesson payload for `--json` (the printed block's twin). */
export function demoLessonObject(lesson: DemoLesson): {
  bundled_example: true;
  plan_label: string;
  plan_source: string;
  swap_in: string;
} {
  return {
    bundled_example: true,
    plan_label: lesson.planLabel,
    plan_source: lesson.planSource,
    swap_in: lesson.swapInCli,
  };
}

/** Build the ordered lesson lines: the labeled example, its source, and the swap-in re-entry. */
export function demoLessonLines(lesson: DemoLesson): string[] {
  const lines: string[] = [];
  lines.push("");
  lines.push(`demo plan  ${lesson.planLabel}`);
  lines.push("  this run used a BUNDLED EXAMPLE plan, not your strategy — it is what makes the");
  lines.push("  grade above a real, filled trade instead of an empty ($0, no-orders) session:");
  for (const l of lesson.planSource.split("\n")) lines.push(`     ${l}`);
  lines.push("  the `grade` line above is exactly what this example did (a naive one-share");
  lines.push("  buy-and-hold — an honest result, never dressed up as alpha). author your own:");
  lines.push(`     ${lesson.swapInCli}`);
  return lines;
}

/** Write the bundled-demo one-frame lesson to stdout as pure text. */
export function renderDemoLesson(lesson: DemoLesson): string {
  return demoLessonLines(lesson).join("\n");
}

/* ─────────────────────── the plan-less stand-down lesson (v6et) ─────────────────── */

/**
 * The one-frame lesson `sim <slug>` prints when it ran the STAND-DOWN baseline (no `--plans`).
 *
 * Bug v6et: `sim <slug>` with no `--plans` certifies an honest but EMPTY session (order_count=0,
 * $0) — and said NOTHING about why. A cold user browsing the menu (or the crypto door routing
 * straight to `sim <btc/eth-slug>`) reads the all-zeros grade + a minted "proof of nothing" as
 * "broken / nothing happened", and the referral loop dies at the artifact. This is the honest
 * counterpart to `prove`'s bundled-example lesson: `sim`'s default is a stand-down BY DESIGN
 * (the raw funnel arms nothing), so this frame says so plainly and points at the next step —
 * author a plan and pass `--plans`, or run `prove` for a bundled example that trades.
 *
 * NEVER fabricates a fill: it teaches that the empty grade is correct for a plan-less run and
 * how to make the next run trade. Pure + transport-agnostic; the caller decides WHEN to emit it
 * (only a `sim` run that fell back to the stand-down default — never when `--plans` was authored).
 */
export interface StandDownLesson {
  /** The scenario slug the caller ran — echoed into the swap-in so the next step is copy-pasteable. */
  readonly scenarioSlug: string;
  /** A minimal, replaceable example plan the reader can drop into a file — echoed verbatim. */
  readonly examplePlanSource: string;
  /** The `--plans` re-entry that authors your OWN plan ON THIS scenario (built with {@link scenarioSlug}). */
  readonly swapInCli: string;
  /** The zero-credential alternative front door that trades out of the box (`prove`). */
  readonly proveCli: string;
}

/** Build the structured stand-down payload for `--json` (the printed block's twin). */
export function standDownLessonObject(lesson: StandDownLesson): {
  stand_down_demo: true;
  reason: string;
  example_plan_source: string;
  swap_in: string;
  prove: string;
} {
  return {
    stand_down_demo: true,
    reason: "no --plans supplied — this run used the labeled stand-down baseline, which arms no orders",
    example_plan_source: lesson.examplePlanSource,
    swap_in: lesson.swapInCli,
    prove: lesson.proveCli,
  };
}

/** Build the ordered stand-down lesson lines: the honest by-design framing + the two next steps. */
export function standDownLessonLines(lesson: StandDownLesson): string[] {
  const lines: string[] = [];
  lines.push("");
  lines.push("stand-down  no --plans supplied — this run used the labeled stand-down baseline BY DESIGN");
  lines.push("  nothing is broken: with no strategy authored there is nothing to arm, so the grade");
  lines.push("  above is an honest $0 / no-orders result — a plan-less stand-down, not a failure.");
  lines.push("  author a strategy and pass it with --plans to place real orders. a minimal example:");
  for (const l of lesson.examplePlanSource.split("\n")) lines.push(`     ${l}`);
  lines.push("  save that to a file, then run it on this scenario:");
  lines.push(`     ${lesson.swapInCli}`);
  lines.push("  or run a bundled example that trades out of the box, no plan to write:");
  lines.push(`     ${lesson.proveCli}`);
  return lines;
}

/** Write the plan-less stand-down lesson to stdout as pure text. */
export function renderStandDownLesson(lesson: StandDownLesson): string {
  return standDownLessonLines(lesson).join("\n");
}

/* ─────────────────────── the free-path paid-boundary nudge (kestrel-yk9n) ─────────────────────── */

/**
 * The one-frame nudge a FREE `sim <slug>` run prints so the paid boundary is legible on the happy path
 * (kestrel-yk9n, mirror of kestrel-markets-f6ly).
 *
 * Bug f6ly: a persona following the advertised free CLI path completes a run, reads its grade + proof
 * URL, and is only ever offered MORE free stuff — the 402 value boundary (the signed, settleable Offer
 * that certifies a run against real spend) is reachable only by knowing the undocumented-to-humans
 * `POST /api/offers/quote`. So a Robinhood human never learns they could "pay to certify MY book", and
 * an allocator never feels what paid `grade.certified` buys — the value boundary never converts.
 *
 * This surfaces, on the FREE path only (no `--budget` declared), one honest frame: this run cost
 * nothing, and HOW to cross into the paid boundary — pass `--budget <amount>`, which caps a metered run
 * and returns the price as a signed Offer (data, never auto-charged). It states the mechanism plainly
 * and points at the copy-pasteable next step; it makes NO claim about the free run's grade and invents
 * no price (the Offer is signed server-side — the CLI only tells you how to reach it). Pure +
 * transport-agnostic; the caller decides WHEN to emit it (only a free `sim` run — never once `--budget`
 * opted into the boundary, and never on the gated/settlement outcome, which already prints the Offer).
 */
export interface PaidBoundaryNudge {
  /** The canonical scenario slug — echoed into the `--budget` re-entry so the next step is copy-pasteable. */
  readonly scenarioSlug: string;
}

/** Build the structured paid-boundary nudge for `--json` (the printed block's twin). */
export function paidBoundaryNudgeObject(nudge: PaidBoundaryNudge): {
  free_run: true;
  reason: string;
  reach_paid_boundary: string;
} {
  return {
    free_run: true,
    reason: "this run was free — nothing was owed; the paid boundary (a signed, settleable Offer) is opt-in via --budget",
    reach_paid_boundary: `npx kestrel.markets sim ${nudge.scenarioSlug} --budget 0`,
  };
}

/** Build the ordered nudge lines: this run was free + how to cross into the paid boundary. */
export function paidBoundaryNudgeLines(nudge: PaidBoundaryNudge): string[] {
  const lines: string[] = [];
  lines.push("");
  lines.push("paid path   this run was FREE — metered against the anonymous trial, nothing owed, no card");
  lines.push("  the paid boundary certifies a run against real spend: pass --budget <amount> and a metered");
  lines.push("  run pauses at the Spend boundary and returns the price as a signed, settleable Offer (data,");
  lines.push("  never auto-charged) — the same 402 an agent reaches. see it on this scenario:");
  lines.push(`     npx kestrel.markets sim ${nudge.scenarioSlug} --budget 0`);
  return lines;
}

/** Write the free-path paid-boundary nudge to stdout as pure text. */
export function renderPaidBoundaryNudge(nudge: PaidBoundaryNudge): string {
  return paidBoundaryNudgeLines(nudge).join("\n");
}

/* ─────────────────────────────── the menu ──────────────────────────────── */

/** One row of the bare-`sim` menu — a scenario slug + its title/instrument/artifact. */
export interface MenuScenario {
  readonly slug: string;
  readonly title: string;
  readonly instrument: string;
  readonly artifactId: string;
  readonly free: boolean;
}

/** The menu payload: the derived scenarios + the marketing aliases. */
export interface SimMenu {
  readonly scenarios: readonly MenuScenario[];
  readonly aliases: Readonly<Record<string, string>>;
}

/**
 * Render the bare-`sim` menu as pure text: the scenario slugs (with artifact_id), the
 * marketing aliases, and one usage line. Node-light — this is the free front door.
 */
export function renderSimMenu(menu: SimMenu, _ctx: OutputCtx): void {
  const lines: string[] = [];
  // The headline is derived from the rows, never asserted over them: only an all-free
  // catalog earns the anonymous-trial promise. A paid row is self-labelled below.
  // (An empty catalog earns no promise either — `every` is vacuously true over nothing.)
  const allFree = menu.scenarios.length > 0 && menu.scenarios.every((s) => s.free);
  lines.push(`kestrel sim — run a curated market scenario${allFree ? ", free (anonymous trial)" : ""}`);
  lines.push("usage: kestrel sim <scenario-slug> [--plans <file>]");
  lines.push("");
  lines.push("scenarios:");
  const wSlug = Math.max(8, ...menu.scenarios.map((s) => s.slug.length));
  const wTitle = Math.max(5, ...menu.scenarios.map((s) => s.title.length));
  for (const s of menu.scenarios) {
    const price = s.free ? "free" : "paid";
    lines.push(`  ${s.slug.padEnd(wSlug)}  ${s.title.padEnd(wTitle)}  ${s.instrument.padEnd(5)}  ${price}  ${s.artifactId}`);
  }
  const aliasKeys = Object.keys(menu.aliases);
  if (aliasKeys.length > 0) {
    lines.push("");
    lines.push("aliases:");
    for (const k of aliasKeys) lines.push(`  ${k} → ${menu.aliases[k]}`);
  }
  lines.push("");
  const first = menu.scenarios[0]?.slug ?? "meme-stock-short-squeeze";
  lines.push(`run one:   kestrel sim ${first}`);
  process.stdout.write(lines.join("\n") + "\n");
}

/* ─────────────────────── the graded-session report (human) ─────────────────────── */

/**
 * Render a graded {@link EpisodeReport} as a MULTI-LINE text report for `run`/`day` `--format human`
 * (kestrel-0hx). Text mode stays the one-line settle digest (the token-cheap agent default), and
 * `--json` stays the canonical machine object; this is the third face the site-is-spec principle asks
 * for — the FULL story (grade channels, plan lifecycle, orders/fills) as pure text, legible to a human
 * AND to an agent, without paying the JSON parse cost.
 *
 * Pure + deterministic: it reads only report fields (no wall clock, no color), so the same report
 * renders byte-identically every time — and every SECTION is sourced from a distinct report key, so a
 * dropped section changes the bytes (the teeth of `tests/cli/grade-report.test.ts`). ONE line per beat,
 * mirroring {@link simStoryLines}, so the sections read top-to-bottom as the session's arc.
 */
export function sessionReportLines(report: EpisodeReport): string[] {
  const s = report.session;
  const t = report.totals;
  const lines: string[] = [];
  lines.push(`kestrel session · ${s.date} · ${s.instruments.join(",")} · ${s.mode} · ${s.fill_model}`);

  // Grade channels — the three-channel P&L contract (RUNTIME §6): the headline + the floor/expected
  // (and sampled, when the channel ran) bounds beside it, and the taint that rides whichever channel
  // leads. NEVER just the headline number: an honest grade shows the bounds it was selected from.
  const h = t.headline;
  const chans = [`floor=${money(t.realized_floor_usd)}`, `expected=${money(t.expected_usd)}`];
  if (t.sampled_usd !== undefined) chans.push(`sampled=${money(t.sampled_usd)}`);
  lines.push(`grade      headline=${h.channel} ${money(h.usd)} · ${chans.join(" ")}`);
  if (h.tainted) lines.push(`  tainted  ${h.reasons.join("; ")}`);

  // The settle mark's provenance + fail-closed staleness verdict (kestrel-xwf) + premium spent.
  const m = t.settle_mark;
  const staleTag = m.stale ? " STALE" : "";
  const uncertainTag = m.mark_uncertain ? " mark_uncertain" : "";
  lines.push(`settle     mark=${money(m.px)} source=${m.source}${staleTag}${uncertainTag} · premium_spent=${money(t.premium_spent)}`);

  // Plan lifecycle — one line per Plan INSTANCE (kestrel-22j.15), its full state arc → terminal state.
  lines.push(`plans      ${report.plans.length}`);
  for (const p of report.plans) {
    const arc = p.lifecycle
      .map((step) => (step.outcome !== undefined ? `${step.state}(${step.outcome}${step.reason !== undefined ? `:${step.reason}` : ""})` : step.state))
      .join(" → ");
    lines.push(`  ${p.plan}  ${arc === "" ? "—" : arc}  ⇒ ${p.final_state}`);
    // The never-fired reason for THIS plan (kestrel-kglw / kestrel-y6vo): a 0-order plan is the
    // highest-value teaching moment, and the arc drops a reason that rides a non-terminal step (a
    // regime-DOA plan's `authored` reject). Surfaced HERE, per plan, so every never-fired class self-
    // explains on the default face — driven by the real `run` command (this render), so deleting this
    // call is what a wiring guard catches.
    const why = planNeverFiredReason(p, report.orders);
    if (why !== undefined) lines.push(`    why      ${why}`);
  }

  // Orders + fills — the position each order took and how it resolved (floor-filled / cancelled /
  // resting-unfilled), joined with its role, price-resolution annotation, and realized floor P&L.
  lines.push(`orders     ${report.orders.length}`);
  for (const o of report.orders) {
    const leg = o.strike !== undefined && o.right !== undefined ? `${o.strike}${o.right}` : "SPOT";
    const outcome = o.filled
      ? `filled@${o.fillPx} floorPnl=${money(o.floorPnl)}`
      : o.cancelled === true
        ? "cancelled"
        : `resting pFill=${o.pFill}`;
    lines.push(`  ${o.side} ${o.qty} ${o.instrument} ${leg} @ ${o.px}  ${o.role}  ${outcome}  (${o.annotation})`);
  }

  return lines;
}

/** Write the graded-session report to stdout as pure human text (the `--format human` face). */
export function renderEpisodeReport(report: EpisodeReport, _ctx: OutputCtx): void {
  process.stdout.write(sessionReportLines(report).join("\n") + "\n");
}
