// Type-specific authoring tools — catalogue §3.0 "New widgets to build".
//
//   ImageHotspotPlacer   2D image · add via "+ Add hotspot" · drag to reposition
//   PanoramaHotspotPlacer  360° image · drag-to-look viewer · add via "+ Add hotspot"
//   ThreeDHotspotPlacer  orbit a .glb model · add via "+ Add hotspot" (x,y,z)
//   InVideoInteractionTimeline  video scrubber + marker editor
//
// None of these are decorative: the four hotspot-bearing layouts
// (`hidden_items_flat_image`, `hidden_items_360_image`, `object_viewer`,
// image-hotspot questions in `quiz_images`) literally cannot be authored
// without them.
// In-video interactions are required by `fullscreen_video`, `small_video`,
// and video tabs inside `sequence` / `horizontal_tabs`.

// ── 1. ImageHotspotPlacer — for hidden_items_flat_image + quiz_images hotspots ──
function ImageHotspotPlacer({
  imageUrl, imageLabel,
  pins = [],                  // [{ id, x, y, title }]
  onChange,                   // (nextPins) => void
  selectedIndex = -1,
  onSelect,
  colors = { fill: '#FF80FF', stroke: '#FF80FF' },
  height = 320,
}) {
  /* Click-to-drop on the background is DISABLED (see onContainerClick
     below). Hotspots are added only via the "+ Add hotspot" kind picker,
     so the only gestures here are: drag a pin to reposition, click a pin
     to select. `wasDraggedRef` still latches a just-finished drag so the
     synthetic click fired on mouse-up is swallowed rather than mistaken
     for a select-elsewhere. */
  const ref = React.useRef(null);
  const dragRef = React.useRef(null); // { index, dx, dy, moved }
  const wasDraggedRef = React.useRef(false);
  const pinsRef = React.useRef(pins);
  pinsRef.current = pins;
  const onChangeRef = React.useRef(onChange);
  onChangeRef.current = onChange;
  const onSelectRef = React.useRef(onSelect);
  onSelectRef.current = onSelect;
  const [grabbingIdx, setGrabbingIdx] = React.useState(-1);
  const DRAG_THRESHOLD = 4;

  /* Click-to-drop on the background is intentionally disabled (matches
     PanoramaHotspotPlacer / ThreeDHotspotPlacer). New hotspots are
     created ONLY via the "+ Add hotspot" kind picker in the inspector —
     the one place the author chooses the media kind (Text+Image / Video /
     Question) — then dragged into position. A bare click here just clears
     any in-flight drag-flag; it must never create a stray, kind-less pin. */
  const onContainerClick = () => {
    if (wasDraggedRef.current) wasDraggedRef.current = false;
  };

  const startDrag = (e, i) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    e.preventDefault();
    onSelectRef.current?.(i);
    const box = ref.current.getBoundingClientRect();
    const px = (pinsRef.current[i].x / 100) * box.width + box.left;
    const py = (pinsRef.current[i].y / 100) * box.height + box.top;
    dragRef.current = { index: i, dx: e.clientX - px, dy: e.clientY - py,
      startX: e.clientX, startY: e.clientY, moved: false };
    setGrabbingIdx(i);
  };

  React.useEffect(() => {
    const move = (e) => {
      const d = dragRef.current;
      if (!d) return;
      if (!d.moved && (Math.abs(e.clientX - d.startX) > DRAG_THRESHOLD
                    || Math.abs(e.clientY - d.startY) > DRAG_THRESHOLD)) {
        d.moved = true;
      }
      if (!d.moved) return;
      const box = ref.current?.getBoundingClientRect();
      if (!box) return;
      const x = ((e.clientX - d.dx - box.left) / box.width) * 100;
      const y = ((e.clientY - d.dy - box.top) / box.height) * 100;
      const clamp = (v) => Math.max(0, Math.min(100, v));
      const next = pinsRef.current.map((p, i) => i === d.index
        ? { ...p, x: Math.round(clamp(x) * 10) / 10, y: Math.round(clamp(y) * 10) / 10 }
        : p);
      pinsRef.current = next;
      onChangeRef.current?.(next);
    };
    const up = () => {
      if (dragRef.current) {
        wasDraggedRef.current = dragRef.current.moved;
        dragRef.current = null;
        setGrabbingIdx(-1);
      }
    };
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    return () => {
      window.removeEventListener('mousemove', move);
      window.removeEventListener('mouseup', up);
    };
  }, []);

  return (
    <div>
      <div ref={ref} onClick={onContainerClick} style={{
        position: 'relative', height, width: '100%',
        overflow: 'hidden', cursor: grabbingIdx >= 0 ? 'grabbing' : 'default',
        background: 'var(--surface-inset)', border: '1px solid var(--border)',
        borderRadius: 'var(--radius-md)', userSelect: 'none',
      }}>
        <MockImage url={imageUrl} label={imageLabel || 'Background image'}
          style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
        {pins.map((p, i) => (
          <div key={p.id || i} onMouseDown={(e) => startDrag(e, i)}
            onClick={(e) => e.stopPropagation()}
            title={`Hotspot ${i + 1} — drag to reposition`}
            style={{
              position: 'absolute', left: `${p.x}%`, top: `${p.y}%`,
              transform: 'translate(-50%, -50%)',
              cursor: grabbingIdx === i ? 'grabbing' : 'grab',
              width: 30, height: 30, borderRadius: '50%',
              background: i === selectedIndex ? colors.fill : 'rgba(255,255,255,0.9)',
              border: `2px solid ${colors.stroke}`, color: i === selectedIndex ? '#fff' : colors.stroke,
              fontSize: 16, fontWeight: 700, lineHeight: 1,
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
              boxShadow: grabbingIdx === i
                ? `0 0 0 4px ${colors.fill}40, 0 2px 6px rgba(0,0,0,.4)`
                : (i === selectedIndex ? '0 0 0 3px rgba(255,255,255,.5)' : '0 1px 2px rgba(0,0,0,.3)'),
              userSelect: 'none', zIndex: i === selectedIndex ? 2 : 1,
              transition: 'box-shadow 120ms',
            }}>
            {i + 1}
          </div>
        ))}
        {pins.length === 0 && (
          <div style={{
            position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
            justifyContent: 'center', color: 'rgba(255,255,255,.85)',
            background: 'rgba(0,0,0,.3)', fontSize: 13, pointerEvents: 'none',
            textAlign: 'center', padding: '0 24px', lineHeight: 1.5,
          }}>
            <span>Use <strong>“+ Add hotspot”</strong> above to place a hotspot, then drag it where you want it.</span>
          </div>
        )}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8,
        fontSize: 11.5, color: 'var(--text-faint)' }}>
        <span><kbd>+ Add hotspot</kbd> to place</span>
        <span><kbd>drag</kbd> reposition</span>
        <div style={{ flex: 1 }} />
        <span style={{ fontFamily: 'var(--font-mono)' }}>
          {pins.length} {pins.length === 1 ? 'hotspot' : 'hotspots'}
        </span>
      </div>
    </div>
  );
}

// ── 2. PanoramaHotspotPlacer — for hidden_items_360_image ─────────────────
function PanoramaHotspotPlacer({
  imageUrl, image360CoverText,
  pins = [],
  onChange, selectedIndex = -1, onSelect,
  height = 320,
}) {
  /* 360° panoramas are wider-than-tall equirectangular images. The
     viewer can:
       • drag the background → yaw (look left/right)
       • click empty space   → drop a new hotspot
       • click an existing pin → select it
       • drag an existing pin → reposition it
     Distinguishing these reliably is the whole point of this rewrite
     — previously a drag-on-dot bubbled to the container, started a
     yaw drag, and on mouseup the synthetic click handler fired and
     dropped a *new* pin at the release point. Two interactions
     collided as one. The fix has three parts:

       1. Track drag state in a single ref with a `mode`: 'view' or
          'pin'. Mouse-down on a pin starts mode 'pin'; mouse-down on
          the background starts mode 'view'.
       2. Read pins/yaw/onChange via refs from the global mouse
          handlers so move events always see the latest values without
          re-binding the listeners each render.
       3. Capture `moved` in a SEPARATE ref that survives past mouseup
          (it gates the click handler — addAtPoint runs only if the
          gesture wasn't a drag). The synthetic click on the container
          fires after the dom mouseup, so we can't rely on
          `dragRef.current` being non-null inside it. */
  const [yaw, setYaw] = React.useState(50);
  const [grabbing, setGrabbing] = React.useState(null); // 'view' | 'pin' | null — cosmetic
  const yawRef = React.useRef(yaw);
  yawRef.current = yaw;
  const pinsRef = React.useRef(pins);
  pinsRef.current = pins;
  const onChangeRef = React.useRef(onChange);
  onChangeRef.current = onChange;
  const onSelectRef = React.useRef(onSelect);
  onSelectRef.current = onSelect;

  const ref = React.useRef(null);
  const dragRef = React.useRef(null);
  const wasDraggedRef = React.useRef(false);
  const DRAG_THRESHOLD = 4;

  const beginViewDrag = (e) => {
    // Left-button only; ignore right/middle so context-menus still work.
    if (e.button !== 0) return;
    dragRef.current = { mode: 'view', startX: e.clientX, startY: e.clientY,
      startYaw: yawRef.current, moved: false };
    setGrabbing('view');
  };
  const beginPinDrag = (e, i) => {
    if (e.button !== 0) return;
    // Stop the event before it reaches the container so the background's
    // mousedown handler doesn't ALSO start a yaw drag — the bug that used
    // to make pin-drags turn into pin-drops.
    e.stopPropagation();
    e.preventDefault();
    onSelectRef.current?.(i);
    dragRef.current = { mode: 'pin', pinIdx: i, startX: e.clientX, startY: e.clientY,
      moved: false };
    setGrabbing('pin');
  };

  React.useEffect(() => {
    const screenToWorldX = (clientX) => {
      const box = ref.current?.getBoundingClientRect();
      if (!box) return 0;
      const local = ((clientX - box.left) / box.width) * 100;
      const raw = local + (yawRef.current - 50);
      return ((raw % 100) + 100) % 100;
    };
    const move = (e) => {
      const d = dragRef.current;
      if (!d) return;
      const dx = e.clientX - d.startX;
      const dy = e.clientY - d.startY;
      if (!d.moved && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
        d.moved = true;
      }
      if (d.mode === 'view') {
        if (!d.moved) return;
        const next = Math.max(0, Math.min(100, d.startYaw - dx * 0.2));
        setYaw(next);
      } else if (d.mode === 'pin') {
        const box = ref.current?.getBoundingClientRect();
        if (!box) return;
        const x = screenToWorldX(e.clientX);
        const y = Math.max(0, Math.min(100, ((e.clientY - box.top) / box.height) * 100));
        const rounded = { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
        const next = pinsRef.current.map((p, j) =>
          j === d.pinIdx ? { ...p, ...rounded } : p);
        pinsRef.current = next;
        onChangeRef.current?.(next);
      }
    };
    const up = () => {
      if (dragRef.current) {
        // Preserve `moved` for the click handler that will fire next.
        wasDraggedRef.current = dragRef.current.moved;
        dragRef.current = null;
        setGrabbing(null);
      }
    };
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    return () => {
      window.removeEventListener('mousemove', move);
      window.removeEventListener('mouseup', up);
    };
  }, []);

  /* Click-to-drop on the background is intentionally disabled. New
     hotspots are created via the "+ Add hotspot" kind picker in the
     inspector (the only way to choose between Text+Image / Video /
     Question), then dragged into position on the panorama. A bare
     click here either clears any in-flight drag-flag or does nothing
     — clicking onto the image must not create a stray pin. */
  const onContainerClick = () => {
    if (wasDraggedRef.current) wasDraggedRef.current = false;
  };

  return (
    <div>
      <div ref={ref} onMouseDown={beginViewDrag} onClick={onContainerClick}
        style={{
          position: 'relative', height, width: '100%',
          overflow: 'hidden',
          cursor: grabbing ? 'grabbing' : 'grab',
          background: 'var(--surface-inset)', border: '1px solid var(--border)',
          borderRadius: 'var(--radius-md)',
          userSelect: 'none',
        }}>
        <div style={{ position: 'absolute', inset: 0,
          transform: `translateX(${(50 - yaw) * 2}px)`,
          pointerEvents: 'none' /* let mouse events reach the container */ }}>
          <MockImage url={imageUrl} label="360° panorama"
            style={{ position: 'absolute', inset: 0 }} />
        </div>
        {/* Pins offset by yaw too. Each pin owns its own mouse-down to
            start a pin-drag (stopped from bubbling to the container so
            the background's view-drag doesn't activate). */}
        {pins.map((p, i) => {
          const left = ((p.x - (yaw - 50)) + 200) % 100;
          const isSel = i === selectedIndex;
          const isGrabbing = grabbing === 'pin' && dragRef.current?.pinIdx === i;
          return (
            <div key={p.id || i}
              onMouseDown={(e) => beginPinDrag(e, i)}
              onClick={(e) => e.stopPropagation()}
              title={`Hotspot ${i + 1} — drag to reposition`}
              style={{
                position: 'absolute', left: `${left}%`, top: `${p.y}%`,
                transform: 'translate(-50%, -50%)',
                width: 30, height: 30, borderRadius: '50%',
                background: isSel ? '#FF80FF' : 'rgba(255,255,255,0.95)',
                border: `2px solid #FF80FF`,
                color: isSel ? '#fff' : '#FF80FF',
                fontSize: 16, fontWeight: 700,
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                boxShadow: isGrabbing
                  ? '0 0 0 4px rgba(255,128,255,.25), 0 2px 6px rgba(0,0,0,.4)'
                  : '0 1px 2px rgba(0,0,0,.3)',
                cursor: isGrabbing ? 'grabbing' : 'grab',
                transition: 'box-shadow 120ms',
                zIndex: isSel ? 2 : 1,
              }}>{i + 1}</div>
          );
        })}
        {/* Cover hint */}
        <div style={{
          position: 'absolute', top: 16, left: '50%', transform: 'translateX(-50%)',
          padding: '6px 12px', background: 'rgba(0,0,0,.5)', color: '#fff',
          fontSize: 11.5, borderRadius: 'var(--radius)',
          display: 'flex', alignItems: 'center', gap: 6, pointerEvents: 'none',
        }}>
          <span style={{ fontFamily: 'var(--font-mono)' }}>360°</span>
          <span>{image360CoverText || 'Drag to look around · drag pin to reposition'}</span>
        </div>
        {/* Yaw indicator */}
        <div style={{
          position: 'absolute', bottom: 12, left: 16, right: 16, height: 3,
          background: 'rgba(255,255,255,.25)', pointerEvents: 'none', borderRadius: 2,
        }}>
          <div style={{ width: 32, height: '100%', background: '#fff',
            transform: `translateX(${(yaw - 16) * 4}px)`, borderRadius: 2 }} />
        </div>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8,
        fontSize: 11.5, color: 'var(--text-faint)' }}>
        <span><kbd>drag</kbd> look around</span>
        <span><kbd>drag pin</kbd> reposition</span>
        <div style={{ flex: 1 }} />
        <span style={{ fontFamily: 'var(--font-mono)' }}>
          yaw {Math.round(yaw)} · {pins.length} hotspots
        </span>
      </div>
    </div>
  );
}

// ── 3. ThreeDHotspotPlacer — for object_viewer .glb hotspots ───────────────
function ThreeDHotspotPlacer({
  objectUrl, objectPosterImgUrl,
  backgroundColor,           // CSS colour — fills the canvas so the
                             //   author sees the same `<backgroundColor>`
                             //   the Player will paint behind the model.
                             //   Defaults to a neutral dark when unset.
  pins = [],                 // [{ id, x, y, z, title }]
  onChange, selectedIndex = -1, onSelect,
  height = 320,
}) {
  /* Mirrors the gesture model of PanoramaHotspotPlacer (§19):
       • drag the background → orbit (yaw + pitch)
       • click empty space   → no-op (new pins are created via the
         "+ Add hotspot" kind picker, never by clicking the canvas —
         clicking a kind already implies "create", so a double-create
         path here would be confusing)
       • click an existing pin → select it
       • drag an existing pin → reposition (back-project cursor → x,y,z)
     Without separating 'view' and 'pin' drag modes the dot was either
     unselectable (orbit started immediately and the pin lost its
     mouse-down) or every pin click created a duplicate at the cursor
     coordinate. The fix follows the same three-part pattern documented
     in §19: per-mode dragRef, refs for live state, latched moved flag. */
  const ref = React.useRef(null);
  const [rot, setRot] = React.useState({ yaw: 30, pitch: -15 });
  const [grabbing, setGrabbing] = React.useState(null); // 'view' | 'pin' | null — cosmetic

  const rotRef = React.useRef(rot);
  rotRef.current = rot;
  const pinsRef = React.useRef(pins);
  pinsRef.current = pins;
  const onChangeRef = React.useRef(onChange);
  onChangeRef.current = onChange;
  const onSelectRef = React.useRef(onSelect);
  onSelectRef.current = onSelect;

  const dragRef = React.useRef(null);
  const wasDraggedRef = React.useRef(false);
  const DRAG_THRESHOLD = 4;

  /* rAF-throttled commit. The pin-drag updates pinsRef synchronously
     so the placer can repaint immediately, but we coalesce the call
     to the parent onChange so the app-level layout draft re-renders
     at most once per frame. Without this every mousemove cascades
     through Module preview + PreviewPane and the dot lags visibly. */
  const rafIdRef = React.useRef(null);
  const pendingPinsRef = React.useRef(null);
  const flushCommit = () => {
    rafIdRef.current = null;
    const next = pendingPinsRef.current;
    if (next) {
      pendingPinsRef.current = null;
      onChangeRef.current?.(next);
    }
  };
  const scheduleCommit = (next) => {
    pendingPinsRef.current = next;
    if (rafIdRef.current == null) {
      rafIdRef.current = requestAnimationFrame(flushCommit);
    }
  };

  /* Force a local re-render on every mousemove so the dot follows the
     cursor between rAF commits — the `pins` prop only updates when the
     throttle fires, so we read pinsRef directly when rendering and
     bump a counter here. */
  const [, bump] = React.useReducer(x => x + 1, 0);

  const beginViewDrag = (e) => {
    // Left-button only; ignore right/middle so context-menus still work.
    if (e.button !== 0) return;
    dragRef.current = { mode: 'view', startX: e.clientX, startY: e.clientY,
      startYaw: rotRef.current.yaw, startPitch: rotRef.current.pitch, moved: false };
    setGrabbing('view');
  };
  const beginPinDrag = (e, i) => {
    if (e.button !== 0) return;
    // Stop the event before it reaches the container so the background's
    // mousedown handler doesn't ALSO start an orbit drag — the bug that
    // used to make pin-drags impossible.
    e.stopPropagation();
    e.preventDefault();
    onSelectRef.current?.(i);
    dragRef.current = { mode: 'pin', pinIdx: i, startX: e.clientX, startY: e.clientY,
      moved: false };
    setGrabbing('pin');
  };

  // Back-project a screen position (0..1, 0..1) into a normalised
  // (x,y,z) using the current yaw — same math as the original
  // addAtPoint, factored out so both add-and-drag share one path.
  // Pitch is ignored on purpose: the mock orbit only re-projects yaw,
  // so pitch-aware back-projection would push pins off the cursor.
  const screenToModel = (sx, sy) => {
    const yawRad = (rotRef.current.yaw * Math.PI) / 180;
    const cx = sx - 0.5, cy = sy - 0.5;
    const x = 0.5 + cx * Math.cos(yawRad);
    const z = 0.5 + cx * Math.sin(yawRad);
    const y = 0.5 + cy;
    return {
      x: Math.max(0, Math.min(1, Math.round(x * 100) / 100)),
      y: Math.max(0, Math.min(1, Math.round(y * 100) / 100)),
      z: Math.max(0, Math.min(1, Math.round(z * 100) / 100)),
    };
  };

  React.useEffect(() => {
    const move = (e) => {
      const d = dragRef.current;
      if (!d) return;
      const dx = e.clientX - d.startX;
      const dy = e.clientY - d.startY;
      if (!d.moved && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
        d.moved = true;
      }
      if (d.mode === 'view') {
        if (!d.moved) return;
        setRot({
          yaw: d.startYaw + dx * 0.4,
          pitch: Math.max(-60, Math.min(60, d.startPitch + dy * 0.3)),
        });
      } else if (d.mode === 'pin') {
        const box = ref.current?.getBoundingClientRect();
        if (!box) return;
        const sx = (e.clientX - box.left) / box.width;
        const sy = (e.clientY - box.top) / box.height;
        const xyz = screenToModel(sx, sy);
        const next = pinsRef.current.map((p, j) =>
          j === d.pinIdx ? { ...p, ...xyz } : p);
        pinsRef.current = next;
        scheduleCommit(next);
        bump(); // repaint locally; pins prop will catch up next frame
      }
    };
    const up = () => {
      if (dragRef.current) {
        // Preserve `moved` for the click handler that will fire next.
        wasDraggedRef.current = dragRef.current.moved;
        dragRef.current = null;
        setGrabbing(null);
        // Flush any in-flight rAF immediately so the final position
        // commits before the user does anything else.
        if (rafIdRef.current != null) {
          cancelAnimationFrame(rafIdRef.current);
          rafIdRef.current = null;
          flushCommit();
        }
      }
    };
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
  }, []);

  /* Click-to-drop on the background is intentionally disabled. New
     hotspots are created via the "+ Add hotspot" kind picker in the
     inspector (the only way to choose between Text+Image / Video /
     Question), then dragged into position on the model. A bare click
     here either clears any in-flight drag-flag or does nothing —
     clicking onto the canvas must not create a stray pin. */
  const onContainerClick = () => {
    if (wasDraggedRef.current) wasDraggedRef.current = false;
  };

  // Project a pin's x,y,z into screen pixels for display.
  const project = (p) => {
    const yawRad = (rot.yaw * Math.PI) / 180;
    const dx = p.x - 0.5, dy = p.y - 0.5, dz = p.z - 0.5;
    const sx = 0.5 + (dx * Math.cos(yawRad) - dz * Math.sin(yawRad));
    const sy = 0.5 + dy + dz * Math.sin((rot.pitch * Math.PI) / 180) * 0.3;
    const visible = (dx * Math.sin(yawRad) + dz * Math.cos(yawRad)) > -0.6;
    return { sx, sy, visible };
  };

  return (
    <div>
      <div ref={ref} onMouseDown={beginViewDrag} onClick={onContainerClick}
        style={{
          position: 'relative', height, width: '100%',
          overflow: 'hidden',
          cursor: grabbing ? 'grabbing' : 'grab',
          background: backgroundColor || '#0f172a',
          border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
          userSelect: 'none',
        }}>
        <div style={{ position: 'absolute', inset: 0,
          transform: `rotateY(${rot.yaw}deg) rotateX(${rot.pitch}deg)`,
          transformStyle: 'preserve-3d', transition: 'transform 60ms linear',
          pointerEvents: 'none' /* let mouse events reach the container */ }}>
          <MockImage url={objectPosterImgUrl} label=".glb model"
            style={{ position: 'absolute', inset: '15%' }} />
        </div>
        {/* Orientation indicator */}
        <div style={{
          position: 'absolute', top: 12, right: 12, padding: '4px 10px',
          background: 'rgba(255,255,255,.15)', color: '#fff', fontFamily: 'var(--font-mono)',
          fontSize: 11, borderRadius: 'var(--radius)', pointerEvents: 'none',
        }}>x,y,z · yaw {Math.round(rot.yaw)}°</div>
        {/* Hotspots — each owns its own mouse-down to start a pin-drag
            (stopped from bubbling so the background's view-drag doesn't
            activate). Iterate pinsRef (not the prop) so the placer
            repaints immediately while the parent onChange is being
            rAF-throttled. */}
        {pinsRef.current.map((p, i) => {
          const pr = project(p);
          if (!pr.visible) return null;
          const isSel = i === selectedIndex;
          const isGrabbing = grabbing === 'pin' && dragRef.current?.pinIdx === i;
          return (
            <div key={p.id || i}
              onMouseDown={(e) => beginPinDrag(e, i)}
              onClick={(e) => e.stopPropagation()}
              title={`Hotspot ${i + 1} — drag to reposition`}
              style={{
                position: 'absolute', left: `${pr.sx * 100}%`, top: `${pr.sy * 100}%`,
                transform: 'translate(-50%, -50%)',
                width: 28, height: 28, borderRadius: '50%',
                background: isSel ? '#D32F2F' : 'rgba(255,255,255,0.9)',
                border: '2px solid #D32F2F', color: isSel ? '#fff' : '#D32F2F',
                fontSize: 14, fontWeight: 700,
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                boxShadow: isGrabbing
                  ? '0 0 0 4px rgba(211,47,47,.25), 0 2px 6px rgba(0,0,0,.4)'
                  : '0 1px 2px rgba(0,0,0,.3)',
                cursor: isGrabbing ? 'grabbing' : 'grab',
                transition: 'box-shadow 120ms',
                zIndex: isSel ? 2 : 1,
              }}>{i + 1}</div>
          );
        })}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8,
        fontSize: 11.5, color: 'var(--text-faint)' }}>
        <span><kbd>drag</kbd> orbit</span>
        <span><kbd>drag pin</kbd> reposition</span>
        <div style={{ flex: 1 }} />
        <span style={{ fontFamily: 'var(--font-mono)' }}>
          {pins.length} hotspots · {objectUrl?.split('/').pop() || 'model.glb'}
        </span>
      </div>
    </div>
  );
}

// ── 4. InVideoInteractionTimeline — for fullscreen_video, small_video,    ───
//     sequence/horizontal_tabs video tabs
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));

function InVideoInteractionTimeline({
  videoUrl, videoThumbUrl, duration = 272,    // seconds (default 04:32)
  interactions = [],                          // [{ id, type, timeStarts, timeEnds?, ...}]
  onChange, selectedIndex: ctrlSelected, onSelect: ctrlOnSelect,
}) {
  const ref = React.useRef(null);
  const [drag, setDrag] = React.useState(null);
  const [adding, setAdding] = React.useState(false);
  // Manage selection internally when the parent doesn't pass one through.
  // Without this, every layout caller (fullscreen_video, small_video,
  // sequence/horizontal_tabs video tabs) lost access to the precise
  // MM:SS.mmm time inputs because the inspector requires `selectedIndex >= 0`.
  const [selfSelected, setSelfSelected] = React.useState(-1);
  const selectedIndex = ctrlSelected != null ? ctrlSelected : selfSelected;
  const onSelect = ctrlOnSelect || setSelfSelected;

  const fmt = (s) => {
    const totalMs = Math.max(0, Math.round((+s || 0) * 1000));
    const mm = String(Math.floor(totalMs / 60000)).padStart(2, '0');
    const ss = String(Math.floor((totalMs % 60000) / 1000)).padStart(2, '0');
    return `${mm}:${ss}`;
  };

  const addInteraction = (type) => {
    const t = Math.round(duration / 2);
    const next = [...interactions, {
      id: 'iv_' + Date.now().toString(36),
      type,
      timeStarts: t,
      interactionText: type === 'discover' ? 'Reveal' : 'Your turn',
      ...(type === 'discover' ? { timeEnds: t + 12, descriptionTitle: 'New reveal', descriptionText: '' } : {}),
      ...(type !== 'discover' ? { options: [{ text: 'Option A', isCorrect: true }, { text: 'Option B' }] } : {}),
    }];
    onChange?.(next);
    onSelect?.(next.length - 1);   // surface the inspector immediately
    setAdding(false);
  };

  const startDrag = (e, i, edge) => {
    e.stopPropagation();
    const box = ref.current.getBoundingClientRect();
    setDrag({ index: i, edge, startX: e.clientX, boxWidth: box.width, original: { ...interactions[i] } });
  };
  React.useEffect(() => {
    if (!drag) return;
    const move = (e) => {
      const dt = ((e.clientX - drag.startX) / drag.boxWidth) * duration;
      const next = interactions.map((iv, i) => {
        if (i !== drag.index) return iv;
        if (drag.edge === 'start') {
          return { ...iv, timeStarts: clamp(drag.original.timeStarts + dt, 0, duration - 1) };
        }
        if (drag.edge === 'end') {
          return { ...iv, timeEnds: clamp(drag.original.timeEnds + dt, drag.original.timeStarts + 1, duration) };
        }
        if (drag.edge === 'move') {
          // Move the whole marker — preserves duration for ranges, point-shift
          // for points (question / mandatory).
          const ts = clamp(drag.original.timeStarts + dt, 0, duration - 0.001);
          if (drag.original.timeEnds != null) {
            const span = drag.original.timeEnds - drag.original.timeStarts;
            const tsClamped = Math.min(ts, duration - span);
            return { ...iv, timeStarts: tsClamped, timeEnds: tsClamped + span };
          }
          return { ...iv, timeStarts: ts };
        }
        return iv;
      });
      onChange?.(next);
    };
    const up = () => setDrag(null);
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
  }, [drag, interactions, duration, onChange]);

  return (
    <div style={{
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)', padding: 12,
    }}>
      {/* No duplicated video preview here — the Video media block above
          already shows the same frame. The timeline is the focus of this
          card. */}

      {/* Time markings */}
      <div style={{ display: 'flex', justifyContent: 'space-between',
        fontFamily: 'var(--font-mono)', fontSize: 10.5, color: 'var(--text-faint)', marginBottom: 4 }}>
        {[0, 0.25, 0.5, 0.75, 1].map(t => (
          <span key={t}>{fmt(t * duration)}</span>
        ))}
      </div>

      {/* Track */}
      <div ref={ref} style={{
        position: 'relative', height: 38, background: 'var(--surface-inset)',
        borderRadius: 'var(--radius-sm)', overflow: 'hidden', marginBottom: 12,
      }}>
        {interactions.map((iv, i) => {
          const isRange = iv.type === 'discover' && iv.timeEnds != null;
          const startPct = (iv.timeStarts / duration) * 100;
          const endPct = ((iv.timeEnds ?? iv.timeStarts) / duration) * 100;
          const sel = i === selectedIndex;
          const c = iv.type === 'mandatoryQuestion' ? 'var(--warning)' :
                    iv.type === 'question' ? 'var(--accent)' : 'var(--success)';
          return (
            <div key={iv.id || i}
              onClick={() => onSelect?.(i)}
              onMouseDown={(e) => {
                // Drag the whole marker to retime. Edge handles inside still
                // win via stopPropagation, so resize beats move on the rim.
                if (e.target === e.currentTarget || !e.target.dataset?.edge) {
                  startDrag(e, i, 'move');
                }
              }}
              title={isRange ? 'Drag to move · drag edges to resize'
                             : 'Drag to move'}
              style={{
                position: 'absolute', top: 0, bottom: 0,
                left: `${startPct}%`,
                width: isRange ? `calc(${endPct - startPct}% + 1px)` : 12,
                marginLeft: isRange ? 0 : -6,   // centre point markers on time
                background: c, opacity: sel ? 1 : 0.7,
                border: sel ? '2px solid #fff' : 'none',
                outline: sel ? '2px solid var(--accent)' : 'none',
                cursor: drag?.index === i && drag?.edge === 'move' ? 'grabbing' : 'grab',
                borderRadius: isRange ? 0 : 3,
              }}>
              {isRange && (
                <>
                  <div data-edge="start"
                    onMouseDown={(e) => startDrag(e, i, 'start')}
                    style={{
                      position: 'absolute', left: -3, top: 0, bottom: 0, width: 6,
                      cursor: 'ew-resize',
                    }} />
                  <div data-edge="end"
                    onMouseDown={(e) => startDrag(e, i, 'end')}
                    style={{
                      position: 'absolute', right: -3, top: 0, bottom: 0, width: 6,
                      cursor: 'ew-resize',
                    }} />
                </>
              )}
              <span style={{
                position: 'absolute', top: -18, left: 0, fontSize: 10,
                fontFamily: 'var(--font-mono)', color: c, whiteSpace: 'nowrap',
                pointerEvents: 'none',
              }}>
                {iv.type} @ {fmt(iv.timeStarts)}{isRange ? `–${fmt(iv.timeEnds)}` : ''}
              </span>
            </div>
          );
        })}
        {/* Playhead */}
        <div style={{ position: 'absolute', left: '0%', top: 0, bottom: 0,
          width: 1.5, background: 'var(--text)', opacity: 0.5 }} />
      </div>

      {/* Add interaction menu */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
          {interactions.length} interaction{interactions.length === 1 ? '' : 's'} · drag a marker to retime
        </span>
        <div style={{ flex: 1 }} />
        {!adding ? (
          <button className="btn sm" onClick={() => setAdding(true)}>
            <I.Plus size={12} />Add interaction
          </button>
        ) : (
          <div style={{ display: 'flex', gap: 4 }}>
            <button className="btn sm" onClick={() => addInteraction('discover')}>
              <I.Eye size={11} />discover
            </button>
            <button className="btn sm" onClick={() => addInteraction('question')}>
              <I.AlertCircle size={11} />question
            </button>
            <button className="btn sm" onClick={() => addInteraction('mandatoryQuestion')}>
              <I.Lock size={11} />mandatory
            </button>
            <button className="btn sm ghost" onClick={() => setAdding(false)}><I.X size={12} /></button>
          </div>
        )}
      </div>

      {/* Selected interaction editor */}
      {selectedIndex >= 0 && interactions[selectedIndex] && (
        <InteractionInspector
          interaction={interactions[selectedIndex]}
          onChange={(patch) => {
            const next = interactions.map((iv, i) => i === selectedIndex ? { ...iv, ...patch } : iv);
            onChange?.(next);
          }}
          onDelete={() => {
            onChange?.(interactions.filter((_, i) => i !== selectedIndex));
            onSelect?.(-1);
          }}
        />
      )}
    </div>
  );
}

// ─── AiGenerateBar — the sparkle toggle + inline prompt + Generate button.
// ─── Used by the in-video Question inspector AND by the Sequence question
// ─── step (see docs/video-media-patterns.md §13, the binding inline-AI
// ─── pattern). Both surfaces need the same shape:
// ─── • A 30×30 sparkle icon sits to the right of the block's label.
// ─── • Click reveals an inline `.field` prompt + `.btn.sm.primary` Generate
// ─── button in the header's flex space; nothing else reflows.
// ─── • Click the active sparkle (or press Esc) dismisses.
//
// ─── Owns its own open/prompt state — callers don't manage the panel.
// ─── `onGenerate(prompt)` is called when the author fires Generate; the
// ─── caller decides what to write to the underlying record.
function AiGenerateBar({ placeholder, onGenerate, instanceKey }) {
  const [aiOpen, setAiOpen] = React.useState(false);
  const [aiPrompt, setAiPrompt] = React.useState('');
  const [generating, setGenerating] = React.useState(false);
  // Reset when the host swaps the underlying record — e.g. selecting a
  // different marker / step. The prompt is per-instance, not per-surface.
  React.useEffect(() => {
    setAiOpen(false); setAiPrompt(''); setGenerating(false);
  }, [instanceKey]);
  const fire = () => {
    if (generating) return;
    setGenerating(true);
    // Mocked latency — the real backend call happens in `onGenerate`.
    Promise.resolve(onGenerate?.(aiPrompt)).finally(() => {
      // Small minimum delay so the spinner text is legible even when the
      // mocked write returns synchronously.
      setTimeout(() => setGenerating(false), 600);
    });
  };
  return (
    <>
      <button type="button"
        onClick={() => setAiOpen(v => !v)}
        aria-pressed={aiOpen}
        aria-label={aiOpen ? 'Close AI generate' : placeholder}
        title={aiOpen ? 'Close AI generate' : placeholder}
        style={{
          width: 30, height: 30, padding: 0,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          background: aiOpen ? 'var(--accent-bg)' : 'var(--surface)',
          color: aiOpen ? 'var(--accent-text)' : 'var(--text-muted)',
          border: '1px solid',
          borderColor: aiOpen ? 'var(--accent-border)' : 'var(--border-strong)',
          borderRadius: 'var(--radius)',
          cursor: 'pointer', fontFamily: 'inherit',
          transition: 'background 120ms, color 120ms, border-color 120ms',
          flex: '0 0 auto',
        }}>
        <I.Sparkle size={14} />
      </button>

      {aiOpen ? (
        <div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
          <input className="field"
            placeholder={placeholder}
            value={aiPrompt}
            onChange={e => setAiPrompt(e.target.value)}
            onKeyDown={e => {
              if (e.key === 'Enter') fire();
              if (e.key === 'Escape') setAiOpen(false);
            }}
            style={{ flex: 1, minWidth: 0, height: 30, fontSize: 13 }} />
          <button type="button"
            className="btn sm primary"
            onClick={fire}
            disabled={generating}
            style={{ flex: '0 0 auto' }}>
            <I.Sparkle size={11} />
            {generating ? 'Generating…' : 'Generate'}
          </button>
        </div>
      ) : (
        <div style={{ flex: 1 }} />
      )}
    </>
  );
}

// ─── QuizQuestionBody — the textarea + Answers section that's shared
// ─── between the in-video Question inspector and the Sequence question
// ─── step. Owns no state; everything is driven by `value` + `onChange`.
// ─── Value shape: { text, options: [{ text, isCorrect, feedback }] }.
function QuizQuestionBody({ value, onChange, instanceKey, type = 'question',
  host = 'video',
  textPlaceholder = 'The question to surface to the learner at this moment.' }) {
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  const options = value?.options || [];
  const fb = value?.feedbacks || {};
  // Feedback surface + correct-marker behaviour are DERIVED from `type`,
  // `host`, and how many answers are ticked correct (§27) — no toggle.
  //   host 'video'   + type 'question'          → passive overlay: no correct
  //     marker, no feedback at all (the presenter delivers it on-screen;
  //     XML carries no correct= / <feedbacks>).
  //   type 'mandatoryQuestion' (video)          → radio, 1 correct, shared block
  //   host 'sequence-step' (sequence / hotspot)  → multi-tick; ≤1 = per-answer,
  //     ≥2 = shared feedbacks block.
  // `host` defaults to 'video' so existing callers don't break.
  const isMandatory = type === 'mandatoryQuestion';
  const isPassiveVideoQuestion = host === 'video' && type === 'question';
  const hideCorrect = isPassiveVideoQuestion;
  const hideFeedback = isPassiveVideoQuestion;
  const correctCount = options.filter(o => o.isCorrect).length;
  const isMultiCorrect = !isMandatory && correctCount >= 2;
  const showGenericFeedback = !hideFeedback && (isMandatory || isMultiCorrect);
  const showPerAnswerFeedback = !hideFeedback && !isMandatory && !isMultiCorrect;
  const hideCorrectIndicator = hideCorrect;
  const updOpt = (i, patch) => onChange({
    ...value,
    options: options.map((o, j) => j === i ? { ...o, ...patch } : o),
  });
  const addOpt = () => onChange({
    ...value,
    options: [...options, { text: '', isCorrect: false, feedback: '' }],
  });
  const removeOpt = (i) => onChange({
    ...value,
    options: options.filter((_, j) => j !== i),
  });
  const setSingleCorrect = (i) => {
    // Radio semantics for mandatoryQuestion: ticking a row makes it the sole
    // correct answer. Clicking the already-correct row is a no-op so the
    // marker can never be removed — the only way to change it is to tick a
    // different row (§6, §27).
    if (options[i]?.isCorrect) return;
    onChange({
      ...value,
      options: options.map((o, j) => ({ ...o, isCorrect: j === i })),
    });
  };
  const setFb = (patch) => onChange({ ...value, feedbacks: { ...fb, ...patch } });
  const labelStyle = {
    display: 'block', marginBottom: 6,
    fontSize: 12, fontWeight: 500, color: 'var(--text-muted)',
  };
  const hintStyle = {
    fontSize: 11.5, color: 'var(--text-faint)', fontWeight: 400,
  };
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
      <div>
        <label style={labelStyle}>Question</label>
        <textarea className="field"
          style={{ width: '100%', minHeight: 64, fontSize: 13.5, resize: 'vertical' }}
          placeholder={textPlaceholder}
          value={locText(value?.text, lang)}
          onChange={e => onChange({ ...value, text: locSet(value?.text, lang, e.target.value) })} />
      </div>
      <div>
        <div style={{
          display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 8,
        }}>
          <span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text)' }}>
            Answers
          </span>
          <span style={hintStyle}>
            {isMandatory
              ? '· pick exactly one correct answer'
              : isPassiveVideoQuestion
                ? '· the video presenter delivers the answer on-screen'
                : '· tick every answer that should count as correct'}
          </span>
          <div style={{ flex: 1 }} />
          <button className="btn sm" onClick={addOpt}>
            <I.Plus size={11} />Add answer
          </button>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {options.map((o, i) => (
            <AnswerRow key={i} interactionId={instanceKey}
              index={i} count={options.length} type={type}
              option={{ ...o, text: locText(o.text, lang), feedback: locText(o.feedback, lang) }}
              showFeedback={showPerAnswerFeedback}
              hideCorrectIndicator={hideCorrectIndicator}
              onText={v => updOpt(i, { text: locSet(o.text, lang, v) })}
              onFeedback={v => updOpt(i, { feedback: locSet(o.feedback, lang, v) })}
              onCorrect={() => {
                if (hideCorrect) return;
                if (isMandatory) return setSingleCorrect(i);
                updOpt(i, { isCorrect: !o.isCorrect });
              }}
              onRemove={() => removeOpt(i)} />
          ))}
          {options.length < 2 && (
            <div style={{ fontSize: 11.5, color: 'var(--text-faint)',
              padding: '2px 4px' }}>
              Add at least two answers — one of them marked correct.
            </div>
          )}
        </div>
        {showGenericFeedback && (
          <div style={{ marginTop: 12, display: 'grid', gap: 8 }}>
            <label style={{ display: 'grid', gap: 4 }}>
              <span style={hintStyle}>Correct feedback</span>
              <input className="field" value={locText(fb.correct, lang)}
                onChange={e => setFb({ correct: locSet(fb.correct, lang, e.target.value) })}
                placeholder="Shown when the learner picks the correct answer"
                style={{ width: '100%', height: 30, fontSize: 12.5 }} />
            </label>
            <label style={{ display: 'grid', gap: 4 }}>
              <span style={hintStyle}>Incorrect feedback</span>
              <input className="field" value={locText(fb.wrong, lang)}
                onChange={e => setFb({ wrong: locSet(fb.wrong, lang, e.target.value) })}
                placeholder="Shown for any incorrect answer"
                style={{ width: '100%', height: 30, fontSize: 12.5 }} />
            </label>
          </div>
        )}
      </div>
    </div>
  );
}

function InteractionInspector({ interaction, onChange, onDelete }) {
  const t = interaction.type;
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  // Short labels per video-media-patterns.md §11. The header is identity
  // alone — no blurb, no accent stripe, no accent-tinted strip. The body
  // controls already explain what kind of interaction this is.
  const typeLabel = ({
    discover: 'Discover',
    question: 'Question',
    mandatoryQuestion: 'Mandatory',
  })[t] || t;
  const isQuiz = t === 'question' || t === 'mandatoryQuestion';

  // Mocked AI generate handler — see AiGenerateBar above for the inline
  // pattern (§13). In production the server reads the video's subtitle
  // track plus the optional prompt hint and returns a draft.
  const generateFromSubtitles = (_prompt) => new Promise(resolve => {
    setTimeout(() => {
      onChange({
        text: 'Which of the following best captures the SBI sequence the speaker demonstrates?',
        options: [
          { text: 'She names the situation, then the behaviour, then the impact',
            isCorrect: true,
            feedback: 'Correct — SBI orders the three pieces so the behaviour is anchored in time and weight.' },
          { text: 'She leads with the personal impact to set the stakes',
            isCorrect: false,
            feedback: 'Impact comes last in SBI — without the situation and behaviour first it lands as judgement.' },
          { text: 'She softens the behaviour to keep the conversation positive',
            isCorrect: false,
            feedback: 'SBI works because the behaviour is named precisely; softening it makes the feedback vague.' },
        ],
      });
      resolve();
    }, 900);
  });

  return (
    <div style={{
      marginTop: 14,
      background: 'var(--surface)',
      border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)',
      padding: 18,
      display: 'flex', flexDirection: 'column', gap: 18,
    }}>
      {/* Header — type label on the left, inline timing + trash on the
          right. No blurb, no accent stripe. Timing is colocated here so
          "when does this fire" lives next to "what is this", per
          video-media-patterns.md §11. */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 14,
      }}>
        <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>
          {typeLabel}
        </span>

        {/* AI generate (quiz only) — sparkle + inline prompt + Generate.
            See AiGenerateBar + docs §11.7 / §13. */}
        {isQuiz ? (
          <AiGenerateBar
            placeholder="Generate question and answers based on the video subtitles"
            onGenerate={generateFromSubtitles}
            instanceKey={interaction.id} />
        ) : (
          <div style={{ flex: 1 }} />
        )}

        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>
            Starts at
          </span>
          <InlineTimeInput value={interaction.timeStarts}
            onChange={v => onChange({ timeStarts: v })} />
          {t === 'discover' && (
            <>
              {/* Divider between Starts at and Ends at — both edit the
                  same range, but they're distinct actions; the rule
                  pacing them apart prevents the eye from reading them
                  as one continuous control. */}
              <span style={{ width: 1, height: 22, background: 'var(--border)',
                margin: '0 4px' }} aria-hidden />
              <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>
                Ends at
              </span>
              <InlineTimeInput value={interaction.timeEnds ?? (interaction.timeStarts + 5)}
                onChange={v => onChange({ timeEnds: v })} />
            </>
          )}
        </div>
        {/* Divider between timing controls and the destructive trash —
            keeps a destructive action visually distinct from edit ones,
            so misclicks don't happen when nudging seconds. */}
        <span style={{ width: 1, height: 22, background: 'var(--border)' }}
          aria-hidden />
        <button type="button" onClick={onDelete}
          title="Remove this interaction"
          style={{
            width: 30, height: 30, padding: 0,
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
            background: 'var(--surface)',
            border: '1px solid var(--border-strong)',
            borderRadius: 'var(--radius)',
            color: 'var(--error-text)', cursor: 'pointer',
          }}>
          <I.Trash size={13} />
        </button>
      </div>

      {/* Floating label — the caption shown above the video WHILE this
          interaction is active (the "+" pin's on-canvas label). Distinct
          from the description Title / question header below, which only
          appear once the learner opens the overlay. Required for every
          interaction kind (InteractionDiscover/Question/MandatoryQuestion
          schemas — see layouts.ts). */}
      <div>
        <label style={{ display: 'block', marginBottom: 6, fontSize: 12,
          fontWeight: 500, color: 'var(--text-muted)' }}>Floating label</label>
        <input className="field"
          style={{ width: '100%' }}
          placeholder="Short caption shown over the video, e.g. Watch this"
          value={locText(interaction.interactionText, lang)}
          onChange={e => onChange({ interactionText: locSet(interaction.interactionText, lang, e.target.value) })} />
      </div>

      {t === 'discover' && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div>
            <label style={{ display: 'block', marginBottom: 6, fontSize: 12,
              fontWeight: 500, color: 'var(--text-muted)' }}>Title</label>
            <input className="field"
              style={{ width: '100%' }}
              placeholder="Short, scannable. e.g. SBI in action"
              value={locText(interaction.descriptionTitle, lang)}
              onChange={e => onChange({ descriptionTitle: locSet(interaction.descriptionTitle, lang, e.target.value) })} />
          </div>
          <div>
            <label style={{ display: 'block', marginBottom: 6, fontSize: 12,
              fontWeight: 500, color: 'var(--text-muted)' }}>Body</label>
            {/* Rich text — same editor used everywhere else in the app
                (bold / italic / lists / links). The Discover overlay
                renders this body inline next to the playing video and
                authors often want a link to a source or a key emphasis,
                so plain text would have been a regression. */}
            <ControlledRich value={interaction.descriptionText || ''}
              minHeight={84}
              placeholder="Plain text. Two or three sentences is usually right."
              onChange={v => onChange({ descriptionText: v })} />
          </div>
        </div>
      )}

      {isQuiz && (
        <QuizQuestionBody
          value={{ text: interaction.text, options: interaction.options || [],
            feedbacks: interaction.feedbacks }}
          onChange={v => onChange({ text: v.text, options: v.options,
            feedbacks: v.feedbacks })}
          instanceKey={interaction.id}
          type={t}
          host="video"
          textPlaceholder={t === 'mandatoryQuestion'
            ? 'The question the learner must answer correctly to continue.'
            : 'The question to surface to the learner at this moment.'} />
      )}
    </div>
  );
}


// ─── AnswerRow — a single answer card with inline per-answer feedback. ─────
// Each answer carries its own follow-up message (not a single global
// "wrong feedback") so authors can tailor the response to the specific
// reasoning behind each option. The feedback input is always visible.
function AnswerRow({ option, index, count, type, interactionId, showFeedback = true,
  hideCorrectIndicator = false,
  onText, onFeedback, onCorrect, onRemove }) {
  const isCorrect = !!option.isCorrect;
  // Indicator: filled green check when correct, hollow neutral square
  // otherwise. Radio shape for Mandatory (single answer); rounded square
  // for Question (multi). The row card itself stays neutral — only the
  // indicator carries the correct-state colour, so a list of mixed
  // correct/incorrect answers reads cleanly without alternating tints.
  // When `hideCorrectIndicator` is set (small_video `question`, whose XML
  // carries no `correct=`), the toggle column is dropped entirely (§27).
  const isRadio = type === 'mandatoryQuestion';
  return (
    <div style={{
      display: 'grid',
      gridTemplateColumns: hideCorrectIndicator ? '1fr auto' : 'auto 1fr auto',
      alignItems: 'start', gap: 12,
      padding: '12px 14px',
      background: 'var(--surface)',
      border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)',
    }}>
      {/* Correct toggle — custom-styled radio / check. Omitted when
          `hideCorrectIndicator` (the small_video `question` type). */}
      {!hideCorrectIndicator && (
      <button type="button" onClick={onCorrect}
        title={isCorrect ? 'Marked correct — click to unmark'
          : (isRadio ? 'Mark as the correct answer' : 'Mark as a correct answer')}
        style={{
          width: 26, height: 26, marginTop: 3,
          padding: 0, display: 'inline-flex',
          alignItems: 'center', justifyContent: 'center',
          borderRadius: isRadio ? '50%' : 6,
          background: isCorrect ? 'var(--success)' : 'var(--surface)',
          border: '1.5px solid',
          borderColor: isCorrect ? 'var(--success)' : 'var(--border-strong)',
          color: '#fff', cursor: 'pointer',
          transition: 'background 120ms, border-color 120ms',
        }}>
        {isCorrect && (isRadio
          ? <span style={{ width: 9, height: 9, borderRadius: '50%', background: '#fff' }} />
          : <I.Check size={15} strokeWidth={3} />)}
      </button>
      )}

      <div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 }}>
        {/* Answer text */}
        <input className="field"
          placeholder={`Option ${String.fromCharCode(65 + index)}`}
          value={option.text || ''}
          style={{ width: '100%', height: 32, fontSize: 13 }}
          onChange={e => onText(e.target.value)} />
        {/* Per-answer feedback — shown only in "Per answer" feedback mode
            (§27). In "Correct / incorrect" mode the shared feedback pair
            lives below the answer list instead. */}
        {showFeedback && (
          <input
            placeholder="Type the feedback for this question"
            value={option.feedback || ''}
            onChange={e => onFeedback(e.target.value)}
            className="field"
            style={{ width: '100%', height: 30, fontSize: 12.5 }} />
        )}
      </div>

      {/* Trash on every row — matches the reference. Falling below two
          answers surfaces the inline "Add at least two answers" hint above
          the row list, which is the floor-enforcement signal. */}
      <button type="button" onClick={onRemove}
        title="Remove this answer"
        style={{
          width: 30, height: 30, marginTop: 1, padding: 0,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          background: 'var(--surface)',
          border: '1px solid var(--border-strong)',
          borderRadius: 'var(--radius)',
          color: 'var(--error-text)', cursor: 'pointer',
        }}>
        <I.Trash size={13} />
      </button>
    </div>
  );
}

// Lighter inline hint colour used inside AnswerRow's feedback label.
const hintStyleLite = {
  fontSize: 11, color: 'var(--text-faint)', fontWeight: 400,
};

Object.assign(window, {
  ImageHotspotPlacer, PanoramaHotspotPlacer, ThreeDHotspotPlacer,
  InVideoInteractionTimeline, InteractionInspector,
  AiGenerateBar, QuizQuestionBody, AnswerRow,
});
