/**
 * # parse — recursive-descent parser (canonical text -> the typed object model)
 *
 * The inverse projection of {@link ./print.ts} (ADR-0004): `parse(print(x))` deep-equals
 * `x`, and `print(parse(text))` is byte-stable. The parser reads the {@link LineNode} tree
 * the {@link ./lex.ts} lexer produces — a statement's header is one line, its clauses are
 * the lines nested beneath, and a continued clause folds in via {@link flatten}.
 *
 * Design commitments:
 * - **Fail closed** (ARCHITECTURE §6): every escape is a {@link KestrelParseError} with
 *   `line`/`col` and an instructive message (the text is read by LLM agents — it names
 *   what was expected and lists the legal alternatives). No silent clause-dropping.
 * - **Registry-open names** (CONTEXT: Registry): any well-formed identifier parses as a
 *   series reference; resolution happens at arm time, never at parse time.
 * - **`atomic` is refused, not faked** (ADR-0005): the keyword parses to a loud rejection.
 * - **USING defaults thread through** (ARCHITECTURE §2): a statement inside a module with
 *   an ambient `USING` is fully qualified by merging the ambient with any explicit clause,
 *   so the elided form round-trips.
 */

import type {
  AlsoTicket,
  Anchor,
  AnchorName,
  Arbitration,
  ArmClause,
  Baseline,
  BookStatement,
  Budget,
  CancelIfClause,
  Citation,
  CommentLayer,
  LineComment,
  Comparison,
  Counterfactual,
  Coverage,
  CrossEvent,
  DoTicket,
  Duration,
  EscStage,
  ExitClause,
  ExpirySelector,
  FillEvent,
  GradeDimension,
  GradeStatement,
  GradeSubject,
  HeldQuantifier,
  ImportDecl,
  Instrument,
  InvalidateClause,
  KestrelNode,
  Leg,
  Module,
  Operand,
  OrderPolicy,
  PaneArg,
  PaneRef,
  PathSegment,
  PlanClause,
  PlanStatement,
  PodStatement,
  PriceExpr,
  Provenance,
  ProvenanceTier,
  Quantity,
  RegimeGate,
  RegimeTagBinding,
  ReloadClause,
  RiskLine,
  SeriesRef,
  Standing,
  Statement,
  StrikeSpec,
  TimeOfDay,
  TimeUnit,
  TpClause,
  TpTarget,
  Trigger,
  Ttl,
  Using,
  ViewStatement,
  WakeBudget,
  WakeStatement,
  Window,
} from "./ast.ts";
import { flatten, KestrelParseError, lex, type LineNode, type Token } from "./lex.ts";
import {
  ANCHOR_NAMES,
  ANCHORS,
  CITATION_ALGOS,
  CLOCK_STOP_KEYWORD,
  CMP_FROM_PUNCT,
  FILL_EVENTS,
  GRADE_WHATS,
  EXPIRY_PREFIX,
  HELD_STOP_KEYWORD,
  ORDINAL_PREFIX,
  ORDINALS,
  PROV_RANK,
  PROV_TIERS,
  STANDINGS,
  TIME_UNITS,
  TIME_UNITS_LIST,
} from "./vocab.ts";
import {
  ATOMIC_MESSAGE,
  bandMessage,
  budgetMessage,
  citationBadHashMessage,
  CITATION_INLINE_BODY_MESSAGE,
  citationOnMessage,
  elevatesProvenance,
  findMarkInTrigger,
  HELD_CROSS_MESSAGE,
  isCitationHash,
  isSimpleSeries,
  markMessage,
  provenanceMessage,
} from "./validate.ts";

export { KestrelParseError } from "./lex.ts";

// The closed vocabularies (anchors, time units, fill events, standings, provenance tiers,
// grade subjects, comparison ops, ordinals) live in ./vocab.ts, derived from the ast.ts
// unions; the six doctrine invariants live in ./validate.ts. This file reads both — it keeps
// no private copy — so parser and printer can never drift from the types or from each other.

const CLAUSE_KEYWORDS = "WHEN, USING, DO, ALSO, RELOAD, TP, EXIT, INVALIDATE, CANCEL-IF, or ARM";

/**
 * The clause keywords that live INSIDE a PLAN block (indented beneath the `PLAN <name>` header).
 * Used ONLY to make diagnostics repair-guiding when a live author (a) collapses two clauses onto one
 * line (`WHEN … DO …`) or (b) hoists a clause to the top level (a bare `WHEN …` statement) — the two
 * dominant live-model authoring mistakes (dry-run-1, docs/results/dry-run-1-live-baseline.md, 45 of
 * ~54 escapes). PURELY DIAGNOSTIC: what the parser accepts/rejects is unchanged (a trailing/top-level
 * clause keyword was always an error) — only the MESSAGE on rejection names the fix. No grammar
 * contract change, no honesty/fail-closed-guard change.
 */
const PLAN_CLAUSE_KEYWORD_SET: ReadonlySet<string> = new Set([
  "WHEN",
  "USING",
  "DO",
  "ALSO",
  "RELOAD",
  "TP",
  "EXIT",
  "INVALIDATE",
  "CANCEL-IF",
  "ARM",
]);

/**
 * Natural-language trigger verbs a strong model reaches for in the infix `<operand> <verb> …`
 * position where Kestrel's ONLY verbs are `crosses`/`touches` (or a `<`/`>` comparison). Observed
 * live (finding #1): `spot breaks below …`, `spot dips to …`. PURELY DIAGNOSTIC — none of these is
 * an accepted token; the parser still rejects them. The MESSAGE names the bad verb and steers to the
 * canonical crossing form, mirroring the WHEN/DO-collapse hint. No grammar-contract change.
 */
const TRIGGER_VERB_SYNONYMS: ReadonlySet<string> = new Set([
  "breaks",
  "break",
  "dips",
  "dip",
  "rises",
  "rise",
  "falls",
  "fall",
  "moves",
  "move",
  "drops",
  "drop",
  "climbs",
  "climb",
  "jumps",
  "jump",
  "hits",
  "hit",
  "reaches",
  "reach",
  "exceeds",
  "exceed",
]);

/** Repair hint for a natural-language trigger verb (diagnostic only — `verb` stays rejected). */
function triggerVerbSynonymMessage(verb: string): string {
  return (
    `unexpected \`${verb}\` in a trigger — Kestrel has no \`${verb}\` trigger verb. ` +
    `Write a threshold event as \`crosses above|below\` (a strict crossing) or ` +
    `\`touches above|below\` (at-or-touch), e.g. \`spot crosses below 499.15\` — ` +
    `or a plain comparison with \`<\`/\`>\`, e.g. \`spot < 499.15\`.`
  );
}

/**
 * Natural-language price-anchor tokens a model reaches for to express a bounded price where Kestrel's
 * bounding constructors are `min`/`max`/`lean`. PURELY DIAGNOSTIC — none is an accepted anchor; the
 * MESSAGE names the bad token and steers to the canonical constructor.
 *
 * `cap` USED to live here but has since GRADUATED to an accepted synonym (ADR-0030 measured-grammar
 * relaxation, kestrel-hvgd): `cap A, B` normalizes to `min(A, B)` in {@link parsePriceAtom}. The
 * remaining tokens stay rejected-with-a-steer — the cluster for them has not cleared teaching.
 */
const PRICE_ANCHOR_SYNONYMS: ReadonlySet<string> = new Set(["ceiling", "floor", "limit"]);

/** Repair hint for a natural-language price anchor (diagnostic only — `tok` stays rejected). */
function priceAnchorSynonymMessage(tok: string): string {
  return (
    `unexpected \`${tok}\` — Kestrel has no \`${tok}\` price anchor. ` +
    `To bound a price use \`min(...)\` to cap it or \`max(...)\` to floor it, ` +
    `or \`lean(a,b,x)\` to lean a fraction between two anchors — ` +
    `e.g. \`min(fair, 0.95)\` caps the price at 0.95.`
  );
}

function describe(t: Token | undefined): string {
  if (t === undefined) return "end of line";
  if (t.type === "string") return "a string";
  if (t.type === "number") return `number ${t.text}`;
  return `\`${t.text}\``;
}

// ─────────────────────────────────────────────────────────────────────────────
// Comment trivia — carry the byte-stable "why" from the line tree into the AST (ADR-0033)
// ─────────────────────────────────────────────────────────────────────────────

/** Build a {@link LineComment} for one printed line from its source {@link LineNode} trivia,
 * or undefined when the line carries no comments. */
function lineCommentOf(node: LineNode, at: number): LineComment | undefined {
  const leading = node.leading;
  const trailing = node.trailing;
  const hasLeading = leading !== undefined && leading.length > 0;
  if (!hasLeading && trailing === undefined) return undefined;
  return {
    at,
    ...(hasLeading ? { leading } : {}),
    ...(trailing !== undefined ? { trailing } : {}),
  };
}

/**
 * Assemble a block's comment sidecar from its header line (ordinal 0), its direct interior
 * lines in canonical print order (ordinals 1..N), and the header node's `tail`. Child blocks
 * (a pod's books/sub-pods) own their own trivia and are NOT passed as interior lines. Returns
 * undefined when nothing is present, so a comment-free statement carries no `comments` field
 * and stays deep-equal to its builder output.
 */
function commentLayer(header: LineNode, interior: readonly LineNode[]): CommentLayer | undefined {
  const lines: LineComment[] = [];
  const h = lineCommentOf(header, 0);
  if (h !== undefined) lines.push(h);
  interior.forEach((n, i) => {
    const lc = lineCommentOf(n, i + 1);
    if (lc !== undefined) lines.push(lc);
  });
  const tail = header.tail;
  const hasTail = tail !== undefined && tail.length > 0;
  if (lines.length === 0 && !hasTail) return undefined;
  return {
    ...(lines.length > 0 ? { lines } : {}),
    ...(hasTail ? { tail } : {}),
  };
}

/** Spread helper: `{ ...withComments(layer) }` adds a `comments` field only when present. */
function withComments(layer: CommentLayer | undefined): { comments?: CommentLayer } {
  return layer === undefined ? {} : { comments: layer };
}

// ─────────────────────────────────────────────────────────────────────────────
// Cursor — a positioned token stream over one logical line
// ─────────────────────────────────────────────────────────────────────────────

class Cursor {
  private i = 0;
  constructor(
    private readonly toks: readonly Token[],
    private readonly fallbackLine: number,
  ) {}

  peek(k = 0): Token | undefined {
    return this.toks[this.i + k];
  }

  /** Text of the token at `k` iff it is a word, else undefined (for keyword dispatch). */
  wordAt(k = 0): string | undefined {
    const t = this.toks[this.i + k];
    return t !== undefined && t.type === "word" ? t.text : undefined;
  }

  atEnd(): boolean {
    return this.i >= this.toks.length;
  }

  private here(): { line: number; col: number } {
    const t = this.toks[this.i];
    if (t !== undefined) return { line: t.line, col: t.col };
    const last = this.toks[this.toks.length - 1];
    return last !== undefined ? { line: last.line, col: last.end } : { line: this.fallbackLine, col: 1 };
  }

  fail(message: string): never {
    const { line, col } = this.here();
    throw new KestrelParseError(message, line, col);
  }

  advance(): Token {
    const t = this.toks[this.i];
    if (t === undefined) this.fail("unexpected end of line");
    this.i++;
    return t;
  }

  matchWord(text: string): boolean {
    return this.wordAt() === text;
  }

  optWord(text: string): boolean {
    if (this.matchWord(text)) {
      this.i++;
      return true;
    }
    return false;
  }

  expectWord(text: string, ctx: string): void {
    if (!this.matchWord(text)) this.fail(`expected \`${text}\` ${ctx}, got ${describe(this.peek())}`);
    this.i++;
  }

  matchPunct(text: string): boolean {
    const t = this.peek();
    return t !== undefined && t.type === "punct" && t.text === text;
  }

  optPunct(text: string): boolean {
    if (this.matchPunct(text)) {
      this.i++;
      return true;
    }
    return false;
  }

  expectPunct(text: string, ctx: string): void {
    if (!this.matchPunct(text)) this.fail(`expected \`${text}\` ${ctx}, got ${describe(this.peek())}`);
    this.i++;
  }

  expectString(ctx: string): string {
    const t = this.peek();
    if (t === undefined || t.type !== "string") this.fail(`expected a quoted string ${ctx}, got ${describe(t)}`);
    this.i++;
    return t.text;
  }

  expectNumber(ctx: string): number {
    const t = this.peek();
    if (t === undefined || t.type !== "number") this.fail(`expected a number ${ctx}, got ${describe(t)}`);
    this.i++;
    return Number(t.text);
  }

  expectEnd(ctx: string): void {
    if (!this.atEnd()) this.fail(`unexpected ${describe(this.peek())} after ${ctx}`);
  }

  /**
   * Read an identifier, reassembling the tight hyphen/digit forms the lexer split
   * (`zoom-1d`, `chase-urgent`). Extends across a maximal run of adjacent word/number
   * tokens joined by `-`/`_`, stopping at the first gap or non-name token.
   */
  readName(what: string): string {
    const first = this.peek();
    if (first === undefined || first.type !== "word") this.fail(`expected ${what}, got ${describe(first)}`);
    let text = first.text;
    let end = first.end;
    this.i++;
    for (;;) {
      const t = this.peek();
      if (t === undefined || t.col !== end) break;
      if (t.type === "punct" && (t.text === "-" || t.text === "_")) {
        const after = this.peek(1);
        if (after !== undefined && after.col === t.end && (after.type === "word" || after.type === "number")) {
          text += t.text + after.text;
          end = after.end;
          this.i += 2;
          continue;
        }
        break;
      }
      if (t.type === "word" || t.type === "number") {
        text += t.text;
        end = t.end;
        this.i++;
        continue;
      }
      break;
    }
    return text;
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Repair-guiding clause-end (diagnostic-only — accept/reject set unchanged)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * End a PLAN clause line. If a SIBLING clause keyword trails on the same line, fail with a
 * repair-guiding message naming the fix instead of the bare `unexpected \`DO\` after …`. The
 * WHEN/DO-on-one-line collapse was the single dominant live-model authoring error (dry-run-1: 35 of
 * ~54 escapes). Diagnostic only — a trailing clause keyword was always an error; this just teaches
 * the reader (a model or a human reading the journalled error) how to repair it.
 */
function expectClauseEnd(c: Cursor, clauseCtx: string): void {
  const t = c.peek();
  if (t !== undefined && t.type === "word" && PLAN_CLAUSE_KEYWORD_SET.has(t.text)) {
    c.fail(
      `unexpected \`${t.text}\` after ${clauseCtx} — this looks like two clauses on one line. ` +
        `Each PLAN clause goes on its OWN line, indented two spaces beneath the \`PLAN <name>\` header: ` +
        `write \`WHEN …\` and \`DO …\` as SEPARATE lines, never \`WHEN … DO …\` together.`,
    );
  }
  c.expectEnd(clauseCtx);
}

// ─────────────────────────────────────────────────────────────────────────────
// Lexical readers
// ─────────────────────────────────────────────────────────────────────────────

function readTimeUnit(c: Cursor, ctx: string): TimeUnit {
  const units = TIME_UNITS_LIST.join(", ");
  const u = c.readName(`a time unit (${units}) ${ctx}`);
  if (!TIME_UNITS.has(u)) c.fail(`unknown time unit \`${u}\`; expected one of ${units}`);
  return u as TimeUnit;
}

function readWindow(c: Cursor): Window {
  const value = c.expectNumber("for a window size");
  const unit = readTimeUnit(c, "for a window");
  return { kind: "window", value, unit };
}

function readDuration(c: Cursor, ctx: string): Duration {
  const value = c.expectNumber(`for a duration ${ctx}`);
  const unit = readTimeUnit(c, ctx);
  return { kind: "duration", value, unit };
}

function readTimeOfDay(c: Cursor): TimeOfDay {
  const hour = c.expectNumber("for the hour of a clock time");
  c.expectPunct(":", "in a clock time (HH:MM)");
  const minute = c.expectNumber("for the minute of a clock time");
  if (!Number.isInteger(hour) || hour < 0 || hour > 23) c.fail(`bad clock hour ${hour}; expected 0..23`);
  if (!Number.isInteger(minute) || minute < 0 || minute > 59) c.fail(`bad clock minute ${minute}; expected 0..59`);
  return { kind: "time-of-day", hour, minute };
}

/** A quantity: an optional sign, a number, an optional unit (`R`, `c`, `%`, `bp`). */
function readQuantity(c: Cursor, ctx: string): Quantity {
  let sign = 1;
  if (c.matchPunct("+")) c.advance();
  else if (c.matchPunct("-")) {
    c.advance();
    sign = -1;
  }
  const value = sign * c.expectNumber(ctx);
  const unit = readOptUnit(c);
  return unit === undefined ? { kind: "quantity", value } : { kind: "quantity", value, unit };
}

/** A cross re-arm band width: a positive quantity (the band>0 doctrine invariant lives in
 * ./validate.ts; here it fails closed on the value with position). */
function readBand(c: Cursor): Quantity {
  const q = readQuantity(c, "for a cross re-arm band width (`band 5c`)");
  if (!(q.value > 0)) c.fail(bandMessage(quantityText(q)));
  return q;
}

/** A human-readable rendering of a quantity for error messages (parse-only; does not need
 * to be canonical). */
function quantityText(q: Quantity): string {
  return q.unit === undefined ? String(q.value) : `${q.value}${q.unit}`;
}

function readOptUnit(c: Cursor): Quantity["unit"] | undefined {
  if (c.matchPunct("%")) {
    c.advance();
    return "%";
  }
  const w = c.wordAt();
  if (w === "R" || w === "c" || w === "bp") {
    c.advance();
    return w;
  }
  return undefined;
}

// ─────────────────────────────────────────────────────────────────────────────
// Series references & operands
// ─────────────────────────────────────────────────────────────────────────────

function parseSeries(c: Cursor): SeriesRef {
  const segments: PathSegment[] = [];
  for (;;) {
    const name = c.readName("a series name");
    // A selector is `(word...)`; a window is `(number...)` and belongs to the whole series.
    if (c.matchPunct("(") && c.peek(1)?.type === "word") {
      c.advance(); // (
      const q = c.wordAt();
      let selector: PathSegment["selector"];
      if (q === "any" || q === "all") {
        c.advance();
        selector = { kind: "sel-quant", q };
      } else {
        selector = { kind: "sel-name", name: c.readName("a selector name inside `(...)`") };
      }
      c.expectPunct(")", "to close a series selector");
      segments.push({ name, selector });
    } else {
      segments.push({ name });
    }
    if (c.optPunct(".")) continue;
    break;
  }
  let window: Window | undefined;
  if (c.matchPunct("(") && c.peek(1)?.type === "number") {
    c.advance(); // (
    window = readWindow(c);
    c.expectPunct(")", "to close a series window");
  }
  return window === undefined ? { kind: "series", segments } : { kind: "series", segments, window };
}

/** An operand of a comparison/cross: a number/quantity, a baseline, or a series. */
function parseOperand(c: Cursor): Operand {
  const t = c.peek();
  if (t !== undefined && (t.type === "number" || (t.type === "punct" && (t.text === "+" || t.text === "-")))) {
    // signed number: sigma baseline (`3sigma`), a quantity, or a bare number
    let sign = 1;
    if (c.matchPunct("+")) c.advance();
    else if (c.matchPunct("-")) {
      c.advance();
      sign = -1;
    }
    const value = c.expectNumber("for an operand");
    if (c.matchWord("sigma")) {
      c.advance();
      return { kind: "baseline", stat: "sigma", n: value };
    }
    const unit = readOptUnit(c);
    return unit === undefined ? { kind: "quantity", value: sign * value } : { kind: "quantity", value: sign * value, unit };
  }
  const w = c.wordAt();
  if (w !== undefined && /^p\d+$/.test(w)) {
    c.advance();
    return { kind: "baseline", stat: "p", n: Number(w.slice(1)) };
  }
  if (w !== undefined) return parseSeries(c);
  c.fail(`expected an operand (a series name, a number, or a baseline like p99/3sigma), got ${describe(t)}`);
}

// `isSimpleSeries` and the EXIT-may-not-condition-on-a-mark walk (`findMarkInTrigger`) live in
// ./validate.ts, shared with the builder/printer so a mark-conditioned EXIT is refused on every
// surface; this file imports them.

// ─────────────────────────────────────────────────────────────────────────────
// Trigger algebra (OR < AND < NOT < postfix < atom) — mirrors print.ts precedence
// ─────────────────────────────────────────────────────────────────────────────

function parseTrigger(c: Cursor): Trigger {
  return parseOr(c);
}

function parseOr(c: Cursor): Trigger {
  const terms = [parseAnd(c)];
  while (c.optWord("OR")) terms.push(parseAnd(c));
  return terms.length === 1 ? terms[0]! : { kind: "or", terms };
}

function parseAnd(c: Cursor): Trigger {
  const terms = [parseNot(c)];
  while (c.optWord("AND")) terms.push(parseNot(c));
  return terms.length === 1 ? terms[0]! : { kind: "and", terms };
}

function parseNot(c: Cursor): Trigger {
  if (c.optWord("NOT")) return { kind: "not", term: parseNot(c) };
  return parsePostfix(c);
}

function parsePostfix(c: Cursor): Trigger {
  let inner = parseAtom(c);
  for (;;) {
    if (c.matchWord("held")) {
      // Fail closed at the `held` token: holding a cross (a bare edge) is the sfg8 anti-pattern.
      if (inner.kind === "cross") c.fail(HELD_CROSS_MESSAGE);
      c.advance();
      inner = { kind: "break-hold", inner, dur: readDuration(c, "after `held`") };
    } else if (c.matchWord("within")) {
      c.advance();
      inner = { kind: "within", inner, dur: readDuration(c, "after `within`") };
    } else if (c.matchWord("until")) {
      // The thesis temporal envelope (kestrel-rtf): `until`/`at` are postfix combinators over
      // the SAME predicate surface as `within` — siblings, not a second predicate language —
      // each taking a wall-clock time via the existing `readTimeOfDay`.
      c.advance();
      inner = { kind: "until", inner, at: readTimeOfDay(c) };
    } else if (c.matchWord("at")) {
      c.advance();
      inner = { kind: "at", inner, at: readTimeOfDay(c) };
    } else break;
  }
  return inner;
}

function ordinalValue(c: Cursor): number | undefined {
  const w = c.wordAt();
  if (w !== undefined) {
    const idx = ORDINALS.indexOf(w);
    if (idx >= 1) return idx;
  }
  // numeric ordinal: `<n>th`
  const t = c.peek();
  if (t !== undefined && t.type === "number" && c.wordAt(1) === "th") return Number(t.text);
  return undefined;
}

function parseAtom(c: Cursor): Trigger {
  if (c.optPunct("(")) {
    const t = parseTrigger(c);
    c.expectPunct(")", "to close a parenthesized trigger");
    return t;
  }

  const ord = ordinalValue(c);
  if (ord !== undefined) {
    // consume the ordinal (a word, or `<number> th`)
    if (c.wordAt() !== undefined) c.advance();
    else {
      c.advance(); // number
      c.advance(); // th
    }
    return { kind: "nth", ordinal: ord, event: parseAtom(c) };
  }

  if (c.matchWord("phase")) {
    c.advance();
    return { kind: "phase", phase: c.readName("a phase name after `phase`") };
  }

  if (c.matchWord("time")) {
    c.advance();
    return parseTimeWindow(c);
  }

  // 0DTE time-exit stops (docs/results/fomc-options-axis) — a bare, LEADING time-clock, distinct
  // from the `<inner> held <dur>` break-hold postfix (which always has an inner atom before `held`,
  // so it never reaches this leading position). `held <dur>` = a TimeHeldStop; `clockET <clock>` = a
  // wall-clock ClockStop. A leading `held` is intercepted ONLY when a duration number follows, so a
  // bare `held` identifier keeps its old reading (a structural event named `held`) — strict superset.
  if (c.matchWord(HELD_STOP_KEYWORD) && c.peek(1)?.type === "number") {
    c.advance();
    return { kind: "held-stop", dur: readDuration(c, "after `held` (a 0DTE time-held stop, `held 90m`)") };
  }
  if (c.matchWord(CLOCK_STOP_KEYWORD)) {
    c.advance();
    return { kind: "clock-stop", at: readTimeOfDay(c) };
  }

  const w = c.wordAt();
  if (w !== undefined && FILL_EVENTS.has(w)) {
    c.advance();
    const event = w as FillEvent["event"];
    if (c.optWord("leg")) {
      const leg = c.expectNumber("for a fill-event leg index");
      return { kind: "fill", event, leg };
    }
    return { kind: "fill", event };
  }

  return parseOperandLed(c);
}

function parseTimeWindow(c: Cursor): Trigger {
  if (c.optWord("after")) return { kind: "time-window", from: readTimeOfDay(c) };
  if (c.optWord("before")) return { kind: "time-window", to: readTimeOfDay(c) };
  const from = readTimeOfDay(c);
  c.expectPunct("..", "in a time window (`time HH:MM..HH:MM`)");
  const to = readTimeOfDay(c);
  return { kind: "time-window", from, to };
}

function parseOperandLed(c: Cursor): Trigger {
  const left = parseOperand(c);

  if (c.matchWord("crosses") || c.matchWord("touches")) {
    // `touches` is the at-or-touch predicate (>=/<=); `crosses` is a strict crossing (>/<).
    const touch = c.advance().text === "touches";
    const kw = touch ? "touches" : "crosses";
    // `left crosses|touches outside LO-HI` — a RANGE-BREAKOUT (ADR-0030 measured-grammar widening,
    // kestrel-hvgd): fire on a breakout past EITHER edge of the band. It DESUGARS to the OR of the
    // two hardened single-edge crosses — `left <verb> above HI OR left <verb> below LO` — so it adds
    // NO new runtime node (the engine already evaluates OR + cross) and inherits the sfg8 hardening.
    // `touches outside` keeps the at-or-touch predicate on both edges; `crosses outside` is strict.
    if (c.optWord("outside")) {
      const lo = parseOperand(c);
      c.expectPunct("-", "between the low and high of a breakout range (`crosses outside LO-HI`)");
      const hi = parseOperand(c);
      const touchOpt = touch ? { touch: true as const } : {};
      const above: CrossEvent = { kind: "cross", left, dir: "above", right: hi, ...touchOpt };
      const below: CrossEvent = { kind: "cross", left, dir: "below", right: lo, ...touchOpt };
      return { kind: "or", terms: [above, below] };
    }
    let dir: CrossEvent["dir"];
    if (c.optWord("above")) dir = "above";
    else if (c.optWord("below")) dir = "below";
    else c.fail(`expected \`above\`, \`below\`, or \`outside LO-HI\` (a breakout) after \`${kw}\`, got ${describe(c.peek())}`);
    const right = parseOperand(c);
    // Optional re-arm band: `... band 5c`. The band must be a positive width (a zero/negative
    // band can never re-arm) — fail closed on it rather than arm a cross that can't re-arm.
    let band: Quantity | undefined;
    if (c.optWord("band")) band = readBand(c);
    return {
      kind: "cross",
      left,
      dir,
      right,
      ...(touch ? { touch } : {}),
      ...(band !== undefined ? { band } : {}),
    } satisfies CrossEvent;
  }

  const opTok = c.peek();
  if (opTok !== undefined && opTok.type === "punct" && opTok.text in CMP_FROM_PUNCT) {
    c.advance();
    const op = CMP_FROM_PUNCT[opTok.text]!;
    return { kind: "cmp", op, left, right: parseOperand(c) } satisfies Comparison;
  }

  // A natural-language trigger verb in the infix position (where only crosses/touches/`<`/`>` are
  // legal) — name it and steer to the canonical crossing form rather than swallowing the operand as a
  // bare event and failing downstream with a bare `unexpected \`breaks\` …`. Diagnostic only.
  const infix = c.wordAt();
  if (infix !== undefined && TRIGGER_VERB_SYNONYMS.has(infix)) c.fail(triggerVerbSynonymMessage(infix));

  // otherwise this is a structural event named by a bare identifier
  if (isSimpleSeries(left)) {
    const name = left.segments[0]!.name;
    if (c.optWord("of")) return { kind: "event", name, of: parseOperand(c) };
    return { kind: "event", name };
  }
  c.fail(
    `expected a comparison (>, >=, <, <=, ==, !=), \`crosses above/below\`, or \`of\` ` +
      `after this operand, got ${describe(c.peek())}`,
  );
}

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

function parsePriceAtom(c: Cursor): PriceExpr {
  const t = c.peek();
  if (t !== undefined && t.type === "word") {
    if (ANCHORS.has(t.text)) {
      c.advance();
      return { kind: "anchor", name: t.text as AnchorName } satisfies Anchor;
    }
    if (t.text === "min" || t.text === "max") {
      c.advance();
      c.expectPunct("(", `after \`${t.text}\``);
      const args: PriceExpr[] = [parsePrice(c)];
      while (c.optPunct(",")) args.push(parsePrice(c));
      c.expectPunct(")", `to close \`${t.text}(...)\``);
      return { kind: "price-fn", fn: t.text, args };
    }
    // `cap A, B` — a measured-grammar synonym (ADR-0030, kestrel-hvgd) for `min(A, B)`. Models
    // author `@ cap fair, 0.95` to CAP a price at a ceiling — exactly what `min` does. Parenless,
    // comma-separated, ≥2 operands; it NORMALIZES to the canonical `min(...)` price-fn so the
    // printer emits only `min` (one canonical form) and round-trip stays byte-stable (ADR-0004).
    // A strict superset: `cap` in a PRICE position was rejected-with-a-steer before, never accepted,
    // and the order-policy `cap <price>` list keyword is consumed one rung up (parseOrderPolicy)
    // before this atom is ever reached, so no previously-parsing form changes meaning.
    if (t.text === "cap") {
      c.advance();
      const args: PriceExpr[] = [parsePrice(c)];
      while (c.optPunct(",")) args.push(parsePrice(c));
      if (args.length < 2) {
        c.fail(
          "`cap` needs a ceiling — write `cap <price>, <ceiling>` (it normalizes to " +
            "`min(<price>, <ceiling>)`), e.g. `cap fair, 0.95`",
        );
      }
      return { kind: "price-fn", fn: "min", args };
    }
    if (t.text === "lean") {
      c.advance();
      c.expectPunct("(", "after `lean`");
      const a = parsePrice(c);
      c.expectPunct(",", "between lean arguments");
      const b = parsePrice(c);
      c.expectPunct(",", "between lean arguments");
      const x = c.expectNumber("for the lean fraction");
      c.expectPunct(")", "to close `lean(...)`");
      return { kind: "lean", a, b, x };
    }
  }
  if (t !== undefined && t.type === "number") {
    c.advance();
    return { kind: "price-abs", value: Number(t.text) };
  }
  // A natural-language price-anchor token (`ceiling`/`floor`/`limit`) — name it and steer to the
  // canonical bounding constructor rather than the bare `got \`ceiling\``. Diagnostic only. (`cap`
  // is no longer here: it graduated to an accepted `min` synonym above — kestrel-hvgd.)
  if (t !== undefined && t.type === "word" && PRICE_ANCHOR_SYNONYMS.has(t.text)) {
    c.fail(priceAnchorSynonymMessage(t.text));
  }
  c.fail(
    `expected a price: an anchor (${ANCHOR_NAMES.join(", ")}), a number, or min/max/lean(...), ` +
      `got ${describe(t)}`,
  );
}

function parsePrice(c: Cursor): PriceExpr {
  let base = parsePriceAtom(c);
  for (;;) {
    const t = c.peek();
    const next = c.peek(1);
    if (t !== undefined && t.type === "punct" && (t.text === "+" || t.text === "-") && next?.type === "number") {
      c.advance(); // sign
      const amount = c.expectNumber("for a price offset");
      let unit: "c" | "%";
      if (c.optPunct("%")) unit = "%";
      else if (c.matchWord("c")) {
        c.advance();
        unit = "c";
      } else {
        c.fail(`expected \`c\` or \`%\` after a price offset amount, got ${describe(c.peek())}`);
      }
      base = { kind: "price-offset", base, sign: t.text as "+" | "-", amount, unit };
      continue;
    }
    break;
  }
  return base;
}

// ─────────────────────────────────────────────────────────────────────────────
// Legs, order policy, held quantifiers
// ─────────────────────────────────────────────────────────────────────────────

function parseStrike(c: Cursor): StrikeSpec {
  if (c.optWord("atm")) return { kind: "strike-atm" };
  if (c.matchPunct("+") || c.matchPunct("-")) {
    const sign = c.advance().text === "-" ? -1 : 1;
    return { kind: "strike-rel", steps: sign * c.expectNumber("for a relative strike") };
  }
  const t = c.peek();
  if (t !== undefined && t.type === "number") {
    c.advance();
    if (c.matchWord("d")) {
      c.advance();
      return { kind: "strike-delta", delta: Number(t.text) };
    }
    return { kind: "strike-abs", strike: Number(t.text) };
  }
  // Name BOTH continuations after a leg quantity: a strike (the option leg) OR `shares` (the equity
  // leg, ADR-0017). Every mistake must price in one frame — an author who wrote `buy 100 @ mid` needs
  // to see that `buy 100 shares @ mid` is the equity form, not just that a strike was expected.
  c.fail(
    `expected a strike (+N/-N relative, N absolute, atm, or Nd delta) for an option leg, ` +
      `or \`shares\` for an equity leg (\`buy N shares\`, ADR-0017), got ${describe(t)}`,
  );
}

function parseLeg(c: Cursor): Leg {
  let side: "buy" | "sell";
  if (c.optWord("buy")) side = "buy";
  else if (c.optWord("sell")) side = "sell";
  else c.fail(`expected \`buy\` or \`sell\` to start a leg, got ${describe(c.peek())}`);
  const qty = c.expectNumber("for a leg quantity");
  // An equity/spot leg (`buy 100 shares`, ADR-0017): the `shares` marker replaces strike+right;
  // the symbol is the ambient `USING exec`. Anything else is an option leg (`buy 2 +1 C`).
  if (c.optWord("shares")) {
    // Fail-closed: a spot instrument has no strike or right, so `shares` is a COMPLETE leg (ADR-0017).
    // A strike/right token trailing it is the equity↔option confusion — refuse loudly, never silently
    // reinterpret. (A `,` next leg, an `@` price, or end-of-ticket are the only legal continuations.)
    const t = c.peek();
    const w = c.wordAt();
    if ((t !== undefined && t.type === "number") || w === "C" || w === "P" || w === "atm" || c.matchPunct("+") || c.matchPunct("-")) {
      c.fail(`an equity/spot leg carries no strike or right — \`${qty} shares\` is complete (ADR-0017); drop the strike/right`);
    }
    return { kind: "equity-leg", side, qty };
  }
  const strike = parseStrike(c);
  let right: "C" | "P";
  if (c.optWord("C")) right = "C";
  else if (c.optWord("P")) right = "P";
  else c.fail(`expected \`C\` or \`P\` (option right) after a strike, got ${describe(c.peek())}`);
  // An optional PER-LEG expiry (`buy 2 +1 C exp 0dte`, kestrel-ih5h seam 1). The selector vocabulary is
  // the SAME `parseExpiry` the execution instrument uses (`USING exec SPY 0dte`) — `Ndte`, a date, or a
  // tag — so an author learns one expiry language, not two. Unlike the instrument form (positional, held
  // apart by a stop-set) a leg's expiry needs the `exp` MARKER: a leg is followed by `,` or `@`, and a
  // bare tag selector reads names, so a positional tag would silently swallow whatever came next. Absent
  // `exp`, the leg leaves `expiry` undefined and inherits the ambient `USING exec` tenor (additive:
  // every plan written before this syntax prints byte-identically, ADR-0004).
  let expiry: ExpirySelector | undefined;
  if (c.optWord("exp")) {
    // Fail closed: `exp` with nothing usable after it is a parse escape, NEVER a silent undefined — an
    // undefined expiry means "inherit the exec tenor", so accepting a bare `exp` would quietly read back
    // as the ambient (often 0dte) expiry the author was trying to override. No selector can begin with
    // punctuation, so `exp @ mid` / `exp ,` are caught here rather than misread as a tag.
    const t = c.peek();
    if (t === undefined || t.type === "punct") {
      c.fail(`\`exp\` needs an expiry selector after it — \`0dte\`, a date (\`2026-07-17\`), or a tag (\`weekly\`) — got ${describe(t)}`);
    }
    expiry = parseExpiry(c);
    if (expiry === undefined) c.fail("`exp` needs an expiry selector after it (`0dte`, a date like `2026-07-17`, or a tag like `weekly`)");
  }
  return { kind: "leg", side, qty, strike, right, ...(expiry !== undefined ? { expiry } : {}) };
}

function parseLegs(c: Cursor): Leg[] {
  const legs = [parseLeg(c)];
  while (c.optPunct(",")) legs.push(parseLeg(c));
  return legs;
}

function parseHeldQuantifier(c: Cursor): HeldQuantifier | undefined {
  if (c.matchWord("foreach")) {
    c.advance();
    c.expectWord("held", "in a held-leg quantifier (`foreach held leg`)");
    c.expectWord("leg", "in a held-leg quantifier (`foreach held leg`)");
    return { kind: "held-foreach" };
  }
  if (c.matchWord("any")) {
    c.advance();
    c.expectWord("held", "in a held-leg quantifier (`any held leg`)");
    c.expectWord("leg", "in a held-leg quantifier (`any held leg`)");
    return { kind: "held-any" };
  }
  // A bare `held leg` with no `foreach`/`any` head is a MIS-TYPED quantifier — the author reached for
  // the adoption idiom and dropped the quantifier keyword (`ARM held leg` instead of `ARM foreach held
  // leg`). Without this, the leading `held` falls through to a generic `expectClauseEnd` "unexpected
  // `held`" that flags the wrong token and never teaches the fix — unlike the `foreach`/`any` siblings,
  // which echo the full form. Detect `held leg` specifically (a bare `held <duration>` is a legitimate
  // TimeHeldStop inside a trigger and never reaches here) and echo BOTH correct forms (kestrel-b4wx).
  if (c.wordAt() === "held" && c.wordAt(1) === "leg") {
    c.fail(
      "a held-leg quantifier needs a `foreach` or `any` head — `held leg` alone is not a quantifier. " +
        "Did you mean `foreach held leg` (every held leg) or `any held leg`?",
    );
  }
  return undefined;
}

function parseOrderPolicy(c: Cursor): OrderPolicy | undefined {
  let pricing: "peg" | "fix" | undefined;
  const esc: EscStage[] = [];
  const caps: PriceExpr[] = [];
  const floors: PriceExpr[] = [];
  let cancelIf: Trigger | undefined;
  let gtc = false;

  for (;;) {
    if (c.matchWord("peg") || c.matchWord("fix")) {
      if (pricing !== undefined) c.fail("a ticket may declare `peg` or `fix` only once");
      pricing = c.advance().text as "peg" | "fix";
    } else if (c.optWord("esc")) {
      const to = parsePrice(c);
      esc.push({ kind: "esc-stage", to, after: readDuration(c, "for an esc stage") });
    } else if (c.optWord("cap")) {
      caps.push(parsePrice(c));
    } else if (c.optWord("floor")) {
      floors.push(parsePrice(c));
    } else if (c.optWord("cancel-if")) {
      cancelIf = parseTrigger(c);
    } else if (c.optWord("gtc")) {
      gtc = true;
    } else break;
  }

  if (pricing === undefined && esc.length === 0 && caps.length === 0 && floors.length === 0 && cancelIf === undefined && !gtc) {
    return undefined;
  }
  return {
    kind: "order-policy",
    ...(pricing !== undefined ? { pricing } : {}),
    ...(esc.length > 0 ? { esc } : {}),
    ...(caps.length > 0 ? { caps } : {}),
    ...(floors.length > 0 ? { floors } : {}),
    ...(cancelIf !== undefined ? { cancelIf } : {}),
    ...(gtc ? { gtc } : {}),
  };
}

function rejectAtomicIfPresent(c: Cursor): void {
  if (c.matchWord("atomic")) c.fail(ATOMIC_MESSAGE);
}

// ─────────────────────────────────────────────────────────────────────────────
// Plan clauses
// ─────────────────────────────────────────────────────────────────────────────

function parseTpTarget(c: Cursor): TpTarget {
  if (c.matchPunct("+") || c.matchPunct("-")) {
    const sign = c.advance().text === "-" ? -1 : 1;
    const pct = sign * c.expectNumber("for a TP percentage");
    c.expectPunct("%", "after a TP percentage (`+100%`)");
    return { kind: "tp-pct", pct };
  }
  const t = c.peek();
  if (t !== undefined && t.type === "number") {
    if (c.peek(1)?.type === "punct" && c.peek(1)!.text === "%") {
      c.advance();
      c.advance();
      return { kind: "tp-pct", pct: Number(t.text) };
    }
    if (c.wordAt(1) === "x") {
      c.advance();
      c.advance();
      return { kind: "tp-mult", mult: Number(t.text) };
    }
  }
  return { kind: "tp-price", price: parsePrice(c) };
}

function parsePlanClause(c: Cursor): PlanClause {
  const kw = c.readName(`a clause keyword (${CLAUSE_KEYWORDS})`);
  switch (kw) {
    case "DO":
    case "ALSO": {
      rejectAtomicIfPresent(c);
      const legs = parseLegs(c);
      c.expectPunct("@", "before the ticket price");
      const price = parsePrice(c);
      const policy = parseOrderPolicy(c);
      expectClauseEnd(c, `the ${kw} ticket`);
      const base = { legs, price, ...(policy !== undefined ? { policy } : {}) };
      return kw === "DO" ? ({ kind: "do", ...base } satisfies DoTicket) : ({ kind: "also", ...base } satisfies AlsoTicket);
    }
    case "RELOAD": {
      let when: Trigger | undefined;
      if (c.optWord("WHEN")) when = parseTrigger(c);
      rejectAtomicIfPresent(c);
      const legs = parseLegs(c);
      c.expectPunct("@", "before the RELOAD price");
      const price = parsePrice(c);
      const policy = parseOrderPolicy(c);
      expectClauseEnd(c, "the RELOAD ticket");
      return {
        kind: "reload",
        ...(when !== undefined ? { when } : {}),
        legs,
        price,
        ...(policy !== undefined ? { policy } : {}),
      } satisfies ReloadClause;
    }
    case "TP": {
      const target = parseTpTarget(c);
      let frac: number | undefined;
      if (c.optWord("frac")) frac = c.expectNumber("for a TP fraction");
      const over = parseHeldQuantifier(c);
      let price: PriceExpr | undefined;
      if (c.optPunct("@")) price = parsePrice(c);
      const policy = parseOrderPolicy(c);
      expectClauseEnd(c, "the TP clause");
      return {
        kind: "tp",
        target,
        ...(frac !== undefined ? { frac } : {}),
        ...(over !== undefined ? { over } : {}),
        ...(price !== undefined ? { price } : {}),
        ...(policy !== undefined ? { policy } : {}),
      } satisfies TpClause;
    }
    case "EXIT": {
      const when = parseTrigger(c);
      const mark = findMarkInTrigger(when);
      if (mark !== undefined) c.fail(markMessage(mark));
      const over = parseHeldQuantifier(c);
      let price: PriceExpr | undefined;
      if (c.optPunct("@")) price = parsePrice(c);
      const policy = parseOrderPolicy(c);
      expectClauseEnd(c, "the EXIT clause");
      return {
        kind: "exit",
        when,
        ...(over !== undefined ? { over } : {}),
        ...(price !== undefined ? { price } : {}),
        ...(policy !== undefined ? { policy } : {}),
      } satisfies ExitClause;
    }
    case "INVALIDATE": {
      const when = parseTrigger(c);
      expectClauseEnd(c, "the INVALIDATE clause");
      return { kind: "invalidate", when } satisfies InvalidateClause;
    }
    case "CANCEL-IF": {
      const when = parseTrigger(c);
      expectClauseEnd(c, "the CANCEL-IF clause");
      return { kind: "cancel-if", when } satisfies CancelIfClause;
    }
    case "ARM": {
      let when: Trigger | undefined;
      if (c.optWord("WHEN")) when = parseTrigger(c);
      let basis: PriceExpr | undefined;
      if (c.optWord("basis")) basis = parsePrice(c);
      const over = parseHeldQuantifier(c);
      // An ARM clause must DO something: adopt a held leg (`… foreach held leg`) OR chain on another
      // plan's state (`ARM WHEN …`). A clause carrying NEITHER a held quantifier NOR a WHEN trigger
      // binds nothing — bare `ARM` and `ARM basis <px>` parse valid today and then silently adopt
      // NOTHING (no de-arm reason, no frame notice), re-locking the r866 "a fill is a one-way door"
      // trap one dropped keyword away (kestrel-b4wx). Refuse it AT PARSE — the earliest, loudest signal,
      // naming the two real idioms — rather than let an author ship a plan that arms into the void.
      // A `basis` needs a held leg to anchor onto (RUNTIME §4: `basis` is unresolvable with no held
      // position), but a `basis` alongside a WHEN is legitimate (a chain-armed manage plan carries its
      // cost basis; see the `manage-inventory` golden), so the refusal keys ONLY on "neither when nor
      // held quantifier", never on basis alone.
      if (when === undefined && over === undefined) {
        c.fail(
          basis === undefined
            ? "bare `ARM` binds nothing — an ARM clause must adopt a held leg or chain a plan. " +
                "Did you mean `ARM foreach held leg` (adopt a superseded plan's held leg — the exit " +
                "escape hatch) or `ARM WHEN <trigger>` (chain plans on another's state)?"
            : "`ARM basis <px>` carries an adoption basis but binds no leg — a basis only anchors an " +
                "ADOPTED held leg (RUNTIME §4). Did you mean `ARM basis <px> foreach held leg` (adopt " +
                "the superseded plan's held leg at that basis)?",
        );
      }
      expectClauseEnd(c, "the ARM clause");
      return {
        kind: "arm",
        ...(when !== undefined ? { when } : {}),
        ...(basis !== undefined ? { basis } : {}),
        ...(over !== undefined ? { over } : {}),
      } satisfies ArmClause;
    }
    default:
      c.fail(`unknown clause \`${kw}\`; expected one of ${CLAUSE_KEYWORDS}`);
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Instruments, USING, provenance
// ─────────────────────────────────────────────────────────────────────────────

function parseExpiry(c: Cursor): ExpirySelector | undefined {
  const t = c.peek();
  if (t === undefined) return undefined;
  if (t.type === "number") {
    c.advance();
    if (c.matchWord("dte")) {
      c.advance();
      return { kind: "expiry-dte", dte: Number(t.text) };
    }
    // date token: N(-N)+ — preserve the RAW digit text (zero-padded `07` stays `07`);
    // Number() here would drop leading zeros and break the round-trip.
    if (c.matchPunct("-")) {
      let s = t.text;
      while (c.optPunct("-")) {
        const seg = c.peek();
        if (seg === undefined || seg.type !== "number") c.fail(`expected a number after \`-\` in an expiry date, got ${describe(seg)}`);
        s += "-" + seg.text;
        c.advance();
      }
      return { kind: "expiry-date", date: s };
    }
    c.fail(`expected \`dte\` or a date after ${t.text} in an expiry, got ${describe(c.peek())}`);
  }
  return { kind: "expiry-tag", tag: c.readName("an expiry tag") };
}

function parseInstrument(c: Cursor, opts: { expiry: boolean; stop: ReadonlySet<string> }): Instrument {
  const symbol = c.readName("an instrument symbol");
  if (!opts.expiry) return { kind: "instrument", symbol };
  const w = c.wordAt();
  const nextIsStop = w !== undefined && opts.stop.has(w);
  const hasExpiry = !c.atEnd() && !nextIsStop;
  if (!hasExpiry) return { kind: "instrument", symbol };
  const expiry = parseExpiry(c);
  return expiry === undefined ? { kind: "instrument", symbol } : { kind: "instrument", symbol, expiry };
}

const USING_STOP: ReadonlySet<string> = new Set(["signal", "exec"]);

function parseUsingBody(c: Cursor): Using {
  let signal: Instrument | undefined;
  let exec: Instrument | undefined;
  for (;;) {
    if (c.optWord("signal")) signal = parseInstrument(c, { expiry: true, stop: USING_STOP });
    else if (c.optWord("exec")) exec = parseInstrument(c, { expiry: true, stop: USING_STOP });
    else break;
  }
  if (signal === undefined && exec === undefined) {
    c.fail("`USING` needs at least a `signal` or an `exec` instrument");
  }
  return {
    kind: "using",
    ...(signal !== undefined ? { signal } : {}),
    ...(exec !== undefined ? { exec } : {}),
  };
}

function parseProvenanceBrace(c: Cursor): Provenance {
  c.expectPunct("{", "to open a provenance map");
  let tier: ProvenanceTier | undefined;
  let author: string | undefined;
  let origin: string | undefined;
  let replay: string | undefined;
  if (!c.matchPunct("}")) {
    for (;;) {
      const key = c.readName("a provenance field (tier, author, origin, or replay)");
      c.expectPunct(":", "after a provenance field name");
      switch (key) {
        case "tier": {
          const v = c.readName("a provenance tier (vetted, candidate, or unvetted)");
          if (!PROV_TIERS.has(v)) c.fail(`unknown provenance tier \`${v}\`; expected vetted, candidate, or unvetted`);
          tier = v as ProvenanceTier;
          break;
        }
        case "author":
          author = c.expectString("for a provenance author");
          break;
        case "origin":
          origin = c.expectString("for a provenance origin");
          break;
        case "replay":
          replay = c.expectString("for a provenance replay record");
          break;
        default:
          c.fail(`unknown provenance field \`${key}\`; expected tier, author, origin, or replay`);
      }
      if (!c.optPunct(",")) break;
    }
  }
  c.expectPunct("}", "to close a provenance map");
  return {
    kind: "provenance",
    ...(tier !== undefined ? { tier } : {}),
    ...(author !== undefined ? { author } : {}),
    ...(origin !== undefined ? { origin } : {}),
    ...(replay !== undefined ? { replay } : {}),
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// The `because` pre-registration citation (kestrel-rtf)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Reassemble a content-hash digest from the adjacent tokens the lexer split it into. There is
 * no lexed hash token in Kestrel, so a hex digest lexes as a maximal run of adjacent word/number
 * tokens: a letter-leading digest (`af3c…`) is one word, but a **digit-leading** digest (`82f6…`)
 * splits into a `number` (`82`) then a `word` (`f6…`) — so we join by adjacency exactly like
 * {@link Cursor.readName} / {@link readCorpusToken}, stopping at the first gap or non-hex token.
 */
function readContentHash(c: Cursor): string {
  const first = c.peek();
  if (first === undefined || (first.type !== "word" && first.type !== "number")) {
    c.fail(citationBadHashMessage(describe(first)));
  }
  let text = first.text;
  let end = first.end;
  c.advance();
  for (;;) {
    const t = c.peek();
    if (t === undefined || t.col !== end) break; // a gap (space) ends the digest
    if (t.type !== "word" && t.type !== "number") break;
    text += t.text;
    end = t.end;
    c.advance();
  }
  return text;
}

/**
 * Parse a `because` citation body — the `BECAUSE` keyword is already consumed by the caller.
 * The fixed shape is `<algo>:<64 hex>` (only `sha256` today; {@link CITATION_ALGOS}). Fails
 * closed on a bad/absent hash and on an inline body — the clause carries a content hash ONLY
 * (the thesis prose lives platform-side).
 */
function parseBecause(c: Cursor): Citation {
  const algo = c.readName("a content-hash algorithm in a `because` citation (`sha256:<64 hex>`)");
  if (!CITATION_ALGOS.has(algo)) c.fail(citationBadHashMessage(`${algo}:…`));
  c.expectPunct(":", "between `sha256` and its digest in a `because` citation (`sha256:<64 hex>`)");
  const hash = readContentHash(c);
  if (!isCitationHash(hash)) c.fail(citationBadHashMessage(hash));
  // Content-hash ONLY: any trailing token (a quoted body, more prose) is the refused inline body.
  if (!c.atEnd()) c.fail(CITATION_INLINE_BODY_MESSAGE);
  return { kind: "citation", algo: algo as Citation["algo"], hash };
}

/** Merge an ambient module `USING` with a statement's explicit clause so the AST is fully
 * qualified (ARCHITECTURE §2): explicit sides win; unspecified sides inherit the ambient. */
function mergeUsing(ambient: Using | undefined, explicit: Using | undefined): Using | undefined {
  if (ambient === undefined && explicit === undefined) return undefined;
  const signal = explicit?.signal ?? ambient?.signal;
  const exec = explicit?.exec ?? ambient?.exec;
  return {
    kind: "using",
    ...(signal !== undefined ? { signal } : {}),
    ...(exec !== undefined ? { exec } : {}),
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// Statement headers
// ─────────────────────────────────────────────────────────────────────────────

function headerCursor(node: LineNode, keyword: string): Cursor {
  const c = new Cursor(node.tokens, node.line);
  c.expectWord(keyword, "to open the statement");
  return c;
}

function parseView(node: LineNode): ViewStatement {
  const c = headerCursor(node, "VIEW");
  const name = c.readName("a view name");
  let budget: number | undefined;
  if (c.optWord("budget")) budget = c.expectNumber("for a view token budget");
  c.expectEnd("the VIEW header");
  for (const child of node.children) {
    // Fail closed: a `because` citation may only pre-register a Plan or Wake, never a View
    // (never a fifth statement kind, ADR-0001) — refuse it before it reads as a pane.
    if (child.tokens[0]?.type === "word" && child.tokens[0].text === "BECAUSE") {
      throw new KestrelParseError(citationOnMessage("View"), child.line, child.col);
    }
  }
  const panes = node.children.map(parsePane);
  return {
    kind: "view",
    name,
    panes,
    ...(budget !== undefined ? { budget } : {}),
    ...withComments(commentLayer(node, node.children)),
  };
}

function parsePane(node: LineNode): PaneRef {
  const c = new Cursor(flatten(node), node.line);
  const name = c.readName("a pane name");
  const args: PaneArg[] = [];
  while (!c.atEnd()) {
    const t = c.peek()!;
    // A `:`/`{` here is the third dominant authoring mistake (dry-run-1, 8 escapes) — a pane written
    // in a key:value / JSON shape. Name the flat form. (Diagnostic only — still a hard reject.)
    if (t.type === "punct" && (t.text === ":" || t.text === "{" || t.text === ",")) {
      c.fail(
        `a pane is a NAME followed by plain space-separated arguments (e.g. \`chain fair realness\`, ` +
          `\`tape skyline 5m vwap\`) — not \`${t.text}\`. Drop any \`:\`/\`{…}\`/\`,\` and write one pane per ` +
          `indented line beneath the \`VIEW <name>\` header.`,
      );
    }
    // A SESSION / EXPIRY ORDINAL literal (Train 1B / ADR-0041 §1): the tight `d-<n>` / `e-<n>` shape —
    // the single-letter prefix word (`d`/`e`), an ADJACENT `-`, and an ADJACENT numeral, with zero
    // catalog knowledge (the `wf` rung never picks a semantic sort). Recognised HERE (before the
    // generic ident fallback, which `readName` would otherwise glue into an ident "d-0"); the numeral
    // is carried verbatim and the catalog slot refines it (SessionOrdinal / ExpiryOrdinal) + rejects a
    // non-integer at the `mat` rung, exactly like Count. A bare `d`/`e` (no `-<n>`) is a normal ident;
    // `d - 0` (spaced) is not the tight literal and falls through (a `-` in arg position then refuses).
    if (t.type === "word" && (t.text === ORDINAL_PREFIX || t.text === EXPIRY_PREFIX)) {
      const dash = c.peek(1);
      const numTok = c.peek(2);
      if (
        dash !== undefined && dash.type === "punct" && dash.text === "-" && dash.col === t.end &&
        numTok !== undefined && numTok.type === "number" && numTok.col === dash.end
      ) {
        c.advance(); // the prefix word
        c.advance(); // the `-`
        const value = c.expectNumber(`for a ${t.text === ORDINAL_PREFIX ? "session ordinal (`d-<n>`)" : "expiry ordinal (`e-<n>`)"}`);
        if (t.text === ORDINAL_PREFIX) args.push({ kind: "arg-ordinal", ordinal: value });
        else args.push({ kind: "arg-expiry", expiry: value });
        continue;
      }
    }
    // A numeral's syntactic supersort is decided by what FOLLOWS it, with zero catalog knowledge
    // (ADR-0041 §1): a numeral + a time unit is a WINDOW (`5m`); a bare numeral (no unit) is a COUNT
    // (`chain 12`). The catalog slot later refines Count → its semantic sort at materialization.
    if (t.type === "number") {
      const value = c.expectNumber("for a pane numeral argument");
      const next = c.peek();
      if (next !== undefined && next.type === "word" && TIME_UNITS.has(next.text)) {
        const unit = readTimeUnit(c, "for a pane window argument");
        args.push({ kind: "arg-window", window: { kind: "window", value, unit } });
      } else {
        args.push({ kind: "arg-count", count: value });
      }
    } else args.push({ kind: "arg-ident", name: c.readName("a pane argument") });
  }
  return { kind: "pane", name, args };
}

function parseWake(node: LineNode): WakeStatement {
  const c = headerCursor(node, "WAKE");
  const name = c.readName("a wake name");
  c.expectEnd("the WAKE header");

  let when: Trigger | undefined;
  let because: Citation | undefined;
  let deliver: WakeStatement["deliver"];
  let priority: number | undefined;
  let coalesce: string | undefined;
  let budget: WakeBudget | undefined;
  let universe: WakeStatement["universe"];

  for (const child of node.children) {
    const cc = new Cursor(flatten(child), child.line);
    const kw = cc.readName("a WAKE clause (WHEN, BECAUSE, DELIVER, PRIORITY, COALESCE, BUDGET, or UNIVERSE)");
    switch (kw) {
      case "WHEN":
        when = parseTrigger(cc);
        break;
      case "BECAUSE":
        if (because !== undefined) cc.fail("a WAKE carries at most one `because` citation");
        because = parseBecause(cc);
        break;
      case "DELIVER": {
        const view = cc.readName("a view name after DELIVER");
        const mandatory = cc.optWord("MANDATORY");
        const keyframe = cc.optWord("KEYFRAME");
        deliver = { kind: "deliver", view, ...(mandatory ? { mandatory } : {}), ...(keyframe ? { keyframe } : {}) };
        break;
      }
      case "PRIORITY":
        priority = cc.expectNumber("for a wake priority");
        break;
      case "COALESCE":
        coalesce = cc.readName("a coalesce key");
        break;
      case "BUDGET":
        budget = parseWakeBudget(cc);
        break;
      case "UNIVERSE":
        universe = { kind: "universe", universe: cc.readName("a universe name") };
        break;
      default:
        cc.fail(`unknown WAKE clause \`${kw}\`; expected WHEN, BECAUSE, DELIVER, PRIORITY, COALESCE, BUDGET, or UNIVERSE`);
    }
    cc.expectEnd(`the ${kw} clause`);
  }

  if (when === undefined) throw new KestrelParseError("a WAKE requires a WHEN clause", node.line, node.col);
  return {
    kind: "wake",
    name,
    when,
    ...(because !== undefined ? { because } : {}),
    ...(deliver !== undefined ? { deliver } : {}),
    ...(priority !== undefined ? { priority } : {}),
    ...(coalesce !== undefined ? { coalesce } : {}),
    ...(budget !== undefined ? { budget } : {}),
    ...(universe !== undefined ? { universe } : {}),
    ...withComments(commentLayer(node, node.children)),
  };
}

function parseWakeBudget(c: Cursor): WakeBudget {
  let wakesPerDay: number | undefined;
  let tokensPerDay: number | undefined;
  for (;;) {
    const t = c.peek();
    if (t === undefined || t.type !== "number") break;
    const n = c.expectNumber("for a budget rate");
    const which = c.readName("a budget kind (`wakes` or `tokens`)");
    c.expectPunct("/", "in a budget rate (`wakes/day`)");
    c.expectWord("day", "in a budget rate (`wakes/day`)");
    if (which === "wakes") wakesPerDay = n;
    else if (which === "tokens") tokensPerDay = n;
    else c.fail(`unknown budget kind \`${which}\`; expected \`wakes\` or \`tokens\``);
  }
  if (wakesPerDay === undefined && tokensPerDay === undefined) {
    c.fail("BUDGET needs at least one of `N wakes/day` or `N tokens/day`");
  }
  return {
    kind: "wake-budget",
    ...(wakesPerDay !== undefined ? { wakesPerDay } : {}),
    ...(tokensPerDay !== undefined ? { tokensPerDay } : {}),
  };
}

function parseBudget(c: Cursor): Budget {
  // A sign is read here so a non-positive budget fails on the *value* (the honest
  // invariant) rather than on an "expected a number" surprise at the `-` token.
  let sign = 1;
  if (c.optPunct("-")) sign = -1;
  else if (c.optPunct("+")) sign = 1;
  const value = sign * c.expectNumber("for a risk budget");
  c.expectWord("R", "as the risk-fraction unit of a budget (e.g. `0.2R`)");
  // Budget positivity is a doctrine invariant (ARCHITECTURE §6.1), enforced on every surface
  // from ./validate.ts; here it fails closed on the *value* with position.
  if (!(value > 0)) c.fail(budgetMessage(value));
  return { kind: "budget", value, unit: "R" };
}

function parseTtl(c: Cursor): Ttl {
  if (c.optPunct("+")) return { kind: "ttl-rel", dur: readDuration(c, "for a relative ttl") };
  const t = c.peek();
  if (t !== undefined && t.type === "number" && c.peek(1)?.type === "punct" && c.peek(1)!.text === ":") {
    return { kind: "ttl-at", at: readTimeOfDay(c) };
  }
  c.fail(`expected a ttl: relative \`+30m\` or an absolute clock \`16:00\`, got ${describe(t)}`);
}

function parseRegimeGate(c: Cursor): RegimeGate {
  c.expectPunct("{", "to open a regime gate");
  const tags: RegimeTagBinding[] = [];
  if (!c.matchPunct("}")) {
    for (;;) {
      const scope = c.readName("a regime scope");
      c.expectPunct(":", "between a regime scope and its value");
      const value = c.readName("a regime tag value");
      tags.push({ scope, value });
      if (!c.optPunct(",")) break;
    }
  }
  c.expectPunct("}", "to close a regime gate");
  return { kind: "regime-gate", tags };
}

function parsePlan(node: LineNode, ambient: Using | undefined): PlanStatement {
  const c = headerCursor(node, "PLAN");
  const name = c.readName("a plan name");

  let budget: Budget | undefined;
  let ttl: Ttl | undefined;
  let regime: RegimeGate | undefined;
  let priority: number | undefined;
  let standing: Standing | undefined;
  let provenance: Provenance | undefined;

  while (!c.atEnd()) {
    const w = c.wordAt();
    switch (w) {
      case "budget":
        c.advance();
        budget = parseBudget(c);
        break;
      case "ttl":
        c.advance();
        ttl = parseTtl(c);
        break;
      case "regime":
        c.advance();
        regime = parseRegimeGate(c);
        break;
      case "priority":
        c.advance();
        priority = c.expectNumber("for a plan priority");
        break;
      case "standing": {
        c.advance();
        const s = c.readName("a standing (authored, armed, versioned, or superseded)");
        if (!STANDINGS.has(s)) c.fail(`unknown standing \`${s}\`; expected authored, armed, versioned, or superseded`);
        standing = s as Standing;
        break;
      }
      case "prov":
        c.advance();
        provenance = parseProvenanceBrace(c);
        break;
      case "atomic":
        c.fail(ATOMIC_MESSAGE);
        break;
      default:
        c.fail(
          `unexpected ${describe(c.peek())} in the PLAN header; expected budget, ttl, regime, ` +
            `priority, standing, prov, or atomic`,
        );
    }
  }

  let explicitUsing: Using | undefined;
  let when: Trigger | undefined;
  let because: Citation | undefined;
  const clauses: PlanClause[] = [];

  for (const child of node.children) {
    const cc = new Cursor(flatten(child), child.line);
    const w = cc.wordAt();
    if (w === "USING") {
      cc.expectWord("USING", "to open a USING clause");
      explicitUsing = parseUsingBody(cc);
      expectClauseEnd(cc, "the USING clause");
    } else if (w === "WHEN") {
      cc.expectWord("WHEN", "to open a WHEN clause");
      when = parseTrigger(cc);
      expectClauseEnd(cc, "the WHEN clause");
    } else if (w === "BECAUSE") {
      cc.expectWord("BECAUSE", "to open a BECAUSE citation clause");
      if (because !== undefined) cc.fail("a PLAN carries at most one `because` citation");
      because = parseBecause(cc);
      cc.expectEnd("the BECAUSE clause");
    } else {
      clauses.push(parsePlanClause(cc));
    }
  }

  const using = mergeUsing(ambient, explicitUsing);
  return {
    kind: "plan",
    name,
    clauses,
    ...(budget !== undefined ? { budget } : {}),
    ...(ttl !== undefined ? { ttl } : {}),
    ...(regime !== undefined ? { regime } : {}),
    ...(priority !== undefined ? { priority } : {}),
    ...(standing !== undefined ? { standing } : {}),
    ...(provenance !== undefined ? { provenance } : {}),
    ...(using !== undefined ? { using } : {}),
    ...(when !== undefined ? { when } : {}),
    ...(because !== undefined ? { because } : {}),
    ...withComments(commentLayer(node, node.children)),
  };
}

function parseGrade(node: LineNode): GradeStatement {
  const c = headerCursor(node, "GRADE");
  const what = c.readName("what to grade (plan, wake, view, pod, or tag)");
  if (!GRADE_WHATS.has(what)) c.fail(`cannot grade \`${what}\`; expected plan, wake, view, pod, or tag`);
  const name = c.readName("the name of the thing to grade");

  let over: GradeStatement["over"];
  if (c.optWord("OVER")) {
    const from = readCorpusToken(c, true);
    c.expectPunct("..", "in a corpus range (`from..to`)");
    const to = readCorpusToken(c, false);
    over = { kind: "corpus-range", from, to };
  }
  let fill: string | undefined;
  if (c.optWord("FILL")) fill = c.readName("a fill-model name after FILL");
  c.expectEnd("the GRADE header");

  const versus: Counterfactual[] = [];
  const by: GradeDimension[] = [];
  for (const child of node.children) {
    const cc = new Cursor(flatten(child), child.line);
    const kw = cc.readName("a GRADE clause (VS or BY)");
    if (kw === "VS") {
      while (!cc.atEnd()) {
        const w = cc.readName("a counterfactual (ungated, null, or bracket)");
        if (w === "ungated") versus.push({ kind: "cf-ungated" });
        else if (w === "null") versus.push({ kind: "cf-null" });
        else if (w === "bracket") versus.push({ kind: "cf-bracket" });
        else cc.fail(`unknown counterfactual \`${w}\`; expected ungated, null, or bracket`);
      }
    } else if (kw === "BY") {
      for (;;) {
        by.push(parseGradeDimension(cc));
        if (!cc.optPunct(",")) break;
      }
      cc.expectEnd("the BY clause");
    } else if (kw === "BECAUSE") {
      // Fail closed: a `because` citation may only pre-register a Plan or Wake, never a Grade
      // (never a fifth statement kind, ADR-0001).
      cc.fail(citationOnMessage("Grade"));
    } else {
      cc.fail(`unknown GRADE clause \`${kw}\`; expected VS or BY`);
    }
  }

  return {
    kind: "grade",
    subject: { kind: "grade-subject", what: what as GradeSubject["what"], name },
    versus,
    by,
    ...(over !== undefined ? { over } : {}),
    ...(fill !== undefined ? { fill } : {}),
    ...withComments(commentLayer(node, node.children)),
  };
}

function readCorpusToken(c: Cursor, untilRange: boolean): string {
  let s = "";
  for (;;) {
    const t = c.peek();
    if (t === undefined) break;
    if (untilRange && t.type === "punct" && t.text === "..") break;
    if (!untilRange && t.type === "word" && t.text === "FILL") break;
    s += c.advance().text;
  }
  if (s === "") c.fail("expected a corpus date token (e.g. 2025-01)");
  return s;
}

function parseGradeDimension(c: Cursor): GradeDimension {
  const w = c.wordAt();
  const next = c.peek(1);
  const bareKeyword = next === undefined || (next.type === "punct" && next.text === ",");
  if (bareKeyword && w === "vehicle") {
    c.advance();
    return { kind: "dim-vehicle" };
  }
  if (bareKeyword && w === "name") {
    c.advance();
    return { kind: "dim-name" };
  }
  if (bareKeyword && w === "lineage") {
    c.advance();
    return { kind: "dim-lineage" };
  }
  return { kind: "dim-series", series: parseSeries(c) };
}

function parseRiskLine(node: LineNode): RiskLine {
  const c = new Cursor(flatten(node), node.line);
  c.expectWord("RISK", "to open a risk line");
  const metric = c.readName("a risk metric");
  const threshold = readQuantity(c, "for a risk threshold");
  c.expectPunct("->", "between a risk threshold and its action (`-> halt`)");
  const action = c.readName("a risk action");
  c.expectEnd("the RISK line");
  return { kind: "risk", metric, threshold, action };
}

function parseCoverage(c: Cursor): Coverage {
  const instruments: Instrument[] = [];
  while (c.wordAt() !== undefined && c.wordAt() !== "thesis") {
    instruments.push(parseInstrument(c, { expiry: false, stop: new Set() }));
  }
  if (instruments.length === 0) c.fail("coverage needs at least one instrument before `thesis`");
  c.expectWord("thesis", "after coverage instruments (instruments alone are not coverage)");
  const thesis = c.expectString("for a coverage thesis");
  return { kind: "coverage", instruments, thesis };
}

function parseArbitration(c: Cursor): Arbitration {
  const policy = c.readName("an arbitration policy");
  let concurrency: number | undefined;
  if (c.optWord("concurrency")) concurrency = c.expectNumber("for an arbitration concurrency");
  return { kind: "arbitration", policy, ...(concurrency !== undefined ? { concurrency } : {}) };
}

function parseBook(node: LineNode): BookStatement {
  const c = headerCursor(node, "BOOK");
  const name = c.readName("a book name");
  let budget: Budget | undefined;
  let coverage: Coverage | undefined;
  let arbitration: Arbitration | undefined;
  for (;;) {
    const w = c.wordAt();
    if (w === "budget") {
      c.advance();
      budget = parseBudget(c);
    } else if (w === "coverage") {
      c.advance();
      coverage = parseCoverage(c);
    } else if (w === "arbitration") {
      c.advance();
      arbitration = parseArbitration(c);
    } else break;
  }
  c.expectEnd("the BOOK header");
  const risk = node.children.map((child) => {
    if (child.tokens[0]?.text !== "RISK") {
      throw new KestrelParseError(
        `a BOOK contains only RISK lines; got ${describe(child.tokens[0])}`,
        child.line,
        child.col,
      );
    }
    return parseRiskLine(child);
  });
  return {
    kind: "book",
    name,
    ...(budget !== undefined ? { budget } : {}),
    ...(coverage !== undefined ? { coverage } : {}),
    ...(arbitration !== undefined ? { arbitration } : {}),
    ...(risk.length > 0 ? { risk } : {}),
    ...withComments(commentLayer(node, node.children)),
  };
}

function parsePod(node: LineNode, ambient: Using | undefined): PodStatement {
  const c = headerCursor(node, "POD");
  const name = c.readName("a pod name");
  c.expectEnd("the POD header");
  const risk: RiskLine[] = [];
  const riskNodes: LineNode[] = [];
  const children: (PodStatement | BookStatement)[] = [];
  for (const child of node.children) {
    const kw = child.tokens[0]?.text;
    if (kw === "RISK") {
      risk.push(parseRiskLine(child));
      riskNodes.push(child);
    } else if (kw === "BOOK") children.push(parseBook(child));
    else if (kw === "POD") children.push(parsePod(child, ambient));
    else {
      throw new KestrelParseError(
        `unknown pod member ${describe(child.tokens[0])}; expected RISK, BOOK, or POD`,
        child.line,
        child.col,
      );
    }
  }
  // Only RISK lines are the pod's OWN interior lines (ordinals 1..r, matching the printer,
  // which emits RISK before children); child BOOK/POD blocks own their own comment trivia.
  return { kind: "pod", name, risk, children, ...withComments(commentLayer(node, riskNodes)) };
}

// ─────────────────────────────────────────────────────────────────────────────
// Module assembly + public entry points
// ─────────────────────────────────────────────────────────────────────────────

const STATEMENT_KEYWORDS: ReadonlySet<string> = new Set(["VIEW", "WAKE", "PLAN", "GRADE", "POD", "BOOK"]);

function parseStatement(node: LineNode, ambient: Using | undefined): Statement {
  const kw = node.tokens[0]?.text;
  switch (kw) {
    case "VIEW":
      return parseView(node);
    case "WAKE":
      return parseWake(node);
    case "PLAN":
      return parsePlan(node, ambient);
    case "GRADE":
      return parseGrade(node);
    case "POD":
      return parsePod(node, ambient);
    case "BOOK":
      return parseBook(node);
    default:
      // A PLAN clause hoisted to the top level (a bare `WHEN …` / `DO …` statement) is the second
      // dominant live-model authoring mistake (dry-run-1, 10 escapes) — usually a lost indent.
      // Name the fix, not just the closed set. (Diagnostic only — still a hard reject.)
      if (kw !== undefined && PLAN_CLAUSE_KEYWORD_SET.has(kw)) {
        throw new KestrelParseError(
          `\`${kw}\` is a PLAN clause, not a top-level statement — it must be indented two spaces ` +
            `beneath a \`PLAN <name>\` header line, alongside its sibling clauses (WHEN, DO, TP, EXIT). ` +
            `A top-level statement is one of VIEW, WAKE, PLAN, GRADE, POD, or BOOK.`,
          node.line,
          node.col,
        );
      }
      throw new KestrelParseError(
        `unknown statement ${describe(node.tokens[0])}; expected VIEW, WAKE, PLAN, GRADE, POD, or BOOK`,
        node.line,
        node.col,
      );
  }
}

function parseImport(node: LineNode): ImportDecl {
  const c = headerCursor(node, "IMPORT");
  c.expectPunct("{", "to open an import list");
  const names: string[] = [];
  if (!c.matchPunct("}")) {
    for (;;) {
      names.push(c.readName("an imported statement name"));
      if (!c.optPunct(",")) break;
    }
  }
  c.expectPunct("}", "to close an import list");
  c.expectWord("FROM", "before the module path");
  const from = c.expectString("for a module path");
  c.expectEnd("the IMPORT declaration");
  return { kind: "import", names, from };
}

/** Build the module's own comment sidecar: its directive lines carry 0-based ordinals in
 * canonical emit order (IMPORT…, PROVENANCE, USING — a module has no header line of its own),
 * plus the document tail. Comments before/on a statement live on that statement, not here. */
function moduleComments(directiveNodes: readonly LineNode[], tail: readonly string[]): CommentLayer | undefined {
  const lines: LineComment[] = [];
  directiveNodes.forEach((n, i) => {
    const lc = lineCommentOf(n, i);
    if (lc !== undefined) lines.push(lc);
  });
  const hasTail = tail.length > 0;
  if (lines.length === 0 && !hasTail) return undefined;
  return {
    ...(lines.length > 0 ? { lines } : {}),
    ...(hasTail ? { tail } : {}),
  };
}

function documentFromRoots(roots: readonly LineNode[], tail: readonly string[]): KestrelNode {
  const imports: ImportDecl[] = [];
  const importNodes: LineNode[] = [];
  let moduleUsing: Using | undefined;
  let provenanceNode: LineNode | undefined;
  let provenance: Provenance | undefined;
  let usingNode: LineNode | undefined;
  const statementNodes: LineNode[] = [];

  for (const root of roots) {
    const kw = root.tokens[0]?.text;
    if (kw === "IMPORT") {
      imports.push(parseImport(root));
      importNodes.push(root);
    } else if (kw === "PROVENANCE") {
      const c = headerCursor(root, "PROVENANCE");
      provenance = parseProvenanceBrace(c);
      c.expectEnd("the PROVENANCE directive");
      provenanceNode = root;
    } else if (kw === "USING" && !STATEMENT_KEYWORDS.has(kw)) {
      const c = headerCursor(root, "USING");
      moduleUsing = parseUsingBody(c);
      c.expectEnd("the module USING directive");
      usingNode = root;
    } else {
      statementNodes.push(root);
    }
  }

  const statements = statementNodes.map((n) => parseStatement(n, moduleUsing));

  // Provenance ceiling (ARCHITECTURE §6.6) — a doctrine invariant homed in ./validate.ts and
  // shared with the builder/printer; here it throws positionally at the offending statement.
  if (provenance !== undefined && provenance.tier !== undefined) {
    const modRank = PROV_RANK[provenance.tier];
    statements.forEach((s, i) => {
      const bad = elevatesProvenance(s, modRank);
      if (bad !== undefined) {
        const node = statementNodes[i]!;
        throw new KestrelParseError(provenanceMessage(bad, provenance.tier!), node.line, node.col);
      }
    });
  }

  // Directive lines in canonical emit order (imports, then provenance, then using) — their
  // ordinals must match printModule's header-line order for byte-stable comment placement.
  const directiveNodes: LineNode[] = [
    ...importNodes,
    ...(provenanceNode !== undefined ? [provenanceNode] : []),
    ...(usingNode !== undefined ? [usingNode] : []),
  ];
  const comments = moduleComments(directiveNodes, tail);

  const isModule =
    imports.length > 0 ||
    moduleUsing !== undefined ||
    provenance !== undefined ||
    statements.length !== 1 ||
    comments !== undefined; // a document tail (incl. a comment-only doc) forces the module form
  if (!isModule) return statements[0]!;

  return {
    kind: "module",
    imports,
    statements,
    ...(moduleUsing !== undefined ? { using: moduleUsing } : {}),
    ...(provenance !== undefined ? { provenance } : {}),
    ...withComments(comments),
  } satisfies Module;
}

/**
 * Parse a Kestrel document into the typed object model. Returns a bare {@link Statement}
 * when the text is a single statement with no module-level directives (so `parse(print(x))`
 * round-trips a bare statement); otherwise a {@link Module}. Fails closed: any escape is a
 * {@link KestrelParseError} carrying `line`/`col`.
 */
export function parse(src: string): KestrelNode {
  const { roots, tail } = lex(src);
  if (roots.length === 0 && tail.length === 0) {
    throw new KestrelParseError("empty document: expected at least one statement", 1, 1);
  }
  for (const root of roots) {
    if (root.indent !== 0) {
      throw new KestrelParseError(
        "unexpected indentation: top-level statements start at column 0",
        root.line,
        root.col,
      );
    }
  }
  // A comment-only document (no statements, only own-line comments) is a valid module carrying
  // just its tail comments — the authored "why" survives even with nothing to execute (ADR-0033).
  return documentFromRoots(roots, tail);
}

const FENCE = /```kestrel[^\n]*\n([\s\S]*?)\n?```/g;

/**
 * Extract every ```` ```kestrel ```` fenced block from Markdown and parse each identically
 * to a `.kestrel` file (ADR-0001). Returns one {@link KestrelNode} per block, in order.
 */
export function parseMarkdown(src: string): KestrelNode[] {
  const out: KestrelNode[] = [];
  for (const m of src.matchAll(FENCE)) out.push(parse(m[1]!));
  return out;
}
