/* loop-scene9.jsx — Chapter 9 inductor loop (the flywheel). Charges have
   MOMENTUM: their speed follows `cur` (the sim's lagged current), so they're
   slow to speed up when the source switches ON, and keep COASTING when it's
   cut. Energy is stored in the magnetic FIELD (rings around the coil, growing
   with cur) — the inductor's mirror of the capacitor's plate charge. Cutting
   the drive while current is high fires a back-EMF SPIKE (arc + flash).
   Driven by props (cur 0..1, driveOn). Names fl9-/LoopScene9 prefixed. */

function fl9HexRgb(h) { h = (h || "").trim().replace("#", ""); if (h.length === 3) h = h.split("").map(c => c + c).join(""); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
function fl9MixHex(a, b, t) { const A = fl9HexRgb(a), B = fl9HexRgb(b); return `rgb(${Math.round(A[0] + (B[0] - A[0]) * t)},${Math.round(A[1] + (B[1] - A[1]) * t)},${Math.round(A[2] + (B[2] - A[2]) * t)})`; }
const FL9_BLUE = "#2f6db0", FL9_AMBER = "#e0a32e", FL9_RED = "#c0392b";
function fl9Spectrum(e) { e = Math.max(0, Math.min(1, e)); return e >= 0.5 ? fl9MixHex(FL9_AMBER, FL9_BLUE, (e - 0.5) / 0.5) : fl9MixHex(FL9_RED, FL9_AMBER, e / 0.5); }

const FL9 = (() => {
  const X0 = 130, X1 = 690, Y0 = 110, Y1 = 350, rc = 64;
  const arc = (cx, cy, a0, a1, k) => { const o = []; for (let i = 0; i <= k; i++) { const a = a0 + (a1 - a0) * (i / k); o.push([cx + rc * Math.cos(a), cy + rc * Math.sin(a)]); } return o; };
  let pts = [[(X0 + X1) / 2, Y1], [X0 + rc, Y1]];
  pts.push(...arc(X0 + rc, Y1 - rc, Math.PI / 2, Math.PI, 12), [X0, Y0 + rc]);
  pts.push(...arc(X0 + rc, Y0 + rc, Math.PI, 1.5 * Math.PI, 12), [X1 - rc, Y0]);
  pts.push(...arc(X1 - rc, Y0 + rc, -Math.PI / 2, 0, 12), [X1, Y1 - rc]);
  pts.push(...arc(X1 - rc, Y1 - rc, 0, Math.PI / 2, 12), [(X0 + X1) / 2, Y1]);
  const dense = [pts[0].slice()];
  for (let i = 1; i < pts.length; i++) { const a = pts[i - 1], b = pts[i], d = Math.hypot(b[0] - a[0], b[1] - a[1]), k = Math.max(1, Math.round(d / 6)); for (let j = 1; j <= k; j++) { const t = j / k; dense.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); } }
  pts = dense;
  const n = pts.length, cum = [0];
  for (let i = 1; i < n; i++) cum.push(cum[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
  const P = cum[n - 1];
  function at(s) { s = ((s % P) + P) % P; let i = 1; while (i < n && cum[i] < s) i++; const a = pts[i - 1], b = pts[i] || pts[0], seg = cum[i] - cum[i - 1] || 1, f = (s - cum[i - 1]) / seg; let tx = b[0] - a[0], ty = b[1] - a[1]; const tl = Math.hypot(tx, ty) || 1; return { x: a[0] + (b[0] - a[0]) * f, y: a[1] + (b[1] - a[1]) * f, tx: tx / tl, ty: ty / tl }; }
  function sAt(px, py) { let best = 0, bd = Infinity; for (let i = 0; i < n; i++) { const d = (pts[i][0] - px) ** 2 + (pts[i][1] - py) ** 2; if (d < bd) { bd = d; best = cum[i]; } } return best; }
  return { P, at, sAt, pts, cum, X0, X1, Y0, Y1 };
})();

function LoopScene9({ cur, driveOn, kids }) {
  const cv = React.useRef(null);
  const propRef = React.useRef({ cur, driveOn, kids });
  propRef.current = { cur, driveOn, kids };

  React.useEffect(() => {
    const canvas = cv.current, ctx = canvas.getContext("2d");
    const VBW = 820, VBH = 460;
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    let scale = 1;
    function fit() { const cw = canvas.clientWidth || VBW; scale = cw / VBW; canvas.width = Math.round(cw * dpr); canvas.height = Math.round(VBH * scale * dpr); ctx.setTransform(dpr * scale, 0, 0, dpr * scale, 0, 0); }
    fit(); window.addEventListener("resize", fit);

    // re-read every frame (cheap) instead of once at mount — theme can switch
    // AFTER this effect runs (useCrossChapterPersistence sets data-theme in a
    // separate later effect), so a cached snapshot goes stale on first paint.
    function readColors() {
      const cs = getComputedStyle(document.body);
      return { ink: cs.getPropertyValue("--ink").trim() || "#2c2a26", faint: cs.getPropertyValue("--ink-faint").trim() || "#a79f90", card: cs.getPropertyValue("--bg-card").trim() || "#faf6ec", deep: cs.getPropertyValue("--bg-deeper").trim() || "#e6dfca" };
    }
    let C = readColors();
    const hWide = 16, rad = 4.6;

    const coilS = FL9.sAt(410, FL9.Y0);                 // coil on top straight
    const swS = FL9.sAt(190, FL9.Y0);                   // drive switch top-left
    const battS = FL9.sAt((FL9.X0 + FL9.X1) / 2, FL9.Y1);
    const coilPt = FL9.at(coilS), swPt = FL9.at(swS), battPt = FL9.at(battS);

    const N = 150;
    const ps = [];
    for (let i = 0; i < N; i++) ps.push({ s: Math.random() * FL9.P, u: (Math.random() * 2 - 1) * hWide * 0.5 });
    let prevDrive = propRef.current.driveOn, prevCur = propRef.current.cur, spike = 0, swOpen = propRef.current.driveOn ? 0 : 1;
    // vPeak is a PEAK-HOLD meter (like a real analog VU meter): it jumps
    // instantly to a new high and only decays slowly, so a one-frame spike
    // (which the raw per-frame di/dt estimate is — noisy and often only a
    // couple of animation frames wide) stays readable for ~1s instead of
    // flickering past before you can read the number.
    let vPeak = 0, dCurSmooth = 0, vFast = 0;
    let lastT = performance.now();
    let raf;

    function frame() {
      const nowT = performance.now();
      const dt = Math.max(0.004, Math.min(0.1, (nowT - lastT) / 1000)); lastT = nowT;
      C = readColors();
      const { cur: CUR, driveOn: DRV, kids: K } = propRef.current;
      const c = Math.max(0, Math.min(1, CUR));
      // di/dt this frame -> instantaneous coil voltage (opposes the change: rises as current FALLS)
      const dCur = c - prevCur;
      dCurSmooth += (dCur - dCurSmooth) * 0.3;         // smoothed direction, for the arrow/wording only
      // time-normalized derivative (per second), then EMA-averaged (~150ms)
      // so frame-to-frame jitter can't flicker the meter; the peak-hold on top
      // still catches the cut. Scale: ON-rise (rate ≈ 0.9/s) reads ~0.3 — a
      // small opposing voltage — while the forced OFF-collapse (offFactor×
      // faster, rate ≈ 3.2/s) pegs near the top: the real asymmetry.
      vFast += (Math.abs(dCur / dt) * 0.33 - vFast) * 0.12;
      const rawAbs = Math.min(1.05, vFast);
      if (rawAbs > vPeak) vPeak = rawAbs;
      else vPeak *= (vPeak > 1 ? 0.94 : 0.985);        // shed any over-ceiling excess fast, then decay readably
      // detect the cut: drive just went OFF while current was high -> back-EMF spike
      if (prevDrive && !DRV && prevCur > 0.45) spike = 1;
      spike *= 0.94;
      prevDrive = DRV; prevCur = c;
      swOpen += ((DRV ? 0 : 1) - swOpen) * 0.2;

      const speed = c * 4.2;                              // momentum: speed follows the lagged current
      for (let i = 0; i < N; i++) { const p = ps[i]; p.s = (p.s + speed) % FL9.P; p.u += (-p.u) * 0.05; }

      ctx.clearRect(0, 0, VBW, VBH);
      // channel
      const out = [], inn = [];
      for (let i = 0; i < FL9.pts.length; i++) { const a = FL9.pts[Math.max(0, i - 1)], cc = FL9.pts[Math.min(FL9.pts.length - 1, i + 1)]; let tx = cc[0] - a[0], ty = cc[1] - a[1]; const tl = Math.hypot(tx, ty) || 1; tx /= tl; ty /= tl; const nx = -ty, ny = tx; out.push([FL9.pts[i][0] + nx * hWide, FL9.pts[i][1] + ny * hWide]); inn.push([FL9.pts[i][0] - nx * hWide, FL9.pts[i][1] - ny * hWide]); }
      ctx.beginPath(); out.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])); for (let i = inn.length - 1; i >= 0; i--) ctx.lineTo(inn[i][0], inn[i][1]); ctx.closePath(); ctx.fillStyle = C.deep; ctx.fill();

      // magnetic field rings around the coil — energy stored in the B-field, grows with cur
      const fieldR = 10 + c * 46;
      if (c > 0.04) { ctx.save(); ctx.globalAlpha = 0.15 + 0.5 * c; ctx.strokeStyle = FL9_BLUE; ctx.lineWidth = 1.6; for (let k = 1; k <= 3; k++) { const r = fieldR * k / 3; ctx.beginPath(); ctx.ellipse(coilPt.x, coilPt.y, r, r * 0.62, 0, 0, 7); ctx.stroke(); } ctx.restore(); }

      // particles — colour follows current magnitude (brighter blue = more flow)
      for (let i = 0; i < N; i++) { const p = ps[i], a = FL9.at(p.s), nx = -a.ty, ny = a.tx; ctx.beginPath(); ctx.arc(a.x + nx * p.u, a.y + ny * p.u, rad, 0, 7); ctx.fillStyle = c > 0.05 ? fl9Spectrum(0.5 + 0.5 * c) : C.faint; ctx.fill(); }

      // channel outline
      ctx.lineWidth = 2; ctx.strokeStyle = C.ink; ctx.lineJoin = "round";
      ctx.beginPath(); out.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])); ctx.stroke();
      ctx.beginPath(); inn.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])); ctx.stroke();

      // ── the coil (inductor): a row of humps on the top wire — the ENERGY STORE ──
      const vAbs = Math.max(0, Math.min(1, vPeak));
      const arcAlpha = Math.max(spike, vAbs > 0.16 ? Math.min(1, (vAbs - 0.16) / 0.4) : 0);
      ctx.strokeStyle = arcAlpha > 0.15 ? FL9_RED : C.ink; ctx.lineWidth = 3; ctx.lineCap = "round";
      const humps = 4, hw = 56, hx0 = coilPt.x - hw / 2;
      ctx.beginPath();
      for (let i = 0; i <= 40; i++) { const t = i / 40, x = hx0 + t * hw, y = coilPt.y - Math.abs(Math.sin(t * humps * Math.PI)) * 13; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }
      ctx.stroke();
      ctx.fillStyle = C.faint; ctx.font = "600 10px 'IBM Plex Mono', monospace"; ctx.textAlign = "center";
      ctx.fillText(K ? "the coil (stores the push)" : "INDUCTOR (L) \u2014 stores energy", coilPt.x, coilPt.y - 30);

      // ── the drive switch: a REAL break in the wire, right where the circuit is cut ──
      // this is the WHOLE point: when it opens, current has nowhere left to go —
      // so voltage piles up right here until it's strong enough to leap the air gap.
      const gapW = 6 + swOpen * 32;
      const gx0 = swPt.x - gapW / 2, gx1 = swPt.x + gapW / 2;
      if (swOpen > 0.03) {
        ctx.clearRect(gx0 - 2, FL9.Y0 - hWide - 4, (gx1 - gx0) + 4, hWide * 2 + 8);
        ctx.strokeStyle = arcAlpha > 0.15 ? FL9_RED : C.ink; ctx.lineWidth = 3; ctx.lineCap = "round";
        ctx.beginPath(); ctx.moveTo(gx0, FL9.Y0 - 8); ctx.lineTo(gx0, FL9.Y0 + 8); ctx.stroke();
        ctx.beginPath(); ctx.moveTo(gx1, FL9.Y0 - 8); ctx.lineTo(gx1, FL9.Y0 + 8); ctx.stroke();
      }
      // the switch lever, seated ABOVE the break so it reads as "what cut this"
      const lever = 26, hingeX = gx0, baseY = FL9.Y0 - 30;
      ctx.strokeStyle = C.ink; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(gx0, baseY); ctx.lineTo(gx0, FL9.Y0 - 8); ctx.stroke();
      ctx.fillStyle = C.ink; ctx.beginPath(); ctx.arc(hingeX, baseY, 4, 0, 7); ctx.fill();
      const la = swOpen * 0.9, tx = hingeX + Math.cos(-la) * lever, ty = baseY - Math.sin(la) * lever - 2;
      ctx.strokeStyle = swOpen > 0.5 ? FL9_RED : C.ink; ctx.lineWidth = 4; ctx.lineCap = "round";
      ctx.beginPath(); ctx.moveTo(hingeX, baseY); ctx.lineTo(tx, ty); ctx.stroke();
      ctx.fillStyle = swOpen > 0.5 ? FL9_RED : FL9_BLUE; ctx.font = "600 10px 'IBM Plex Mono', monospace"; ctx.textAlign = "center";
      ctx.fillText(DRV ? (K ? "SOURCE ON" : "DRIVE ON") : (K ? "SOURCE OFF \u2014 path broken!" : "DRIVE OFF \u2014 path broken!"), swPt.x, baseY - 12);

      // the arc: current forced to LEAP the gap — this IS the voltage spike, made visible
      if (arcAlpha > 0.1) {
        ctx.save(); ctx.globalAlpha = arcAlpha; ctx.strokeStyle = FL9_RED; ctx.lineWidth = 2.2;
        let zx = gx0, zy = FL9.Y0; ctx.beginPath(); ctx.moveTo(zx, zy);
        for (let i = 0; i < 4; i++) { zx += (gx1 - gx0) / 4; zy = FL9.Y0 + (i % 2 ? 1 : -1) * (5 + Math.random() * 4); ctx.lineTo(zx, zy); }
        ctx.lineTo(gx1, FL9.Y0); ctx.stroke(); ctx.restore();
        ctx.fillStyle = FL9_RED; ctx.font = "600 12px 'Newsreader', serif"; ctx.textAlign = "center";
        ctx.globalAlpha = Math.min(1, arcAlpha * 1.4);
        ctx.fillText(K ? "\u26a1 jumps the gap!" : "\u26a1 forced to leap the gap", swPt.x, FL9.Y0 + 30);
        ctx.globalAlpha = 1;
      }

      // battery
      ctx.strokeStyle = C.ink; ctx.lineCap = "round";
      ctx.lineWidth = 3.4; ctx.beginPath(); ctx.moveTo(battPt.x - 30, battPt.y - 9); ctx.lineTo(battPt.x + 30, battPt.y - 9); ctx.stroke();
      ctx.lineWidth = 3.4; ctx.beginPath(); ctx.moveTo(battPt.x - 16, battPt.y + 6); ctx.lineTo(battPt.x + 16, battPt.y + 6); ctx.stroke();
      ctx.fillStyle = FL9_BLUE; ctx.font = "600 15px 'IBM Plex Mono', monospace"; ctx.fillText("+", battPt.x - 44, battPt.y - 3);
      ctx.fillStyle = C.ink; ctx.fillText("\u2013", battPt.x - 44, battPt.y + 15);

      // centre readout — CURRENT stacked directly above VOLTAGE, joined by an
      // arrow, so the causal chain reads top-to-bottom instead of two
      // side-by-side gauges: "current is forced to hold steady → that
      // resistance to change is what shows up as voltage, right below it."
      const mx = VBW / 2, my = VBH / 2 - 6, bw = 176, bh = 13;
      ctx.textAlign = "center";

      // current row — its OUTLINE flashes red in sync with the voltage spike
      // below; during a spike the label itself becomes the red flash text
      // (swapped, not stacked, so the two lines never overprint)
      if (vAbs > 0.1) {
        ctx.fillStyle = FL9_RED; ctx.font = "600 11px 'IBM Plex Mono', monospace";
        ctx.globalAlpha = 0.5 + Math.min(0.5, vAbs); ctx.fillText(K ? "\u25bc CURRENT — changing fast" : "\u25bc CURRENT — di/dt large right now", mx, my - 78); ctx.globalAlpha = 1;
      } else {
        ctx.fillStyle = C.faint; ctx.font = "600 11px 'IBM Plex Mono', monospace";
        ctx.fillText(K ? "WHEEL SPEED (current)" : "CURRENT", mx, my - 78);
      }
      ctx.fillStyle = C.deep; if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(mx - bw / 2, my - 70, bw, bh, 6.5); ctx.fill(); } else ctx.fillRect(mx - bw / 2, my - 70, bw, bh);
      ctx.fillStyle = fl9Spectrum(0.5 + 0.5 * c); const fw = Math.max(bh, bw * c); if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(mx - bw / 2, my - 70, fw, bh, 6.5); ctx.fill(); } else ctx.fillRect(mx - bw / 2, my - 70, fw, bh);
      if (vAbs > 0.1) { ctx.save(); ctx.globalAlpha = Math.min(1, vAbs * 1.6); ctx.strokeStyle = FL9_RED; ctx.lineWidth = 2.4; if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(mx - bw / 2 - 1, my - 71, bw + 2, bh + 2, 7); ctx.stroke(); } else ctx.strokeRect(mx - bw / 2 - 1, my - 71, bw + 2, bh + 2); ctx.restore(); }
      ctx.fillStyle = C.ink; ctx.font = "600 26px 'Newsreader', serif"; ctx.fillText(Math.round(c * 100) + "%", mx, my - 40);

      // the causal link — text centered, arrow drawn to its LEFT so it never
      // strikes through the words
      const vAbsShow = vAbs;
      const causal = dCurSmooth < -0.002 ? (K ? "won't stop \u2192 pushes voltage up" : "resists the drop \u2192 forces this voltage")
                  : dCurSmooth > 0.002 ? (K ? "slow to start \u2192 tiny push-back" : "resists the rise \u2192 small opposing voltage")
                  : vAbsShow > 0.05 ? (K ? "still bleeding off the spike" : "still bleeding off the last spike (holding peak)")
                  : (K ? "steady \u2014 no push" : "steady \u2014 no voltage needed");
      ctx.font = "500 10.5px 'IBM Plex Mono', monospace";
      const ax = mx - ctx.measureText(causal).width / 2 - 14;   // just left of the text
      ctx.strokeStyle = vAbsShow > 0.15 ? FL9_RED : C.faint; ctx.globalAlpha = 0.6 + vAbsShow * 0.4; ctx.lineWidth = 2;
      ctx.beginPath(); ctx.moveTo(ax, my - 22); ctx.lineTo(ax, my - 6); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(ax - 5, my - 11); ctx.lineTo(ax, my - 4); ctx.lineTo(ax + 5, my - 11); ctx.stroke();
      ctx.globalAlpha = 1;
      ctx.fillStyle = vAbsShow > 0.15 ? FL9_RED : C.faint;
      ctx.fillText(causal, mx, my - 12);

      // voltage row — the effect, directly beneath its cause
      ctx.fillStyle = C.faint; ctx.font = "600 11px 'IBM Plex Mono', monospace";
      ctx.fillText(K ? "SPARK PUSH (voltage)" : "VOLTAGE \u2014 V = \u2013L\u00b7di/dt", mx, my + 10);
      const vCol = vAbsShow > 0.35 ? FL9_RED : fl9MixHex(C.deep, FL9_RED, vAbsShow);
      ctx.fillStyle = C.deep; if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(mx - bw / 2, my + 18, bw, bh, 6.5); ctx.fill(); } else ctx.fillRect(mx - bw / 2, my + 18, bw, bh);
      const vfw = Math.max(vAbsShow > 0.02 ? bh : 0, bw * vAbsShow);
      ctx.fillStyle = vCol; if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(mx - bw / 2, my + 18, vfw, bh, 6.5); ctx.fill(); } else ctx.fillRect(mx - bw / 2, my + 18, vfw, bh);
      ctx.fillStyle = vAbsShow > 0.35 ? FL9_RED : C.ink; ctx.font = "600 26px 'Newsreader', serif";
      // quantize to steps of 5 so the last digit doesn't churn while decaying
      const vNum = Math.round(vAbsShow * (K ? 100 : 340) / 5) * 5;
      ctx.fillText((vAbsShow > 0.35 ? "\u26a1 " : "") + vNum + (K ? "%" : "V"), mx, my + 56);
      ctx.fillStyle = C.faint; ctx.font = "500 11px 'IBM Plex Mono', monospace";
      // three tiers on the OFF branch so the words never claim "zero" while the
      // current row above still shows a nonzero %
      const status = arcAlpha > 0.2 ? (K ? "too high \u2014 it leaps the gap!" : "exceeds the gap \u2014 forced to arc across")
        : DRV ? (c > 0.92 ? (K ? "spun up to full, settled" : "settled \u00b7 field full, di/dt \u2248 0")
                          : (K ? "still speeding up" : "current still rising toward target"))
              : (c > 0.15 ? (K ? "coasting down gently" : "coasting down \u2014 stored field draining")
                : c > 0.03 ? (K ? "almost stopped" : "nearly drained \u2014 current tapering to zero")
                           : (K ? "wound down, done" : "field emptied \u00b7 current at zero"));
      ctx.fillText(status, mx, my + 76);

      raf = requestAnimationFrame(frame);
    }
    raf = requestAnimationFrame(frame);
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", fit); };
  }, []);

  return <canvas ref={cv} className="loop9-canvas" style={{ width: "100%", display: "block" }}></canvas>;
}

Object.assign(window, { LoopScene9 });
