/**
 * # validate/unknown-series — the ONE arm-time unknown-series case-variant refusal (kestrel-h0ax)
 *
 * The single, LIGHT home of the arm-time market-vocabulary integrity check (kestrel-mte / OSS-ADR-0045):
 * a trigger operand that names a CASE-VARIANT of a registered market fact (`HOD` for the scalar `hod`,
 * `VELOCITY` for the windowed `velocity`) resolves UNKNOWN forever — the plan would arm but never fire.
 * Because {@link SeriesRegistry.lookup} is EXACT-MATCH (OSS-ADR-0045; the 0.4.5 case-insensitive
 * forgiveness is reverted), the case-variant name has NO phonebook record, so this refuses it AS DATA at
 * arm — naming the offending token AND the intended market series — its healthy siblings still arm.
 *
 * ## Why this file exists (the mei bug: parse ≠ arm)
 * This check used to live in exactly ONE place — `PlanEngine.#unknownSeriesRejection` in
 * `src/engine/plans.ts` — reachable only by constructing the HEAVY {@link PlanEngine} (which drags the
 * fill engine, the gate, the bus). A parse-only `/validate` never saw it, so `parse(HOD…)` answered
 * `{valid:true}` and the same document then `422`'d at `/sim` (platform bead kestrel-markets-mei). This
 * module lifts the check onto a LIGHT dependency set — the lang AST + the {@link SeriesRegistry}
 * phonebook, with NO lake/feed/fill/session/bus/native dep — so a consumer (the hosted `/validate`
 * route) can run the SAME semantic gate a real arm runs WITHOUT standing up a session.
 * `PlanEngine.#unknownSeriesRejection` now DELEGATES here, so there is ONE implementation and the
 * light-path verdict is byte-identical to the arm verdict (never a third drifting copy).
 *
 * Pure and deterministic: it reads only the phonebook (never a live value), so a currently-DARK
 * registered series is never flagged (fail-closed on a value is the runtime's concern, not a NAME's);
 * document-order traversal; no clock, no RNG, no IO.
 */

import { assertNever, type Module, type PlanStatement, type SeriesRef, type Trigger } from "../lang/index.ts";
import type { SeriesRegistry } from "../series/registry.ts";
import type { ArmRefusal } from "../protocol/index.ts";

/** Every bare {@link SeriesRef} operand a trigger references, collected recursively — the arm-time
 * input to the unknown-series integrity check (kestrel-mte). Mirrors `findMarkInTrigger`'s structural
 * walk: only `cmp`/`cross`/`event` carry operands; the compound/decorator nodes recurse into their
 * sub-triggers; `phase`/`time-window`/`fill` reference no named series. */
export function collectSeriesOperands(t: Trigger, out: SeriesRef[]): void {
  switch (t.kind) {
    case "cmp":
    case "cross":
      if (t.left.kind === "series") out.push(t.left);
      if (t.right.kind === "series") out.push(t.right);
      return;
    case "event": {
      const of = t.of;
      if (of?.kind === "series") out.push(of);
      return;
    }
    case "break-hold":
    case "within":
    case "until":
    case "at":
      collectSeriesOperands(t.inner, out);
      return;
    case "nth":
      collectSeriesOperands(t.event, out);
      return;
    case "not":
      collectSeriesOperands(t.term, out);
      return;
    case "and":
    case "or":
      for (const term of t.terms) collectSeriesOperands(term, out);
      return;
    case "phase":
    case "time-window":
    case "fill":
    // A 0DTE time-stop references the clock / hold-duration, never a named series — no operand to collect.
    case "held-stop":
    case "clock-stop":
      return;
    default:
      return assertNever(t, "collectSeriesOperands");
  }
}

/**
 * Arm-time unknown-series integrity (kestrel-mte / OSS-ADR-0045) as a PURE preview (no mutation, no
 * throw): the coded refusal a case-variant market-series trigger raises at arm — its DISPLAY `message`
 * and a concrete did-you-mean `repair` (the canonical lowercase series name) — or `null` when every
 * trigger operand resolves.
 *
 * The failure it catches: a trigger operand names a CASE-VARIANT of a registered market fact
 * (`VELOCITY` for the windowed `velocity`, `VWAP` for the scalar `vwap`). Because
 * {@link SeriesRegistry.lookup} is now EXACT-MATCH (OSS-ADR-0045; the 0.4.5 case-insensitive
 * forgiveness is reverted), that name has NO phonebook record, so the org/market split routes it to the
 * ORG side where it reads UNKNOWN forever: the plan would arm but never fire. The platform market
 * vocabulary is FROZEN and known at arm time (the shared phonebook), so this is STATICALLY knowable and
 * refused AS DATA.
 *
 * Scoped narrowly to a case-variant near-miss of a KNOWN market name (zero false positives — a genuine
 * org fact like `pnl` never case-matches a market name); a truly unknown bare name with no market
 * near-match stays an org path (org facts register implicitly / delegate to the book).
 */
export function unknownSeriesRejection(
  st: PlanStatement,
  registry: SeriesRegistry,
): { message: string; repair: string } | null {
  const triggers: Trigger[] = [];
  if (st.when !== undefined) triggers.push(st.when);
  for (const c of st.clauses) if ("when" in c && c.when !== undefined) triggers.push(c.when);
  const refs: SeriesRef[] = [];
  for (const t of triggers) collectSeriesOperands(t, refs);
  for (const ref of refs) {
    // Only a bare, un-selected single-segment name can be a market fact (windowed metrics carry
    // their window on the ref, not a segment selector — so they are checked too).
    if (ref.segments.length !== 1) continue;
    const seg = ref.segments[0];
    if (seg === undefined || seg.selector !== undefined) continue;
    const name = seg.name;
    if (registry.lookup(name)?.kind === "market") continue; // an exact market fact — fine
    const canon = registry.lookup(name.toLowerCase());
    if (canon?.kind === "market") {
      return {
        message:
          `arm: unknown series ${JSON.stringify(name)} in a trigger of plan ${JSON.stringify(st.name)} — ` +
          `no such series is registered. Did you mean the market series ${JSON.stringify(canon.name)}? ` +
          `Series names are case-sensitive and the platform market facts are lowercase — fix the name ` +
          `(fail-closed at arm, RUNTIME §8; a plan armed on an unknown series can never fire).`,
        repair: canon.name,
      };
    }
  }
  return null;
}

/**
 * The whole-document unknown-series gate: the coded {@link ArmRefusal}s for every plan statement whose
 * trigger names a case-variant of a known market fact, in document order. Byte-identical to the
 * `unknown-series` refusals `PlanEngine.armDocument` returns on its {@link import("../protocol/index.ts").ArmReport}
 * (both call {@link unknownSeriesRejection}). Non-plan statements carry no arm trigger and are skipped.
 */
export function collectUnknownSeriesRefusals(mod: Module, registry: SeriesRegistry): ArmRefusal[] {
  const refusals: ArmRefusal[] = [];
  for (const st of mod.statements) {
    if (st.kind !== "plan") continue;
    const rejection = unknownSeriesRejection(st, registry);
    if (rejection !== null) {
      refusals.push({ statement: st.name, code: "unknown-series", message: rejection.message, repair: rejection.repair });
    }
  }
  return refusals;
}
