/**
 * # frame/options-analytics — the per-strike options surface projection (pane-library-spec §5)
 *
 * The **options-analytics projection**: the single context extension that turns the owner's GEX/IV
 * hypothesis from unanswerable to measurable. It rides on {@link ../frame/types.ts MarketPane} as
 * an optional `options` view and carries, per strike / expiry:
 *   - **NBBO bid / ask + mid** — OBSERVED (from the OPRA book), the two-sided liquid mid or `null`.
 *   - **implied vol** — a **MODEL** output, Black-Scholes (r = 0) back-out from the option mid +
 *     underlier spot + time-to-expiry `tau` + rate. Never observed; `null` when there is no
 *     positive-vol solution (a strike with no two-sided quote reads UNKNOWN, never a guessed IV).
 *   - **greeks** (delta, gamma, vega) — **MODEL** outputs from the same Black-Scholes read.
 *   - **open interest** — OBSERVED, from the OI sidecar (OPRA `statistics`), `null` when absent.
 *
 * ## The honesty split (CONTEXT.md — OBS vs MODEL)
 * The projection delivers OBSERVED NBBO + OI and computes the MODEL (IV/greeks) at build time from
 * that frozen input. IV and greeks are **computed, never sourced** — so every pane that renders one
 * is a MODEL pane and MUST carry the Black-Scholes receipt: {@link OptionsAnalytics.method} names
 * the method (Black-Scholes) and the rate / `tau` assumptions, the receipt *basis* every dependent
 * pane cites. The construct-time honesty guard ({@link ../frame/types.ts makeField}) refuses a MODEL
 * Field without its receipt + confidence.
 *
 * ## Purity + the T-5m guard (ADR-0009)
 * The projection is a **pure function of the frozen frame input** — no wall clock, no RNG, no
 * cursor. `tau` is computed from the **frozen session time at the cutoff sequence** (`cutoffTs`, the
 * ts of the last event ≤ `throughSeq`), never from a wall clock and never with look-ahead: the
 * surface folds only events at or before the cutoff (the T-5m no-look-ahead discipline). `asOfSeq`
 * is the engine's monotonic ordinal, never a date. A stale/frozen underlier is flagged
 * ({@link OptionsAnalytics.spotStale}) so a dependent pane can fail closed — a GEX read off a dead
 * spot is a lie, not a number.
 */

import type { BusEvent } from "../bus/types.ts";
import type { Right } from "../bus/types.ts";
import { MS_PER_YEAR, greeks as bsGreeks, impliedVol } from "../fair/black76.ts";

// ─────────────────────────────────────────────────────────────────────────────
// The projection shapes (what rides on MarketPane.options)
// ─────────────────────────────────────────────────────────────────────────────

/** One strike/right leg of the options-analytics surface. NBBO + mid + OI are OBSERVED; IV +
 * greeks are Black-Scholes MODEL outputs (`null` = UNKNOWN, never a guessed number). */
export interface OptionAnalyticsLeg {
  readonly strike: number;
  readonly right: Right;
  /** Top-of-book bid; `null` ⇒ dark/absent side (OBS). */
  readonly bid: number | null;
  /** Top-of-book ask; `null` ⇒ dark/absent side (OBS). */
  readonly ask: number | null;
  /** The two-sided liquid mid (`null` unless BOTH sides are present and the mid is positive) (OBS). */
  readonly mid: number | null;
  /** Black-Scholes implied vol (MODEL); `null` when no positive-vol solution exists. */
  readonly iv: number | null;
  /** Black-Scholes delta (MODEL); `null` when IV is unknown. */
  readonly delta: number | null;
  /** Black-Scholes gamma per $1 of underlier (MODEL); `null` when IV is unknown. */
  readonly gamma: number | null;
  /** Black-Scholes vega per 1.00 vol (MODEL); `null` when IV is unknown. */
  readonly vega: number | null;
  /** Open interest for the leg (OBS, from the OI sidecar); `null` when the sidecar has no row. */
  readonly oi: number | null;
}

/** One expiry's slice of the surface — its shared time-to-expiry `tau` and its legs (all strikes,
 * both rights: the full-chain view GEX needs, not near-money-only). */
export interface OptionsExpiry {
  /** The expiry label (a date `2024-03-05` or a tag). */
  readonly expiry: string;
  /** Year-fraction from the frozen cutoff to this expiry's close (`null` when the close is unknown);
   * the shared `tau` the leg IV/greeks were computed under. Never a wall clock — a duration. */
  readonly tauYears: number | null;
  /** Whole days to expiry from the cutoff (a small integer, date-blind); `null` when unknown. */
  readonly daysToExpiry: number | null;
  /** Every leg quoted at/before the cutoff, sorted by strike then right (full chain). */
  readonly legs: readonly OptionAnalyticsLeg[];
}

/**
 * The options-analytics surface for one underlier at the frozen cutoff. `spot` is the OBSERVED
 * underlier at `asOfSeq`; `spotStale` flags a frozen/stale underlier (a GEX read off it must fail
 * closed to UNKNOWN). `method` is the MODEL receipt BASIS every dependent pane cites — it names the
 * Black-Scholes method + the rate / `tau` assumptions the IV/greeks rest on.
 */
export interface OptionsAnalytics {
  readonly underlier: string;
  /** OBSERVED underlier spot at the cutoff; `null` when never observed. */
  readonly spot: number | null;
  /** The underlier is stale/frozen (age > the staleness backstop, or never observed): a dependent
   * MODEL read (GEX) must taint to UNKNOWN rather than compute off a dead spot. */
  readonly spotStale: boolean;
  /** Age of the underlier quote at the cutoff, in whole seconds (`null` when never observed). */
  readonly spotStaleSeconds: number | null;
  /** The engine monotonic sequence at the cutoff (an ordinal, NEVER a wall clock / date). */
  readonly asOfSeq: number;
  /** The per-contract multiplier the greeks/OI are scaled by (e.g. 100 for SPY options). */
  readonly multiplier: number;
  /** The risk-free rate assumption (Kestrel runs the surface at r = 0). */
  readonly rate: number;
  /** The MODEL receipt basis: the method + the rate/`tau` assumptions IV & greeks rest on. */
  readonly method: string;
  /** Front-first expiries (each a full-chain slice). */
  readonly expiries: readonly OptionsExpiry[];
}

// ─────────────────────────────────────────────────────────────────────────────
// Step 1 — reduce the OPRA BOOK/SPOT tape to a causal surface at the cutoff (pure over events)
// ─────────────────────────────────────────────────────────────────────────────

/** A reduced NBBO surface + the underlier context, folded causally to the cutoff. */
export interface ReducedSurface {
  readonly underlier: string;
  /** Latest NBBO per `${expiry}:${strike}:${right}`, latest-wins at/before the cutoff. */
  readonly legs: ReadonlyMap<string, { readonly expiry: string; readonly strike: number; readonly right: Right; readonly bid: number | null; readonly ask: number | null }>;
  /** The last observed underlier spot at/before the cutoff (`null` when none). */
  readonly spot: number | null;
  /** The ts the spot was last observed at (`null` when none) — used only to age the spot. */
  readonly spotTs: number | null;
  /** The cutoff sequence (the last folded seq). */
  readonly cutoffSeq: number;
  /** The cutoff ts (the last folded ts) — the frozen session time `tau` is measured from. */
  readonly cutoffTs: number;
}

/** The leg key an NBBO folds into: expiry + strike + right (this tape interleaves two expiries). */
function legKey(expiry: string, strike: number, right: Right): string {
  return `${expiry}:${strike}:${right}`;
}

/**
 * Fold the OPRA SPOT + BOOK tape to the causal surface at `throughSeq` — latest NBBO per
 * `(expiry, strike, right)` and the latest underlier spot, considering ONLY events at/before the
 * cutoff (no look-ahead — the T-5m guard). Pure over the events; reads only `ts` (for aging/`tau`),
 * never a wall clock. `throughSeq` defaults to the last event's seq (the whole tape).
 */
export function reduceOptionSurface(events: Iterable<BusEvent>, throughSeq?: number): ReducedSurface {
  const legs = new Map<string, { expiry: string; strike: number; right: Right; bid: number | null; ask: number | null }>();
  let underlier = "";
  let spot: number | null = null;
  let spotTs: number | null = null;
  let cutoffSeq = -1;
  let cutoffTs = 0;
  for (const e of events) {
    if (throughSeq !== undefined && e.seq > throughSeq) break;
    cutoffSeq = e.seq;
    cutoffTs = e.ts;
    if (e.stream !== "TICK") continue;
    if (e.type === "SPOT") {
      spot = e.px;
      spotTs = e.ts;
      if (underlier === "") underlier = e.instrument;
    } else if (e.type === "BOOK") {
      const expiry = e.expiry ?? "";
      if (underlier === "") underlier = e.instrument;
      for (const l of e.legs) {
        legs.set(legKey(expiry, l.strike, l.right), {
          expiry,
          strike: l.strike,
          right: l.right,
          bid: l.bid,
          ask: l.ask,
        });
      }
    }
  }
  return { underlier, legs, spot, spotTs, cutoffSeq, cutoffTs };
}

// ─────────────────────────────────────────────────────────────────────────────
// Step 2 — build the projection (IV/greeks computed here, MODEL) from the frozen surface
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The declared default age (ms) past which an underlier spot is STALE — the feed stopped printing,
 * whatever the market did. Named ONCE (kestrel-rs4) so the two surfaces that age the SAME spot read
 * the SAME backstop and can never disagree about whether it is alive: this projection (a GEX/skew
 * read off a dead spot taints to UNKNOWN) and the levels pane (which tags the rendered value
 * `[STALE <age>]` rather than present a dead price with live confidence).
 */
export const SPOT_STALE_AFTER_MS = 120_000;

/** The knobs the projection is built under. `expiryCloseTs` maps an expiry label to its close
 * epoch-ms (supplied by the caller — dates are external context; the projection stays date-free and
 * only ever computes a duration from them). `openInterest` maps `${expiry}:${strike}:${right}` → OI. */
export interface OptionsAnalyticsConfig {
  /** Expiry label → close epoch-ms (the caller computes it; the projection only diffs it vs cutoff). */
  readonly expiryCloseTs: ReadonlyMap<string, number>;
  /** `${expiry}:${strike}:${right}` → open interest (from the OI sidecar). Absent ⇒ leg OI `null`. */
  readonly openInterest?: ReadonlyMap<string, number>;
  /** Per-contract multiplier (default 100). */
  readonly multiplier?: number;
  /** Risk-free rate (default 0 — Kestrel runs the surface at r = 0). */
  readonly rate?: number;
  /** Staleness backstop in ms: a spot older than this at the cutoff is stale (default
   * {@link SPOT_STALE_AFTER_MS}). */
  readonly staleMs?: number;
}

/** The two-sided liquid mid, or `null` when a side is dark/absent or the mid is non-positive. */
function liquidMid(bid: number | null, ask: number | null): number | null {
  if (bid === null || ask === null) return null;
  const mid = 0.5 * (bid + ask);
  return mid > 0 ? mid : null;
}

/** Whole days between two epoch-ms instants (floored, non-negative). Date-blind: a duration. */
function daysBetween(fromTs: number, toTs: number): number {
  return Math.max(0, Math.floor((toTs - fromTs) / 86_400_000));
}

/**
 * Build the {@link OptionsAnalytics} projection from a frozen {@link ReducedSurface} — computing
 * the MODEL IV/greeks per leg via Black-Scholes (r = 0) from the observed mid + spot + `tau`. Pure:
 * a total function of `surface` + `config`. A leg with no two-sided mid, an unknown `tau`, or an
 * unknown/stale spot yields `iv = null` (UNKNOWN — never a guessed IV); its greeks follow.
 */
export function buildOptionsAnalytics(surface: ReducedSurface, config: OptionsAnalyticsConfig): OptionsAnalytics {
  const multiplier = config.multiplier ?? 100;
  const rate = config.rate ?? 0;
  const staleMs = config.staleMs ?? SPOT_STALE_AFTER_MS;
  const { spot, spotTs, cutoffTs, cutoffSeq } = surface;

  const spotStaleSeconds = spotTs === null ? null : Math.max(0, Math.floor((cutoffTs - spotTs) / 1000));
  const spotStale = spot === null || spotTs === null || cutoffTs - spotTs > staleMs;

  // Group the reduced legs by expiry.
  const byExpiry = new Map<string, Array<{ strike: number; right: Right; bid: number | null; ask: number | null }>>();
  for (const l of surface.legs.values()) {
    const arr = byExpiry.get(l.expiry) ?? [];
    arr.push({ strike: l.strike, right: l.right, bid: l.bid, ask: l.ask });
    byExpiry.set(l.expiry, arr);
  }

  const expiries: OptionsExpiry[] = [];
  for (const [expiry, rawLegs] of byExpiry) {
    const closeTs = config.expiryCloseTs.get(expiry);
    const tauYears = closeTs === undefined ? null : Math.max(0, (closeTs - cutoffTs) / MS_PER_YEAR);
    const daysToExpiry = closeTs === undefined ? null : daysBetween(cutoffTs, closeTs);

    const legs: OptionAnalyticsLeg[] = rawLegs
      .map((r): OptionAnalyticsLeg => {
        const mid = liquidMid(r.bid, r.ask);
        const oi = config.openInterest?.get(legKey(expiry, r.strike, r.right)) ?? null;
        // IV/greeks are UNKNOWN unless we have a real mid, a real forward, and a positive tau.
        let iv: number | null = null;
        let delta: number | null = null;
        let gamma: number | null = null;
        let vega: number | null = null;
        if (mid !== null && spot !== null && !spotStale && tauYears !== null && tauYears > 0) {
          iv = impliedVol({ forward: spot, strike: r.strike, tauYears, price: mid, right: r.right });
          if (iv !== null) {
            const g = bsGreeks({ forward: spot, strike: r.strike, tauYears, sigma: iv, right: r.right });
            delta = g.delta;
            gamma = g.gamma;
            vega = g.vega;
          }
        }
        return { strike: r.strike, right: r.right, bid: r.bid, ask: r.ask, mid, iv, delta, gamma, vega, oi };
      })
      .sort((a, b) => (a.strike - b.strike) || (a.right < b.right ? -1 : a.right > b.right ? 1 : 0));

    expiries.push({ expiry, tauYears, daysToExpiry, legs });
  }
  // Front-first (earliest close first; unknown-close expiries sort last, stably).
  expiries.sort((a, b) => {
    const ca = config.expiryCloseTs.get(a.expiry);
    const cb = config.expiryCloseTs.get(b.expiry);
    if (ca === undefined && cb === undefined) return a.expiry < b.expiry ? -1 : 1;
    if (ca === undefined) return 1;
    if (cb === undefined) return -1;
    return ca - cb;
  });

  const method = `Black-Scholes r=${rate}; tau=(expiry-close − cutoff)/365d; IV from two-sided NBBO mid`;

  return {
    underlier: surface.underlier,
    spot,
    spotStale,
    spotStaleSeconds,
    asOfSeq: cutoffSeq,
    multiplier,
    rate,
    method,
    expiries,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// OI sidecar helper — parse the normalized open-interest rows into the projection key map
// ─────────────────────────────────────────────────────────────────────────────

/** One row of the normalized OI sidecar (`data/tape-corpus/normalized/*-oi-*.json`). */
export interface OpenInterestRow {
  readonly expiry: string;
  readonly strike: number;
  readonly right: Right;
  readonly oi: number;
}

/** Fold OI sidecar rows into the `${expiry}:${strike}:${right}` → OI map the projection reads. */
export function openInterestMap(rows: readonly OpenInterestRow[]): ReadonlyMap<string, number> {
  const m = new Map<string, number>();
  for (const r of rows) m.set(legKey(r.expiry, r.strike, r.right), r.oi);
  return m;
}
