// ═══════════════════════════════════════════════════════════════════════════
// The instrument — one market, two sizing rules.
//
// Both paths receive exactly the same shocks. They differ only in how much
// they bet:
//
//   Plug-in Kelly  f = κ̂                  constant leverage on a point estimate
//                                          of the edge that happens to be too high
//   Bayes GZ       f = κ̄(n) · (1 − F/W)   the same estimate, shrunk by evidence
//                                          n, applied to the cushion above the
//                                          drawdown floor F = (1 − b)·M (Grossman–
//                                          Zhou: the multiplier on the cushion is κ̄)
//
// c = (1 − F/W) / b is the normalised cushion: 1 at the peak, 0 at the floor.
// Because the multiplier is on the cushion, not on wealth, the Bayes path
// moves far less than the plug-in path — which is the finding, not a bug.
//
// The Bayes path is coloured by c through the ramp, the cushion band between
// path and floor is filled with the same colour, and the exposure bars at the
// foot show f collapsing as the floor approaches. The plug-in path is stopped
// where it breaches the mandate.
//
// Illustrative, not calibrated: GBM with daily steps; the plug-in estimate is
// twice the true Kelly fraction, i.e. a bet with zero expected log-growth.
// ═══════════════════════════════════════════════════════════════════════════

const SIM = {
  N: 480,          // steps shown per episode
  SIG: 0.009,      // vol per step
  MU: 1.2 * 0.009 * 0.009, // true drift → true Kelly κ = MU/SIG² = 1.2
  K_HAT: 2.4,      // plug-in estimate of κ: twice the truth
  N0: 60,          // observations behind that estimate
  N_PRIOR: 140,    // prior weight, in observations, toward zero edge
  SPEED: 36,       // steps per second
  HOLD: 3.8,       // seconds to hold a finished episode
};

const mulberry32 = (a) => () => {
  a |= 0; 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;
};

const simulate = (seed, b) => {
  const { N, MU, SIG, K_HAT, N0, N_PRIOR } = SIM;
  const rnd = mulberry32(seed);
  const gauss = () => {
    let u = 0, v = 0;
    while (u === 0) u = rnd();
    while (v === 0) v = rnd();
    return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
  };

  const A = { w: new Float64Array(N + 1), floor: new Float64Array(N + 1), c: new Float64Array(N + 1), f: new Float64Array(N + 1), n: new Float64Array(N + 1) };
  const B = { w: new Float64Array(N + 1), dd: new Float64Array(N + 1), breach: -1 };

  let wa = 1, ma = 1, wb = 1, mb = 1;
  // Running estimate of the edge: starts at the (optimistic) window estimate.
  let sumR = K_HAT * SIG * SIG * N0, n = N0;

  for (let t = 0; t <= N; t++) {
    const floor = (1 - b) * ma;
    const cushion = Math.max(0, 1 - floor / wa);
    const c = Math.min(1, cushion / b);
    const kHat = (sumR / n) / (SIG * SIG);
    const kBar = kHat * n / (n + N_PRIOR);
    const fa = Math.max(0, kBar * cushion);

    A.w[t] = wa; A.floor[t] = floor; A.c[t] = c; A.f[t] = fa; A.n[t] = n;
    B.w[t] = wb; B.dd[t] = 1 - wb / mb;
    if (t === N) break;

    const r = MU + SIG * gauss();
    wa = Math.max(1e-6, wa * (1 + fa * r));
    ma = Math.max(ma, wa);
    if (B.breach < 0) {
      wb = Math.max(1e-6, wb * (1 + K_HAT * r));
      mb = Math.max(mb, wb);
      if (1 - wb / mb >= b) B.breach = t + 1;
    }
    sumR += r; n += 1;
  }
  if (B.breach >= 0) B.w[B.breach] = (1 - b) * Math.max(...B.w.slice(0, B.breach));
  return { A, B, b, seed };
};

// Pick an episode that tells the story honestly but legibly: the plug-in rule
// breaches somewhere in the middle, and the Bayes path visibly uses its
// cushion. Rejected seeds are not hidden — the caption says it is illustrative.
const pickEpisode = (start, b) => {
  for (let s = start; s < start + 600; s++) {
    const e = simulate(s, b);
    const minC = Math.min(...e.A.c);
    if (e.B.breach > SIM.N * 0.15 && e.B.breach < SIM.N * 0.8 && minC < 0.75 && e.A.w[SIM.N] > 1) return e;
  }
  return simulate(start, b);
};

const fmt = (x, d = 2) => (Math.abs(x) < 1e-9 ? 0 : x).toFixed(d);

const Instrument = () => {
  const I = SITE.instrument;
  const host = React.useRef(null);
  const cvs = React.useRef(null);
  const flag = React.useRef(null);
  const hud = React.useRef({});
  const stops = useRamp();
  const visible = useInView(host, '120px');

  const [b, setB] = React.useState(0.2);
  const [playing, setPlaying] = React.useState(() => !REDUCED());
  const [seedBase, setSeedBase] = React.useState(() => 1 + Math.floor(Math.random() * 5000));

  const ep = React.useMemo(() => pickEpisode(seedBase, b), [seedBase, b]);
  const st = React.useRef({ k: REDUCED() ? SIM.N : 0, hold: 0, fade: 1 });

  // A new episode or a new barrier restarts the draw.
  React.useEffect(() => { st.current = { k: REDUCED() ? SIM.N : 0, hold: 0, fade: 1 }; }, [ep]);

  const segRef = React.useRef(null);
  const segBox = usePill(segRef, 'button[aria-pressed="true"]', [b]);

  React.useEffect(() => {
    const canvas = cvs.current, wrap = host.current;
    if (!canvas || !wrap) return;
    const ctx = canvas.getContext('2d');
    let W = 0, H = 0, dpr = 1;

    const size = () => {
      const r = canvas.getBoundingClientRect();
      dpr = Math.min(2, window.devicePixelRatio || 1);
      W = r.width; H = r.height;
      canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
    };
    size();
    const ro = new ResizeObserver(() => { size(); draw(); });
    ro.observe(canvas);

    const tok = () => {
      const cs = getComputedStyle(document.documentElement);
      return { ink3: cs.getPropertyValue('--ink-3').trim(), line: cs.getPropertyValue('--line').trim(), bg2: cs.getPropertyValue('--bg-2').trim() };
    };
    let T = tok();

    // y-range from the whole episode so the axes never jump mid-draw
    const { A, B } = ep;
    const kEnd = SIM.N;
    let lo = Infinity, hi = -Infinity;
    for (let t = 0; t <= kEnd; t++) {
      lo = Math.min(lo, Math.log(A.floor[t]), Math.log(A.w[t]));
      hi = Math.max(hi, Math.log(A.w[t]));
      if (B.breach < 0 || t <= B.breach) { lo = Math.min(lo, Math.log(B.w[t])); hi = Math.max(hi, Math.log(B.w[t])); }
    }
    const pad = (hi - lo) * 0.12;
    lo -= pad; hi += pad * 1.4;
    // Exposure band is scaled to the Bayes rule; the plug-in level is usually
    // off the top of it, and is labelled as such.
    const fMax = Math.max(...A.f) * 1.15 || 1;
    const bOff = SIM.K_HAT > fMax;

    const draw = () => {
      const k = Math.floor(st.current.k);
      const L = 8, R = W - 8;
      const top = 20, barH = Math.min(70, H * 0.17), gap = 22;
      const plotB = H - barH - gap - 8;
      const X = (t) => L + (t / SIM.N) * (R - L);
      const Y = (w) => top + (1 - (Math.log(w) - lo) / (hi - lo)) * (plotB - top);
      const BY = (f) => H - 8 - (f / fMax) * barH;

      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, W, H);
      ctx.globalAlpha = st.current.fade;

      // grid
      ctx.strokeStyle = T.line; ctx.lineWidth = 1;
      for (let i = 1; i < 4; i++) {
        const y = Math.round(top + (i / 4) * (plotB - top)) + 0.5;
        ctx.beginPath(); ctx.moveTo(L, y); ctx.lineTo(R, y); ctx.stroke();
      }
      const yb = Math.round(H - 8) + 0.5;
      ctx.beginPath(); ctx.moveTo(L, yb); ctx.lineTo(R, yb); ctx.stroke();

      // cushion band: floor → path, coloured by c
      const dx = (R - L) / SIM.N;
      for (let t = 0; t < k; t++) {
        const y0 = Y(A.w[t]), y1 = Y(A.floor[t]);
        ctx.fillStyle = rampAt(stops, A.c[t]);
        ctx.globalAlpha = 0.13 * st.current.fade;
        ctx.fillRect(X(t), y0, dx + 0.6, Math.max(0, y1 - y0));
      }
      ctx.globalAlpha = st.current.fade;

      // floor
      ctx.strokeStyle = rampAt(stops, 0); ctx.lineWidth = 1.5; ctx.setLineDash([5, 5]);
      ctx.beginPath();
      for (let t = 0; t <= k; t++) { const x = X(t), y = Y(A.floor[t]); t ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }
      ctx.stroke(); ctx.setLineDash([]);

      // plug-in path
      const kb = B.breach >= 0 ? Math.min(k, B.breach) : k;
      ctx.strokeStyle = T.ink3; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.75 * st.current.fade;
      ctx.lineJoin = 'round';
      ctx.beginPath();
      for (let t = 0; t <= kb; t++) { const x = X(t), y = Y(B.w[t]); t ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }
      ctx.stroke();
      ctx.globalAlpha = st.current.fade;

      // Bayes path, in runs of equal colour
      ctx.lineWidth = 2.4; ctx.lineCap = 'round';
      let t0 = 0;
      while (t0 < k) {
        const q = Math.round(A.c[t0] * 24);
        let t1 = t0 + 1;
        while (t1 < k && Math.round(A.c[t1] * 24) === q) t1++;
        ctx.strokeStyle = rampAt(stops, q / 24);
        ctx.beginPath(); ctx.moveTo(X(t0), Y(A.w[t0]));
        for (let t = t0 + 1; t <= t1; t++) ctx.lineTo(X(t), Y(A.w[t]));
        ctx.stroke();
        t0 = t1;
      }

      // exposure: Bayes bars coloured by c, plug-in as a flat rule
      for (let t = 0; t <= k; t += 2) {
        ctx.fillStyle = rampAt(stops, A.c[t]);
        const y = BY(A.f[t]);
        ctx.fillRect(X(t), y, Math.max(1, dx * 1.2), H - 8 - y);
      }
      if (kb > 0) {
        const yK = bOff ? H - 8 - barH - 4 : BY(SIM.K_HAT);
        ctx.strokeStyle = T.ink3; ctx.lineWidth = 1.2; ctx.setLineDash([2, 3]);
        ctx.beginPath(); ctx.moveTo(X(0), yK); ctx.lineTo(X(kb), yK); ctx.stroke();
        ctx.setLineDash([]);
        ctx.fillStyle = T.ink3; ctx.font = '600 11.5px ' + getComputedStyle(document.body).getPropertyValue('--f-sans');
        ctx.fillText(`plug-in ${SIM.K_HAT.toFixed(1)}×${bOff ? ' ↑ off scale' : ''}`, X(0) + 4, yK - 5);
      }

      // heads
      const hx = X(k), hy = Y(A.w[k]);
      const col = rampAt(stops, A.c[k]);
      if (k < SIM.N) {
        ctx.fillStyle = col; ctx.globalAlpha = 0.2 * st.current.fade;
        ctx.beginPath(); ctx.arc(hx, hy, 9 + 3 * Math.sin(performance.now() / 240), 0, Math.PI * 2); ctx.fill();
        ctx.globalAlpha = st.current.fade;
      }
      ctx.fillStyle = T.bg2; ctx.beginPath(); ctx.arc(hx, hy, 5, 0, Math.PI * 2); ctx.fill();
      ctx.strokeStyle = col; ctx.lineWidth = 2.4; ctx.stroke();

      if (B.breach >= 0 && k >= B.breach) {
        const x = X(B.breach), y = Y(B.w[B.breach]);
        ctx.strokeStyle = rampAt(stops, 0); ctx.lineWidth = 2;
        ctx.beginPath(); ctx.moveTo(x - 5, y - 5); ctx.lineTo(x + 5, y + 5); ctx.moveTo(x + 5, y - 5); ctx.lineTo(x - 5, y + 5); ctx.stroke();
      }
      ctx.globalAlpha = 1;

      // DOM overlays
      const fl = flag.current;
      if (fl) {
        const on = B.breach >= 0 && k >= B.breach;
        fl.dataset.on = on ? '1' : '0';
        if (on) {
          const x = Math.max(110, Math.min(W - 110, X(B.breach)));
          fl.style.left = x + 'px'; fl.style.top = Y(B.w[B.breach]) + 'px';
        }
      }
      const h = hud.current;
      if (h.c) {
        h.c.textContent = fmt(A.c[k]);
        h.cm.style.transform = `scaleX(${A.c[k].toFixed(3)})`;
        h.cm.style.background = col;
        h.f.textContent = fmt(A.f[k]) + '×';
        h.fm.style.transform = `scaleX(${(A.f[k] / fMax).toFixed(3)})`;
        h.fm.style.background = col;
        h.n.textContent = Math.round(A.n[k]);
        h.nm.style.transform = `scaleX(${(A.n[k] / (SIM.N0 + SIM.N)).toFixed(3)})`;
        const broke = B.breach >= 0 && k >= B.breach;
        h.b.textContent = broke ? 'breached' : '−' + fmt(B.dd[k] * 100, 1) + '%';
        h.b.style.color = broke ? 'var(--bar-ink)' : '';
        h.bm.style.transform = `scaleX(${broke ? 1 : Math.min(1, B.dd[k] / ep.b).toFixed(3)})`;
        h.bm.style.background = broke ? rampAt(stops, 0) : 'var(--ink-3)';
      }
    };

    const mo = new MutationObserver(() => { T = tok(); draw(); });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });

    let raf = 0, last = 0;
    const loop = (now) => {
      const dt = last ? Math.min(0.1, (now - last) / 1000) : 0;
      last = now;
      const s = st.current;
      if (s.k < SIM.N) s.k = Math.min(SIM.N, s.k + dt * SIM.SPEED);
      else if (s.hold < SIM.HOLD) s.hold += dt;
      else if (s.fade > 0) s.fade = Math.max(0, s.fade - dt * 2.2);
      else { setSeedBase((x) => x + 601); return; }
      draw();
      raf = requestAnimationFrame(loop);
    };

    draw();
    if (playing && visible && !REDUCED()) raf = requestAnimationFrame(loop);
    return () => { cancelAnimationFrame(raf); ro.disconnect(); mo.disconnect(); };
  }, [ep, stops, playing, visible]);

  const H = (key, label) => (
    <div className="hud">
      <div className="label">{label}</div>
      <div className="hud-v" ref={(el) => (hud.current[key] = el)}>—</div>
      <div className="hud-meter"><i ref={(el) => (hud.current[key + 'm'] = el)} /></div>
    </div>
  );

  return (
    <figure className="inst fade-up" style={{ '--d': '900ms' }} ref={host} aria-label={I.title}>
      <div className="inst-top">
        <div className="inst-legend">
          <span className="lg"><i className="lg-a" />{I.ruleA}</span>
          <span className="lg"><i className="lg-b" />{I.ruleB}</span>
          <span className="lg"><i className="lg-f" />Drawdown floor</span>
        </div>
        <div className="inst-ctl">
          <span className="label" style={{ marginRight: 4 }}>Barrier</span>
          <div className="seg" ref={segRef} role="group" aria-label="Drawdown barrier">
            {segBox && <span className="seg-pill" style={{ width: segBox.w, transform: `translateX(${segBox.x}px)` }} />}
            {[0.1, 0.2, 0.3].map((v) => (
              <button key={v} aria-pressed={b === v} onClick={() => setB(v)}>{Math.round(v * 100)}%</button>
            ))}
          </div>
          <button className="iconbtn" onClick={() => setSeedBase((x) => x + 601)} aria-label="New market path">
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3.2h-3.2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
          </button>
          <button className="iconbtn" onClick={() => setPlaying((p) => !p)} aria-label={playing ? 'Pause' : 'Play'}>
            {playing
              ? <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><rect x="3" y="2" width="3" height="10" rx="1" fill="currentColor" /><rect x="8" y="2" width="3" height="10" rx="1" fill="currentColor" /></svg>
              : <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 2.5v9l7.5-4.5z" fill="currentColor" /></svg>}
          </button>
        </div>
      </div>

      <div className="inst-plot">
        <canvas ref={cvs} role="img" aria-label={I.caption} />
        <div className="inst-flag" ref={flag}><i />{I.ruleB} breaches the mandate</div>
      </div>

      <div className="inst-hud">
        {H('c', 'Cushion  c')}
        {H('f', 'Exposure  f*')}
        {H('n', 'Evidence  n')}
        {H('b', 'Plug-in drawdown')}
      </div>

      <figcaption className="inst-cap">
        <p className="prose small">{I.caption}</p>
        <p className="small dim">{I.note}</p>
      </figcaption>
    </figure>
  );
};

Object.assign(window, { Instrument, simulate });
