/**
 * # series/windows — window series over ring buffers + trailing baselines (RUNTIME §2)
 *
 * The magnitude/rate half of canonical state. Every rate/magnitude series carries a
 * **window** (`velocity(1m)`, `move(1w)`, `range(1d)`) and there is **no absolute shock**:
 * a value is judged against *that window's own trailing baseline* (`p99`, `3sigma`,
 * CONTEXT: Window). This file owns:
 *
 * 1. A spot **ring buffer** — the `(ts, px)` samples fed from SPOT ticks, the substrate
 *    every windowed metric reads back through (never recomputed per consumer, RUNTIME §2).
 * 2. The **three window metrics**, each with a `$/window` and a `%/window` reading:
 *    - `velocity(w)` — the **signed** change over the trailing window `spot(now) −
 *      spot(now−w)`: direction matters (a fast *up*-move). `$/window` = the signed dollar
 *      change; `%/window` = that change over the window-start price.
 *    - `move(w)` — the **magnitude** `|spot(now) − spot(now−w)|`: "how big was the move"
 *      regardless of sign (so `move(1w) > p99` catches a big move either way).
 *    - `range(w)` — the peak-to-trough **excursion** `max − min` over the trailing window.
 *    (velocity is signed, move is its unsigned magnitude — they are deliberately different
 *    series, not a duplication.)
 * 3. **Trailing baseline distributions** per (metric, window): a fixed-capacity ring of
 *    recent window-values supporting `p50/p95/p99` (nearest-rank) and `sigma` (population
 *    stddev).
 *
 * ## Warmup (UNKNOWN until warm — RUNTIME §2, documented rule)
 * - A **window metric** reads UNKNOWN until the buffer *spans* the window — i.e. there is a
 *   sample at or before `now − w`. Before that a lookback would extrapolate off the start
 *   of the buffer, so it reads UNKNOWN, never a guess.
 * - A **baseline** reads UNKNOWN until its ring holds at least `minBaselineSamples` values
 *   (default 30) — a floor for a stable tail estimate; configurable.
 *
 * ## Determinism (RUNTIME §0)
 * All time is injected via the sample `ts`; no wall clock, no RNG. A (metric, window)
 * baseline is *tracked* (its ring starts filling) from the first tick after it is
 * registered — so for byte-identical replay the driver pre-{@link WindowEngine.track}s the
 * combos referenced by the armed documents (their set is fixed a priori). Same bus + same
 * tracked set ⇒ identical baselines.
 */

import type { TimeUnit } from "../lang/index.ts";
import { durationMs, UNKNOWN, type Unknown } from "./types.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Metrics + readings
// ─────────────────────────────────────────────────────────────────────────────

/** The three window metrics. `velocity` is signed; `move` is its unsigned magnitude;
 * `range` is the peak-to-trough excursion. */
export type WindowMetric = "velocity" | "move" | "range";

/** A window reading in both framings: `dollars` = the `$/window` value; `pct` = the
 * `%/window` value (a fraction, e.g. 0.012 = 1.2%). */
export interface WindowValue {
  readonly dollars: number;
  readonly pct: number;
}

/** The baseline statistic requested against a window series. `p` with `n∈[0,100]` is a
 * nearest-rank percentile; `sigma` with `n` is `n` population standard deviations above the
 * mean (the portable form of "big", CONTEXT: Window). */
export type BaselineStat = { readonly kind: "p"; readonly n: number } | { readonly kind: "sigma"; readonly n: number };

/** Configuration for the window engine. All optional; the defaults are documented above. */
export interface WindowConfig {
  /** Trailing samples retained per baseline ring (default 512). */
  readonly baselineCapacity?: number;
  /** Minimum baseline samples before `p*`/`sigma` warm from UNKNOWN (default 30). */
  readonly minBaselineSamples?: number;
  /** Hard cap on retained spot samples (oldest evicted beyond it; default 200_000). A
   * bounded session never approaches this — it only fences a pathological stream. */
  readonly maxSamples?: number;
}

const DEFAULTS = { baselineCapacity: 512, minBaselineSamples: 30, maxSamples: 200_000 } as const;

// ─────────────────────────────────────────────────────────────────────────────
// Baseline ring — a fixed-capacity trailing distribution
// ─────────────────────────────────────────────────────────────────────────────

class BaselineRing {
  private readonly buf: number[] = [];
  private head = 0;
  constructor(private readonly cap: number) {}

  push(v: number): void {
    if (this.buf.length < this.cap) {
      this.buf.push(v);
    } else {
      this.buf[this.head] = v;
      this.head = (this.head + 1) % this.cap;
    }
  }

  get count(): number {
    return this.buf.length;
  }

  /** Nearest-rank percentile `n∈[0,100]` over the current trailing sample. */
  percentile(n: number): number {
    const sorted = [...this.buf].sort((a, b) => a - b);
    const N = sorted.length;
    // nearest-rank: rank = ceil(p/100 * N), clamped to [1, N]; index rank-1.
    const rank = Math.min(N, Math.max(1, Math.ceil((n / 100) * N)));
    return sorted[rank - 1] as number; // rank∈[1,N] ⇒ index in range
  }

  /** Mean + `k` population standard deviations. */
  sigmaBand(k: number): number {
    const N = this.buf.length;
    let sum = 0;
    for (const v of this.buf) sum += v;
    const mean = sum / N;
    let ss = 0;
    for (const v of this.buf) ss += (v - mean) * (v - mean);
    const sd = Math.sqrt(ss / N);
    return mean + k * sd;
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// The window engine
// ─────────────────────────────────────────────────────────────────────────────

interface Sample {
  readonly ts: number;
  readonly px: number;
}

/** The key a (metric, windowMs) pair reduces to for the baseline registry. */
function comboKey(metric: WindowMetric, windowMs: number): string {
  return `${metric}|${windowMs}`;
}

export class WindowEngine {
  private readonly samples: Sample[] = [];
  private readonly baselines = new Map<string, BaselineRing>();
  private readonly cfg: Required<WindowConfig>;

  constructor(cfg: WindowConfig = {}) {
    this.cfg = {
      baselineCapacity: cfg.baselineCapacity ?? DEFAULTS.baselineCapacity,
      minBaselineSamples: cfg.minBaselineSamples ?? DEFAULTS.minBaselineSamples,
      maxSamples: cfg.maxSamples ?? DEFAULTS.maxSamples,
    };
  }

  /** Register a (metric, window) combo so its trailing baseline starts filling. Idempotent.
   * Pre-track the armed set for byte-identical replay (see file header). */
  track(metric: WindowMetric, value: number, unit: TimeUnit): void {
    const key = comboKey(metric, durationMs(value, unit));
    if (!this.baselines.has(key)) this.baselines.set(key, new BaselineRing(this.cfg.baselineCapacity));
  }

  /** Feed one SPOT sample (ts strictly non-decreasing — the injected clock). Appends to the
   * ring, evicts beyond `maxSamples`, then pushes the current value of every *tracked* combo
   * into its trailing baseline. */
  pushSpot(ts: number, px: number): void {
    this.samples.push({ ts, px });
    if (this.samples.length > this.cfg.maxSamples) this.samples.shift();
    for (const [key, ring] of this.baselines) {
      const [metric, windowMsStr] = key.split("|") as [WindowMetric, string];
      const wv = this.compute(metric, Number(windowMsStr), ts);
      if (wv !== UNKNOWN) ring.push(wv.dollars);
    }
  }

  /** The current reading of a window metric at `now`, or UNKNOWN until the buffer spans the
   * window. `now` is the injected clock (typically the latest sample's ts). */
  value(metric: WindowMetric, value: number, unit: TimeUnit, now: number): WindowValue | Unknown {
    return this.compute(metric, durationMs(value, unit), now);
  }

  /** The baseline statistic of a window metric, or UNKNOWN if the combo is untracked or has
   * fewer than `minBaselineSamples` trailing samples. */
  baseline(metric: WindowMetric, value: number, unit: TimeUnit, stat: BaselineStat): number | Unknown {
    const ring = this.baselines.get(comboKey(metric, durationMs(value, unit)));
    if (ring === undefined || ring.count < this.cfg.minBaselineSamples) return UNKNOWN;
    return stat.kind === "p" ? ring.percentile(stat.n) : ring.sigmaBand(stat.n);
  }

  /** Number of retained spot samples (test/introspection). */
  get sampleCount(): number {
    return this.samples.length;
  }

  reset(): void {
    this.samples.length = 0;
    this.baselines.clear();
  }

  // ── internals ──────────────────────────────────────────────────────────────

  private compute(metric: WindowMetric, windowMs: number, now: number): WindowValue | Unknown {
    const cutoff = now - windowMs;
    const startPx = this.priceAtOrBefore(cutoff);
    if (startPx === undefined) return UNKNOWN; // buffer does not span the window
    const nowPx = this.latestPx();
    if (nowPx === undefined) return UNKNOWN;

    if (metric === "range") {
      let hi = -Infinity;
      let lo = Infinity;
      for (let i = this.samples.length - 1; i >= 0; i--) {
        const s = this.samples[i] as Sample;
        if (s.ts < cutoff) break;
        if (s.px > hi) hi = s.px;
        if (s.px < lo) lo = s.px;
      }
      const dollars = hi - lo;
      return { dollars, pct: startPx !== 0 ? dollars / Math.abs(startPx) : 0 };
    }

    const delta = nowPx - startPx;
    const signed = metric === "velocity" ? delta : Math.abs(delta);
    return { dollars: signed, pct: startPx !== 0 ? signed / Math.abs(startPx) : 0 };
  }

  /** The most recent sample price at or before `cutoff`, or undefined if every sample is
   * newer than the cutoff (the buffer does not span the window ⇒ UNKNOWN). */
  private priceAtOrBefore(cutoff: number): number | undefined {
    for (let i = this.samples.length - 1; i >= 0; i--) {
      const s = this.samples[i] as Sample;
      if (s.ts <= cutoff) return s.px;
    }
    return undefined;
  }

  private latestPx(): number | undefined {
    const last = this.samples[this.samples.length - 1];
    return last?.px;
  }
}
