// Arise Helix — DNA strand that morphs with scroll.
// 6 visual states keyed to sections: hero, about, programs, lab, testimonials, cta.

function lerp(a, b, t) { return a + (b - a) * t; }
function smoothStep(t) { return t * t * (3 - 2 * t); }

/* Build a single helix strand path between y=0 and y=h with given amplitude, frequency, phase, baseline x. */
function helixPath({ h = 1000, amp = 80, freq = 4, phase = 0, baseX = 0, samples = 120 }) {
  let d = "";
  for (let i = 0; i <= samples; i++) {
    const t = i / samples;
    const y = t * h;
    const x = baseX + Math.sin(t * freq * Math.PI * 2 + phase) * amp;
    d += (i === 0 ? "M " : "L ") + x.toFixed(2) + " " + y.toFixed(2) + " ";
  }
  return d.trim();
}

/* Rungs between the two strands. */
function helixRungs({ h = 1000, amp = 80, freq = 4, phase = 0, baseX = 0, count = 28 }) {
  const lines = [];
  for (let i = 0; i < count; i++) {
    const t = (i + 0.5) / count;
    const y = t * h;
    const x1 = baseX + Math.sin(t * freq * Math.PI * 2 + phase) * amp;
    const x2 = baseX + Math.sin(t * freq * Math.PI * 2 + phase + Math.PI) * amp;
    lines.push({ x1, y1: y, x2, y2: y, t });
  }
  return lines;
}

// Per-section helix parameters
const STATES = [
  // 0 hero — tight elegant
  { amp: 70, freq: 5.0, opacity: 0.55, rotate: 6, scale: 1.0, rungCount: 26, color: 0 },
  // 1 about — wider, slower
  { amp: 130, freq: 2.6, opacity: 0.45, rotate: -4, scale: 1.05, rungCount: 22, color: 0.1 },
  // 2 programs — energetic
  { amp: 110, freq: 7.5, opacity: 0.55, rotate: 10, scale: 1.0, rungCount: 36, color: 0.25 },
  // 3 lab — featured, larger
  { amp: 150, freq: 3.5, opacity: 0.65, rotate: -2, scale: 1.2, rungCount: 24, color: 0.4 },
  // 4 testimonials — soft wave, lower freq
  { amp: 95, freq: 1.8, opacity: 0.35, rotate: 4, scale: 1.0, rungCount: 18, color: 0.5 },
  // 5 cta — converges into single line
  { amp: 8, freq: 1.0, opacity: 0.85, rotate: 0, scale: 1.0, rungCount: 0, color: 0.8 },
];

function HelixStrand() {
  const progress = usePageProgress();
  const activeIdx = useActiveSection([
    "helix-hero", "helix-about", "helix-programs", "helix-lab", "helix-testimonials", "helix-cta",
  ]);
  const [s, setS] = React.useState(STATES[0]);
  React.useEffect(() => {
    let raf;
    const target = STATES[activeIdx] || STATES[0];
    const tick = () => {
      setS((cur) => {
        const next = {};
        let stillMoving = false;
        Object.keys(target).forEach((k) => {
          const v = lerp(cur[k], target[k], 0.08);
          if (Math.abs(v - target[k]) > 0.001) stillMoving = true;
          next[k] = v;
        });
        if (stillMoving) raf = requestAnimationFrame(tick);
        return next;
      });
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [activeIdx]);

  const [phase, setPhase] = React.useState(0);
  React.useEffect(() => {
    let raf;
    const start = performance.now();
    const tick = (now) => {
      const t = (now - start) / 1000;
      setPhase(t * 0.45 + progress * 6);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [progress]);

  const W = 400;
  const H = 1100;
  const baseX = W / 2;

  // Render dense set of nucleotide pairs along the helix, depth-sorted by z so
  // the result reads like a 3D rendered DNA rather than a flat SVG.
  const N = 64; // rungs
  const ampMax = s.amp;
  const opacity = s.opacity;

  const pairs = [];
  for (let i = 0; i < N; i++) {
    const t = (i + 0.5) / N;
    const y = t * H;
    const ang = t * s.freq * Math.PI * 2 + phase;
    const sinA = Math.sin(ang);
    const cosA = Math.cos(ang); // z-depth proxy (-1..1)
    const xa = baseX + sinA * ampMax;
    const xb = baseX - sinA * ampMax;
    pairs.push({ i, t, y, xa, xb, za: cosA, zb: -cosA });
  }

  // Build strand ribbon points (left/right) sampled densely so the curves are smooth.
  const RIBBON_SAMPLES = 240;
  const strandA = [];
  const strandB = [];
  for (let i = 0; i <= RIBBON_SAMPLES; i++) {
    const t = i / RIBBON_SAMPLES;
    const y = t * H;
    const ang = t * s.freq * Math.PI * 2 + phase;
    const sinA = Math.sin(ang);
    const cosA = Math.cos(ang);
    const xa = baseX + sinA * ampMax;
    const xb = baseX - sinA * ampMax;
    strandA.push({ x: xa, y, z: cosA });
    strandB.push({ x: xb, y, z: -cosA });
  }
  const toPath = (pts) =>
    pts.map((p, i) => (i === 0 ? "M " : "L ") + p.x.toFixed(2) + " " + p.y.toFixed(2)).join(" ");

  // Sphere visual params — derived from z so the helix has parallax depth.
  const nucleoRadius = (z) => {
    // z in [-1,1] — front bigger, back smaller
    const norm = (z + 1) / 2; // 0..1
    return 4.5 + norm * 5.0;
  };
  const nucleoOpacity = (z) => {
    const norm = (z + 1) / 2;
    return 0.55 + norm * 0.45;
  };

  // Sort rungs by z so back rungs render first, front rungs on top.
  const sortedPairs = [...pairs].sort((a, b) => a.za - b.za);

  return (
    <div className="pointer-events-none fixed inset-0 z-0" style={{ overflow: "hidden" }}>
      <div
        className="absolute"
        style={{
          right: "-60px",
          top: 0,
          height: "100vh",
          width: `${W}px`,
          transform: `rotate(${s.rotate}deg) scale(${s.scale})`,
          transformOrigin: "center",
          transition: "transform 0.7s cubic-bezier(0.16, 1, 0.3, 1)",
        }}
      >
        <svg
          viewBox={`0 0 ${W} ${H}`}
          width={W}
          height="100%"
          preserveAspectRatio="xMidYMid meet"
        >
          <defs>
            {/* Strand ribbon gradient (along length) */}
            <linearGradient id="strandGrad" x1="0%" y1="0%" x2="0%" y2="100%">
              <stop offset="0%" stopColor="rgba(176,141,87,0.15)" />
              <stop offset="20%" stopColor="rgba(176,141,87,0.85)" />
              <stop offset="50%" stopColor="rgba(216,195,165,1)" />
              <stop offset="80%" stopColor="rgba(176,141,87,0.85)" />
              <stop offset="100%" stopColor="rgba(176,141,87,0.15)" />
            </linearGradient>
            {/* Ribbon highlight (thin, for shine) */}
            <linearGradient id="strandShine" x1="0%" y1="0%" x2="0%" y2="100%">
              <stop offset="0%" stopColor="rgba(255,255,255,0)" />
              <stop offset="50%" stopColor="rgba(255,255,255,0.45)" />
              <stop offset="100%" stopColor="rgba(255,255,255,0)" />
            </linearGradient>
            {/* Sphere radial — front-lit nucleotide */}
            <radialGradient id="sphereFront" cx="35%" cy="30%" r="65%">
              <stop offset="0%" stopColor="#FFF6E5" stopOpacity="1" />
              <stop offset="35%" stopColor="#E5C896" stopOpacity="1" />
              <stop offset="70%" stopColor="#B08D57" stopOpacity="1" />
              <stop offset="100%" stopColor="#6B4F2A" stopOpacity="1" />
            </radialGradient>
            {/* Sphere radial — back-lit nucleotide (cooler, deeper) */}
            <radialGradient id="sphereBack" cx="35%" cy="30%" r="65%">
              <stop offset="0%" stopColor="#D8C3A5" stopOpacity="0.9" />
              <stop offset="55%" stopColor="#8A6D3F" stopOpacity="0.85" />
              <stop offset="100%" stopColor="#4A361E" stopOpacity="0.7" />
            </radialGradient>
            {/* Rung shading */}
            <linearGradient id="rungGrad" x1="0%" y1="0%" x2="100%" y2="0%">
              <stop offset="0%" stopColor="rgba(176,141,87,0.85)" />
              <stop offset="50%" stopColor="rgba(255,238,205,0.95)" />
              <stop offset="100%" stopColor="rgba(176,141,87,0.85)" />
            </linearGradient>
            <filter id="helixSoftGlow" x="-30%" y="-30%" width="160%" height="160%">
              <feGaussianBlur stdDeviation="6" result="blur" />
              <feMerge>
                <feMergeNode in="blur" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>
            <filter id="helixOuterGlow" x="-50%" y="-50%" width="200%" height="200%">
              <feGaussianBlur stdDeviation="14" />
            </filter>
          </defs>

          {/* Outer glow halo behind both strands */}
          <g opacity={Math.min(1, opacity * 1.2)} filter="url(#helixOuterGlow)">
            <path d={toPath(strandA)} stroke="rgba(176,141,87,0.55)" strokeWidth="22" fill="none" />
            <path d={toPath(strandB)} stroke="rgba(216,195,165,0.45)" strokeWidth="22" fill="none" />
          </g>

          {/* RENDER ORDER: back rungs → back strand segments → back nucleotides → front rungs → front strand → front nucleotides */}

          {/* Back rungs (z < 0) */}
          {sortedPairs.filter((p) => p.za < 0).map((p) => {
            const front = p.za >= 0;
            const dx = p.xb - p.xa;
            const dy = p.yb !== undefined ? 0 : 0; // rungs are horizontal here
            return (
              <g key={"br" + p.i} opacity={opacity * 0.55}>
                <line
                  x1={p.xa}
                  y1={p.y}
                  x2={p.xb}
                  y2={p.y}
                  stroke="url(#rungGrad)"
                  strokeWidth={1.4}
                  strokeLinecap="round"
                  opacity={0.55}
                />
              </g>
            );
          })}

          {/* Back-half strands rendered with low opacity */}
          <path d={toPath(strandA)} stroke="url(#strandGrad)" strokeWidth={3.2} fill="none" opacity={opacity * 0.55} strokeLinecap="round" />
          <path d={toPath(strandB)} stroke="url(#strandGrad)" strokeWidth={3.2} fill="none" opacity={opacity * 0.55} strokeLinecap="round" />

          {/* Back nucleotides */}
          {sortedPairs.filter((p) => p.za < 0).map((p) => (
            <g key={"bn" + p.i}>
              <circle
                cx={p.xa}
                cy={p.y}
                r={nucleoRadius(p.za)}
                fill="url(#sphereBack)"
                opacity={nucleoOpacity(p.za) * opacity}
              />
              <circle
                cx={p.xb}
                cy={p.y}
                r={nucleoRadius(p.zb)}
                fill={p.zb >= 0 ? "url(#sphereFront)" : "url(#sphereBack)"}
                opacity={nucleoOpacity(p.zb) * opacity}
              />
            </g>
          ))}

          {/* Front rungs (z >= 0) */}
          {sortedPairs.filter((p) => p.za >= 0).map((p) => (
            <g key={"fr" + p.i}>
              <line
                x1={p.xa}
                y1={p.y}
                x2={p.xb}
                y2={p.y}
                stroke="url(#rungGrad)"
                strokeWidth={2.4}
                strokeLinecap="round"
                opacity={opacity * 0.95}
              />
              {/* Inner shine on rung */}
              <line
                x1={p.xa}
                y1={p.y - 0.6}
                x2={p.xb}
                y2={p.y - 0.6}
                stroke="rgba(255,250,235,0.6)"
                strokeWidth={0.7}
                strokeLinecap="round"
                opacity={opacity * 0.8}
              />
            </g>
          ))}

          {/* Front-half strand ribbons — thicker with shine highlight */}
          <path d={toPath(strandA)} stroke="url(#strandGrad)" strokeWidth={5.5} fill="none" opacity={opacity * 0.95} strokeLinecap="round" />
          <path d={toPath(strandA)} stroke="url(#strandShine)" strokeWidth={1.4} fill="none" opacity={opacity * 0.85} strokeLinecap="round" transform="translate(-1.2 0)" />

          <path d={toPath(strandB)} stroke="url(#strandGrad)" strokeWidth={5.5} fill="none" opacity={opacity * 0.95} strokeLinecap="round" />
          <path d={toPath(strandB)} stroke="url(#strandShine)" strokeWidth={1.4} fill="none" opacity={opacity * 0.85} strokeLinecap="round" transform="translate(1.2 0)" />

          {/* Front nucleotides — render large 3D spheres */}
          {sortedPairs.filter((p) => p.za >= 0).map((p) => (
            <g key={"fn" + p.i}>
              {/* outer rim (glow) */}
              <circle
                cx={p.xa}
                cy={p.y}
                r={nucleoRadius(p.za) + 2}
                fill="rgba(255,235,200,0.18)"
              />
              <circle
                cx={p.xa}
                cy={p.y}
                r={nucleoRadius(p.za)}
                fill="url(#sphereFront)"
                opacity={nucleoOpacity(p.za) * opacity}
              />
              {/* tiny highlight */}
              <ellipse
                cx={p.xa - nucleoRadius(p.za) * 0.32}
                cy={p.y - nucleoRadius(p.za) * 0.38}
                rx={nucleoRadius(p.za) * 0.35}
                ry={nucleoRadius(p.za) * 0.22}
                fill="rgba(255,251,240,0.85)"
                opacity={opacity}
              />

              <circle
                cx={p.xb}
                cy={p.y}
                r={nucleoRadius(p.zb) + 2}
                fill="rgba(255,235,200,0.18)"
              />
              <circle
                cx={p.xb}
                cy={p.y}
                r={nucleoRadius(p.zb)}
                fill={p.zb >= 0 ? "url(#sphereFront)" : "url(#sphereBack)"}
                opacity={nucleoOpacity(p.zb) * opacity}
              />
              {p.zb >= 0 && (
                <ellipse
                  cx={p.xb - nucleoRadius(p.zb) * 0.32}
                  cy={p.y - nucleoRadius(p.zb) * 0.38}
                  rx={nucleoRadius(p.zb) * 0.35}
                  ry={nucleoRadius(p.zb) * 0.22}
                  fill="rgba(255,251,240,0.85)"
                  opacity={opacity}
                />
              )}
            </g>
          ))}
        </svg>
      </div>
    </div>
  );
}

/* ──────────────── Cursor radial glow ──────────────── */

function CursorGlow() {
  const { x, y } = useCursor();
  if (x < 0) return null;
  return (
    <div
      className="pointer-events-none fixed inset-0 z-[1]"
      style={{
        background: `radial-gradient(360px circle at ${x}px ${y}px, rgba(176,141,87,0.10), transparent 65%)`,
      }}
    />
  );
}

/* ──────────────── Floating particles ──────────────── */

function HelixParticles({ count = 18 }) {
  const items = React.useMemo(
    () => Array.from({ length: count }).map((_, i) => ({
      left: (i * 17.3) % 100,
      size: 2 + (i % 4) * 1.2,
      delay: (i * 0.83) % 16,
      duration: 18 + (i % 7) * 2.4,
      gold: i % 3 === 0,
    })),
    [count]
  );
  return (
    <div className="pointer-events-none absolute inset-0 overflow-hidden">
      {items.map((p, i) => (
        <span
          key={i}
          className="absolute block rounded-full"
          style={{
            left: `${p.left}%`,
            bottom: "-3%",
            width: p.size,
            height: p.size,
            background: p.gold ? "rgba(176,141,87,0.55)" : "rgba(31,31,31,0.25)",
            boxShadow: p.gold ? "0 0 8px rgba(176,141,87,0.45)" : "none",
            animation: `helixDrift ${p.duration}s linear ${p.delay}s infinite`,
          }}
        />
      ))}
    </div>
  );
}

/* ──────────────── Section label pill ──────────────── */

function SectionLabel({ children }) {
  return (
    <FadeUp>
      <div
        className="helix-glass inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] tracking-[0.18em] uppercase"
        style={{ color: "#1F1F1F" }}
      >
        <span
          className="inline-block w-1.5 h-1.5 rounded-full"
          style={{
            background: "#B08D57",
            boxShadow: "0 0 8px rgba(176,141,87,0.7)",
          }}
        />
        {children}
      </div>
    </FadeUp>
  );
}

Object.assign(window, { HelixStrand, CursorGlow, HelixParticles, SectionLabel, ScrollVideoBg });

/* ──────────────── Scroll-scrubbed video background ──────────────── */

// IndexedDB helpers for frame cache — survives reloads so only the first visit
// shows the "Preparing helix" screen. Bump CACHE_VERSION to invalidate.
const HELIX_DB_NAME = "helix-dna-cache";
const HELIX_DB_STORE = "frames";
const HELIX_CACHE_VERSION = 1;

function helixOpenDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(HELIX_DB_NAME, 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(HELIX_DB_STORE)) db.createObjectStore(HELIX_DB_STORE);
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}
async function helixCacheGet(key) {
  const db = await helixOpenDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(HELIX_DB_STORE, "readonly");
    const req = tx.objectStore(HELIX_DB_STORE).get(key);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}
async function helixCachePut(key, value) {
  const db = await helixOpenDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(HELIX_DB_STORE, "readwrite");
    tx.objectStore(HELIX_DB_STORE).put(value, key);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

function ScrollVideoBg({ src }) {
  const canvasRef = React.useRef(null);
  const stateRef = React.useRef({ frames: [], fps: 24, w: 0, h: 0, ready: false });
  const [progress, setProgress] = React.useState(0); // 0..1 extraction progress
  const [ready, setReady] = React.useState(false);

  // Detect mobile/touch devices — they get a simple <video> instead of the
  // expensive scroll-scrubbed canvas (iOS Safari can't seek hidden videos
  // reliably; mobile CPUs also struggle with frame-by-frame extraction).
  const [isMobile, setIsMobile] = React.useState(() =>
    typeof window !== "undefined" && window.innerWidth < 768
  );
  React.useEffect(() => {
    const check = () => setIsMobile(window.innerWidth < 768);
    window.addEventListener("resize", check);
    return () => window.removeEventListener("resize", check);
  }, []);

  // ── 1. Load pre-extracted frames (assets/frames/manifest.json) if available;
  //       fall back to runtime video-seek extraction with IndexedDB cache.
  //       Run `prep-frames.ps1` once to bake frames and get instant first load.
  // Desktop only — mobile path skips this entire effect.
  React.useEffect(() => {
    if (isMobile) return;
    let cancelled = false;

    const FPS = 12;
    const MAX_WIDTH = 640; // runtime fallback only — prep-frames.ps1 uses 960
    const cacheKey = `${src}::v${HELIX_CACHE_VERSION}::fps${FPS}::w${MAX_WIDTH}`;

    const tryLoadPreExtracted = async () => {
      let manifest;
      try {
        const res = await fetch("assets/frames/manifest.json", { cache: "force-cache" });
        if (!res.ok) return false;
        manifest = await res.json();
      } catch { return false; }

      const count = manifest.count | 0;
      const fps = manifest.fps || 12;
      const pattern = manifest.pattern || "assets/frames/{n}.webp";
      if (count < 2) return false;

      const urls = [];
      for (let i = 1; i <= count; i++) {
        urls.push(pattern.replace("{n}", String(i).padStart(4, "0")));
      }

      const loadOne = (url) =>
        new Promise((resolve, reject) => {
          const img = new Image();
          img.decoding = "async";
          img.onload = async () => {
            try { resolve(await createImageBitmap(img)); }
            catch { resolve(img); }
          };
          img.onerror = reject;
          img.src = url;
        });

      try {
        const frames = await Promise.all(urls.map(loadOne));
        if (cancelled) { frames.forEach((f) => f.close && f.close()); return true; }
        const first = frames[0];
        const w = first.width || manifest.width || 960;
        const h = first.height || manifest.height || 0;
        stateRef.current = { frames, fps, w, h, ready: true };
        setProgress(1);
        setReady(true);
        return true;
      } catch { return false; }
    };

    const runtimeExtract = async () => {
      // Try IndexedDB cache first — decoding stored WebP blobs to ImageBitmaps
      // is ~10x faster than re-seeking the video frame by frame.
      try {
        const cached = await helixCacheGet(cacheKey);
        if (cached && Array.isArray(cached.blobs) && cached.blobs.length > 1) {
          const frames = await Promise.all(cached.blobs.map((b) => createImageBitmap(b)));
          if (cancelled) { frames.forEach((f) => f.close && f.close()); return; }
          stateRef.current = { frames, fps: cached.fps, w: cached.w, h: cached.h, ready: true };
          setProgress(1);
          setReady(true);
          return;
        }
      } catch {}

      let blobUrl = src;
      try {
        const blob = await (await fetch(src)).blob();
        blobUrl = URL.createObjectURL(blob);
      } catch {}

      const v = document.createElement("video");
      v.src = blobUrl;
      v.muted = true;
      v.playsInline = true;
      v.preload = "auto";
      v.crossOrigin = "anonymous";

      await new Promise((resolve, reject) => {
        v.onloadedmetadata = resolve;
        v.onerror = reject;
      });

      const dur = v.duration;
      const scale = Math.min(1, MAX_WIDTH / v.videoWidth);
      const w = Math.floor(v.videoWidth * scale);
      const h = Math.floor(v.videoHeight * scale);
      const total = Math.max(2, Math.ceil(dur * FPS));

      const off = document.createElement("canvas");
      off.width = w;
      off.height = h;
      const offCtx = off.getContext("2d", { willReadFrequently: false });

      const frames = [];
      const seek = (t) =>
        new Promise((res) => {
          const handler = () => { v.removeEventListener("seeked", handler); res(); };
          v.addEventListener("seeked", handler);
          try { v.currentTime = t; } catch { v.removeEventListener("seeked", handler); res(); }
        });

      for (let i = 0; i < total; i++) {
        if (cancelled) return;
        const t = (i / total) * dur;
        await seek(t);
        offCtx.drawImage(v, 0, 0, w, h);
        try {
          frames.push(await createImageBitmap(off));
        } catch {
          const c = document.createElement("canvas");
          c.width = w; c.height = h;
          c.getContext("2d").drawImage(off, 0, 0);
          frames.push(c);
        }
        setProgress((i + 1) / total);
      }

      stateRef.current = { frames, fps: FPS, w, h, ready: true };
      setReady(true);

      // Persist to IndexedDB for repeat visits on this browser.
      (async () => {
        try {
          const blobs = [];
          const enc = document.createElement("canvas");
          enc.width = w; enc.height = h;
          const encCtx = enc.getContext("2d");
          for (const f of frames) {
            encCtx.clearRect(0, 0, w, h);
            encCtx.drawImage(f, 0, 0, w, h);
            const blob = await new Promise((r) => enc.toBlob(r, "image/webp", 0.78));
            if (!blob) return;
            blobs.push(blob);
          }
          await helixCachePut(cacheKey, { blobs, fps: FPS, w, h });
        } catch {}
      })();
    };

    (async () => {
      const ok = await tryLoadPreExtracted();
      if (!ok && !cancelled) await runtimeExtract();
    })().catch(() => {});

    return () => { cancelled = true; };
  }, [src, isMobile]);

  // ── 2. Paint the canvas based on scrollY ──
  React.useEffect(() => {
    if (isMobile) return;
    if (!ready) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");

    const fit = () => {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    };
    fit();
    window.addEventListener("resize", fit);

    let raf = null;
    let target = 0;
    let current = 0;

    const computeTarget = () => {
      const total = document.body.scrollHeight - window.innerHeight;
      const p = total <= 0 ? 0 : Math.max(0, Math.min(1, window.scrollY / total));
      const frames = stateRef.current.frames;
      target = p * (frames.length - 1);
      if (raf == null) raf = requestAnimationFrame(loop);
    };

    const paint = (idx) => {
      const { frames, w, h } = stateRef.current;
      const i = Math.max(0, Math.min(frames.length - 1, Math.round(idx)));
      const bmp = frames[i];
      if (!bmp) return;
      // cover-fit
      const cw = canvas.width;
      const ch = canvas.height;
      const scale = Math.max(cw / w, ch / h);
      const dw = w * scale;
      const dh = h * scale;
      const dx = (cw - dw) / 2;
      const dy = (ch - dh) / 2;
      ctx.fillStyle = "#F6F3EE";
      ctx.fillRect(0, 0, cw, ch);
      ctx.drawImage(bmp, dx, dy, dw, dh);
    };

    const loop = () => {
      const diff = target - current;
      if (Math.abs(diff) < 0.01) {
        current = target;
        paint(current);
        raf = null;
        return;
      }
      // Smooth ease so motion glides instead of jumping
      current += diff * 0.22;
      paint(current);
      raf = requestAnimationFrame(loop);
    };

    computeTarget();
    paint(0);
    window.addEventListener("scroll", computeTarget, { passive: true });
    return () => {
      window.removeEventListener("scroll", computeTarget);
      window.removeEventListener("resize", fit);
      if (raf != null) cancelAnimationFrame(raf);
    };
  }, [ready, isMobile]);

  // Mobile path — autoplay video element, no scroll-scrub, no frame extraction.
  if (isMobile) {
    return (
      <div className="pointer-events-none fixed inset-0 z-0" aria-hidden="true">
        <video
          src={src}
          autoPlay
          muted
          loop
          playsInline
          preload="auto"
          className="absolute inset-0 w-full h-full"
          style={{ objectFit: "cover", background: "#F6F3EE" }}
        />
        <div
          className="absolute inset-0 pointer-events-none"
          style={{ background: "rgba(0,0,0,0.75)" }}
        />
      </div>
    );
  }

  // Desktop path — scroll-scrubbed canvas (frames pre-extracted to ImageBitmaps).
  return (
    <div className="pointer-events-none fixed inset-0 z-0" aria-hidden="true">
      <canvas
        ref={canvasRef}
        className="absolute inset-0 w-full h-full"
        style={{ background: "#F6F3EE", opacity: ready ? 1 : 0, transition: "opacity 0.5s ease" }}
      />
      <div
        className="absolute inset-0 pointer-events-none"
        style={{ background: "rgba(0,0,0,0.75)" }}
      />
    </div>
  );
}
