/**
 * # series/author-lint — the unknown-series trigger operand, made legible (kestrel unknown-series-legible)
 *
 * A registry-aware, author-time semantic check that turns a SILENT never-fire into a LOUD,
 * repair-guiding rejection. The failure it catches: a trigger operand names a series that PARSES
 * as a well-formed {@link SeriesRef} but has **no phonebook record** because the authored name is a
 * case-clash of a known key (`VELOCITY` against the lowercase key `velocity`). Such an operand resolves UNKNOWN, so a
 * `crosses` never observes its transition and the plan arms then silently expires — 0 orders, and
 * the plan reads `armed`, not blocked. That VIOLATES the registry's own fail-closed charter ("the
 * phonebook never guesses … a logged reason … never a silent default", `registry.ts` header): here
 * the unknown-NAME is a *silent* never-fire the author cannot see.
 *
 * This check restores legibility at author-time (Option A / iq5-shaped): a document whose trigger
 * references an unknown series NAME that is a case-variant of a known market series is rejected
 * with a message that names the offending series, the known series it clashes with ("did you mean 'velocity'?"),
 * and the whole known vocabulary — a repairable `invalid` outcome the harness feeds back so the
 * agent self-corrects next wake (the gxz reason-feedback loop). It is pure (no clock/RNG/IO) and
 * off the graded path: a validation rejection is not graded-bus content.
 *
 * ## Case-variants are ALL flagged (OSS-ADR-0045) — the 0.4.5 forgiveness is reverted
 * `registry.lookup` is now EXACT-MATCH: a case-variant of ANY known market series — the seven scalars
 * (`VWAP`/`HOD`) OR a windowed metric (`VELOCITY`) — has no phonebook record, so this check's
 * `registry.lookup(name) !== undefined` gate does NOT skip it and `nearestKnown` finds the canonical
 * lowercase name it clashes with. A scalar case-variant is thus a flaggable author error exactly like
 * a windowed one — the same coded `unknown-series` refusal the arm-time guard raises. This reverts the
 * kestrel-7tbi case-insensitive forgiveness: forgiving a case-clash at resolution armed a plan on a
 * name the author never registered and hid a de-arm they could not see (a silent default, RUNTIME §8).
 *
 * ## What it flags — and, deliberately, what it does NOT
 * The hard part is telling a **market-fact case-clash** (`VELOCITY`) apart from a **legitimate org
 * fact** (`pnl`, `plan(chase).state`), a **state literal** (`done`), or a **regime tag** (`chop`) —
 * all are `registry.lookup(name) === undefined`, because org facts register implicitly / delegate to
 * the book (`series/provider`) and literals/tags resolve symbolically. Structure alone can't
 * distinguish them (`VELOCITY` and `pnl` are both bare single-segment identifiers). The discriminator
 * is **legibility-only / the phonebook never guesses**: we flag a bare, unregistered operand ONLY when
 * it is a case-INSENSITIVE-EXACT match to a known series (a pure case-clash). We do NOT guess at
 * arbitrary edit-distance typos — that arm false-positived on first-class operands (`done`, `chop`,
 * `gap`, `long`, …). So:
 *
 *   - `VELOCITY`      → flagged (case-clash of `velocity`, a windowed metric) — author error
 *   - `VWAP` / `HOD`  → flagged (a scalar case-clash of `vwap`/`hod`; exact-match, OSS-ADR-0045) — author error
 *   - `vwpa`          → NOT flagged (a genuine typo, but no case-exact known match; never guessed at)
 *   - `vwap`          → NOT flagged (it IS registered)
 *   - `pnl`           → NOT flagged (no case-exact market series; a real org fact routed to the book)
 *   - `plan(x).state` → NOT flagged (has a selector ⇒ never a market read)
 *   - `armed` / `done`/ `chop` → NOT flagged (a symbolic literal / regime tag, not a known series name)
 *
 * A series that IS in the phonebook but is currently **dark** (resolves UNKNOWN as a transient data
 * state) is never touched here — that stays silently-armed by design (fail-closed on a value, not a
 * name). We only ever read the phonebook, never a live value.
 */

import type {
  Module,
  Operand,
  PlanStatement,
  SeriesRef,
  Statement,
  Trigger,
  WakeStatement,
} from "../lang/ast.ts";
import type { SeriesRegistry } from "./registry.ts";

/** A repair-guiding diagnostic for a trigger operand naming a series with no phonebook record but a
 * near-match to a known market series (a case-clash or typo). Off the graded path. */
export interface UnknownSeriesDiagnostic {
  /** The offending series NAME exactly as authored (e.g. `"VWAP"`). */
  readonly name: string;
  /** The nearest known market series NAME (e.g. `"vwap"`) — the repair target. */
  readonly suggestion: string;
  /** The full repair-guiding message: the unknown name, the did-you-mean, and the known vocabulary. */
  readonly message: string;
}

/** A bare single-segment operand with no org selector — the only shape that can be a market-fact
 * read (a windowed metric like `velocity(1m)` still qualifies; a selector or a dotted path is an org
 * path and is never a market series). */
function bareName(ref: SeriesRef): string | undefined {
  if (ref.segments.length !== 1) return undefined;
  const seg = ref.segments[0];
  if (seg === undefined || seg.selector !== undefined) return undefined;
  return seg.name;
}

/** The known series NAME that `name` is a case-INSENSITIVE-EXACT match of (a pure case-clash like
 * `VWAP` against `vwap`), or undefined when there is no such match (⇒ treat as a legitimate org fact
 * / state literal / regime tag / genuinely novel name, NOT an author error). Legibility-only: the
 * phonebook never *guesses* at arbitrary typos — an edit-distance arm here false-positived on
 * first-class operands (`done`, `chop`, `gap`, `long`, …), so we surface ONLY the case-clash idiom.
 * Deterministic: at most one known name differs from `name` by case alone. */
function nearestKnown(name: string, known: readonly string[]): string | undefined {
  const lower = name.toLowerCase();
  return known.find((k) => k.toLowerCase() === lower);
}

/** The operands a Trigger reads, recursively across the algebra. Only leaf operands are yielded;
 * combinators (and/or/not/held/within/until/at/nth) are traversed into. */
function* triggerOperands(t: Trigger): Generator<Operand> {
  switch (t.kind) {
    case "cmp":
    case "cross":
      yield t.left;
      yield t.right;
      return;
    case "event":
      if (t.of !== undefined) yield t.of;
      return;
    case "break-hold":
    case "within":
    case "until":
    case "at":
      yield* triggerOperands(t.inner);
      return;
    case "nth":
      yield* triggerOperands(t.event);
      return;
    case "and":
    case "or":
      for (const term of t.terms) yield* triggerOperands(term);
      return;
    case "not":
      yield* triggerOperands(t.term);
      return;
    // phase / time-window / fill carry no series operand.
    default:
      return;
  }
}

/** Every WHEN/EXIT/INVALIDATE/CANCEL-IF/ARM/RELOAD trigger in a Plan, in document order. */
function* planTriggers(p: PlanStatement): Generator<Trigger> {
  if (p.when !== undefined) yield p.when;
  for (const c of p.clauses) {
    switch (c.kind) {
      case "exit":
      case "invalidate":
      case "cancel-if":
        yield c.when;
        break;
      case "arm":
      case "reload":
        if (c.when !== undefined) yield c.when;
        break;
      default:
        break; // do / also / tp carry no trigger
    }
  }
}

/** Every trigger a statement contributes (Plan clauses + the Wake WHEN); other statement kinds
 * carry no trigger operand relevant to the never-fire failure. */
function* statementTriggers(s: Statement): Generator<Trigger> {
  if (s.kind === "plan") {
    yield* planTriggers(s);
  } else if (s.kind === "wake") {
    yield (s as WakeStatement).when;
  }
}

function* documentTriggers(node: Module | Statement): Generator<Trigger> {
  if (node.kind === "module") {
    for (const s of node.statements) yield* statementTriggers(s);
  } else {
    yield* statementTriggers(node);
  }
}

/**
 * The FIRST unknown-series trigger operand in a parsed document that near-matches the known market
 * vocabulary — a repairable author error — or `undefined` when every trigger operand is either a
 * registered series, a legitimate org path, or a symbolic literal too far from any market series to
 * be a typo. Reads only the phonebook (never a live value), so a currently-dark registered series is
 * never flagged. Pure and deterministic (document-order traversal; deterministic suggestion).
 */
export function unknownSeriesDiagnostic(
  node: Module | Statement,
  registry: SeriesRegistry,
): UnknownSeriesDiagnostic | undefined {
  const known = registry.dump().map((r) => r.name);
  for (const trig of documentTriggers(node)) {
    for (const operand of triggerOperands(trig)) {
      if (operand.kind !== "series") continue;
      const name = bareName(operand);
      if (name === undefined) continue; // selector / dotted path ⇒ an org read, never a market series
      if (registry.lookup(name) !== undefined) continue; // a registered NAME (dark VALUE is not our concern)
      const suggestion = nearestKnown(name, known);
      if (suggestion === undefined) continue; // no near market series ⇒ a real org fact / literal
      const message =
        `WHEN references unknown series '${name}' — did you mean '${suggestion}'? ` +
        `Series names are case-sensitive and '${name}' has no phonebook record, so the trigger ` +
        `resolves UNKNOWN and the plan arms but never fires. Known market series: ${known.join(", ")}.`;
      return { name, suggestion, message };
    }
  }
  return undefined;
}
