/**
 * # series/trigger — the tri-state, causal trigger evaluator (RUNTIME §3)
 *
 * Evaluates a `Trigger` (from `src/lang`) to `true | false | UNKNOWN`. **Only a definite
 * `true` fires** (RUNTIME §3). One {@link TriggerEvaluator} instance belongs to one armed
 * statement instance and owns that instance's **per-node causal memory** — the edge/anchor
 * state that makes crosses, break-holds, withins, and nth-events causal rather than
 * level-only.
 *
 * ## The exact tri-state algebra (RUNTIME §3)
 * - **AND**: `false` wins; else `UNKNOWN` if any term is UNKNOWN; else `true`.
 * - **OR**: `true` wins; else `UNKNOWN` if any term is UNKNOWN; else `false`.
 * - **NOT**: `UNKNOWN` maps to `UNKNOWN`; otherwise it negates.
 * - Every operand UNKNOWN propagates — a comparison/cross over an UNKNOWN operand is
 *   UNKNOWN, never silently `false`.
 *
 * ## All subtrees evaluate every tick (causal-memory correctness)
 * AND/OR do **not** short-circuit their traversal: every child is evaluated in full each
 * tick *before* the tri-state is folded, so a `crosses`/`held`/`within`/`nth` buried under a
 * sibling that already decided the parent still advances its own edge memory. Short-circuit
 * would silently drop the transition sample that makes those predicates causal.
 *
 * ## Per-node causal memory (RUNTIME §3)
 * - `crosses above|below` fires only on an **observed transition** — it needs a prior sample
 *   strictly on the *other* side; never true on first sight. Straddling equality holds the
 *   last non-equal anchor (does not overwrite it). `touches above|below` is the at-or-touch
 *   variant: reaching the level (`>=`/`<=`) counts as the target side, so an exact touch fires.
 *   A `band` adds a **re-arm** gate: after a fire the cross is disarmed until the
 *   series clears back past `right ∓ band`, so an in-band wiggle cannot phantom-fire.
 * - `held Ns` anchors continuity: the inner must be *continuously* `true` for the duration;
 *   any tick where the inner is `false` **or UNKNOWN** resets the anchor (a flicker restarts
 *   the clock).
 * - `within Nm` anchors at the qualifying event: once the inner was `true`, the window is
 *   `true` until `N` elapses, then `false`.
 * - `nth` counts rising edges of its inner event and latches `true` once the count reaches
 *   the ordinal.
 *
 * Memory is keyed by a **structural path** (a fixed tree ⇒ a fixed key per node), so it is
 * both deterministic and serializable — {@link TriggerEvaluator.dumpState} returns it for the
 * determinism tests; {@link TriggerEvaluator.reset} clears it.
 *
 * ## Detector / calendar / fill inputs are provider hooks
 * `failed-break`-style structural events, `time HH:MM` windows, and fill-lifecycle events
 * are inputs from other modules; they resolve through OPTIONAL {@link SeriesProvider} hooks
 * and read UNKNOWN (fail-closed, absent-with-reason) when the provider does not supply them.
 * `phase` is answered directly from canonical state.
 */

import type { CompareOp, Operand, SeriesRef, Trigger } from "../lang/index.ts";
import { assertNever } from "../lang/index.ts";
import { UNKNOWN, durationMs, isUnknown, type Resolved, type TriState, type Unknown } from "./types.ts";
import type { SeriesProvider } from "./provider.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Per-node memory
// ─────────────────────────────────────────────────────────────────────────────

type CrossMem = {
  readonly kind: "cross";
  prevSide: "above" | "below" | null;
  /** Re-arm gate for a banded cross: a fire disarms until the series clears back
   * past `right ∓ band`. Always `true` for a bare (band-less) cross. */
  armed: boolean;
};
type HeldMem = { readonly kind: "held"; since: number | null };
type WithinMem = { readonly kind: "within"; lastTrue: number | null };
type NthMem = { readonly kind: "nth"; count: number; prevInner: boolean };

/** The causal memory a single stateful trigger node carries. */
export type NodeMem = CrossMem | HeldMem | WithinMem | NthMem;

/** A deterministic, serializable dump of the evaluator's memory: `[path, mem]` pairs sorted
 * by path. Two runs over the same bus + same armed document yield equal dumps. */
export type EvaluatorState = readonly (readonly [string, NodeMem])[];

const ROOT = "$";

/** The quantifier of a **quantified** operand (`children(any|all).<fact>`), or `null` when the
 * operand is not a quantified aggregate. Detected purely structurally (a leading `sel-quant`
 * selector, core AST) so the evaluator stays vocabulary-agnostic. */
function quantOf(op: Operand): "any" | "all" | null {
  if (op.kind !== "series" || op.window !== undefined) return null;
  const head = op.segments[0];
  return head?.selector?.kind === "sel-quant" ? head.selector.q : null;
}

// ─────────────────────────────────────────────────────────────────────────────
// The evaluator
// ─────────────────────────────────────────────────────────────────────────────

export class TriggerEvaluator {
  private readonly mem = new Map<string, NodeMem>();

  /** Evaluate `trigger` against `provider` at the injected `now` (RUNTIME §0). Returns
   * `true | false | UNKNOWN`; only `true` fires. Advances per-node causal memory. */
  evaluate(trigger: Trigger, provider: SeriesProvider, now: number): TriState {
    return this.evalNode(trigger, ROOT, provider, now);
  }

  /** Clear all per-node memory (determinism / re-arm). */
  reset(): void {
    this.mem.clear();
  }

  /** A deterministic dump of the causal memory (sorted, deep-copied). */
  dumpState(): EvaluatorState {
    return [...this.mem.entries()]
      .map(([path, m]) => [path, { ...m }] as const)
      .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
  }

  // ── the recursion ───────────────────────────────────────────────────────────

  private evalNode(node: Trigger, path: string, provider: SeriesProvider, now: number): TriState {
    switch (node.kind) {
      case "cmp": {
        // A quantified operand (`children(any|all).<fact>`) folds the comparator PER MEMBER (∃/∀),
        // not against a comparator-blind scalar — otherwise a `<`/`<=` guard inverts which member is
        // the deciding one (a fail-open on a natural PM safety guard).
        if (quantOf(node.left) !== null || quantOf(node.right) !== null) {
          return this.evalQuantifiedCompare(node.op, node.left, node.right, provider, now);
        }
        const { lv, rv } = this.resolvePair(node.left, node.right, provider, now);
        const lU = isUnknown(lv);
        const rU = isUnknown(rv);
        if (lU && rU) return UNKNOWN;
        if (lU || rU) {
          // eq/ne symbolic-literal fallback: a bare unresolved identifier compared against a
          // resolved value is taken as a string literal equal to its own name (this is how
          // `plan(x).state eq armed` resolves `armed`). Requires the counterpart resolved.
          if (node.op === "eq" || node.op === "ne") {
            const lFinal = lU ? this.literalOf(node.left) : lv;
            const rFinal = rU ? this.literalOf(node.right) : rv;
            if (!isUnknown(lFinal) && !isUnknown(rFinal)) {
              return node.op === "eq" ? lFinal === rFinal : lFinal !== rFinal;
            }
          }
          return UNKNOWN;
        }
        return this.compareResolved(node.op, lv, rv);
      }

      case "cross": {
        // A `crosses` is an edge with per-path memory; a quantified aggregate has no single edge to
        // remember, so a quantified operand in a cross is meaningless ⇒ fail closed (UNKNOWN).
        if (quantOf(node.left) !== null || quantOf(node.right) !== null) return UNKNOWN;
        const { lv, rv } = this.resolvePair(node.left, node.right, provider, now);
        if (isUnknown(lv) || isUnknown(rv)) return UNKNOWN; // cannot observe; memory untouched
        if (typeof lv !== "number" || typeof rv !== "number") return UNKNOWN;
        const mem = this.crossMem(path);
        const cur = lv > rv ? "above" : lv < rv ? "below" : "equal";

        // Re-arm gate: once a banded cross fires it is disarmed until the series
        // clears back past `right ∓ band` — so a lone spurious tick around the level cannot
        // phantom-fire. A bare (band-less) cross stays armed and behaves as before.
        if (node.band !== undefined && !mem.armed) {
          const width = node.band.value;
          const cleared = node.dir === "above" ? lv <= rv - width : lv >= rv + width;
          if (cleared) mem.armed = true;
        }

        // Map the position to a firing side. Strict `crosses` ignores an exact touch (it holds
        // the last non-equal anchor). At-or-touch `touches` counts reaching the level (`>=`/`<=`)
        // as the target side, so it fires the instant the series meets the level.
        const side: "above" | "below" | null =
          cur === "equal" ? (node.touch === true ? node.dir : null) : cur;
        if (side === null) return false; // strict straddle: hold the last non-equal anchor

        const prev = mem.prevSide;
        mem.prevSide = side;
        if (prev === null) return false; // never true on first sight
        // fire only on a genuine transition INTO the target side (prior other-side sample)
        if (!(prev !== side && side === node.dir)) return false;

        // Honor the re-arm band: suppress an in-band re-fire; consume the arm on a real fire.
        if (node.band !== undefined) {
          if (!mem.armed) return false;
          mem.armed = false;
        }
        return true;
      }

      case "break-hold": {
        const inner = this.evalNode(node.inner, `${path}.0`, provider, now); // always evaluate
        const mem = this.heldMem(path);
        const dur = durationMs(node.dur.value, node.dur.unit);
        if (inner === true) {
          if (mem.since === null) mem.since = now;
          return now - mem.since >= dur; // definite false until the duration elapses
        }
        mem.since = null; // false OR UNKNOWN breaks continuity — reset the anchor
        return inner === UNKNOWN ? UNKNOWN : false;
      }

      case "within": {
        const inner = this.evalNode(node.inner, `${path}.0`, provider, now); // always evaluate
        const mem = this.withinMem(path);
        const dur = durationMs(node.dur.value, node.dur.unit);
        if (inner === true) mem.lastTrue = now;
        if (mem.lastTrue !== null) return now - mem.lastTrue <= dur;
        return inner === UNKNOWN ? UNKNOWN : false;
      }

      case "until":
      case "at": {
        // kestrel-rtf: the {until,at} thesis temporal envelope is a LANGUAGE-surface addition (a
        // postfix sibling of `within`). Evaluate the inner so a buried cross/held/within/nth still
        // advances its per-node causal memory every tick (RUNTIME §3), then route the envelope's
        // firing decision through the OPTIONAL provider hook — parity with `failed-break`/`time`/`fill`
        // (kestrel-22j.21): a provider that does not supply the temporal-envelope capability reads
        // UNKNOWN (fail-closed, absent-with-reason, de-armed with a logged reason), never a silent
        // true/false. The concrete firing semantics are wired through this seam by a future runtime
        // ADR; until then the capability is honestly ABSENT (a disclosed optional hook), not a
        // hardcoded dead UNKNOWN the author cannot see.
        const inner = this.evalNode(node.inner, `${path}.0`, provider, now);
        return provider.temporalEnvelope ? provider.temporalEnvelope(node, inner, now) : UNKNOWN;
      }

      case "held-stop": {
        // A 0DTE time-HELD stop (`EXIT held 90m`) fires a fixed duration after the POSITION was
        // acquired — a fact the generic evaluator (which sees the tape, not the book) cannot know.
        // The PlanEngine's EXIT path special-cases it against the plan's first-acquire time
        // (src/engine/plans.ts). Everywhere else it has no referent (nothing is "held"), so it
        // reads UNKNOWN — fail-closed, never a silent fire — exactly like {until,at}.
        return UNKNOWN;
      }

      case "clock-stop": {
        // A 0DTE wall-clock stop (`EXIT clockET 15:40`) — semantically `now >= <clock>` ET. The
        // engine EXIT path fires it against the session calendar; here (any non-EXIT position) it
        // reads UNKNOWN so it can never silently fire outside a managed position.
        return UNKNOWN;
      }

      case "nth": {
        const inner = this.evalNode(node.event, `${path}.0`, provider, now); // always evaluate
        const mem = this.nthMem(path);
        if (inner === true && !mem.prevInner) mem.count += 1;
        mem.prevInner = inner === true;
        if (mem.count >= node.ordinal) return true; // latched
        return inner === UNKNOWN ? UNKNOWN : false;
      }

      case "phase": {
        const p = provider.phase();
        if (isUnknown(p)) return UNKNOWN;
        return p === node.phase;
      }

      case "time-window": {
        if (provider.timeOfDayMinutes === undefined) return UNKNOWN; // calendar absent
        const m = provider.timeOfDayMinutes(now);
        if (isUnknown(m)) return UNKNOWN;
        const from = node.from ? node.from.hour * 60 + node.from.minute : null;
        const to = node.to ? node.to.hour * 60 + node.to.minute : null;
        const afterFrom = from === null || m >= from;
        const beforeTo = to === null || m < to; // half-open [from, to)
        return afterFrom && beforeTo;
      }

      case "event": {
        return provider.structural ? provider.structural(node, now) : UNKNOWN; // detector absent
      }

      case "fill": {
        return provider.fillEvent ? provider.fillEvent(node) : UNKNOWN; // fill telemetry absent
      }

      case "and": {
        const vals = node.terms.map((t, i) => this.evalNode(t, `${path}.${i}`, provider, now));
        let anyUnknown = false;
        for (const v of vals) {
          if (v === false) return false;
          if (isUnknown(v)) anyUnknown = true;
        }
        return anyUnknown ? UNKNOWN : true;
      }

      case "or": {
        const vals = node.terms.map((t, i) => this.evalNode(t, `${path}.${i}`, provider, now));
        let anyUnknown = false;
        for (const v of vals) {
          if (v === true) return true;
          if (isUnknown(v)) anyUnknown = true;
        }
        return anyUnknown ? UNKNOWN : false;
      }

      case "not": {
        const v = this.evalNode(node.term, `${path}.0`, provider, now);
        if (isUnknown(v)) return UNKNOWN;
        return !v;
      }

      default:
        return assertNever(node, "series/trigger evalNode");
    }
  }

  // ── operand resolution ───────────────────────────────────────────────────────

  /** Resolve a comparison/cross operand pair, honoring a `Baseline` on either side (a
   * baseline resolves against its paired *series* operand's trailing distribution). */
  private resolvePair(
    left: Operand,
    right: Operand,
    provider: SeriesProvider,
    now: number,
  ): { lv: Resolved; rv: Resolved } {
    if (right.kind === "baseline") {
      if (left.kind !== "series") return { lv: UNKNOWN, rv: UNKNOWN };
      return { lv: provider.resolve(left, now), rv: provider.baseline(left, right) };
    }
    if (left.kind === "baseline") {
      if (right.kind !== "series") return { lv: UNKNOWN, rv: UNKNOWN };
      return { lv: provider.baseline(right, left), rv: provider.resolve(right, now) };
    }
    return {
      lv: this.operandValue(left, provider, now),
      rv: this.operandValue(right, provider, now),
    };
  }

  private operandValue(op: Operand, provider: SeriesProvider, now: number): Resolved {
    switch (op.kind) {
      case "series":
        return provider.resolve(op, now);
      case "quantity":
        return op.value; // raw magnitude; unit normalization is the registry's job (later phase)
      case "baseline":
        return UNKNOWN; // a baseline without a paired series operand is meaningless
      default:
        return assertNever(op, "series/trigger operandValue");
    }
  }

  /** A bare single-segment series operand → its own name as a symbolic string literal; any
   * other operand → UNKNOWN. Used only in the eq/ne literal fallback. */
  private literalOf(op: Operand): Resolved {
    if (op.kind !== "series" || op.window !== undefined || op.segments.length !== 1) return UNKNOWN;
    const seg0 = op.segments[0];
    if (seg0 === undefined || seg0.selector !== undefined) return UNKNOWN;
    return seg0.name;
  }

  /**
   * Fold a comparison where exactly one operand is a **quantified** aggregate
   * (`children(any|all).<fact>`): resolve the operand to its per-member vector, apply the authored
   * comparator to EACH member (preserving operand order), then combine by the quantifier —
   * `any` = ∃ (tri-state OR), `all` = ∀ (tri-state AND). This makes the ∃/∀ meaning honor the
   * comparator direction, so a `<`/`<=` guard cannot silently invert (the fail-open the scalar fold
   * hid). An unresolvable vector (no member published, or an unknown quantified path) ⇒ UNKNOWN,
   * de-armed loudly by the provider (RUNTIME §8). Both operands quantified, or the counterpart
   * unresolved, ⇒ UNKNOWN (fail closed). `eq`/`ne` fold per member too; only `crosses` (an edge)
   * is excluded, handled in the `cross` node.
   */
  private evalQuantifiedCompare(op: CompareOp, left: Operand, right: Operand, provider: SeriesProvider, now: number): TriState {
    const lq = quantOf(left);
    const rq = quantOf(right);
    if (lq !== null && rq !== null) return UNKNOWN; // both quantified is not a well-formed guard
    const quant = (lq ?? rq)!;
    const qRef = (lq !== null ? left : right) as SeriesRef;
    const other = lq !== null ? right : left;
    const values = provider.quantify?.(qRef, now) ?? UNKNOWN;
    if (isUnknown(values)) return UNKNOWN;
    const threshold = this.operandValue(other, provider, now);
    if (isUnknown(threshold)) return UNKNOWN;
    let anyTrue = false;
    let anyFalse = false;
    let anyUnknown = false;
    for (const v of values) {
      // Preserve operand order: `children(*).x < t` vs `t < children(*).x` are different guards.
      const r = lq !== null ? this.compareResolved(op, v, threshold) : this.compareResolved(op, threshold, v);
      if (r === true) anyTrue = true;
      else if (isUnknown(r)) anyUnknown = true;
      else anyFalse = true;
    }
    if (quant === "any") return anyTrue ? true : anyUnknown ? UNKNOWN : false; // ∃
    return anyFalse ? false : anyUnknown ? UNKNOWN : true; // ∀
  }

  private compareResolved(op: CompareOp, lv: Resolved, rv: Resolved): TriState {
    switch (op) {
      case "eq":
        return lv === rv;
      case "ne":
        return lv !== rv;
      case "gt":
      case "ge":
      case "lt":
      case "le": {
        if (typeof lv !== "number" || typeof rv !== "number") return UNKNOWN; // ordering needs numbers
        return op === "gt" ? lv > rv : op === "ge" ? lv >= rv : op === "lt" ? lv < rv : lv <= rv;
      }
      default:
        return assertNever(op, "series/trigger compareResolved");
    }
  }

  // ── memory accessors (create-on-first-touch, kind-checked) ────────────────────

  private crossMem(path: string): CrossMem {
    const m = this.mem.get(path);
    if (m !== undefined) {
      if (m.kind !== "cross") throw new Error(`series/trigger: memory kind clash at ${path}`);
      return m;
    }
    const fresh: CrossMem = { kind: "cross", prevSide: null, armed: true };
    this.mem.set(path, fresh);
    return fresh;
  }

  private heldMem(path: string): HeldMem {
    const m = this.mem.get(path);
    if (m !== undefined) {
      if (m.kind !== "held") throw new Error(`series/trigger: memory kind clash at ${path}`);
      return m;
    }
    const fresh: HeldMem = { kind: "held", since: null };
    this.mem.set(path, fresh);
    return fresh;
  }

  private withinMem(path: string): WithinMem {
    const m = this.mem.get(path);
    if (m !== undefined) {
      if (m.kind !== "within") throw new Error(`series/trigger: memory kind clash at ${path}`);
      return m;
    }
    const fresh: WithinMem = { kind: "within", lastTrue: null };
    this.mem.set(path, fresh);
    return fresh;
  }

  private nthMem(path: string): NthMem {
    const m = this.mem.get(path);
    if (m !== undefined) {
      if (m.kind !== "nth") throw new Error(`series/trigger: memory kind clash at ${path}`);
      return m;
    }
    const fresh: NthMem = { kind: "nth", count: 0, prevInner: false };
    this.mem.set(path, fresh);
    return fresh;
  }
}
