// demo-browser.jsx -- BROWSER MODE: compat verdict + ffmpeg.wasm preview.
//
// The no-local-server fallback: reads the file's container header for a
// ClipGrid decode verdict, then extracts real frames via ffmpeg.wasm and runs
// the engine-mirroring translate animation. Exposes window.ClipGridBrowserDemo.
//
// Loaded as a Babel <script> AFTER demo-server.jsx (shares
// window.ClipGridProgressBar) and demo-verdict.jsx (window.ClipGridVerdictCard),
// and BEFORE the entry script that picks the demo mode. Classic helpers it
// reads, all loaded earlier still:
//
//   demo-geometry.js -> window.ClipGridGeometry   (ClipGrid grid / geometry math)
//   demo-ffmpeg.js   -> window.ClipGridFfmpeg     (ffmpeg.wasm CDN loader)
//   demo-sample.js   -> window.ClipGridSample     (procedural sample + timecode)
(function () {
  const { MAX_DEMO_SECONDS, DEMO_DISPLAY_W, DEMO_DISPLAY_H, DEMO_TARGET_DUR, DEMO_MAX_FRAMES,
          computeGeometry } = window.ClipGridGeometry;
  const { FFMPEG_CORE_BASE, toBlobURL, loadFfmpegWasm } = window.ClipGridFfmpeg;
  const { stampTimecode, generateSampleFrames } = window.ClipGridSample;
  const ProgressBar = window.ClipGridProgressBar;
  const VerdictCard = window.ClipGridVerdictCard;

function BrowserDemo() {
  // High-level state machine. See the BROWSER MODE comment for the flow.
  //   idle         — drop zone visible
  //   inspecting   — phase 1 header parse in progress (sub-second)
  //   unsupported  — header parsed, codec not recognized; verdict shown, no extraction
  //   extracting   — phase 2: ffmpeg.wasm loading or decoding frames
  //   ready        — frames ready, translate animation running
  //   sample       — sample-footage path active (skips compat check)
  //   error        — terminal error; verdict still shown if we got that far
  const [state,        setState]        = React.useState("idle");
  const [error,        setError]        = React.useState("");
  const [filename,     setFilename]     = React.useState("");
  const [inspection,   setInspection]   = React.useState(null); // ClipGridCompat.inspectFile result
  const [verdict,      setVerdict]      = React.useState(null); // ClipGridCompat.decodeVerdict result
  const [duration,     setDuration]     = React.useState(0);
  const [resolution,   setResolution]   = React.useState("");
  const [frameCount,   setFrameCount]   = React.useState(0);
  const [dragOver,     setDragOver]     = React.useState(false);
  const [extractPct,   setExtractPct]   = React.useState(0);
  const [extractPhase, setExtractPhase] = React.useState("");   // human-readable status for ffmpeg stage

  // Frames + geometry live in refs — never stored in React state.
  // This means the animation effect never restarts just because frames changed.
  const framesRef    = React.useRef([]);   // extracted ImageBitmaps in cell-major order
  const geomRef      = React.useRef(null); // current ClipGrid geometry (rows, cols, cellWidth, …)
  const canvasRef    = React.useRef(null);
  const rafRef       = React.useRef(null);
  const ffmpegRef    = React.useRef(null);  // cached FFmpeg instance — load() is expensive

  // stampTimecode() -> demo-sample.js (window.ClipGridSample).

  // ── ffmpeg.wasm: decode the source the same way ClipGrid would ─────────────────
  // libavcodec under ffmpeg.wasm is the same library OpenCV's VideoCapture
  // uses in the native engine. If ffmpeg.wasm decodes it, native ClipGrid will too. The
  // wasm core is ~28 MB on first load and then browser-cached, so subsequent
  // files reuse the already-resident decoder.
  async function ensureFfmpeg() {
    if (ffmpegRef.current && ffmpegRef.current.loaded) return ffmpegRef.current;

    setExtractPhase("Loading ffmpeg.wasm runtime (one-time)…");
    let mod;
    try {
      mod = await loadFfmpegWasm();
    } catch (e) {
      // Re-throw with the diagnostic that loadFfmpegWasm built. handleFile
      // catches this and shows the verdict above + the specific failure.
      throw new Error(e.message);
    }
    const { FFmpeg, workerLoadFileURL } = mod;

    const ffmpeg = ffmpegRef.current || new FFmpeg();
    ffmpegRef.current = ffmpeg;
    // Forward all ffmpeg log + parse status lines. Attached once per ffmpeg
    // instance — re-attaching on every call would leak listeners.
    // Forward ffmpeg log to console.debug for opt-in devtools debugging.
    // Attached once per instance — re-attaching on every call would leak listeners.
    if (!ffmpeg._diagAttached) {
      ffmpeg.on("log", ({ type, message }) => {  // agent-ok: ffmpeg.wasm event emitter API, not internal dispatch
        if (typeof console !== "undefined" && console.debug) {
          console.debug("[ffmpeg/" + type + "]", message);
        }
      });
      ffmpeg._diagAttached = true;
    }
    if (!ffmpeg.loaded) {
      setExtractPhase("Loading ffmpeg-core (~28 MB, browser-cached after)…");
      // Every URL goes through toBlobURL (defined above) so the resulting
      // worker/script tags live in blob: origin — cross-origin worker
      // creation is blocked from https: origins, blob URLs sidestep that.
      try {
        const [coreURL, wasmURL, workerLoadURL] = await Promise.all([
          toBlobURL(`${FFMPEG_CORE_BASE}/ffmpeg-core.js`,   "text/javascript"),
          toBlobURL(`${FFMPEG_CORE_BASE}/ffmpeg-core.wasm`, "application/wasm"),
          toBlobURL(workerLoadFileURL,                      "text/javascript"),
        ]);
        await ffmpeg.load({ coreURL, wasmURL, workerLoadURL });
      } catch (e) {
        throw new Error(`ffmpeg.load() failed: ${e.message}. ffmpeg-core may be unreachable from the CDN.`);
      }
    }
    return ffmpeg;
  }

  // Probe duration, fps, and dimensions in one log-parse run. Runs ffmpeg
  // with -t 0 so the actual decode is bypassed; ffmpeg still emits its full
  // input-format banner ("Duration: …", "Stream #0:0: Video: …, WxH, … fps,
  // … tbr, …") which is all we need to compute geometry.
  async function probeVideoMetadata(ffmpeg, inputName) {
    let durSec = 0, fps = 0, w = 0, h = 0;
    const onLog = ({ message }) => {
      let m;
      if ((m = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(message))) {
        durSec = (+m[1]) * 3600 + (+m[2]) * 60 + parseFloat(m[3]);
      }
      if (/Video:/.test(message)) {
        // Dimensions: first WxH that isn't a SAR/DAR ratio (which look like 1:1).
        const dm = /(\d{2,5})x(\d{2,5})/.exec(message);
        if (dm) { w = +dm[1]; h = +dm[2]; }
        // tbr is the average frame rate; prefer it over the more variable fps tag.
        const tbr = /,\s*([\d.]+)\s*tbr/.exec(message);
        const fpsM = /,\s*([\d.]+)\s*fps/.exec(message);
        if (tbr) fps = parseFloat(tbr[1]);
        else if (fpsM) fps = parseFloat(fpsM[1]);
      }
    };
    ffmpeg.on("log", onLog);  // agent-ok: ffmpeg.wasm event emitter API, not internal dispatch
    try { await ffmpeg.exec(["-i", inputName, "-t", "0", "-f", "null", "-"]); } catch (e) { /* probe always "fails" */ }
    ffmpeg.off("log", onLog);  // agent-ok: ffmpeg.wasm event emitter API, not internal dispatch
    return { durSec, fps, width: w, height: h };
  }

  // Extract cells*framesPerCell frames at cell resolution, evenly spaced
  // across the first MAX_DEMO_SECONDS of source. Returns { frames, geom }
  // where geom is the ClipGrid dynamic geometry computed against the probed
  // source dimensions / duration / fps.
  async function extractFramesViaFfmpeg(file) {
    const ffmpeg = await ensureFfmpeg();

    setExtractPhase("Loading file into ffmpeg's virtual FS…");
    setExtractPct(0);
    const ext = ((file.name.split(".").pop() || "bin").toLowerCase()).replace(/[^a-z0-9]/g, "");
    const inputName = "input." + (ext || "bin");
    const data = new Uint8Array(await file.arrayBuffer());
    console.time("[demo] writeFile");
    await ffmpeg.writeFile(inputName, data);
    console.timeEnd("[demo] writeFile");

    setExtractPhase("Probing video metadata…");
    console.time("[demo] probe");
    const meta = await probeVideoMetadata(ffmpeg, inputName);
    console.timeEnd("[demo] probe");
    console.debug("[demo] probe result:", meta);
    if (meta.durSec === 0) {
      try { await ffmpeg.deleteFile(inputName); } catch (e) {}
      throw new Error("ffmpeg could not determine this file's duration.");
    }
    if (!meta.width || !meta.height) {
      try { await ffmpeg.deleteFile(inputName); } catch (e) {}
      throw new Error("ffmpeg could not determine this file's video dimensions.");
    }
    setDuration(meta.durSec);
    if (meta.width && meta.height) setResolution(meta.width + "×" + meta.height);

    const sourceFps = meta.fps > 0 ? meta.fps : 30;

    // Compute ClipGrid dynamic geometry. Throws if source is too short for target.
    let geom;
    try {
      geom = computeGeometry({
        videoWidth:       meta.width,
        videoHeight:      meta.height,
        inputDurationSec: meta.durSec,
        fps:              sourceFps,
      });
    } catch (e) {
      try { await ffmpeg.deleteFile(inputName); } catch (er) {}
      throw e;
    }

    // CHUNKED EXTRACTION
    //
    // Background: a single ffmpeg.exec that writes hundreds of small JPEGs
    // via the image2 muxer reliably stalls the wasm worker around the
    // ~150-frame mark — no error, no warning, just silent worker death. The
    // root cause is heap exhaustion / MEMFS state accumulation inside the
    // single exec; ffmpeg.wasm doesn't propagate the OOM cleanly. The same
    // source decodes fine if we limit one exec to a small number of frames
    // (the old 60-frame extraction worked reliably).
    //
    // The fix: process the cells*framesPerCell timeline in BATCH_FRAMES-sized
    // chunks. Each batch is a separate ffmpeg.exec with -ss/-t input seek to
    // cover its slice of the source. After each batch we read + delete its
    // frames so the next batch starts with a fresh MEMFS, and ffmpeg
    // releases its internal buffers between exec calls.
    const extractFps = geom.totalFrames / geom.cappedDur;
    const BATCH_FRAMES = 60;
    const numBatches   = Math.ceil(geom.totalFrames / BATCH_FRAMES);

    setExtractPhase(
      "Decoding " + geom.totalFrames + " frames in " + numBatches + " batches — " +
      geom.cols + "×" + geom.rows + " grid · " +
      geom.cellWidth + "×" + geom.cellHeight + " cells · " +
      geom.outWidth + "×" + geom.outHeight + " output"
    );
    setExtractPct(0);
    console.debug("[demo] geom:", geom, "extractFps:", extractFps, "numBatches:", numBatches);

    const frames = [];

    for (let b = 0; b < numBatches; b++) {
      const batchStartIdx = b * BATCH_FRAMES;
      const batchFrames   = Math.min(BATCH_FRAMES, geom.totalFrames - batchStartIdx);
      const batchStartSec = batchStartIdx / extractFps;
      // small overshoot so the fps filter has room to deliver the last frame
      const batchDurSec   = batchFrames / extractFps + 0.5;

      setExtractPhase(
        "Batch " + (b + 1) + "/" + numBatches + " — decoding source seconds " +
        batchStartSec.toFixed(1) + "–" + (batchStartSec + batchDurSec).toFixed(1)
      );

      const onProgress = ({ progress }) => {
        if (typeof progress === "number" && progress >= 0) {
          // Per-batch progress 0..1 scaled into our overall progress.
          const overall = (b + Math.min(1, Math.max(0, progress))) / numBatches;
          const next    = Math.min(100, Math.round(overall * 100));
          // Functional updater so the bar can only move forward — ffmpeg
          // sometimes emits a transient progress=0 at the start of a new
          // batch, which would otherwise visually rewind the bar.
          setExtractPct((cur) => Math.max(cur, next));
        }
      };
      ffmpeg.on("progress", onProgress);  // agent-ok: ffmpeg.wasm event emitter API, not internal dispatch

      console.time("[demo] batch " + (b + 1) + " exec");
      try {
        await ffmpeg.exec([
          // -ss BEFORE -i: fast input seek (lands on nearest keyframe ≤
          // batchStartSec, then decodes forward). Small alignment drift is
          // acceptable for the visual demo.
          "-ss", String(batchStartSec),
          "-t",  String(batchDurSec),
          "-an", // skip audio
          "-i",  inputName,
          "-vf", "fps=" + extractFps.toFixed(6) + ",scale=" + geom.cellWidth + ":" + geom.cellHeight,
          "-q:v", "3",
          "-vframes", String(batchFrames),
          "f%04d.jpg",
        ]);
      } finally {
        ffmpeg.off("progress", onProgress);  // agent-ok: ffmpeg.wasm event emitter API, not internal dispatch
        console.timeEnd("[demo] batch " + (b + 1) + " exec");
      }

      // Read this batch's frames, stamp with global source-time, delete.
      for (let i = 1; i <= batchFrames; i++) {
        const name = "f" + String(i).padStart(4, "0") + ".jpg";
        let buf;
        try { buf = await ffmpeg.readFile(name); }
        catch (e) { break; }  // batch produced fewer than expected (clip ended)
        const blob   = new Blob([buf], { type: "image/jpeg" });
        const bitmap = await createImageBitmap(blob);
        const c = Object.assign(document.createElement("canvas"), { width: bitmap.width, height: bitmap.height });
        const ctx = c.getContext("2d");
        ctx.drawImage(bitmap, 0, 0);
        // Source time of this frame, using its global index (batchStartIdx + i - 1).
        const globalIdx  = batchStartIdx + i - 1;
        const sourceTime = ((globalIdx + 0.5) * geom.cappedDur) / geom.totalFrames;
        stampTimecode(ctx, sourceTime, bitmap.width, bitmap.height);
        frames.push(await createImageBitmap(c));
        bitmap.close && bitmap.close();
        try { await ffmpeg.deleteFile(name); } catch (e) {}
      }
      // Snap the bar to the batch boundary now that this batch is fully
      // read back — guarantees the bar reaches each batch's share even if
      // ffmpeg's last progress event for this batch didn't quite hit 1.0.
      const boundaryPct = Math.round(((b + 1) / numBatches) * 100);
      setExtractPct((cur) => Math.max(cur, boundaryPct));
      console.debug("[demo] batch " + (b + 1) + " — accumulated " + frames.length + "/" + geom.totalFrames + " frames");
    }

    try { await ffmpeg.deleteFile(inputName); } catch (e) {}

    if (frames.length < geom.totalFrames * 0.85) {
      throw new Error(
        "ffmpeg decoded only " + frames.length + " of " + geom.totalFrames +
        " expected frames across " + numBatches + " batches. The source may be shorter than reported or partially corrupt."
      );
    }
    return { frames, geom };
  }

  // generateSampleFrames() -> demo-sample.js (window.ClipGridSample).

  function resetForNewFile(name) {
    cancelAnimationFrame(rafRef.current);
    framesRef.current = [];
    geomRef.current   = null;
    setFrameCount(0);
    setError("");
    setExtractPct(0);
    setExtractPhase("");
    setInspection(null);
    setVerdict(null);
    setDuration(0);
    setResolution("");
    setFilename(name || "");
  }

  // PHASE 1 → PHASE 2 driver.
  // Phase 1 (compat verdict) is the primary value; phase 2 (real-frame
  // preview) is the visual proof that runs only if the verdict is positive.
  //
  // Fallback path: when header inspection identifies the CONTAINER but can't
  // pinpoint the codec (parser limitation, weird structure, non-standard
  // FourCC), we still try ffmpeg.wasm. A successful decode promotes the
  // verdict to "yes — ffmpeg confirmed"; a failure surfaces both halves so
  // the user knows it was the codec, not a bug in our parser.
  async function handleFile(file) {
    resetForNewFile(file.name);
    setState("inspecting");

    let insp, v;
    try {
      insp = await window.ClipGridCompat.inspectFile(file);
      setInspection(insp);
      if (insp.width && insp.height) setResolution(insp.width + "×" + insp.height);
    } catch (e) {
      setError("Header inspection failed: " + (e.message || e));
      setState("error");
      return;
    }

    if (insp.error && !insp.container) {
      // Container itself unrecognized — no point loading ffmpeg.wasm.
      setError(insp.error);
      setState("error");
      return;
    }

    if (insp.error) {
      // Container recognized, but the parser couldn't pinpoint the codec.
      // Show a tentative verdict and let ffmpeg.wasm be the ground truth.
      v = {
        supported: "maybe",
        decoder:   "CPU ffmpeg (probable)",
        label:     insp.container,
        notes: [
          "Container recognized as " + insp.container + " but codec couldn't be identified from metadata (" + insp.error + ").",
          "Attempting an ffmpeg.wasm decode to confirm — if it succeeds, ClipGrid will too.",
        ],
      };
    } else {
      v = window.ClipGridCompat.decodeVerdict(insp.codec);
    }
    setVerdict(v);

    if (v.supported === "unknown") {
      setState("unsupported");
      return;
    }

    // PHASE 2 — real-frame extraction via ffmpeg.wasm.
    // Success on the "maybe" path is meaningful: it's positive proof the
    // file decodes through libavcodec, so we upgrade the verdict to "yes".
    setState("extracting");
    try {
      const { frames, geom } = await extractFramesViaFfmpeg(file);
      framesRef.current = frames;
      geomRef.current   = geom;
      setFrameCount(frames.length);
      if (v.supported === "maybe") {
        setVerdict({
          supported: "yes",
          decoder:   "CPU ffmpeg (confirmed via ffmpeg.wasm decode)",
          label:     v.label,
          family:    v.family,
          notes: [
            "Header inspection couldn't pinpoint the codec from container metadata, but ffmpeg.wasm decoded the file successfully — so ClipGrid's CPU ffmpeg backend will too.",
          ],
        });
      }
      setState("ready");
    } catch (e) {
      const wasMaybe = v.supported === "maybe";
      setError(
        (e.message || String(e)) +
        (wasMaybe
          ? "  Combined with the container-parse warning above, this is a strong signal ClipGrid won't decode this file either. Try transcoding the source to a common codec (H.264 in MP4 is a safe bet)."
          : "  Your file's codec is still supported in ClipGrid itself — this only affects the in-browser preview.")
      );
      setState("error");
    } finally {
      // Recycle the ffmpeg instance after each extraction (success or
      // failure). Reusing a single instance across multiple files
      // accumulates wasm heap + internal-state pressure that surfaces as
      // "RuntimeError: index out of bounds" after roughly 3-4 runs. Cost
      // is a ~1-2 s re-init per new file, which is acceptable for a demo;
      // reliability is more important than that latency. The compat
      // verdict was already produced before this finally runs, so the
      // user always sees their result regardless of recycling cost.
      if (ffmpegRef.current) {
        try {
          if (typeof ffmpegRef.current.terminate === "function") {
            ffmpegRef.current.terminate();
          }
        } catch (termErr) {
          console.warn("[demo] ffmpeg.terminate() threw:", termErr);
        }
        ffmpegRef.current = null;
      }
    }
  }

  async function useSample() {
    resetForNewFile("sample-footage.mp4");
    // Skip compat path entirely — sample is procedural, not a real file.
    // Geometry is computed by the same ClipGrid algorithm as a real file
    // (16:9, 60 s, 30 fps source) so the sample exercises the full pipeline.
    const SAMPLE = {
      videoWidth: 1920, videoHeight: 1080,
      inputDurationSec: 60, fps: 30,
    };
    let geom;
    try { geom = computeGeometry(SAMPLE); }
    catch (e) { setError(e.message); setState("error"); return; }

    setInspection({ container: "Synthetic sample (1920×1080 · 60 s · 30 fps)" });
    setVerdict({ supported: "sample", decoder: "(procedural — no decode)", label: "Sample footage", notes: [] });
    setDuration(SAMPLE.inputDurationSec);
    setResolution(SAMPLE.videoWidth + "×" + SAMPLE.videoHeight);
    setExtractPhase(
      "Generating " + geom.totalFrames + " sample frames — " +
      geom.cols + "×" + geom.rows + " grid · " +
      geom.cellWidth + "×" + geom.cellHeight + " cells · " +
      geom.outWidth + "×" + geom.outHeight + " output"
    );
    setState("extracting");
    try {
      const f = await generateSampleFrames(geom, (p) => setExtractPct(p));
      framesRef.current = f;
      geomRef.current   = geom;
      setFrameCount(f.length);
      setState("ready");
    } catch (e) {
      setError(e.message || "Sample generation failed.");
      setState("error");
    }
  }

  // ── ClipGrid translate animation loop ─────────────────────────────────────────
  //
  // Mirrors the native engine's runBuildPhaseCpu + Compositor::placeTranslate one-to-one:
  //   1. Cell c at (row, col) holds its own framesPerCell-long source segment
  //      — extracted frames at indices c*FPC .. c*FPC+FPC-1.
  //   2. At output frame F (0 .. FPC-1):
  //        for each cell c: draw frames[c*FPC + F] at
  //          (col*cellW + (F/FPC)*cellW,  row*cellH)
  //        if the cell crosses outWidth, the overflow wraps to
  //          (0, ((row+1) % rows) * cellH)
  //      — matches ClipGrid's `moreRows ? originY+cellH : 0` rule, so the
  //      bottom row's wrap lands at (0,0).
  //   3. F advances at playbackFps = totalFrames / cappedDur, so a full
  //      cycle takes cappedDur/cells seconds — exactly what ClipGrid produces
  //      when its output is encoded at source fps.
  //
  // Render order is row-major to match ClipGrid — each row's right-edge wrap
  // lands in the next row's leftward gap before that row's own cells are
  // drawn, producing the same seamless toroidal filmstrip ClipGrid outputs.
  //
  // Starts once when state becomes "ready". Reads from framesRef and
  // geomRef so the loop is unaffected by React state changes.
  React.useEffect(() => {
    if (state !== "ready") return;

    // Wait one microtask so the canvas element is in the DOM and the ref
    // is assigned before we try to get the 2D context.
    const startId = setTimeout(() => {
      const cvs  = canvasRef.current;
      const geom = geomRef.current;
      if (!cvs || !geom) return;
      cvs.width  = geom.outWidth;
      cvs.height = geom.outHeight;
      const ctx  = cvs.getContext("2d");

      // Cell c shows source frames [c*FPC .. c*FPC+FPC-1], indexed by F.
      // At output frame F:
      //   subShift = (F / FPC) * cellWidth   — continuous translate offset
      //   for each cell c at (row, col):
      //     img = frames[c * FPC + F]
      //     pos = (col*cellW + subShift, row*cellH)
      //     overflow past outWidth wraps to (0, (row+1)%rows * cellH)
      // Total cycle = FPC output frames at playbackFps = cells*FPC / cappedDur.
      const playbackFps = geom.totalFrames / geom.cappedDur;
      let outFrame = 0;       // current F (0 .. FPC-1)
      let frameAccum = 0;     // sub-frame accumulator for smooth playback
      let lastNow = null;

      function tick(now) {
        const f = framesRef.current;
        if (!f.length) { rafRef.current = requestAnimationFrame(tick); return; }

        // Advance F at playbackFps. Wall-clock based so we don't drop frames
        // on slow render ticks; modulo FPC to loop the cycle cleanly.
        if (lastNow !== null) frameAccum += ((now - lastNow) / 1000) * playbackFps;
        lastNow = now;
        outFrame = Math.floor(frameAccum) % geom.framesPerCell;

        const subShift = (outFrame / geom.framesPerCell) * geom.cellWidth;

        // Background — visible only at the very first frame before any wrap
        // has had a chance to fill the leftmost gap.
        ctx.fillStyle = "#000";
        ctx.fillRect(0, 0, geom.outWidth, geom.outHeight);

        // Row-major render — matches ClipGrid's runBuildPhaseCpu order and
        // ensures each row's wrap lands in the next row's leftward gap
        // before that row's cells are drawn.
        for (let row = 0; row < geom.rows; row++) {
          for (let col = 0; col < geom.cols; col++) {
            const cellOrder = row * geom.cols + col;
            const frameIdx  = cellOrder * geom.framesPerCell + outFrame;
            const img       = f[frameIdx];
            if (!img) continue;  // defensive — shouldn't happen with a valid geom
            const x = col * geom.cellWidth + subShift;
            const y = row * geom.cellHeight;

            if (x + geom.cellWidth <= geom.outWidth) {
              // No overflow — straight draw.
              ctx.drawImage(img, x, y, geom.cellWidth, geom.cellHeight);
            } else {
              // Cell crosses the output's right edge: visible portion stays
              // at the right, overflow wraps to next row's left (or top-left
              // when row === rows - 1, mirroring ClipGrid's `moreRows` rule).
              const visW    = geom.outWidth - x;
              const wrapW   = geom.cellWidth - visW;
              const wrapRow = (row + 1) % geom.rows;
              const wrapY   = wrapRow * geom.cellHeight;

              const srcSplit = (visW / geom.cellWidth) * img.width;

              ctx.drawImage(img,
                0, 0, srcSplit, img.height,
                x, y, visW, geom.cellHeight);
              ctx.drawImage(img,
                srcSplit, 0, img.width - srcSplit, img.height,
                0, wrapY, wrapW, geom.cellHeight);
            }
          }
        }

        rafRef.current = requestAnimationFrame(tick);
      }

      rafRef.current = requestAnimationFrame(tick);
    }, 0);

    return () => {
      clearTimeout(startId);
      cancelAnimationFrame(rafRef.current);
    };
  }, [state]);

  function onDrop(e) {
    e.preventDefault(); setDragOver(false);
    const file = [...(e.dataTransfer.files || [])].find((f) => f.type.startsWith("video/")) || e.dataTransfer.files[0];
    if (file) handleFile(file);
  }

  function reset() {
    cancelAnimationFrame(rafRef.current);
    framesRef.current = [];
    geomRef.current   = null;
    setFrameCount(0);
    setState("idle");
    setError("");
    setInspection(null);
    setVerdict(null);
  }

  // Kill the running ffmpeg worker and reset state. Used by the Cancel
  // button in the extracting UI when ffmpeg has stalled. The next ensureFfmpeg
  // call will create + load a fresh FFmpeg instance.
  function cancelExtract() {
    try {
      if (ffmpegRef.current && typeof ffmpegRef.current.terminate === "function") {
        ffmpegRef.current.terminate();
      }
    } catch (e) {
      console.warn("[demo] ffmpeg.terminate() threw:", e);
    }
    ffmpegRef.current = null;
    setError("Extraction cancelled. The compatibility verdict above is still valid — only the in-browser preview was aborted.");
    setState("error");
  }

  // Read geom directly from ref for display — it's set before setState("ready"),
  // so the render that follows will see the latest values.
  const liveGeom     = geomRef.current;
  const gridLabel    = liveGeom ? (liveGeom.cols + "×" + liveGeom.rows) : "—";
  const outDimsLabel = liveGeom ? (liveGeom.outWidth + " × " + liveGeom.outHeight) : (DEMO_DISPLAY_W + " × " + DEMO_DISPLAY_H);
  const cycleLabel   = liveGeom ? liveGeom.cycleSec.toFixed(1) + "s" : "—";
  const cellCount    = liveGeom ? liveGeom.cells : "—";
  const showVerdict  = verdict && state !== "idle";
  const previewLive  = state === "ready";

  return (
    <div className="tf-stage">
      {/* LEFT — drop zone / verdict / status */}
      <div
        className={"drop" + (dragOver ? " is-over" : "")}
        role="group"
        aria-label="ClipGrid browser demo: drop a video file here, or choose one, to check compatibility and preview the contact-sheet output"
        aria-describedby="cg-browser-formats"
        onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
        onDragLeave={() => setDragOver(false)}
        onDrop={onDrop}
      >
        <div className="row between center">
          <span className="kicker">Compatibility check</span>
          <span className="kicker" style={{ color: "var(--accent-deep)" }}>
            <span style={{ display: "inline-block", width: 6, height: 6, borderRadius: "50%", background: "var(--accent)", marginRight: 8, verticalAlign: "middle" }} />
            BROWSER PREVIEW · NOTHING UPLOADED
          </span>
        </div>

        {state === "idle" && (
          <div className="col gap-4 fill" style={{ justifyContent: "center", paddingBlock: 18 }}>
            <h3 style={{ fontSize: 30, lineHeight: 1.1 }}>Drop a video to check<br/>if ClipGrid can decode it.</h3>
            <p className="muted" style={{ maxWidth: "40ch" }}>
              We read the file's container metadata locally to identify the codec, then map it to a ClipGrid decode verdict — NVDEC hardware path or CPU ffmpeg fallback. If the codec is supported, we'll also extract real frames via
              <span className="mono" style={{ fontSize: 14 }}> ffmpeg.wasm</span> (the same libavcodec ClipGrid uses) and run ClipGrid's dynamic-geometry translate pipeline on the first {MAX_DEMO_SECONDS}&nbsp;s with target&nbsp;duration {DEMO_TARGET_DUR}&nbsp;s and {DEMO_DISPLAY_W}×{DEMO_DISPLAY_H} display fit — a browser preview budget of up to {DEMO_MAX_FRAMES} frames.
            </p>
            <div className="row gap-3" style={{ marginTop: 8, flexWrap: "wrap" }}>
              <label className="btn" htmlFor="cg-browser-file">Choose file</label>
              <button className="btn ghost" onClick={useSample}>See sample preview</button>
            </div>
            <input id="cg-browser-file" type="file" accept="video/*" className="visually-hidden"
              onChange={(e) => e.target.files[0] && handleFile(e.target.files[0])} />
            <p id="cg-browser-formats" className="mono" style={{ fontSize: 10, color: "var(--ink-mute)", letterSpacing: ".1em" }}>
              DEMO INPUT · MP4 · MOV · MKV · WEBM · AVI — verdict in &lt;1 s, preview in 5-30 s
            </p>
          </div>
        )}

        {state === "inspecting" && (
          <div className="col gap-3 fill" style={{ justifyContent: "center" }}>
            <div className="mono" style={{ fontSize: 11, color: "var(--ink-mute)", letterSpacing: ".12em" }}>READING CONTAINER METADATA…</div>
            <ProgressBar indeterminate />
            <p className="muted" style={{ fontSize: 14 }}>{filename}</p>
          </div>
        )}

        {showVerdict && (state === "unsupported" || state === "extracting" || state === "ready" || state === "error") && (
          <div className="col gap-3 fill">
            <div className="row between" style={{ flexWrap: "wrap", gap: 8 }}>
              <div style={{ minWidth: 0, flex: 1 }}>
                <div className="mono" style={{ fontSize: 11, color: "var(--ink-mute)", letterSpacing: ".12em" }}>SOURCE</div>
                <div className="serif" style={{ fontSize: 20, lineHeight: 1.2, maxWidth: "30ch", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{filename}</div>
              </div>
              <button className="btn ghost" style={{ fontSize: 11, padding: "8px 12px", alignSelf: "flex-start" }} onClick={reset}>Reset</button>
            </div>

            <VerdictCard verdict={verdict} inspection={inspection} />

            {state === "extracting" && (
              <div className="col gap-2" style={{ marginTop: 4, minWidth: 0 }}>
                <div className="row between" style={{ minWidth: 0, gap: 8 }}>
                  <div className="mono" style={{ fontSize: 11, color: "var(--ink-mute)", letterSpacing: ".12em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0, flex: 1 }}>
                    {extractPhase ? extractPhase.toUpperCase() : "EXTRACTING…"}
                  </div>
                  <div className="mono" style={{ fontSize: 11, color: "var(--accent-deep)", flexShrink: 0 }}>{extractPct}%</div>
                </div>
                <ProgressBar progress={extractPct} />
                <div className="row gap-3" style={{ marginTop: 6, flexWrap: "wrap" }}>
                  <button className="btn ghost" onClick={cancelExtract} style={{ fontSize: 11, padding: "6px 12px" }}>
                    Cancel extraction
                  </button>
                </div>
                <p className="muted" style={{ fontSize: 12 }}>
                  Decoding through ffmpeg.wasm — same libavcodec ClipGrid uses under the hood. First-load downloads the ~28 MB wasm core (cached for next time).
                </p>
              </div>
            )}

            {state === "ready" && (
              <div className="tf-controls" style={{ marginTop: 4 }}>
                <div className="ctrl"><label>Source</label><div className="v">{resolution || "—"}{duration ? " · " + duration.toFixed(1) + "s" : ""}</div></div>
                <div className="ctrl"><label>Grid</label><div className="v">{gridLabel} · {cellCount} cells</div></div>
                <div className="ctrl"><label>Output</label><div className="v">{outDimsLabel}</div></div>
                <div className="ctrl"><label>Cycle</label><div className="v">{cycleLabel}{liveGeom ? " · " + liveGeom.framesPerCell + " frames/cell" : ""}</div></div>
              </div>
            )}

            {state === "unsupported" && (
              <p className="muted" style={{ fontSize: 13, marginTop: 4 }}>
                We couldn't identify this file's codec from container metadata, so we can't make a confident promise. The file may still work in ClipGrid — its CPU ffmpeg backend handles more codecs than our matrix tracks. Try opening it in the desktop app to find out for certain.
              </p>
            )}

            {state === "error" && (
              <div className="col gap-2" style={{ marginTop: 4 }}>
                <div className="mono" style={{ fontSize: 11, color: "#c0392b", letterSpacing: ".12em" }}>PREVIEW UNAVAILABLE</div>
                <p style={{ fontSize: 13 }}>{error}</p>
              </div>
            )}
          </div>
        )}

        {state === "error" && !showVerdict && (
          <div className="col gap-3 fill" style={{ justifyContent: "center" }}>
            <div className="mono" style={{ fontSize: 11, color: "#c0392b", letterSpacing: ".12em" }}>ERROR</div>
            <p>{error}</p>
            <button className="btn ghost" onClick={reset}>Try another file</button>
          </div>
        )}
      </div>

      {/* RIGHT — canvas output */}
      <div className="col gap-4" style={{ justifyContent: "flex-start" }}>
        <div className="row between center">
          <span className="kicker">Output preview</span>
          <span className="kicker" style={{ color: previewLive ? "var(--accent-deep)" : "var(--ink-mute)" }}>
            {previewLive ? ("● " + gridLabel + " · " + cellCount + " cells · translate") : "ClipGrid · translate mode"}
          </span>
        </div>
        <div className="thumbnail-tile">
          {previewLive ? (
            <>
              <span className="badge live">● Live</span>
              <canvas ref={canvasRef} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", display: "block" }} />
            </>
          ) : (
            <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", color: "#777", fontFamily: "var(--ff-mono)", fontSize: 12, letterSpacing: ".12em", textAlign: "center", padding: 24 }}>
              {state === "extracting" && "DECODING…"}
              {state === "inspecting" && "INSPECTING HEADER…"}
              {(state === "idle" || state === "error") && "AWAITING SOURCE"}
              {state === "unsupported" && "NO PREVIEW · VERDICT ONLY"}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

  window.ClipGridBrowserDemo = BrowserDemo;
})();
