/**
 * # series/registry — the one shared phonebook of series (CONTEXT: Registry)
 *
 * "Register once → visible to every surface." A **series** is anything with a name whose
 * value changes over time (CONTEXT: Series); the design intent is that every surface that
 * reads a series — trigger operands, panes, grade columns — dials the *same* phonebook to
 * learn what a name *is* before it reads what the name *says*. This module owns that
 * phonebook: **one registration record per series NAME**, carrying its {@link SeriesKind
 * kind} (market | org), its {@link SeriesScope scope path}, and its {@link Windowing window
 * semantics}.
 *
 * ## What v0 wires (cza.1) — the trigger consumer only
 * v0 routes exactly **one** of the three consumers through this phonebook: the **trigger
 * operand**. `series/provider` dials {@link SeriesRegistry.lookup} for market-vs-org routing
 * (the single place that decision is made), so triggers resolve every market fact by name
 * through this table rather than a provider-local copy. **Panes and grade columns do not dial
 * the phonebook yet** — panes assemble their level set directly from canonical state
 * (`src/session`), and the grade surface is charter-only. Those two consumers are routed
 * through this registry as they are built (grade: kestrel-a57.8; observability metadata that
 * panes/grade will read: kestrel-cza.3); v0 deliberately does not stand up a second market
 * vocabulary for them, it leaves them on their current direct reads until that wiring lands.
 *
 * ## Hybrid registration (owner-decided)
 * There are two-and-only-two kinds of series (CONTEXT: Series), and they register two ways:
 *
 * - **Market facts + detector outputs are PLATFORM-declared, in code, with full typed
 *   metadata** — canonical market facts (`spot`, `vwap`, the windowed `velocity/move/range`)
 *   are ambient signal-space facts the platform *knows a priori*, so they are declared here
 *   as {@link PLATFORM_SERIES} with their unit, their window semantics, and the market
 *   {@link MarketBinding binding} the provider dials them through, and each carries its per-series
 *   observability/fidelity **class** ({@link ObservabilityClass}, kestrel-cza.3) — the finest data
 *   granularity at which the fact is faithfully observable, which grading enforces
 *   ({@link SeriesRegistry.admitAtFidelity}).
 * - **Org facts + regime tags register IMPLICITLY on first authorized write**, source-stamped
 *   `authored:` and otherwise **taste-free** — the platform cannot know a pod's `pnl`,
 *   `plan(x).state`, or open-vocabulary regime tags a priori, so they earn a phonebook entry
 *   the first time an *authorized* write names them ({@link SeriesRegistry.noteOrgWrite}).
 *   Registration is not curation: replay decides what earns usage (CONTEXT: Registry —
 *   "Registration is taste-free; replay decides what earns usage").
 *
 *   **v0 status:** {@link SeriesRegistry.noteOrgWrite} is built and unit-tested here, but v0
 *   does **not** yet call it from the engine's authorized-write sites (the book's fill fold and
 *   plan-lifecycle stamps). A live session therefore *resolves* its org facts (through the
 *   injected `OrgFacts` seam in `series/provider`) but does not yet *register* them, so a
 *   surface that enumerates the phonebook ({@link SeriesRegistry.dump}) sees the platform market
 *   facts only. Wiring the org half to real writes is the province of kestrel-cza.4 (the
 *   child-scoped org-fact resolver); the mechanism, its first-write-wins stamp, its
 *   never-over-platform guard, and its empty-path fail-closed are proven now so cza.4 only has
 *   to call it.
 *
 * ## Fail closed (RUNTIME §2/§8)
 * The phonebook never guesses. A NAME with no record resolves to **UNKNOWN** through
 * {@link SeriesRegistry.classify} — the same de-arm-with-a-logged-reason path every
 * unresolvable series takes; never a silent default. (Registration is a *phonebook* fact,
 * not a resolution gate: an unregistered org path still delegates to the book and reads
 * UNKNOWN there if the book does not know it — see `series/provider`. `classify` answers the
 * narrower question "is there a record for this name?", fail-closed.)
 *
 * ## No SERIES language surface (v1)
 * There is no author-facing `SERIES` statement in v1 — the phonebook is populated by the
 * platform in code and implicitly by authorized writes, never by a grammar surface. (If
 * author-defined computed series are ever needed, that arrives via an ADR, not here.)
 *
 * ## Determinism (RUNTIME §0/§7)
 * The platform table is a frozen constant; implicit registrations are a pure function of the
 * ordered authorized writes. {@link SeriesRegistry.dump} is a sorted, deep-copied snapshot,
 * so the same writes yield a byte-identical phonebook.
 */

import type { PathSegment, SeriesRef } from "../lang/index.ts";
import { UNKNOWN, isUnknown, type Unknown } from "./types.ts";
import type { WindowMetric } from "./windows.ts";

// ─────────────────────────────────────────────────────────────────────────────
// The registration record
// ─────────────────────────────────────────────────────────────────────────────

/** The two-and-only-two kinds a series can have (CONTEXT: Series). **market** facts are
 * ambient signal-space facts, spectator-visible; **org** facts are path-scoped in the pod
 * tree and exist only in an acting session. */
export type SeriesKind = "market" | "org";

/** A series' **window semantics**: a `scalar` carries no window (`spot`, `pnl`); a `windowed`
 * series is judged at a timescale against that window's own trailing baseline (`velocity(1m)`,
 * CONTEXT: Window). Windowed *org* facts are not a v1 concept, so every org record is scalar. */
export type Windowing = "scalar" | "windowed";

/** A series' **scope path** — where it lives. Market facts are ambient in **signal** space
 * (one causal coordinate per signal instrument). Org facts are **org**-scoped: the lexical
 * path in the pod tree the write named (`pnl`, `["fills","avg_px"]`, `["plan","state"]`). */
export type SeriesScope =
  | { readonly space: "signal" }
  | { readonly space: "org"; readonly path: readonly string[] };

/** The physical unit a platform-declared series carries (typed metadata). Org records are
 * taste-free and omit it. Kept small; extend as real facts need it, never stubbed ahead. */
export type SeriesUnit =
  | "price" // an absolute price level (spot, vwap, hod, …)
  | "price_delta" // a $/window change or excursion (velocity/move/range $)
  | "fraction"; // a dimensionless ratio (a %/window reading, e.g. 0.012 = 1.2%)

/**
 * The per-series **observability / fidelity class** (kestrel-cza.3): the finest
 * data granularity at which the series is *faithfully observable*. A series judged on coarser data
 * than its class cannot be graded honestly — you cannot see a tick-sensitive series on minute bars —
 * so grading enforces it ({@link SeriesRegistry.admitAtFidelity}).
 *
 * The four rungs run **finest → coarsest** ({@link OBSERVABILITY_ORDER}):
 * - `tick` — the value moves trade-by-trade; a faithful read needs every tick (`spot`, `vwap`, and
 *   every intra-window rate/magnitude metric `velocity`/`move`/`range`, whose sub-minute windows a
 *   coarser bar cannot reconstruct).
 * - `second` — faithful at 1-second resolution (no platform market fact occupies this rung today; it
 *   exists for second-resolution grading data and future/second-class facts).
 * - `minute` — a per-bar summary faithfully captured by minute bars (`hod`/`lod`, `or_high`/`or_low`:
 *   a running or opening-range extreme that a minute bar's high/low preserves).
 * - `session` — one fact per session, faithful with only session-level data (`prior_close`, the prior
 *   settle).
 */
export type ObservabilityClass = "tick" | "second" | "minute" | "session";

/** The observability lattice, ordered **finest → coarsest**. A series' {@link ObservabilityClass}
 * names the finest granularity at which it is faithful; a *grading fidelity* names the finest
 * granularity the grader's data (lake / feed) actually provides. Both index into this order. */
export const OBSERVABILITY_ORDER = ["tick", "second", "minute", "session"] as const;

/** A **grading fidelity**: the finest data granularity a Session's grading data provides (the lake's
 * bar resolution / the feed's cadence). Same lattice as {@link ObservabilityClass} — a session grades
 * at one of these rungs, and may only read series that are that-rung-or-COARSER. Distinct from the
 * fill-realism `Fidelity` (`modeled | realized`, CONTEXT: Fidelity) — this is data granularity, that
 * is fill realism; the two axes are orthogonal. */
export type GradingFidelity = ObservabilityClass;

/** Finest → coarsest rank of an observability rung (0 = `tick`, 3 = `session`), or **-1** when the
 * value is not a lattice rung. Callers MUST treat -1 as fail-closed (an unrecognized, untrusted rung),
 * never as "coarser/finer than everything" — {@link isRung} guards that. */
function observabilityRank(c: ObservabilityClass): number {
  return OBSERVABILITY_ORDER.indexOf(c);
}

/** True iff `v` is a recognized lattice rung. A value that reaches the seam via `as GradingFidelity`
 * or parsed lake/session JSON may be off-lattice (e.g. `"1min"`); such a value is untrusted and the
 * seam must fail closed on it — never compare a bogus rung as though it were the coarsest. */
function isRung(v: string): v is ObservabilityClass {
  return (OBSERVABILITY_ORDER as readonly string[]).includes(v);
}

/**
 * True iff a series of class `cls` requires **finer** data than a `fidelity`-graded session provides
 * (its rung is strictly finer). Such a series cannot be graded honestly at that fidelity — the
 * The lesson: a minute-lake session cannot faithfully see a tick-observable trigger.
 *
 * **Fail-closed on an off-lattice rung.** If either `cls` or `fidelity` is not a recognized rung
 * (e.g. an unrecognized fidelity string that slipped past the type via `as`/JSON), this returns
 * `true` — an untrusted rung is treated as "cannot confirm observable", so the caller refuses. It
 * never lets a bogus rung compare as coarser-than-everything and silently admit (RUNTIME §2/§8).
 */
export function isFinerThanFidelity(cls: ObservabilityClass, fidelity: GradingFidelity): boolean {
  if (!isRung(cls) || !isRung(fidelity)) return true; // untrusted rung ⇒ refuse, never silently admit
  return observabilityRank(cls) < observabilityRank(fidelity);
}

/**
 * The verdict of the grading-fidelity enforcement seam ({@link SeriesRegistry.admitAtFidelity}):
 * either the series is **admitted** (observable at the session's grading fidelity) or **refused**
 * with a logged, human-readable `reason` (fail-closed — the caller de-arms the referencing
 * statement, RUNTIME §2/§8; it never silently grades on data that cannot see the series).
 */
export type SeriesAdmission =
  | { readonly admit: true }
  | { readonly admit: false; readonly reason: string };

/** How the provider dials a **market** fact from canonical state — the resolution binding the
 * platform declares alongside the name (so `series/provider` holds only the mechanics of
 * reading state, not a second copy of the market-fact vocabulary). */
export type MarketBinding =
  | { readonly via: "scalar"; readonly scalar: MarketScalar }
  | { readonly via: "window"; readonly metric: WindowMetric; readonly pct: boolean };

/** The canonical scalar market-fact names (single-segment, no window). */
export type MarketScalar = "spot" | "vwap" | "hod" | "lod" | "prior_close" | "or_high" | "or_low";

/** Where a registration came from — a source-stamp. Platform declarations are `platform`;
 * an implicit org registration stamps the authorizing writer (`authored:`). */
export type RegistrationSource = { readonly kind: "platform" } | { readonly kind: "authored"; readonly by: string };

/**
 * One phonebook entry — **one record per series NAME**. The name is the single-segment head
 * used as the key (`spot`, `velocity_pct`, `pnl`, `plan`); path shape beyond the head lives in
 * {@link scope}. Market records carry a {@link MarketBinding} + unit; org records are taste-free
 * (kind + scope + source only). Market records also carry the {@link observabilityClass} (cza.3).
 */
export interface SeriesRegistration {
  readonly name: string;
  readonly kind: SeriesKind;
  readonly scope: SeriesScope;
  readonly windowing: Windowing;
  readonly source: RegistrationSource;
  /** Typed unit — present on platform (market) declarations; absent on taste-free org records. */
  readonly unit?: SeriesUnit;
  /** Present only on `kind === "market"`: how the provider resolves the fact. */
  readonly market?: MarketBinding;
  /** The finest data granularity at which this fact is faithfully observable (kestrel-cza.3).
   * Present on every platform (market) declaration; absent on taste-free org records — an org fact
   * is the session's own bookkeeping, not a lake read, so lake granularity does not bound it. */
  readonly observabilityClass?: ObservabilityClass;
}

// ─────────────────────────────────────────────────────────────────────────────
// The platform declarations — canonical market facts, in code, typed
// ─────────────────────────────────────────────────────────────────────────────

function marketScalarReg(name: MarketScalar, observabilityClass: ObservabilityClass): SeriesRegistration {
  return {
    name,
    kind: "market",
    scope: { space: "signal" },
    windowing: "scalar",
    source: { kind: "platform" },
    unit: "price",
    market: { via: "scalar", scalar: name },
    observabilityClass,
  };
}

/** Every windowed rate/magnitude metric is `tick`-observable: its window is authored per trigger and
 * may be sub-minute (`velocity(5s)`), and a coarser bar cannot reconstruct an intra-window rate,
 * signed move, or excursion. So the series' class is the finest it can express — `tick`. */
function marketWindowReg(name: string, metric: WindowMetric, pct: boolean): SeriesRegistration {
  return {
    name,
    kind: "market",
    scope: { space: "signal" },
    windowing: "windowed",
    source: { kind: "platform" },
    unit: pct ? "fraction" : "price_delta",
    market: { via: "window", metric, pct },
    observabilityClass: "tick",
  };
}

/**
 * The canonical market-fact vocabulary the platform declares a priori (RUNTIME §2): the seven
 * scalar levels + the three window metrics in their `$/window` and `%/window` framings. This is
 * the single source of the market-vs-org routing every surface reads through.
 *
 * (Detector outputs are the other half of the platform declaration set, per the cza epic; they
 * join this table when they become *named* series — today they resolve as structural-event
 * provider hooks, not named-series reads, so there is no name to register yet.)
 */
export const PLATFORM_SERIES: readonly SeriesRegistration[] = [
  // spot / vwap move trade-by-trade — a faithful read (and any spot-crossing) needs every tick.
  marketScalarReg("spot", "tick"),
  marketScalarReg("vwap", "tick"),
  // hod/lod and the opening-range extremes are per-bar summaries a minute bar's high/low preserves.
  marketScalarReg("hod", "minute"),
  marketScalarReg("lod", "minute"),
  // prior_close is one fact per session (the prior settle) — faithful with session-level data alone.
  marketScalarReg("prior_close", "session"),
  marketScalarReg("or_high", "minute"),
  marketScalarReg("or_low", "minute"),
  marketWindowReg("velocity", "velocity", false),
  marketWindowReg("velocity_pct", "velocity", true),
  marketWindowReg("move", "move", false),
  marketWindowReg("move_pct", "move", true),
  marketWindowReg("range", "range", false),
  marketWindowReg("range_pct", "range", true),
];

// ─────────────────────────────────────────────────────────────────────────────
// The registry
// ─────────────────────────────────────────────────────────────────────────────

/** The head (single) segment name of a ref, or undefined for an empty path. */
function headName(ref: SeriesRef): string | undefined {
  return ref.segments[0]?.name;
}

/** The org **scope path** of a write: the plain segment names, selectors dropped (a scope is a
 * lexical location, not a specific target — `plan(chase).state` and `plan(urgent).state` share
 * the `["plan","state"]` scope). */
function orgScopePath(segments: readonly PathSegment[]): readonly string[] {
  return segments.map((s) => s.name);
}

/**
 * The one shared phonebook of series. Seeded with the frozen platform market-fact declarations;
 * grows by implicit org registration on first authorized write. Read by every surface through
 * {@link lookup} / {@link classify}.
 */
export class SeriesRegistry {
  private readonly records = new Map<string, SeriesRegistration>();

  constructor(platform: readonly SeriesRegistration[] = PLATFORM_SERIES) {
    for (const r of platform) this.records.set(r.name, r);
  }

  /** The registration for a series NAME (the head-segment name), or undefined when the name is
   * not in the phonebook. The provider uses this for market-vs-org routing.
   *
   * **EXACT-MATCH ONLY** (OSS-ADR-0045). Series names are case-SENSITIVE and the platform market
   * facts are lowercase: `lookup("HOD")` MISSES, exactly like `lookup("zzz")`. A case-variant of a
   * known market fact is not silently forgiven — the arm-time integrity guard
   * ({@link import("../engine/plans.ts").PlanEngine.armDocument}) surfaces it as a coded
   * `unknown-series` REFUSAL with a did-you-mean repair, and the author-lint flags it at author
   * time. This reverts the 0.4.5 case-insensitive forgiveness (kestrel-7tbi): forgiving a case-clash
   * at resolution armed a plan on a name the author never registered and hid a de-arm the author
   * could not see — a silent default the platform forbids (AGENTS.md; RUNTIME §8). The phonebook
   * never guesses: it answers ONLY for the exact registered name. */
  lookup(name: string): SeriesRegistration | undefined {
    return this.records.get(name);
  }

  /**
   * Classify a `SeriesRef` → its registration record, or **UNKNOWN** when its head name has no
   * phonebook entry (fail-closed — never a silent default). This answers "is there a record for
   * this name?"; it is NOT a resolution gate (an unregistered org path still delegates to the
   * book — see `series/provider`), it is the phonebook read a surface uses to learn a series'
   * kind/scope/window semantics before rendering or grading it.
   */
  classify(ref: SeriesRef): SeriesRegistration | Unknown {
    const name = headName(ref);
    if (name === undefined) return UNKNOWN;
    return this.records.get(name) ?? UNKNOWN;
  }

  /**
   * The **grading-fidelity enforcement seam** (kestrel-cza.3). Given the Session's
   * grading fidelity (the finest granularity its grading data provides), decide whether a series may
   * be graded — and REFUSE, fail-closed with a logged reason, any series **finer** than that fidelity.
   * You cannot grade a tick-sensitive series honestly on minute bars: a plan judged on minute-lake
   * data may only use minute-observable (or coarser) series.
   *
   * - **Unknown series** (no phonebook record) ⇒ refuse — an unresolvable series is never graded on a
   *   guess (fail-closed, mirrors {@link classify}).
   * - **Org facts** ⇒ admit — an org fact (`pnl`, `plan.state`) is the acting session's own
   *   bookkeeping, computed in-session and not read from the market-data lake, so lake granularity
   *   does not bound its observability.
   * - **Market facts** ⇒ admit iff the fact's {@link ObservabilityClass} is `fidelity`-or-COARSER;
   *   a finer class is refused. A market record missing a class also refuses (fail-closed — an
   *   unclassified fact's observability cannot be confirmed).
   *
   * A `true` verdict never fires anything on its own; a `false` verdict carries the de-arm reason the
   * caller logs. Pure — no clock, no RNG.
   */
  admitAtFidelity(ref: SeriesRef, fidelity: GradingFidelity): SeriesAdmission {
    const rec = this.classify(ref);
    const label = headName(ref) ?? "(empty path)";
    // Fail closed on the FIDELITY axis: an off-lattice fidelity (slipped past the type via `as` or
    // parsed lake/session JSON, e.g. "1min") is untrusted — refuse before any series is admitted,
    // including org facts (RUNTIME §2/§8; never a silent default).
    if (!isRung(fidelity)) {
      return {
        admit: false,
        reason: `unknown grading fidelity "${fidelity}" — not a recognized observability rung (${OBSERVABILITY_ORDER.join(", ")}); series "${label}" cannot be graded on an untrusted fidelity (fail-closed, RUNTIME §2/§8)`,
      };
    }
    if (isUnknown(rec)) {
      return {
        admit: false,
        reason: `series "${label}" has no phonebook record — an unknown series cannot be graded at ${fidelity} fidelity (fail-closed, RUNTIME §2/§8)`,
      };
    }
    if (rec.kind === "org") return { admit: true }; // session-internal bookkeeping — not a lake read
    const cls = rec.observabilityClass;
    if (cls === undefined) {
      return {
        admit: false,
        reason: `market series "${rec.name}" carries no observability class — cannot confirm it is observable at ${fidelity} fidelity (fail-closed)`,
      };
    }
    if (isFinerThanFidelity(cls, fidelity)) {
      return {
        admit: false,
        reason: `series "${rec.name}" is ${cls}-observable but the session grades at ${fidelity} fidelity — a finer-than-fidelity series cannot be graded honestly; de-arm (fidelity-1, RUNTIME §2/§8)`,
      };
    }
    return { admit: true };
  }

  /**
   * Record an **org** fact's phonebook entry on first authorized write (the implicit half of
   * hybrid registration). Idempotent by NAME — first write wins the source-stamp; later writes
   * to the same name (or a sibling path sharing the head) do not re-stamp. Taste-free: it
   * records the name/scope/source, it does not judge whether the write *should* exist (replay
   * culls). Returns the resulting record. Never registers over a platform (market) name.
   *
   * **Head-granularity caveat (v0, deliberate).** The record is keyed by the **head** segment
   * only (matching {@link classify}, which also keys on the head), so genuinely distinct sibling
   * series that share a head — `plan(x).state` vs `plan(x).rungs`, `fills.avg_px` vs
   * `fills.count` — collapse to ONE record, and the retained {@link SeriesScope scope path} is
   * whichever sibling wrote first (a deterministic first-write artifact, not a claim that the
   * others don't exist). Enumerating the phonebook in v0 therefore cannot distinguish collapsed
   * siblings. This matches the trigger routing, which keys market-vs-org on the head; per-path
   * org records (a record per full scope path, with a path-aware `classify`) are the province of
   * kestrel-cza.4, the child-scoped org-fact resolver.
   */
  noteOrgWrite(segments: readonly PathSegment[], by: string): SeriesRegistration | Unknown {
    const head = segments[0];
    if (head === undefined) return UNKNOWN; // an empty path names nothing — fail closed
    const existing = this.records.get(head.name);
    if (existing !== undefined) return existing; // platform name or already-noted org name: first wins
    const reg: SeriesRegistration = {
      name: head.name,
      kind: "org",
      scope: { space: "org", path: orgScopePath(segments) },
      windowing: "scalar", // windowed org facts are not a v1 concept
      source: { kind: "authored", by },
    };
    this.records.set(head.name, reg);
    return reg;
  }

  /** A deterministic, serializable snapshot of the whole phonebook — records sorted by name,
   * deep-copied — for the determinism certification and for surfaces that enumerate the
   * phonebook. */
  dump(): readonly SeriesRegistration[] {
    return [...this.records.values()]
      .map((r) => ({ ...r }))
      .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
  }
}

/** The process-wide default phonebook, seeded with the platform market facts. The market side is
 * static and taste-free, so a shared default is deterministic; a caller that needs an isolated
 * phonebook (e.g. to observe implicit org registrations per session) constructs its own. */
export const defaultSeriesRegistry: SeriesRegistry = new SeriesRegistry();
