/* @file: photo-cycle.jsx — Exposes: window.__app.PhotoCycle
 * Direction C ("Monograph Page") with hand-printed-zine push: per-side
 * jittered bezier hand-drawn frames (multi-stroke overdraw + riso ghost
 * offset in accent red), photo with a hairline cream halo pressed inward
 * at the edges (no shadow, no fade), kicker pinned to the left margin
 * vertically centered, italic Cormorant caption below, ink-stamp seal
 * "№ 03 / OF XI" in the corner replacing dot-row pagination.
 *
 * Reads: window.__app.currentQuestionId,
 *        window.__app.currentQuestionLabel
 *        (set by question-view.jsx)
 */
(() => {
  const STYLE_ID = "photo-cycle-styles";
  const SVG_DEFS_ID = "photo-cycle-svg-defs";

  const CSS = `
    .pc-stage {
      position: fixed; inset: 0; z-index: 100;
      pointer-events: none;
    }
    .pc-loading {
      position: fixed; inset: 0;
      display: flex; align-items: center; justify-content: center;
      font-family: "Cormorant Garamond", "Georgia", serif;
      font-style: italic;
      font-size: 22px;
      color: #b8b0a4;
      animation: pc-pulse 1400ms ease-in-out infinite;
    }
    @keyframes pc-pulse {
      0%, 100% { opacity: 0.30; }
      50%      { opacity: 0.85; }
    }

    /* Editorial kicker pinned to the left margin, vertically centered.
       Reads as a chapter mark in a printed monograph. */
    .pc-kicker {
      position: fixed;
      left: 5vw;
      top: 50%;
      transform: translateY(-50%);
      max-width: 14vw;
      font-family: "Cormorant Garamond", "Georgia", serif;
      font-style: italic;
      font-weight: 400;
      font-size: 17px;
      line-height: 1.35;
      letter-spacing: 0.03em;
      color: #2a2520;
      user-select: none;
      z-index: 102;
    }
    .pc-kicker__rule {
      display: block;
      width: 36px;
      height: 1px;
      background: #c81f1f;
      margin-top: 10px;
      opacity: 0.85;
    }

    /* Photo + frame layer */
    .pc-layer {
      position: fixed;
      top: 50%; left: 50%;
      transform: translate(-50%, -50%);
      will-change: opacity, transform;
      pointer-events: none;
    }
    .pc-layer--out {
      mix-blend-mode: multiply;
    }
    @media (prefers-reduced-motion: reduce) {
      .pc-layer--out { mix-blend-mode: normal; }
    }

    .pc-media {
      position: relative;
      display: inline-block;
    }

    /* Photo nestles into the paper via a hairline inset glow in the
       paper color — the edge stays crisp where content matters, but the
       outermost ~10px softens just enough that the photo doesn't read as
       a digital insert. The hand-drawn frame around it carries most of
       the integration. No shadow. */
    .pc-media > img,
    .pc-media > video {
      display: block;
      max-width: 60vw;
      max-height: 70vh;
      width: auto; height: auto;
      object-fit: contain;
      border-radius: 6px;
      box-shadow:
        inset 0 0 10px 2px rgba(251, 248, 241, 0.55),
        inset 0 0 0 1px rgba(28, 22, 18, 0.05);
    }

    /* Hand-drawn ink-stroke frame, sits well outside the photo for breathing
       room (24px outset) and is rendered via per-side jittered bezier paths
       (see HandDrawnFrame). */
    .pc-ink {
      position: absolute;
      top: -24px; left: -24px;
      pointer-events: none;
    }

    /* Caption row below photo, italic Cormorant matching the editorial voice. */
    .pc-caption-row {
      display: flex;
      justify-content: center;
      margin-top: 52px;
    }
    .pc-caption {
      font-family: "Cormorant Garamond", "Georgia", serif;
      font-style: italic;
      font-weight: 400;
      font-size: 24px;
      letter-spacing: 0.015em;
      color: #2a2520;
      text-align: center;
      max-width: 60vw;
      opacity: 0;
      transition: opacity 320ms ease-out;
      user-select: none;
    }
    .pc-caption--visible { opacity: 1; }

    /* Ink-stamp seal in the corner. Replaces the row of pagination dots —
       single hand-pressed mark with photo number / total inside, slight
       rotation per photo. */
    .pc-seal {
      position: fixed;
      right: 5vw;
      bottom: 6vh;
      width: 92px;
      height: 92px;
      pointer-events: none;
      z-index: 102;
      transition: transform 600ms ease, opacity 600ms ease;
    }

    /* Tap-to-play affordance for videos that fail autoplay */
    .pc-tap-to-play {
      position: absolute;
      top: 50%; left: 50%;
      transform: translate(-50%, -50%);
      width: 64px; height: 64px;
      border-radius: 50%;
      background: rgba(255, 255, 255, 0.78);
      border: none;
      cursor: pointer;
      display: flex;
      align-items: center; justify-content: center;
      font-size: 22px;
      color: #2a2520;
      pointer-events: auto;
      box-shadow: 0 4px 12px rgba(28, 22, 18, 0.18);
      backdrop-filter: blur(2px);
    }
    .pc-tap-to-play:hover { background: rgba(255, 255, 255, 0.92); }
  `;

  const SVG_DEFS = `
    <svg width="0" height="0" style="position:absolute;pointer-events:none" aria-hidden="true">
      <defs>
        <filter id="pc-seal-rough" x="-10%" y="-10%" width="120%" height="120%">
          <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="7" result="noise"/>
          <feDisplacementMap in="SourceGraphic" in2="noise" scale="1.4" xChannelSelector="R" yChannelSelector="G"/>
        </filter>
      </defs>
    </svg>
  `;

  function ensureStyles() {
    if (!document.getElementById(STYLE_ID)) {
      const style = document.createElement("style");
      style.id = STYLE_ID;
      style.textContent = CSS;
      document.head.appendChild(style);
    }
    if (!document.getElementById(SVG_DEFS_ID)) {
      const wrap = document.createElement("div");
      wrap.id = SVG_DEFS_ID;
      wrap.innerHTML = SVG_DEFS;
      document.body.appendChild(wrap);
    }
  }

  function normalize(input) {
    if (!Array.isArray(input)) return [];
    const out = [];
    for (const it of input) {
      if (typeof it === "string") {
        out.push({ kind: "photo", src: it, caption: "" });
      } else if (it && it.src) {
        out.push({
          kind: it.kind || "photo",
          src: it.src,
          caption: it.caption || "",
          poster: it.poster,
          maxPlayMs: it.maxPlayMs,
        });
      }
    }
    return out;
  }

  function mulberry32(seed) {
    let t = seed | 0;
    return () => {
      t = (t + 0x6D2B79F5) | 0;
      let r = Math.imul(t ^ (t >>> 15), 1 | t);
      r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
      return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
    };
  }

  /* Build a hand-drawn rectangle as a single SVG path. Each of the four
     sides is a cubic bezier whose endpoints stay fixed (so the corners
     remain corners) but whose two control points are randomly jittered —
     deconbatch's algorithm. Optional `overshoot` makes the path start a
     few pixels early and end a few pixels late, evoking strokes that
     don't quite meet. */
  function jitteredRectPath(w, h, twist, overshoot, rng) {
    const o = overshoot || 0;
    const side = (sx, sy, ex, ey) => {
      const len = Math.hypot(ex - sx, ey - sy);
      const dLen = len * twist;
      const t1 = 0.18 + rng() * 0.22;
      const t2 = t1 + 0.30 + rng() * 0.22;
      const cp1x = sx + (ex - sx) * t1 + (rng() * 2 - 1) * dLen;
      const cp1y = sy + (ey - sy) * t1 + (rng() * 2 - 1) * dLen;
      const cp2x = sx + (ex - sx) * t2 + (rng() * 2 - 1) * dLen;
      const cp2y = sy + (ey - sy) * t2 + (rng() * 2 - 1) * dLen;
      return `C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)} ${cp2x.toFixed(2)} ${cp2y.toFixed(2)} ${ex.toFixed(2)} ${ey.toFixed(2)}`;
    };
    const startX = -o + rng() * o * 2;
    const startY = -o + rng() * o * 2;
    return [
      `M ${startX.toFixed(2)} ${startY.toFixed(2)}`,
      side(0, 0, w, 0),
      side(w, 0, w, h),
      side(w, h, 0, h),
      side(0, h, 0, 0),
    ].join(" ");
  }

  /* HandDrawnFrame: 2-pass overdraw of a jittered bezier rectangle, plus a
     subtle red ghost stroke offset by 2-3px (riso misregistration). The
     three strokes share the photo's seed so wobble is stable per-photo. */
  function HandDrawnFrame({ targetRef, seed }) {
    const [dims, setDims] = React.useState({ w: 0, h: 0 });

    React.useEffect(() => {
      const el = targetRef && targetRef.current;
      if (!el) return undefined;
      let raf = 0;
      const measure = () => {
        const r = el.getBoundingClientRect();
        const w = Math.max(0, Math.round(r.width));
        const h = Math.max(0, Math.round(r.height));
        setDims((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
      };
      const schedule = () => {
        if (raf) cancelAnimationFrame(raf);
        raf = requestAnimationFrame(measure);
      };
      schedule();
      let ro = null;
      if (typeof ResizeObserver !== "undefined") {
        ro = new ResizeObserver(schedule);
        ro.observe(el);
      }
      const onLoad = () => schedule();
      el.addEventListener("load", onLoad);
      el.addEventListener("loadedmetadata", onLoad);
      return () => {
        if (raf) cancelAnimationFrame(raf);
        if (ro) ro.disconnect();
        el.removeEventListener("load", onLoad);
        el.removeEventListener("loadedmetadata", onLoad);
      };
    }, [targetRef]);

    const paths = React.useMemo(() => {
      if (!dims.w || !dims.h) return null;
      const OUTSET = 24;
      const w = dims.w + OUTSET * 2;
      const h = dims.h + OUTSET * 2;
      const rectW = dims.w + 14;
      const rectH = dims.h + 14;
      const baseX = (w - rectW) / 2;
      const baseY = (h - rectH) / 2;
      const r1 = mulberry32(seed * 9301 + 49297);
      const r2 = mulberry32(seed * 9301 + 233280);
      const r3 = mulberry32(seed * 9301 + 718291);
      const ghostDx = (r3() - 0.5) * 4 + 1.5;
      const ghostDy = (r3() - 0.5) * 3 + 0.8;
      return {
        w, h,
        a: jitteredRectPath(rectW, rectH, 0.045, 3.5, r1),
        b: jitteredRectPath(rectW, rectH, 0.060, 4.5, r2),
        translate: `translate(${baseX.toFixed(2)} ${baseY.toFixed(2)})`,
        ghostTranslate: `translate(${(baseX + ghostDx).toFixed(2)} ${(baseY + ghostDy).toFixed(2)})`,
      };
    }, [dims.w, dims.h, seed]);

    if (!paths) return null;
    return (
      <svg
        className="pc-ink"
        width={paths.w}
        height={paths.h}
        viewBox={`0 0 ${paths.w} ${paths.h}`}
      >
        <g transform={paths.ghostTranslate} style={{ mixBlendMode: "multiply" }}>
          <path
            d={paths.a}
            fill="none"
            stroke="#c81f1f"
            strokeWidth="2.2"
            strokeLinecap="round"
            strokeLinejoin="round"
            opacity="0.42"
          />
        </g>
        <g transform={paths.translate}>
          <path
            d={paths.a}
            fill="none"
            stroke="#2a2520"
            strokeWidth="1.7"
            strokeLinecap="round"
            strokeLinejoin="round"
            opacity="0.92"
          />
          <path
            d={paths.b}
            fill="none"
            stroke="#2a2520"
            strokeWidth="1.4"
            strokeLinecap="round"
            strokeLinejoin="round"
            opacity="0.55"
          />
        </g>
      </svg>
    );
  }

  /* InkSeal: hand-pressed circular stamp showing photo position. Wobble via
     feTurbulence/feDisplacementMap, slight per-photo rotation, accent red. */
  function InkSeal({ current, total, idx }) {
    const pad2 = (n) => String(n).padStart(2, "0");
    const toRoman = (n) => {
      const map = [["X",10],["IX",9],["V",5],["IV",4],["I",1]];
      let s = "";
      let r = Math.max(1, Math.min(39, n));
      for (const [g, v] of map) { while (r >= v) { s += g; r -= v; } }
      return s;
    };
    const rot = ((idx * 137.508) % 9) - 4.5;
    const style = { transform: `rotate(${rot.toFixed(2)}deg)` };
    return (
      <svg
        className="pc-seal"
        viewBox="0 0 120 120"
        aria-label={`Photo ${current} of ${total}`}
        style={style}
      >
        <g filter="url(#pc-seal-rough)">
          <circle cx="60" cy="60" r="51" fill="none" stroke="#c81f1f" strokeWidth="1.6" opacity="0.95"/>
          <circle cx="60" cy="60" r="44" fill="none" stroke="#c81f1f" strokeWidth="0.7" opacity="0.65"/>
          <text
            x="60" y="58"
            textAnchor="middle"
            fontFamily='"Cormorant Garamond", "Georgia", serif'
            fontStyle="italic"
            fontSize="20"
            fill="#c81f1f"
          >
            № {pad2(current)}
          </text>
          <text
            x="60" y="78"
            textAnchor="middle"
            fontFamily='"Cormorant Garamond", "Georgia", serif'
            fontSize="9"
            letterSpacing="2"
            fill="#c81f1f"
            opacity="0.85"
          >
            OF {toRoman(total)}
          </text>
        </g>
      </svg>
    );
  }

  const CROSSFADE_MS         = 800;
  const CAPTION_IN_DELAY_MS  = 400;
  const CAPTION_OUT_LEAD_MS  = 250;
  const ADVANCE_LOCKOUT_MS   = 1000;
  const DEFAULT_PHOTO_DWELL  = 4000;
  const FIRST_PHOTO_BONUS    = 1000;
  const DEFAULT_VIDEO_MAX    = 600000;
  const PRELOAD_HEAD         = 3;
  const VIDEO_PLAY_DELAY     = 100;
  const CROSSFADE_EASE       = "cubic-bezier(0.4, 0, 0.2, 1)";

  function Layer(props) {
    const {
      item,
      idx,
      phase,
      captionVisible,
      reduceMotion,
      attachVideoRef,
      onVideoNeedsClick,
    } = props;

    const [progress, setProgress] = React.useState("start");
    const mediaRef = React.useRef(null);

    React.useLayoutEffect(() => { setProgress("start"); }, [phase]);

    React.useEffect(() => {
      let id1, id2;
      id1 = requestAnimationFrame(() => {
        id2 = requestAnimationFrame(() => setProgress("end"));
      });
      return () => {
        if (id1) cancelAnimationFrame(id1);
        if (id2) cancelAnimationFrame(id2);
      };
    }, [phase]);

    let scale = 1.0, opacity = 1.0;
    if (phase === "enter") {
      scale   = progress === "start" ? 1.02 : 1.0;
      opacity = progress === "start" ? 0    : 1;
    } else if (phase === "exit") {
      scale   = 1.0;
      opacity = progress === "start" ? 1    : 0;
    }
    if (reduceMotion) scale = 1.0;

    const useTransition = progress === "end";
    const layerStyle = {
      opacity,
      transform: `translate(-50%, -50%) scale(${scale})`,
      transition: useTransition
        ? `opacity ${CROSSFADE_MS}ms ${CROSSFADE_EASE}, transform ${CROSSFADE_MS}ms ${CROSSFADE_EASE}`
        : "none",
    };

    const handleVideoRef = React.useCallback((el) => {
      mediaRef.current = el;
      if (attachVideoRef) attachVideoRef.current = el;
    }, [attachVideoRef]);

    const handleImgRef = React.useCallback((el) => {
      mediaRef.current = el;
    }, []);

    const handleTapToPlay = React.useCallback(() => {
      const v = mediaRef.current;
      if (!v) return;
      try { v.load(); } catch (e) {}
      try {
        const p = v.play();
        if (p && typeof p.then === "function") {
          p.then(() => {
            if (onVideoNeedsClick) onVideoNeedsClick(false);
          }).catch((err) => {
            console.error("[vid] tap-to-play also rejected", err.name, err.message);
          });
        }
      } catch (e) {}
    }, [onVideoNeedsClick]);

    return (
      <div
        className={`pc-layer ${phase === "exit" ? "pc-layer--out" : ""}`}
        style={layerStyle}
      >
        <div className="pc-media">
          {item.kind === "video" ? (
            <video
              ref={handleVideoRef}
              poster={item.poster}
              autoPlay
              muted
              playsInline
              preload="auto"
            >
              <source src={item.src} type="video/mp4" />
            </video>
          ) : (
            <img
              ref={handleImgRef}
              src={item.src}
              alt={item.caption || ""}
            />
          )}
          <HandDrawnFrame targetRef={mediaRef} seed={idx + 1} />
          {item.kind === "video" && props.videoNeedsClick ? (
            <button
              type="button"
              className="pc-tap-to-play"
              onClick={handleTapToPlay}
              aria-label="Play video"
            >
              ▶
            </button>
          ) : null}
        </div>
        <div className="pc-caption-row">
          <span
            className={
              "pc-caption" +
              (captionVisible && item.caption ? " pc-caption--visible" : "")
            }
          >
            {item.caption || "\u00a0"}
          </span>
        </div>
      </div>
    );
  }

  function PhotoCycle(props) {
    const { onComplete } = props;
    const items = React.useMemo(
      () => normalize(props.media || props.photos || []),
      [props.media, props.photos]
    );
    const total = items.length;

    const [loaded, setLoaded]                   = React.useState(false);
    const [currentIdx, setCurrentIdx]           = React.useState(-1);
    const [outgoingIdx, setOutgoingIdx]         = React.useState(null);
    const [captionVisible, setCaptionVis]       = React.useState(false);
    const [videoNeedsClick, setVideoNeedsClick] = React.useState(false);
    const [reduceMotion, setReduceMotion]       = React.useState(
      typeof window !== "undefined" && window.matchMedia
        ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
        : false
    );

    const timersRef         = React.useRef(new Set());
    const completedRef      = React.useRef(false);
    const dwellStartRef     = React.useRef(0);
    const dwellMsRef        = React.useRef(0);
    const elapsedAtPauseRef = React.useRef(null);
    const pausedRef         = React.useRef(false);
    const videoElRef        = React.useRef(null);
    const currentIdxRef     = React.useRef(-1);
    const apiRef            = React.useRef(null);

    React.useEffect(() => { currentIdxRef.current = currentIdx; }, [currentIdx]);

    const setT = React.useCallback((fn, ms) => {
      const id = setTimeout(() => {
        timersRef.current.delete(id);
        fn();
      }, ms);
      timersRef.current.add(id);
      return id;
    }, []);

    const clearAllTimers = React.useCallback(() => {
      timersRef.current.forEach((id) => clearTimeout(id));
      timersRef.current.clear();
    }, []);

    React.useEffect(() => { ensureStyles(); }, []);

    React.useEffect(() => {
      if (!window.matchMedia) return undefined;
      const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
      const onChange = () => setReduceMotion(mql.matches);
      try { mql.addEventListener("change", onChange); }
      catch (e) { mql.addListener(onChange); }
      return () => {
        try { mql.removeEventListener("change", onChange); }
        catch (e) { mql.removeListener(onChange); }
      };
    }, []);

    /* Preload pipeline: decode first PRELOAD_HEAD photos before showing the
       cycle; background-load the rest. (Videos: <link rel=preload as=video>
       is silently ignored in Chrome and Safari per crbug 977033.) */
    React.useEffect(() => {
      let cancelled = false;
      if (total === 0) { setLoaded(true); return undefined; }

      const head = items.slice(0, PRELOAD_HEAD).filter((it) => it.kind === "photo");
      const decodes = head.map((it) => new Promise((resolve) => {
        const img = new Image();
        img.src = it.src;
        if (img.decode) {
          img.decode().then(resolve).catch(resolve);
        } else {
          img.onload = resolve;
          img.onerror = resolve;
          setTimeout(resolve, 1500);
        }
      }));

      Promise.all(decodes).then(() => {
        if (!cancelled) setLoaded(true);
      });

      items.slice(PRELOAD_HEAD).forEach((it) => {
        if (it.kind === "photo") {
          const img = new Image();
          img.src = it.src;
          if (img.decode) img.decode().catch(() => {});
        }
      });

      return () => { cancelled = true; };
    }, [items, total]);

    function beginItem(nextIdx, outgoingFromIdx) {
      const item = items[nextIdx];
      if (!item) return;

      setVideoNeedsClick(false);
      setOutgoingIdx(outgoingFromIdx != null && outgoingFromIdx >= 0 ? outgoingFromIdx : null);
      setCurrentIdx(nextIdx);
      setCaptionVis(false);

      let dwell = DEFAULT_PHOTO_DWELL + (nextIdx === 0 ? FIRST_PHOTO_BONUS : 0);
      if (item.kind === "video") {
        dwell = item.maxPlayMs || DEFAULT_VIDEO_MAX;
      }
      dwellMsRef.current = dwell;
      dwellStartRef.current = Date.now();

      if (outgoingFromIdx != null && outgoingFromIdx >= 0) {
        setT(() => setOutgoingIdx(null), CROSSFADE_MS);
      }

      if (item.caption) {
        setT(() => setCaptionVis(true), CROSSFADE_MS + CAPTION_IN_DELAY_MS);
      }

      if (item.kind === "video") {
        setT(() => {
          const v = videoElRef.current;
          if (!v) { console.warn("[vid] no ref at play time"); return; }
          console.log("[vid] pre-play", {
            src: v.currentSrc,
            readyState: v.readyState,
            networkState: v.networkState,
            paused: v.paused,
            muted: v.muted,
            error: v.error && v.error.code,
          });
          const onLoadedData = () => console.log("[vid] loadeddata", v.readyState);
          const onCanPlay    = () => console.log("[vid] canplay", v.readyState);
          const onPlaying    = () => console.log("[vid] PLAYING currentTime=", v.currentTime);
          const onStalled    = () => console.warn("[vid] stalled");
          const onWaiting    = () => console.warn("[vid] waiting");
          const onError      = () => console.error("[vid] error", v.error);
          v.addEventListener("loadeddata", onLoadedData, { once: true });
          v.addEventListener("canplay",    onCanPlay,    { once: true });
          v.addEventListener("playing",    onPlaying,    { once: true });
          v.addEventListener("stalled",    onStalled);
          v.addEventListener("waiting",    onWaiting);
          v.addEventListener("error",      onError);

          try {
            const p = v.play();
            if (p && typeof p.then === "function") {
              p.then(
                () => console.log("[vid] play() resolved"),
                (err) => {
                  console.error("[vid] play() REJECTED", err.name, err.message);
                  setVideoNeedsClick(true);
                }
              );
            }
          } catch (e) {
            console.error("[vid] play() threw", e);
            setVideoNeedsClick(true);
          }
          const onEnded = () => {
            if (apiRef.current) apiRef.current.advance("video-ended");
          };
          v.addEventListener("ended", onEnded, { once: true });
        }, CROSSFADE_MS + VIDEO_PLAY_DELAY);
      }

      scheduleDwell(dwell, nextIdx);
    }

    function scheduleDwell(remainingMs, explicitIdx) {
      const idx = explicitIdx != null ? explicitIdx : currentIdxRef.current;
      const item = items[idx];
      if (!item) return;

      const captionOutAt = remainingMs - CAPTION_OUT_LEAD_MS;
      if (item.caption && captionOutAt > 0) {
        setT(() => setCaptionVis(false), captionOutAt);
      }

      setT(() => {
        const next = idx + 1;
        if (next >= total) {
          beginExitFinal();
        } else {
          beginItem(next, idx);
        }
      }, Math.max(0, remainingMs));
    }

    function beginExitFinal() {
      setOutgoingIdx(currentIdxRef.current);
      setCurrentIdx(total);
      setCaptionVis(false);
      setT(() => {
        if (!completedRef.current) {
          completedRef.current = true;
          if (typeof onComplete === "function") onComplete();
        }
      }, CROSSFADE_MS);
    }

    apiRef.current = {
      advance(reason) {
        if (completedRef.current) return;
        if (currentIdxRef.current < 0) return;
        if (reason === "user") {
          const elapsed = Date.now() - dwellStartRef.current;
          if (elapsed < ADVANCE_LOCKOUT_MS) return;
        }
        clearAllTimers();
        const next = currentIdxRef.current + 1;
        if (next >= total) {
          beginExitFinal();
        } else {
          beginItem(next, currentIdxRef.current);
        }
      },
      goBack(reason) {
        if (completedRef.current) return;
        if (currentIdxRef.current <= 0) return;
        if (reason === "user") {
          const elapsed = Date.now() - dwellStartRef.current;
          if (elapsed < ADVANCE_LOCKOUT_MS) return;
        }
        clearAllTimers();
        beginItem(currentIdxRef.current - 1, currentIdxRef.current);
      },
      pauseNow() {
        if (pausedRef.current) return;
        if (currentIdxRef.current < 0 || currentIdxRef.current >= total) return;
        const elapsed = Date.now() - dwellStartRef.current;
        elapsedAtPauseRef.current = elapsed;
        pausedRef.current = true;
        clearAllTimers();
        if (videoElRef.current) {
          try { videoElRef.current.pause(); } catch (e) {}
        }
      },
      resumeNow() {
        if (!pausedRef.current) return;
        if (currentIdxRef.current < 0 || currentIdxRef.current >= total) return;
        const elapsed = elapsedAtPauseRef.current || 0;
        const remaining = Math.max(0, dwellMsRef.current - elapsed);
        dwellStartRef.current = Date.now() - elapsed;
        elapsedAtPauseRef.current = null;
        pausedRef.current = false;
        scheduleDwell(remaining);
        if (videoElRef.current) {
          try {
            const p = videoElRef.current.play();
            if (p && typeof p.then === "function") p.then(() => {}, () => {});
          } catch (e) {}
        }
      },
    };

    /* Keyboard + visibility. ArrowLeft / ArrowRight only — Space, Enter, and
       click are intentionally not handled (Enter only triggers cycle start
       from the question-text state, owned by question-view.jsx). */
    React.useEffect(() => {
      const onKey = (e) => {
        const t = e.target;
        if (t && t.matches && t.matches("input,textarea,select,[contenteditable]")) return;
        if (e.key === "ArrowRight") {
          if (apiRef.current) apiRef.current.advance("user");
        } else if (e.key === "ArrowLeft") {
          if (apiRef.current) apiRef.current.goBack("user");
        }
      };
      const onVis = () => {
        if (!apiRef.current) return;
        if (document.visibilityState !== "visible") {
          apiRef.current.pauseNow();
        } else {
          apiRef.current.resumeNow();
        }
      };
      document.addEventListener("keydown", onKey);
      document.addEventListener("visibilitychange", onVis);
      return () => {
        document.removeEventListener("keydown", onKey);
        document.removeEventListener("visibilitychange", onVis);
      };
    }, []);

    React.useEffect(() => {
      if (!loaded) return;
      if (total === 0) {
        if (!completedRef.current) {
          completedRef.current = true;
          if (typeof onComplete === "function") onComplete();
        }
        return;
      }
      if (currentIdxRef.current < 0) {
        beginItem(0, null);
      }
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [loaded, total]);

    React.useEffect(() => {
      return () => {
        clearAllTimers();
        if (videoElRef.current) {
          try { videoElRef.current.pause(); } catch (e) {}
        }
      };
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    if (total === 0) return null;

    if (!loaded) {
      return (
        <div className="pc-stage">
          <div className="pc-loading">·</div>
        </div>
      );
    }

    const showCurrent  = currentIdx >= 0 && currentIdx < total;
    const showOutgoing = outgoingIdx != null && outgoingIdx >= 0 && outgoingIdx < total;

    const dispNum = showCurrent
      ? currentIdx + 1
      : Math.min(total, (outgoingIdx != null ? outgoingIdx : 0) + 1);

    const ns = window.__app || {};
    const questionId    = ns.currentQuestionId;
    const questionLabel = (typeof ns.currentQuestionLabel === "string" && ns.currentQuestionLabel) || "Question";
    const sealIdx = showCurrent ? currentIdx : (outgoingIdx != null ? outgoingIdx : 0);

    const layers = [];
    if (showOutgoing) layers.push({ idx: outgoingIdx, phase: "exit" });
    if (showCurrent)  layers.push({ idx: currentIdx,  phase: "enter" });

    return (
      <div className="pc-stage">
        <div className="pc-kicker">
          <span>{questionLabel}</span>
          <span className="pc-kicker__rule" />
        </div>

        {layers.map(({ idx, phase }) => (
          <Layer
            key={`layer-${idx}`}
            item={items[idx]}
            idx={idx}
            phase={phase}
            captionVisible={phase === "enter" ? captionVisible : false}
            reduceMotion={reduceMotion}
            attachVideoRef={
              phase === "enter" && items[idx].kind === "video" ? videoElRef : null
            }
            videoNeedsClick={phase === "enter" ? videoNeedsClick : false}
            onVideoNeedsClick={setVideoNeedsClick}
          />
        ))}

        <InkSeal current={dispNum} total={total} idx={sealIdx} />
      </div>
    );
  }

  window.__app = window.__app || {};
  window.__app.PhotoCycle = PhotoCycle;
})();
