/**
 * # print — the canonical printer (objects -> canonical text)
 *
 * The inverse projection of the phase-3 parser and the round-trip target (ADR-0004):
 * deterministic, minimal, 2-space indented clause lines under statement headers, and
 * **USING-covered qualifiers elided** against the ambient `USING` (ARCHITECTURE §2).
 *
 * Every switch is exhaustive via {@link assertNever}; the printer fails **closed** — a
 * non-finite number, an empty time-window, or an unhandled variant throws loudly rather
 * than emitting text that would not round-trip.
 */

import type {
  AbsolutePrice,
  Anchor,
  Arbitration,
  ArmClause,
  Baseline,
  BookStatement,
  Budget,
  Citation,
  CommentLayer,
  LineComment,
  Coverage,
  Counterfactual,
  DeliverSpec,
  Duration,
  ExpirySelector,
  GradeDimension,
  GradeStatement,
  HeldQuantifier,
  Instrument,
  KestrelNode,
  Leg,
  Module,
  Operand,
  OrderPolicy,
  PaneArg,
  PathSegment,
  PlanClause,
  PlanStatement,
  PodStatement,
  PriceExpr,
  Provenance,
  Quantity,
  RegimeGate,
  RiskLine,
  SeriesRef,
  Statement,
  StrikeSpec,
  TimeOfDay,
  TpTarget,
  Trigger,
  Ttl,
  Using,
  ViewStatement,
  WakeStatement,
  Window,
} from "./ast.ts";
import { CLOCK_STOP_KEYWORD, CMP_TO_PUNCT, EXPIRY_PREFIX, HELD_STOP_KEYWORD, ORDINAL_PREFIX, ORDINALS } from "./vocab.ts";
import {
  refuseAtomic,
  refuseBadCitation,
  refuseExitOnMark,
  refuseHeldOverCross,
  refuseNonPositiveBand,
  refuseNonPositiveBudget,
  refuseProvenanceElevation,
} from "./validate.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Exhaustiveness + fail-closed primitives
// ─────────────────────────────────────────────────────────────────────────────

/** Compile-time exhaustiveness guard; at runtime it fails closed on an unmodeled
 * variant rather than silently emitting nothing. */
export function assertNever(x: never, ctx: string): never {
  throw new Error(`kestrel/print: unhandled variant in ${ctx}: ${JSON.stringify(x)}`);
}

function num(v: number): string {
  if (!Number.isFinite(v)) {
    throw new Error(`kestrel/print: cannot render non-finite number (${String(v)})`);
  }
  if (Object.is(v, -0)) return "0";
  const s = String(v);
  // The lexer tokenizes only plain decimals; `String()` emits exponential notation
  // ("1e+21", "1e-7") for magnitudes outside that range, which the lexer cannot re-read —
  // it would break the ADR-0004 round-trip. Fail closed rather than emit un-lexable text.
  if (!/^-?\d+(\.\d+)?$/.test(s)) {
    throw new Error(
      `kestrel/print: number ${s} has no lexable decimal form — out of Kestrel's numeric range`,
    );
  }
  return s;
}

// The six doctrine invariants (atomic reserved, held-over-cross, band>0, budget>0,
// EXIT-not-on-a-mark, provenance ceiling) live in ./validate.ts; the printer calls its
// `refuse*` guards before emitting so it never prints text `parse` would reject (ADR-0004).

function str(s: string): string {
  return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}

function pad(indent: number): string {
  return "  ".repeat(indent);
}

function pad2(n: number): string {
  if (!Number.isInteger(n) || n < 0) {
    throw new Error(`kestrel/print: bad clock component (${String(n)})`);
  }
  return n < 10 ? `0${n}` : `${n}`;
}

function ordinal(n: number): string {
  // Ordinals are 1-based (`first`, `second`, …); the parser's `ordinalValue` requires
  // idx >= 1 and a `<n>th` suffix would round-trip a negative as bare "-Nth" the lexer
  // rejects. Fail closed rather than emit "zeroth" / "-1th" that will not re-parse.
  if (!Number.isInteger(n) || n < 1) {
    throw new Error(`kestrel/print: ordinal must be a positive integer (>= 1); got ${String(n)}`);
  }
  return n < ORDINALS.length ? ORDINALS[n]! : `${num(n)}th`;
}

// ─────────────────────────────────────────────────────────────────────────────
// Lexical units
// ─────────────────────────────────────────────────────────────────────────────

function windowStr(w: Window): string {
  return `${num(w.value)}${w.unit}`;
}

function durationStr(d: Duration): string {
  return `${num(d.value)}${d.unit}`;
}

function timeOfDay(t: TimeOfDay): string {
  return `${pad2(t.hour)}:${pad2(t.minute)}`;
}

function quantity(q: Quantity): string {
  return q.unit === undefined ? num(q.value) : `${num(q.value)}${q.unit}`;
}

function baseline(b: Baseline): string {
  return b.stat === "p" ? `p${num(b.n)}` : `${num(b.n)}sigma`;
}

function segment(seg: PathSegment): string {
  if (seg.selector === undefined) return seg.name;
  const sel = seg.selector.kind === "sel-quant" ? seg.selector.q : seg.selector.name;
  return `${seg.name}(${sel})`;
}

function seriesRef(s: SeriesRef): string {
  const path = s.segments.map(segment).join(".");
  return s.window === undefined ? path : `${path}(${windowStr(s.window)})`;
}

function operand(o: Operand): string {
  switch (o.kind) {
    case "series":
      return seriesRef(o);
    case "quantity":
      return quantity(o);
    case "baseline":
      return baseline(o);
    default:
      return assertNever(o, "operand");
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Price expressions
// ─────────────────────────────────────────────────────────────────────────────

function anchorName(a: Anchor): string {
  return a.name;
}

function absolutePrice(p: AbsolutePrice): string {
  return num(p.value);
}

function price(p: PriceExpr): string {
  switch (p.kind) {
    case "anchor":
      return anchorName(p);
    case "price-abs":
      return absolutePrice(p);
    case "price-offset":
      return `${price(p.base)}${p.sign}${num(p.amount)}${p.unit}`;
    case "lean":
      return `lean(${price(p.a)}, ${price(p.b)}, ${num(p.x)})`;
    case "price-fn":
      return `${p.fn}(${p.args.map(price).join(", ")})`;
    default:
      return assertNever(p, "price");
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Trigger algebra (precedence-aware; minimal parens)
// ─────────────────────────────────────────────────────────────────────────────

// OR < AND < NOT < postfix(held/within) < atom
const PREC_OR = 1;
const PREC_AND = 2;
const PREC_NOT = 3;
const PREC_POSTFIX = 4;
const PREC_ATOM = 5;

function wrap(s: string, own: number, parent: number): string {
  return own < parent ? `(${s})` : s;
}

function timeWindow(from: TimeOfDay | undefined, to: TimeOfDay | undefined): string {
  if (from !== undefined && to !== undefined) return `time ${timeOfDay(from)}..${timeOfDay(to)}`;
  if (from !== undefined) return `time after ${timeOfDay(from)}`;
  if (to !== undefined) return `time before ${timeOfDay(to)}`;
  throw new Error("kestrel/print: time-window trigger has neither from nor to");
}

function trigger(t: Trigger, parent: number): string {
  switch (t.kind) {
    case "cmp": {
      const op = CMP_TO_PUNCT[t.op];
      if (op === undefined) throw new Error(`kestrel/print: unknown compare op ${t.op}`);
      return wrap(`${operand(t.left)} ${op} ${operand(t.right)}`, PREC_ATOM, parent);
    }
    case "cross": {
      const verb = t.touch === true ? "touches" : "crosses";
      let band = "";
      if (t.band !== undefined) {
        // Refuse to PRINT a non-positive band symmetrically: emitting `band 0c` would print
        // text `parse` throws on, cracking the round-trip (ADR-0004).
        refuseNonPositiveBand(t.band);
        band = ` band ${quantity(t.band)}`;
      }
      return wrap(`${operand(t.left)} ${verb} ${t.dir} ${operand(t.right)}${band}`, PREC_ATOM, parent);
    }
    case "event":
      return wrap(
        t.of === undefined ? t.name : `${t.name} of ${operand(t.of)}`,
        PREC_ATOM,
        parent,
      );
    case "phase":
      return wrap(`phase ${t.phase}`, PREC_ATOM, parent);
    case "time-window":
      return wrap(timeWindow(t.from, t.to), PREC_ATOM, parent);
    case "fill":
      return wrap(
        t.leg === undefined ? t.event : `${t.event} leg ${num(t.leg)}`,
        PREC_ATOM,
        parent,
      );
    case "nth":
      return wrap(`${ordinal(t.ordinal)} ${trigger(t.event, PREC_ATOM)}`, PREC_ATOM, parent);
    case "break-hold":
      // A cross is an edge event; holding it is the sfg8 anti-pattern the parser rejects.
      // Refuse symmetrically — emitting it would print text `parse` throws on (ADR-0004).
      refuseHeldOverCross(t.inner);
      return wrap(
        `${trigger(t.inner, PREC_POSTFIX)} held ${durationStr(t.dur)}`,
        PREC_POSTFIX,
        parent,
      );
    case "within":
      return wrap(
        `${trigger(t.inner, PREC_POSTFIX)} within ${durationStr(t.dur)}`,
        PREC_POSTFIX,
        parent,
      );
    case "until":
      // The thesis temporal envelope (kestrel-rtf) — a postfix sibling of `within`/`held`.
      return wrap(
        `${trigger(t.inner, PREC_POSTFIX)} until ${timeOfDay(t.at)}`,
        PREC_POSTFIX,
        parent,
      );
    case "at":
      return wrap(
        `${trigger(t.inner, PREC_POSTFIX)} at ${timeOfDay(t.at)}`,
        PREC_POSTFIX,
        parent,
      );
    case "held-stop":
      // A 0DTE time-held stop (`held 90m`) — a bare, LEADING hold-clock (no inner predicate),
      // an atom, not the `<inner> held <dur>` postfix (see ast.ts TimeHeldStop).
      return wrap(`${HELD_STOP_KEYWORD} ${durationStr(t.dur)}`, PREC_ATOM, parent);
    case "clock-stop":
      // A 0DTE wall-clock stop (`clockET 15:40`).
      return wrap(`${CLOCK_STOP_KEYWORD} ${timeOfDay(t.at)}`, PREC_ATOM, parent);
    case "not":
      return wrap(`NOT ${trigger(t.term, PREC_NOT)}`, PREC_NOT, parent);
    case "and":
      return wrap(t.terms.map((x) => trigger(x, PREC_AND)).join(" AND "), PREC_AND, parent);
    case "or":
      return wrap(t.terms.map((x) => trigger(x, PREC_OR)).join(" OR "), PREC_OR, parent);
    default:
      return assertNever(t, "trigger");
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Order policy, legs, plan clauses
// ─────────────────────────────────────────────────────────────────────────────

function orderPolicy(p: OrderPolicy): string {
  const parts: string[] = [];
  if (p.pricing !== undefined) parts.push(p.pricing);
  for (const stage of p.esc ?? []) parts.push(`esc ${price(stage.to)} ${durationStr(stage.after)}`);
  for (const c of p.caps ?? []) parts.push(`cap ${price(c)}`);
  for (const f of p.floors ?? []) parts.push(`floor ${price(f)}`);
  if (p.cancelIf !== undefined) parts.push(`cancel-if ${trigger(p.cancelIf, 0)}`);
  if (p.gtc === true) parts.push("gtc");
  return parts.join(" ");
}

function strike(s: StrikeSpec): string {
  switch (s.kind) {
    case "strike-rel":
      return s.steps >= 0 ? `+${num(s.steps)}` : num(s.steps);
    case "strike-abs":
      return num(s.strike);
    case "strike-atm":
      return "atm";
    case "strike-delta":
      return `${num(s.delta)}d`;
    default:
      return assertNever(s, "strike");
  }
}

function leg(l: Leg): string {
  // An equity/spot leg prints `<side> <qty> shares` (ADR-0017); an option leg keeps
  // `<side> <qty> <strike> <right>`, plus an optional per-leg expiry `exp <selector>`
  // (kestrel-ih5h seam 1). A leg with NO expiry prints exactly as it always has — the tail is
  // additive, so every pre-existing plan round-trips byte-identically (ADR-0004).
  if (l.kind === "equity-leg") return `${l.side} ${num(l.qty)} shares`;
  const head = `${l.side} ${num(l.qty)} ${strike(l.strike)} ${l.right}`;
  return l.expiry === undefined ? head : `${head} exp ${expiryStr(l.expiry)}`;
}

function legs(ls: readonly Leg[]): string {
  return ls.map(leg).join(", ");
}

function heldQuant(h: HeldQuantifier): string {
  return h.kind === "held-foreach" ? "foreach held leg" : "any held leg";
}

function tpTarget(t: TpTarget): string {
  switch (t.kind) {
    case "tp-pct":
      return `${t.pct >= 0 ? "+" : ""}${num(t.pct)}%`;
    case "tp-mult":
      return `${num(t.mult)}x`;
    case "tp-price":
      return price(t.price);
    default:
      return assertNever(t, "tp-target");
  }
}

/** Join a head keyword with a trailing policy string, dropping the space when empty. */
function withPolicy(head: string, policy: OrderPolicy | undefined): string {
  if (policy === undefined) return head;
  const p = orderPolicy(policy);
  return p === "" ? head : `${head} ${p}`;
}

/** Print ONE plan clause to its canonical Kestrel text (kestrel-wa0j.29): exported so a consumer that
 * holds a plan's typed AST (the sim driver, capturing the ARMED document's terms for the `armed-plan`
 * pane) prints each clause through the SAME canonical printer the round-trip is defined by — a typed
 * value → its byte-stable text, never a hand-rolled restatement. Pure. */
export function planClause(c: PlanClause): string {
  switch (c.kind) {
    case "do":
    case "also": {
      refuseAtomic(c.atomic);
      const kw = c.kind === "do" ? "DO" : "ALSO";
      return withPolicy(`${kw} ${legs(c.legs)} @ ${price(c.price)}`, c.policy);
    }
    case "reload": {
      refuseAtomic(c.atomic);
      const when = c.when === undefined ? "" : `WHEN ${trigger(c.when, 0)} `;
      return withPolicy(`RELOAD ${when}${legs(c.legs)} @ ${price(c.price)}`, c.policy);
    }
    case "tp": {
      let head = `TP ${tpTarget(c.target)}`;
      if (c.frac !== undefined) head += ` frac ${num(c.frac)}`;
      if (c.over !== undefined) head += ` ${heldQuant(c.over)}`;
      if (c.price !== undefined) head += ` @ ${price(c.price)}`;
      return withPolicy(head, c.policy);
    }
    case "exit": {
      refuseExitOnMark(c.when); // marks lie: never emit an EXIT `parse` would reject (ADR-0005)
      let head = `EXIT ${trigger(c.when, 0)}`;
      if (c.over !== undefined) head += ` ${heldQuant(c.over)}`;
      if (c.price !== undefined) head += ` @ ${price(c.price)}`;
      return withPolicy(head, c.policy);
    }
    case "invalidate":
      return `INVALIDATE ${trigger(c.when, 0)}`;
    case "cancel-if":
      return `CANCEL-IF ${trigger(c.when, 0)}`;
    case "arm": {
      let head = "ARM";
      if (c.when !== undefined) head += ` WHEN ${trigger(c.when, 0)}`;
      if (c.basis !== undefined) head += ` basis ${price(c.basis)}`;
      if (c.over !== undefined) head += ` ${heldQuant(c.over)}`;
      return head;
    }
    default:
      return assertNever(c, "plan-clause");
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Instruments, USING (with elision), provenance, regime, budget, ttl
// ─────────────────────────────────────────────────────────────────────────────

function expiryStr(e: ExpirySelector): string {
  switch (e.kind) {
    case "expiry-dte":
      return `${num(e.dte)}dte`;
    case "expiry-date":
      return e.date;
    case "expiry-tag":
      return e.tag;
    default:
      return assertNever(e, "expiry");
  }
}

function instrument(i: Instrument): string {
  return i.expiry === undefined ? i.symbol : `${i.symbol} ${expiryStr(i.expiry)}`;
}

function expiryEq(a: ExpirySelector | undefined, b: ExpirySelector | undefined): boolean {
  if (a === b) return true;
  if (a === undefined || b === undefined) return false;
  if (a.kind !== b.kind) return false;
  if (a.kind === "expiry-dte" && b.kind === "expiry-dte") return a.dte === b.dte;
  if (a.kind === "expiry-date" && b.kind === "expiry-date") return a.date === b.date;
  if (a.kind === "expiry-tag" && b.kind === "expiry-tag") return a.tag === b.tag;
  return false;
}

function instrEq(a: Instrument | undefined, b: Instrument | undefined): boolean {
  if (a === b) return true;
  if (a === undefined || b === undefined) return false;
  return a.symbol === b.symbol && expiryEq(a.expiry, b.expiry);
}

/** Render the body of a `USING`, elided against the ambient. Returns "" when nothing
 * survives (caller omits the line entirely). */
function usingBody(u: Using, ambient: Using | undefined): string {
  const parts: string[] = [];
  if (u.signal !== undefined && !instrEq(u.signal, ambient?.signal)) {
    parts.push(`signal ${instrument(u.signal)}`);
  }
  if (u.exec !== undefined && !instrEq(u.exec, ambient?.exec)) {
    parts.push(`exec ${instrument(u.exec)}`);
  }
  return parts.join(" ");
}

function provenanceBrace(p: Provenance): string {
  const parts: string[] = [];
  if (p.tier !== undefined) parts.push(`tier: ${p.tier}`);
  if (p.author !== undefined) parts.push(`author: ${str(p.author)}`);
  if (p.origin !== undefined) parts.push(`origin: ${str(p.origin)}`);
  if (p.replay !== undefined) parts.push(`replay: ${str(p.replay)}`);
  return `{${parts.join(", ")}}`;
}

function regimeGate(r: RegimeGate): string {
  const body = r.tags.map((t) => `${t.scope}: ${t.value}`).join(", ");
  return `regime {${body}}`;
}

/** Print a plan's top-level WHEN trigger to its canonical text (kestrel-wa0j.29), at outermost
 * precedence (no enclosing parens) — the ARMED plan's arming premise, captured by the sim driver for
 * the `armed-plan` pane. Pure; the same canonical trigger algebra a round-trip prints. */
export function printTrigger(t: Trigger): string {
  return trigger(t, 0);
}

/** Print a plan's risk BUDGET (the size envelope, e.g. `0.5R`) to its canonical text (kestrel-wa0j.29),
 * exported for the `armed-plan` pane's size-envelope term. Pure; refuses a non-positive budget (as the
 * round-trip does). */
export function budgetStr(b: Budget): string {
  refuseNonPositiveBudget(b.value); // never emit `budget -0.5R` — `parse` would reject it
  return `${num(b.value)}${b.unit}`;
}

function ttlStr(t: Ttl): string {
  return t.kind === "ttl-rel" ? `+${durationStr(t.dur)}` : timeOfDay(t.at);
}

/** The `because` pre-registration citation (kestrel-rtf): `sha256:<64 hex>`, content-hash only.
 * Refuse to emit a malformed digest symmetrically — printing `sha256:deadbeef` would print text
 * `parse` throws on, cracking the round-trip (ADR-0004). */
function citationStr(c: Citation): string {
  refuseBadCitation(c);
  return `${c.algo}:${c.hash}`;
}

// ─────────────────────────────────────────────────────────────────────────────
// Comment trivia — re-emit the byte-stable "why" in canonical position (ADR-0033)
// ─────────────────────────────────────────────────────────────────────────────

/** Render an own-line comment at `pad` indentation: `<pad>#<verbatim-text>`. */
function commentLine(pad: string, raw: string): string {
  return `${pad}#${raw}`;
}

/** Append a trailing-inline comment to a code line, canonical single space before `#`. */
function withTrailing(line: string, trailing: string | undefined): string {
  return trailing === undefined ? line : `${line} #${trailing}`;
}

function lineCommentAt(cmts: CommentLayer | undefined, at: number): LineComment | undefined {
  return cmts?.lines?.find((c) => c.at === at);
}

/**
 * Assembles a block's printed lines while interleaving its comment trivia (ADR-0033). The
 * header is ordinal 0; each `line()` call is the next interior ordinal (1..N); `block()` emits
 * a child block (which owns its own trivia and consumes no ordinal). `finish()` appends the
 * block's tail comments. The ordinal walk MIRRORS the parser's `commentLayer`, so a canonical
 * comment-bearing document round-trips byte-for-byte.
 */
class BlockLines {
  private readonly out: string[] = [];
  private ord = 0;
  constructor(private readonly cmts: CommentLayer | undefined) {}

  header(pad: string, text: string): void {
    const lc = lineCommentAt(this.cmts, 0);
    for (const raw of lc?.leading ?? []) this.out.push(commentLine(pad, raw));
    this.out.push(withTrailing(pad + text, lc?.trailing));
  }

  line(pad: string, text: string): void {
    this.ord += 1;
    const lc = lineCommentAt(this.cmts, this.ord);
    for (const raw of lc?.leading ?? []) this.out.push(commentLine(pad, raw));
    this.out.push(withTrailing(pad + text, lc?.trailing));
  }

  block(rendered: string): void {
    this.out.push(rendered);
  }

  finish(tailPad: string): string {
    for (const raw of this.cmts?.tail ?? []) this.out.push(commentLine(tailPad, raw));
    return this.out.join("\n");
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Statements
// ─────────────────────────────────────────────────────────────────────────────

interface Ctx {
  readonly indent: number;
  readonly ambientUsing?: Using;
}

function paneArg(a: PaneArg): string {
  // One canonical printed form per supersort (ADR-0041 §1): an ident prints its name, a window its
  // `<n><unit>`, a count its bare numeral, a session ordinal `d-<n>`, an expiry ordinal `e-<n>` — so
  // `print(parse(text))` stays byte-stable (ADR-0004).
  if (a.kind === "arg-ident") return a.name;
  if (a.kind === "arg-count") return num(a.count);
  if (a.kind === "arg-ordinal") return `${ORDINAL_PREFIX}-${num(a.ordinal)}`;
  if (a.kind === "arg-expiry") return `${EXPIRY_PREFIX}-${num(a.expiry)}`;
  return windowStr(a.window);
}

function printView(v: ViewStatement, ctx: Ctx): string {
  const cpad = pad(ctx.indent + 1);
  const head = `VIEW ${v.name}${v.budget === undefined ? "" : ` budget ${num(v.budget)}`}`;
  const bl = new BlockLines(v.comments);
  bl.header(pad(ctx.indent), head);
  for (const p of v.panes) {
    const args = p.args.map(paneArg).join(" ");
    bl.line(cpad, args === "" ? p.name : `${p.name} ${args}`);
  }
  return bl.finish(cpad);
}

function printWake(w: WakeStatement, ctx: Ctx): string {
  const cpad = pad(ctx.indent + 1);
  const bl = new BlockLines(w.comments);
  bl.header(pad(ctx.indent), `WAKE ${w.name}`);
  bl.line(cpad, `WHEN ${trigger(w.when, 0)}`);
  if (w.because !== undefined) bl.line(cpad, `BECAUSE ${citationStr(w.because)}`);
  if (w.deliver !== undefined) {
    const d: DeliverSpec = w.deliver;
    let s = `DELIVER ${d.view}`;
    if (d.mandatory === true) s += " MANDATORY";
    if (d.keyframe === true) s += " KEYFRAME";
    bl.line(cpad, s);
  }
  if (w.priority !== undefined) bl.line(cpad, `PRIORITY ${num(w.priority)}`);
  if (w.coalesce !== undefined) bl.line(cpad, `COALESCE ${w.coalesce}`);
  if (w.budget !== undefined) {
    const parts: string[] = [];
    if (w.budget.wakesPerDay !== undefined) parts.push(`${num(w.budget.wakesPerDay)} wakes/day`);
    if (w.budget.tokensPerDay !== undefined) parts.push(`${num(w.budget.tokensPerDay)} tokens/day`);
    if (parts.length > 0) bl.line(cpad, `BUDGET ${parts.join(" ")}`);
  }
  if (w.universe !== undefined) bl.line(cpad, `UNIVERSE ${w.universe.universe}`);
  return bl.finish(cpad);
}

function printPlan(p: PlanStatement, ctx: Ctx): string {
  const cpad = pad(ctx.indent + 1);
  const head = ["PLAN", p.name];
  if (p.budget !== undefined) head.push(`budget ${budgetStr(p.budget)}`);
  if (p.ttl !== undefined) head.push(`ttl ${ttlStr(p.ttl)}`);
  if (p.regime !== undefined) head.push(regimeGate(p.regime));
  if (p.priority !== undefined) head.push(`priority ${num(p.priority)}`);
  if (p.standing !== undefined) head.push(`standing ${p.standing}`);
  if (p.provenance !== undefined) head.push(`prov ${provenanceBrace(p.provenance)}`);
  refuseAtomic(p.atomic);
  const bl = new BlockLines(p.comments);
  bl.header(pad(ctx.indent), head.join(" "));
  if (p.using !== undefined) {
    const body = usingBody(p.using, ctx.ambientUsing);
    if (body !== "") bl.line(cpad, `USING ${body}`);
  }
  if (p.when !== undefined) bl.line(cpad, `WHEN ${trigger(p.when, 0)}`);
  if (p.because !== undefined) bl.line(cpad, `BECAUSE ${citationStr(p.because)}`);
  for (const c of p.clauses) bl.line(cpad, planClause(c));
  return bl.finish(cpad);
}

const CF_ORDER: readonly Counterfactual["kind"][] = ["cf-ungated", "cf-null", "cf-bracket"];
const CF_NAME: Readonly<Record<string, string>> = {
  "cf-ungated": "ungated",
  "cf-null": "null",
  "cf-bracket": "bracket",
};

function gradeDim(d: GradeDimension): string {
  switch (d.kind) {
    case "dim-series":
      return seriesRef(d.series);
    case "dim-vehicle":
      return "vehicle";
    case "dim-name":
      return "name";
    case "dim-lineage":
      return "lineage";
    default:
      return assertNever(d, "grade-dim");
  }
}

function printGrade(g: GradeStatement, ctx: Ctx): string {
  const cpad = pad(ctx.indent + 1);
  let head = `GRADE ${g.subject.what} ${g.subject.name}`;
  if (g.over !== undefined) head += ` OVER ${g.over.from}..${g.over.to}`;
  if (g.fill !== undefined) head += ` FILL ${g.fill}`;
  const bl = new BlockLines(g.comments);
  bl.header(pad(ctx.indent), head);
  if (g.versus.length > 0) {
    const seen = new Set(g.versus.map((c) => c.kind));
    const names = CF_ORDER.filter((k) => seen.has(k)).map((k) => CF_NAME[k]);
    bl.line(cpad, `VS ${names.join(" ")}`);
  }
  if (g.by.length > 0) bl.line(cpad, `BY ${g.by.map(gradeDim).join(", ")}`);
  return bl.finish(cpad);
}

function riskLine(r: RiskLine): string {
  return `RISK ${r.metric} ${quantity(r.threshold)} -> ${r.action}`;
}

function coverageStr(c: Coverage): string {
  return `coverage ${c.instruments.map(instrument).join(" ")} thesis ${str(c.thesis)}`;
}

function arbitrationStr(a: Arbitration): string {
  return `arbitration ${a.policy}${a.concurrency === undefined ? "" : ` concurrency ${num(a.concurrency)}`}`;
}

function printBook(b: BookStatement, ctx: Ctx): string {
  const head = ["BOOK", b.name];
  if (b.budget !== undefined) head.push(`budget ${budgetStr(b.budget)}`);
  if (b.coverage !== undefined) head.push(coverageStr(b.coverage));
  if (b.arbitration !== undefined) head.push(arbitrationStr(b.arbitration));
  const cpad = pad(ctx.indent + 1);
  const bl = new BlockLines(b.comments);
  bl.header(pad(ctx.indent), head.join(" "));
  for (const r of b.risk ?? []) bl.line(cpad, riskLine(r));
  return bl.finish(cpad);
}

function printPod(p: PodStatement, ctx: Ctx): string {
  const childCtx: Ctx = { ...ctx, indent: ctx.indent + 1 };
  const cpad = pad(ctx.indent + 1);
  const bl = new BlockLines(p.comments);
  bl.header(pad(ctx.indent), `POD ${p.name}`);
  // RISK lines are the pod's OWN interior lines (ordinals 1..r); child blocks own their trivia.
  for (const r of p.risk) bl.line(cpad, riskLine(r));
  for (const child of p.children) bl.block(printStatement(child, childCtx));
  return bl.finish(cpad);
}

function printStatement(s: Statement, ctx: Ctx): string {
  switch (s.kind) {
    case "view":
      return printView(s, ctx);
    case "wake":
      return printWake(s, ctx);
    case "plan":
      return printPlan(s, ctx);
    case "grade":
      return printGrade(s, ctx);
    case "pod":
      return printPod(s, ctx);
    case "book":
      return printBook(s, ctx);
    default:
      return assertNever(s, "statement");
  }
}

function printModule(m: Module): string {
  // Provenance ceiling (ARCHITECTURE §6): never emit a module whose statement elevates above
  // its declared tier — `parse` would reject the text, cracking the round-trip.
  refuseProvenanceElevation(m.provenance?.tier, m.statements);

  // Module directive lines carry 0-based comment ordinals in canonical emit order (imports,
  // provenance, using) — the module has no header line of its own (ADR-0033).
  const header: string[] = [];
  let dord = 0;
  const pushDirective = (text: string): void => {
    const lc = lineCommentAt(m.comments, dord);
    for (const raw of lc?.leading ?? []) header.push(commentLine("", raw));
    header.push(withTrailing(text, lc?.trailing));
    dord += 1;
  };
  for (const imp of m.imports) {
    pushDirective(`IMPORT { ${imp.names.join(", ")} } FROM ${str(imp.from)}`);
  }
  if (m.provenance !== undefined) pushDirective(`PROVENANCE ${provenanceBrace(m.provenance)}`);
  if (m.using !== undefined) {
    const body = usingBody(m.using, undefined);
    if (body !== "") pushDirective(`USING ${body}`);
  }
  const ctx: Ctx = {
    indent: 0,
    ...(m.using !== undefined ? { ambientUsing: m.using } : {}),
  };
  const blocks: string[] = [];
  if (header.length > 0) blocks.push(header.join("\n"));
  for (const s of m.statements) blocks.push(printStatement(s, ctx));
  let body = blocks.join("\n\n");
  // Document-tail comments (incl. a comment-only module) sit at column 0 right after the last
  // block — the canonical printer owns vertical spacing, so no blank line precedes them.
  const tail = m.comments?.tail ?? [];
  if (tail.length > 0) {
    const tailText = tail.map((raw) => commentLine("", raw)).join("\n");
    body = body === "" ? tailText : `${body}\n${tailText}`;
  }
  return body;
}

/**
 * Print any node to canonical Kestrel text. The round-trip target: for well-formed
 * input, `print(parse(print(x))) === print(x)` byte-for-byte (ADR-0004). No trailing
 * newline — file serialization appends one (`.editorconfig`).
 */
export function print(node: KestrelNode): string {
  if (node.kind === "module") return printModule(node);
  return printStatement(node, { indent: 0 });
}
