/**
 * # engine/validate — the arm-level validation surface for consumers with no live core (kestrel-bc38.2)
 *
 * `validateArm(document, ctx)` runs BOTH gates a live open runs — the grammar (`parse`, ADR-0001) AND
 * the engine's arm-time integrity ({@link PlanEngine.armDocument}: the kestrel-mte unknown-series
 * refusal, envelope construction) — over an authored document WITHOUT running a session (no bus, no
 * tick loop, no fill engine, nothing graded). It returns tiered diagnostics instead of throwing, so a
 * caller can preview an authoring before committing it.
 *
 * ## Why one surface (the parse≠arm hole, kestrel-7tbi / p48s)
 * Parse-green is NOT arm-green: case is a VOCABULARY fact, not a grammar fact, so a document that
 * parses cleanly can still name a series that resolves UNKNOWN forever (`VELOCITY` for the windowed
 * metric `velocity`) — it arms then silently never fires. Before this surface every consumer hand-rolled
 * its own preview (`fixedPlanAgent`'s construct-time `parse`; the docs arm-gate; the hosted `/validate`
 * route, which historically only PARSED — the 422≠run gap). Routing those through ONE function makes the
 * diagnostic BYTE-IDENTICAL whether it reaches an agent as a 422 or a human as a failed run: the
 * arm-refusal message a consumer shows here is the very message {@link PlanEngine.armDocument} raises on
 * the graded path (returned as a coded `unknown-series` refusal, OSS-ADR-0045), because this function
 * drives that exact path.
 *
 * ## What this surface does NOT cover — the fresh-engine horizon (do not "migrate" day.ts here)
 * This validates a document against an EMPTY engine, so it sees only the refusals that are knowable
 * from the document + context ALONE. It CANNOT see any guard whose input is the LIVE standing book —
 * above all the F4 same-name collision guard, which rejects a revision re-declaring a name still owned
 * by a live plan (`PlanEngine.armDocument`, RUNTIME §8). A fresh engine owns no plans, so F4 is
 * structurally unreachable from here, and two same-name plans in ONE document report `armed: true`
 * (F4 fires on a name colliding with a PRIOR record, not on an intra-document duplicate).
 * Consequently `day.ts` keeps its own preview through the live core (`previewSupersede` →
 * {@link PlanEngine.supersedeArmRejection}): routing it through `validateArm` would SILENTLY DROP F4
 * and let a doomed revision tear down the standing book (the p48s day-swallow bug). This function is
 * the shared preview for consumers WITHOUT a live core, not a replacement for a live-core preview.
 *
 * Pure and deterministic (no clock, no RNG, no IO): a fresh throwaway engine over a no-op Gate/Bus and
 * a stub series provider — arm reads the phonebook, never a live quote — so the same document + same
 * context yields the same diagnostics. Fail-closed: a document only reports `armed` when it would
 * ACTUALLY arm (never merely parse), and an under-specified context is REFUSED, never defaulted
 * through (see {@link ArmContext.instruments}).
 */

import { KestrelParseError, parse, type KestrelNode, type Module } from "../lang/index.ts";
import { CanonicalSeriesProvider, CanonicalState, FakeOrgFacts, type SeriesRegistry } from "../series/index.ts";
import { PlanEngine, type BusSink, type Gate, type InstrumentSpec, type OrderIntent } from "./plans.ts";

/** Which gate a diagnostic came from — the CALLER's context (`context`), the grammar (`parse`), or the
 * engine's arm-time integrity (`armDocument`). The three tiers are checked in that order (an
 * under-specified context is never validated; a document that does not parse is never armed), so a
 * result carries a diagnostic from at most one tier. */
export type ArmDiagnosticTier = "context" | "parse" | "arm";

/** One tiered validation diagnostic. For `parse` and `arm`, `message` is the diagnostic EXACTLY as the
 * corresponding live path surfaces it — the `KestrelParseError` message for `parse`, the thrown
 * arm-refusal message verbatim for `arm` — so a consumer relays it unmodified (the
 * byte-identical-across-422-and-run contract). A `context` message has NO live-path counterpart: it
 * reports a defect in the CALLER's request, not in the authored document, and is worded by this module
 * (which is why it is a distinct tier rather than an `arm` diagnostic the byte-identity contract would
 * then be lying about). */
export interface ArmDiagnostic {
  readonly tier: ArmDiagnosticTier;
  readonly message: string;
}

/** The session facts arm-time validation needs — the same inputs {@link PlanEngine} takes to arm,
 * minus everything only a RUNNING session supplies (gate, bus, live series). A caller derives
 * `instruments` from the session META ({@link import("../session/sim.ts").resolveSpecs}); `registry`
 * defaults to the shared phonebook when omitted. */
export interface ArmContext {
  /** The tape's COMPLETE regime-scope vocabulary, when the caller knows it (kestrel-hk9u/ocyf) — every
   * scope any REGIME event on the target tape writes (`tapeRegimeScopesOf(events)`, src/session/sim.ts).
   * A regime gate whose scope is NOT in this set can never arm on that tape, so the arm preview surfaces
   * the DEFINITIVE dead-on-arrival notice a real batch run raises — byte-identical (validation=execution).
   *
   * DEFAULT when omitted: the EMPTY vocabulary (`[]`) — the closed-world verdict for an arm context that
   * names no tape (`--instruments`, a bare `--arm`, an agent's construct-time check). This is honest, not
   * a hedge: the context the caller handed over carries no regime stream, so a regime-gated plan WILL
   * stay `authored (blocked)` on it. It is a NON-FATAL warning (the document still arms), so a caller who
   * intends to run on a regime-carrying tape is informed, not blocked — the remedy is to pass that tape's
   * scopes (or `--bus <tape>`, which derives them). Unlike a live session's unknown future, a validation
   * preview IS a closed world (there is no acting agent and no later REGIME write to leak), so the
   * definitive grade is legitimate here — the lookahead-oracle firewall that keeps the interactive path
   * conditional (see the header of tests/simulate.regime-notice.test.ts) does not apply to a static preview. */
  readonly tapeRegimeScopes?: readonly string[];
  /** The session's instruments (exec/signal specs), NON-EMPTY. Arm reads their contract facts
   * (multiplier, strike step), never a live quote.
   *
   * An empty list is REFUSED with a `context`-tier diagnostic, never validated (fail-closed). A driver
   * asserts this same precondition — `specs.find(role === "signal") ?? specs[0]!` (`src/session/sim.ts`)
   * — so with no instruments there is no signal symbol to anchor canonical state, and the only way to
   * proceed would be to invent one (an empty-symbol stub). That stub would answer the caller's actual
   * question ("would this document arm in MY session?") with a verdict from a session that does not
   * exist: option plans that a real exec spec's `multiplier` would reject can sail through as
   * `armed: true`. A permissive verdict on an under-specified request is exactly the silent default
   * this repo forbids — so the request is refused instead. */
  readonly instruments: readonly InstrumentSpec[];
  /** The series phonebook the market-vs-org routing dials (CONTEXT: Registry). Omit for the shared
   * default — the same registry a live engine arms against. */
  readonly registry?: SeriesRegistry;
}

/** The outcome of {@link validateArm}: `armed` is true iff the document passed BOTH gates (it would
 * actually arm, not merely parse); `diagnostics` carries the tiered refusal(s) otherwise; `warnings`
 * carries NON-FATAL arm-time notices (kestrel-hk9u) — a document can be `armed: true` and STILL carry
 * a warning, when it arms clean but a plan can never FIRE on the arming context (a regime gate whose
 * scope this tape never writes). `warnings` is always present (empty when there are none), so a caller
 * never has to guard an optional field; it never flips `armed`. */
export interface ArmValidation {
  readonly armed: boolean;
  readonly diagnostics: readonly ArmDiagnostic[];
  readonly warnings: readonly ArmDiagnostic[];
}

/** Normalize a parsed node into a module (a bare statement becomes a one-statement module), matching
 * the driver's own `toModule` (`src/session/sim.ts`) so this validates exactly what the runtime arms. */
function toModule(node: KestrelNode): Module {
  return node.kind === "module" ? node : { kind: "module", imports: [], statements: [node] };
}

/** A no-op Gate — arm submits no orders (it only registers plans + previews their integrity), so this
 * is never called; it exists to satisfy the engine's construction contract. */
const ARM_GATE: Gate = {
  submit(intent: OrderIntent): string {
    return intent.ref;
  },
  cancel(): void {},
};

/** A no-op Bus — `armDocument` ALSO emits its arm-time regime-block notice on the bus (the run-time
 * plan-lifecycle trace); that emit is swallowed here because a preview has no live frame. The SAME
 * notice is returned as DATA on {@link ArmReport.notices} (kestrel-hk9u), which is how this bus-less
 * surface still recovers it — see the `report.notices` handling below. */
const ARM_BUS: BusSink = { emit(): void {} };

/**
 * Validate an authored document through BOTH the grammar and the engine's arm-time integrity WITHOUT
 * running a session, returning tiered diagnostics ({@link ArmValidation}). The shared preview for
 * consumers that have no live core — the docs arm-gate, the hosted `/validate` route, an agent's
 * construct-time check — so the diagnostic is byte-identical whether an agent sees a 422 or a human
 * sees a failed run. NOT a replacement for a live-core preview: it cannot see the F4 same-name
 * collision guard, so `day.ts` keeps `previewSupersede` (see the module header).
 *
 * Order (fail-closed, first refusal wins): (1) `context` — an under-specified {@link ArmContext} (no
 * instruments) is refused outright, because a verdict would have to come from an invented session;
 * (2) `parse` — a grammar escape returns a `parse`-tier diagnostic and stops; (3) `armDocument` on a
 * fresh throwaway engine built from `ctx` — the mte unknown-series refusal and envelope construction —
 * where any throw returns an `arm`-tier diagnostic carrying the thrown message verbatim. A clean pass
 * through all three returns `{ armed: true, diagnostics: [], warnings }` — `warnings` carries any
 * non-fatal arm notice (a regime gate whose scope the context never writes, kestrel-hk9u) and never
 * flips `armed`.
 */
export function validateArm(document: string, ctx: ArmContext): ArmValidation {
  // (1) The caller's context, BEFORE the document: with no instruments there is no session to validate
  // against, and arming a throwaway engine on an invented empty-symbol stub would hand back a
  // permissive `armed: true` for a session that does not exist. Refuse (fail-closed, AGENTS.md: never
  // a silent default) — the caller must supply the specs its session actually resolved.
  if (ctx.instruments.length === 0) {
    return {
      armed: false,
      warnings: [],
      diagnostics: [
        {
          tier: "context",
          message:
            "validateArm: no instruments in the arm context — arm reads instrument contract facts " +
            "(multiplier, strike step), so a document cannot be validated against an empty session. " +
            "Pass the session's resolved specs (`resolveSpecs(meta)`, src/session/sim.ts). Refused " +
            "rather than armed against an invented instrument (fail-closed, RUNTIME §8).",
        },
      ],
    };
  }

  let mod: Module;
  try {
    mod = toModule(parse(document));
  } catch (err) {
    // A grammar escape is a parse-tier refusal — the exact `KestrelParseError` message a live open
    // would raise (ADR-0001). Any non-parse throw is re-raised (a real bug, never a silent default).
    if (err instanceof KestrelParseError)
      return { armed: false, warnings: [], diagnostics: [{ tier: "parse", message: err.message }] };
    throw err;
  }

  // (3) The REAL arm path (kestrel-7tbi/p48s): a fresh engine over a no-op gate/bus + a stub series
  // provider (arm reads the phonebook, not a live quote). `armDocument` surfaces the SAME message the
  // graded path raises (a coded `unknown-series` refusal on the report), so the `arm`-tier diagnostic
  // is byte-identical to a run's refusal.
  // The signal pick mirrors a driver's exactly (`src/session/sim.ts`); the `!` is sound because the
  // empty context was refused above — no invented fallback symbol.
  const signal = ctx.instruments.find((s) => s.role === "signal") ?? ctx.instruments[0]!;
  const provider = new CanonicalSeriesProvider(new CanonicalState({ instrument: signal.symbol }), new FakeOrgFacts());
  const engine = new PlanEngine({
    instruments: [...ctx.instruments],
    seriesProvider: provider,
    gate: ARM_GATE,
    busWriter: ARM_BUS,
    rUsd: 1000,
    now: 0,
    // The closed-world regime vocabulary of the arm context (kestrel-hk9u): the caller's `tapeRegimeScopes`
    // when known (`--bus` derives them from the tape's META), else the EMPTY set — a preview names no live
    // future, so a regime gate on a scope the context does not carry gets the DEFINITIVE dead-on-arrival
    // notice a real batch run raises (byte-identical), rather than being silently swallowed as "arms clean".
    tapeRegimeScopes: ctx.tapeRegimeScopes ?? [],
    ...(ctx.registry !== undefined ? { registry: ctx.registry } : {}),
  });
  let report;
  try {
    report = engine.armDocument(mod);
  } catch (err) {
    // The one arm THROW — `plan-name-in-use` (F4), an `ArmError` carrying its `.code` — is
    // structurally unreachable on a FRESH engine (it owns no prior plan to collide with), but catch
    // defensively and surface it as an arm-tier diagnostic carrying the thrown message verbatim.
    return { armed: false, warnings: [], diagnostics: [{ tier: "arm", message: err instanceof Error ? err.message : String(err) }] };
  }
  // The non-fatal arm NOTICES are DATA on the report (kestrel-hk9u) — a regime gate whose scope the
  // arm context never writes. Each surfaces as an `arm`-tier WARNING carrying the notice's `message`
  // VERBATIM — byte-identical to the plan-lifecycle reason a real batch run emits (validation=execution),
  // because both come from the SAME string built in `armDocument`. A warning never flips `armed`: the
  // document IS integrity-clean, it just can never fire on this tape.
  const warnings: readonly ArmDiagnostic[] = (report.notices ?? []).map((n) => ({ tier: "arm" as const, message: n.message }));
  // The per-statement `unknown-series` refusals are DATA on the report (OSS-ADR-0045), not a throw.
  // Each surfaces as an arm-tier diagnostic carrying the refusal's `message` VERBATIM — byte-identical
  // to the message the graded path would raise (the 422≡run contract), because both come from the same
  // `#unknownSeriesRejection`.
  if (report.refusals.length > 0) {
    return { armed: false, warnings, diagnostics: report.refusals.map((r) => ({ tier: "arm" as const, message: r.message })) };
  }
  return { armed: true, diagnostics: [], warnings };
}
