/**
 * # bus/synthetic — a seeded, deterministic synthetic session generator (public test substrate)
 *
 * A publication-safe substrate for grading, replay, and engine tests: given a seed and a few
 * session parameters, it emits a **full bus** — `META`, `SPOT` ticks along a seeded random
 * walk with **regime segments** (trend / chop / spike), an option `BOOK` around spot whose
 * spreads widen on velocity and that occasionally goes one-sided or dark, and `HEARTBEAT`
 * cadence events. It reveals no strategy and no real market data (ARCHITECTURE §7): tickers
 * are generic and the tape is pure PRNG.
 *
 * **Determinism (RUNTIME §0):** all randomness is a single {@link mulberry32} stream seeded
 * from the config; all time is derived from `date` + bar index (no wall clock). The same
 * config therefore yields a byte-identical bus — the same events in the same order with the
 * same `seq`. Feed the result through {@link ../bus/write.ts serializeBus} to get the bytes.
 */

import type { BusEvent, NewBusEvent, OptionQuote, SessionInstrument } from "./types.ts";
import { type Mode, type SessionPhase } from "./types.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Config
// ─────────────────────────────────────────────────────────────────────────────

export interface SyntheticConfig {
  /** Seeds the single PRNG stream — the sole source of randomness. */
  readonly seed: number;
  /** Session calendar date, `YYYY-MM-DD`. Anchors every timestamp deterministically. */
  readonly date: string;
  /** The underlier symbol (generic only: `SPX` / `SPY` / `QQQ` / `AAPL`). */
  readonly instrument: string;
  /** Opening spot. */
  readonly spot0: number;
  /** Per-bar volatility as a return fraction (e.g. `0.0008`). Scaled by the active regime. */
  readonly vol: number;
  /** Session length in minutes. */
  readonly sessionMinutes: number;
  /** Bar cadence in seconds — one SPOT (and one BOOK, if options) per bar. */
  readonly barSeconds: number;
  /** Order mode stamped on META. Default `sim`. */
  readonly mode?: Mode;
  /** Emit an option BOOK each bar. `false` ⇒ an equity-only session (SPOT tape, no legs).
   * Default `true`. */
  readonly withOptions?: boolean;
  /** Strikes each side of the money (total legs = `(2·strikes + 1)·2` C+P). Default 3. */
  readonly strikes?: number;
  /** Strike grid step. Default 1. */
  readonly strikeStep?: number;
  /** The expiry tag stamped on each BOOK (e.g. `0dte`). Default `0dte`. */
  readonly expiry?: string;
  /** Heartbeat cadence in seconds. Default 60. */
  readonly heartbeatEverySeconds?: number;
}

// ─────────────────────────────────────────────────────────────────────────────
// PRNG (mulberry32) + Gaussian
// ─────────────────────────────────────────────────────────────────────────────

/** A mulberry32 PRNG: 32-bit-seeded, uniform in [0,1). Pure, fast, fully reproducible —
 * the one randomness source on this deterministic path (no unseeded RNG, RUNTIME §0). */
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Standard-normal draw via Box–Muller, consuming two uniforms from `rng`. */
function gaussian(rng: () => number): number {
  const u1 = Math.max(rng(), 1e-12);
  const u2 = rng();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// ─────────────────────────────────────────────────────────────────────────────
// Rounding helpers (deterministic)
// ─────────────────────────────────────────────────────────────────────────────

const r2 = (x: number): number => Math.round(x * 100) / 100;

// ─────────────────────────────────────────────────────────────────────────────
// Regimes
// ─────────────────────────────────────────────────────────────────────────────

type Regime = "trend" | "chop" | "spike";

interface RegimeState {
  regime: Regime;
  /** Bars remaining in this segment before a new one is drawn. */
  left: number;
  /** Signed drift for the active segment (per bar). */
  drift: number;
  /** Volatility multiplier for the active segment. */
  volMult: number;
  /** Log-price anchor a chop segment mean-reverts toward. */
  anchorLog: number;
}

/** Draw the next regime segment (kind, duration, drift, vol multiplier) from the PRNG. */
function nextRegime(rng: () => number, vol: number, curLog: number): RegimeState {
  const pick = rng();
  const dir = rng() < 0.5 ? -1 : 1;
  if (pick < 0.5) {
    // chop: flat drift, damped vol, mean-reverting to where it started
    return { regime: "chop", left: 8 + Math.floor(rng() * 20), drift: 0, volMult: 0.6, anchorLog: curLog };
  }
  if (pick < 0.9) {
    // trend: a persistent signed drift, normal vol
    return {
      regime: "trend",
      left: 10 + Math.floor(rng() * 25),
      drift: dir * vol * (1.2 + rng() * 1.8),
      volMult: 1,
      anchorLog: curLog,
    };
  }
  // spike: brief, violent, directional
  return {
    regime: "spike",
    left: 2 + Math.floor(rng() * 3),
    drift: dir * vol * (6 + rng() * 8),
    volMult: 3,
    anchorLog: curLog,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// Option book synthesis
// ─────────────────────────────────────────────────────────────────────────────

/** Build the option legs around `spot` for one bar. Spreads widen with `ewmaVel` (velocity)
 * and with distance from the money; a small PRNG-driven fraction of legs go one-sided or
 * fully dark (the MM-pull fingerprint). Consumes the PRNG in a fixed per-leg order. */
function buildLegs(
  rng: () => number,
  spot: number,
  ewmaVel: number,
  remainingBars: number,
  totalBars: number,
  strikes: number,
  step: number,
  vol: number,
): OptionQuote[] {
  const legs: OptionQuote[] = [];
  const center = Math.round(spot / step) * step;
  const tvScale = step * (strikes + 2);
  const timeMag = Math.sqrt(Math.max(remainingBars, 1) / totalBars);

  for (let k = -strikes; k <= strikes; k++) {
    const strike = r2(center + k * step);
    for (const right of ["C", "P"] as const) {
      const intrinsic = right === "C" ? Math.max(spot - strike, 0) : Math.max(strike - spot, 0);
      const dist = Math.abs(spot - strike);
      const tv = spot * vol * timeMag * 8 * Math.exp(-0.5 * (dist / tvScale) ** 2);
      const mid = intrinsic + tv;

      // half-spread: a floor, widened by velocity and by distance from the money
      let half = Math.max(0.02, 0.01 * mid);
      half *= 1 + 40 * ewmaVel;
      half *= 1 + 0.5 * (dist / (strikes * step + step));
      half = Math.min(half, Math.max(mid * 0.5, 0.05));

      const bidRaw = Math.max(0, mid - half);
      const askRaw = mid + half;

      // darkness: rare full-dark, occasional one-sided
      const droll = rng();
      let bid: number | null;
      let ask: number | null;
      let bsz: number | null;
      let asz: number | null;
      if (droll < 0.02) {
        // fully dark
        bid = null;
        ask = null;
        bsz = null;
        asz = null;
        // keep PRNG cadence stable across branches: still burn the size/last draws
        rng();
        rng();
        rng();
      } else if (droll < 0.1) {
        // one-sided: drop bid or ask
        const dropBid = rng() < 0.5;
        const szB = 1 + Math.floor(rng() * 40);
        const szA = 1 + Math.floor(rng() * 40);
        if (dropBid) {
          bid = null;
          bsz = null;
          ask = r2(askRaw);
          asz = szA;
        } else {
          bid = r2(bidRaw);
          bsz = szB;
          ask = null;
          asz = null;
        }
      } else {
        // two-sided
        bid = r2(bidRaw);
        ask = r2(askRaw);
        bsz = 1 + Math.floor(rng() * 40);
        asz = 1 + Math.floor(rng() * 40);
        rng(); // keep draw count aligned with the one-sided branch (dropBid + 2 sizes)
      }

      // last trade prints sometimes (independent of current book side)
      const lastRoll = rng();
      const includeLast = lastRoll < 0.4 && (intrinsic > 0 || tv > 0.03);

      legs.push({
        strike,
        right,
        bid,
        ask,
        bsz,
        asz,
        ...(includeLast ? { last: r2(mid) } : {}),
      });
    }
  }
  return legs;
}

// ─────────────────────────────────────────────────────────────────────────────
// Session generation
// ─────────────────────────────────────────────────────────────────────────────

/** The session phase for bar `i` of `n` bars — open on the first, close on the last. */
function phaseFor(i: number, n: number): SessionPhase {
  if (i === 0) return "open";
  if (i >= n - 1) return "close";
  return "regular";
}

/** Parse `YYYY-MM-DD` into the session's opening epoch ms. The time-of-day anchor (13:30 UTC)
 * is a fixed, timezone-free constant chosen only so timestamps are deterministic; the bus is
 * its own clock (RUNTIME §0), so the absolute wall value is immaterial. */
function sessionStartMs(date: string): number {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
  if (m === null) throw new Error(`synthetic: date must be YYYY-MM-DD, got ${JSON.stringify(date)}`);
  const [, y, mo, d] = m;
  return Date.UTC(Number(y), Number(mo) - 1, Number(d), 13, 30, 0, 0);
}

/**
 * Generate a full synthetic session as an in-order, `seq`-stamped {@link BusEvent} array.
 * Serialize with {@link ../bus/write.ts serializeBus} (or {@link ../bus/write.ts writeBusFile}).
 * Same config ⇒ byte-identical output.
 */
export function generateSession(cfg: SyntheticConfig): readonly BusEvent[] {
  const mode: Mode = cfg.mode ?? "sim";
  const withOptions = cfg.withOptions ?? true;
  const strikes = cfg.strikes ?? 3;
  const step = cfg.strikeStep ?? 1;
  const expiry = cfg.expiry ?? "0dte";
  const hbEvery = cfg.heartbeatEverySeconds ?? 60;

  if (cfg.barSeconds <= 0) throw new Error("synthetic: barSeconds must be positive");
  if (cfg.sessionMinutes <= 0) throw new Error("synthetic: sessionMinutes must be positive");

  const rng = mulberry32(cfg.seed);
  const startMs = sessionStartMs(cfg.date);
  const nBars = Math.max(1, Math.floor((cfg.sessionMinutes * 60) / cfg.barSeconds));

  const events: BusEvent[] = [];
  let seq = 0;
  const emit = (ev: NewBusEvent): void => {
    events.push({ seq: seq++, ...ev } as BusEvent);
  };

  // — META (session header) —
  const assetClass: SessionInstrument["assetClass"] = withOptions ? "option-underlier" : "equity";
  const instrument: SessionInstrument = { symbol: cfg.instrument, assetClass, role: "signal" };
  emit({
    ts: startMs,
    stream: "META",
    type: "session",
    session_date: cfg.date,
    instruments: [instrument],
    mode,
    bus_schema: 5, // INPUT-TAPE PINNED LITERAL (clock-honest wakes §1): an input tape never carries a deliberation record; re-stamping the corpus would re-mint every frozen tape hash (ADR-0018). Only graded OUTPUT buses follow BUS_SCHEMA.
  });

  // — the walk —
  let spot = cfg.spot0;
  let ewmaVel = 0;
  let reg = nextRegime(rng, cfg.vol, Math.log(spot));

  for (let i = 0; i < nBars; i++) {
    const ts = startMs + i * cfg.barSeconds * 1000;

    if (reg.left <= 0) reg = nextRegime(rng, cfg.vol, Math.log(spot));
    reg.left -= 1;

    const z = gaussian(rng);
    let ret = reg.drift + cfg.vol * reg.volMult * z;
    if (reg.regime === "chop") ret += -0.15 * (Math.log(spot) - reg.anchorLog);
    spot = Math.max(0.01, spot * (1 + ret));
    ewmaVel = 0.7 * ewmaVel + 0.3 * Math.abs(ret);

    // SPOT
    emit({ ts, stream: "TICK", type: "SPOT", instrument: cfg.instrument, px: r2(spot) });

    // BOOK
    if (withOptions) {
      const legs = buildLegs(rng, spot, ewmaVel, nBars - i, nBars, strikes, step, cfg.vol);
      emit({
        ts,
        stream: "TICK",
        type: "BOOK",
        instrument: cfg.instrument,
        underlier_px: r2(spot),
        expiry,
        legs,
      });
    }

    // HEARTBEAT on cadence
    if ((i * cfg.barSeconds) % hbEvery === 0) {
      emit({ ts, stream: "TICK", type: "HEARTBEAT", phase: phaseFor(i, nBars) });
    }
  }

  // — closing heartbeat, if the cadence did not already land on the last bar —
  if (((nBars - 1) * cfg.barSeconds) % hbEvery !== 0) {
    const ts = startMs + (nBars - 1) * cfg.barSeconds * 1000;
    emit({ ts, stream: "TICK", type: "HEARTBEAT", phase: "close" });
  }

  return events;
}
