// Field widgets — small editable primitives every layout editor uses.
// All are CONTROLLED (value + onChange) so the layout editor's draft is the
// single source of truth and the PlayerPreview can mirror live.

// ─── ColorField ────────────────────────────────────────────────────────────
// Native color picker + hex input + a quick palette pop-out.
function ColorField({ label, value = '#000000', onChange, palette }) {
  const [open, setOpen] = React.useState(false);
  const hex = normaliseHex(value);
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8,
      padding: '6px 10px', background: 'var(--surface)',
      border: '1px solid var(--border)', borderRadius: 'var(--radius-md)' }}>
      {label && <span style={{ fontSize: 12, color: 'var(--text)', flex: 1,
        lineHeight: 1.3 }}>{label}</span>}
      <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
        <button type="button" onClick={() => setOpen(o => !o)}
          title="Open palette"
          style={{
            width: 22, height: 22, borderRadius: 4,
            background: value, border: '1px solid var(--border-strong)',
            cursor: 'default', padding: 0,
          }} />
        <input type="color" value={hex}
          onChange={e => onChange?.(e.target.value)}
          style={{ position: 'absolute', inset: 0, width: 22, height: 22,
            opacity: 0, cursor: 'default' }} />
        <input className="field" value={value}
          onChange={e => onChange?.(e.target.value)}
          style={{ width: 110, height: 24, fontFamily: 'var(--font-mono)',
            fontSize: 11 }} />
        {open && (
          <>
            <div onClick={() => setOpen(false)}
              style={{ position: 'fixed', inset: 0, zIndex: 30 }} />
            <div style={{
              position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 31,
              padding: 8, background: 'var(--surface)', border: '1px solid var(--border)',
              borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-lg)',
              display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4, width: 240,
            }}>
              {(palette || DEFAULT_PALETTE).map(c => (
                <button key={c} type="button"
                  onClick={() => { onChange?.(c); setOpen(false); }}
                  title={c}
                  style={{
                    width: 24, height: 24, borderRadius: 4, background: c,
                    border: '1px solid var(--border-strong)', cursor: 'default',
                  }} />
              ))}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

const DEFAULT_PALETTE = [
  '#000000','#ffffff','#0f172a','#1e293b','#475569','#94a3b8','#cccccc','#f1f5f9',
  '#dc2626','#ea580c','#d97706','#16a34a','#0e7490','#2563eb','#7c3aed','#db2777',
  '#fef3c7','#dbeafe','#dcfce7','#fee2e2','#fce7f3','#e0e7ff','#fed7aa','#bbf7d0',
  '#FFD166','#F26430','#D32F2F','#3b82f6',
];

function normaliseHex(v) {
  if (!v || typeof v !== 'string') return '#000000';
  if (v.startsWith('#')) {
    if (v.length === 4) return '#' + v.slice(1).split('').map(c => c+c).join('');
    if (v.length === 7) return v;
  }
  // Named colours / rgba — fall back to a neutral so the native picker doesn't
  // crash. The text input still shows the original value.
  return '#000000';
}

// ─── GateControl ───────────────────────────────────────────────────────────
// The blockingSection 3-state — true | false | (empty). Three-position segmented.
function GateControl({ value, onChange }) {
  const states = [
    { id: 'true',  label: 'Required', icon: 'Lock',
      hint: 'Learner must complete this screen before continuing' },
    { id: 'false', label: 'Optional', icon: 'ChevronDown',
      hint: 'Arrow always available; completion not required' },
    { id: '',      label: 'No gate',  icon: 'Minus',
      hint: 'No gate — the layout doesn\u2019t block; the learner can move on immediately' },
  ];
  return (
    <div style={{ display: 'flex', gap: 4, padding: 3,
      background: 'var(--surface-inset)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius)', width: 'fit-content' }}>
      {states.map(s => {
        const sel = (value || '') === s.id;
        const Ic = I[s.icon] || I.Circle;
        return (
          <button key={s.id || 'empty'} onClick={() => onChange?.(s.id)}
            title={s.hint}
            style={{
              display: 'inline-flex', alignItems: 'center', gap: 5,
              padding: '5px 10px', fontSize: 11.5, fontFamily: 'inherit',
              cursor: 'default', borderRadius: 4, border: 0,
              background: sel ? 'var(--surface)' : 'transparent',
              color: sel ? 'var(--text)' : 'var(--text-muted)',
              fontWeight: sel ? 600 : 500,
              boxShadow: sel ? 'var(--shadow-sm)' : 'none',
            }}>
            <Ic size={11} />{s.label}
          </button>
        );
      })}
    </div>
  );
}

// ─── Toggle (controlled Switch) ────────────────────────────────────────────
function Toggle({ value, onChange }) {
  const v = !!value;
  return (
    <button type="button" onClick={() => onChange?.(!v)}
      style={{
        width: 32, height: 18, padding: 0, border: 0,
        background: v ? 'var(--accent)' : 'var(--border-strong)',
        borderRadius: 9, cursor: 'default', position: 'relative',
        transition: 'background 150ms',
      }}>
      <span style={{
        position: 'absolute', top: 2, left: v ? 16 : 2,
        width: 14, height: 14, borderRadius: '50%',
        background: '#fff', transition: 'left 150ms',
        boxShadow: 'var(--shadow-sm)',
      }} />
    </button>
  );
}

// ─── TextField ─────────────────────────────────────────────────────────────
function TextField({ value, onChange, placeholder, mono, style }) {
  return (
    <input className="field" value={value || ''}
      placeholder={placeholder}
      onChange={e => onChange?.(e.target.value)}
      style={{
        width: '100%', fontFamily: mono ? 'var(--font-mono)' : 'inherit',
        ...(style || {}),
      }} />
  );
}

// ─── NumberField ───────────────────────────────────────────────────────────
function NumberField({ value, onChange, min, max, suffix, style }) {
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
      <input className="field" type="number" value={value ?? ''}
        min={min} max={max}
        onChange={e => onChange?.(e.target.value === '' ? null : +e.target.value)}
        style={{ width: 70, fontFamily: 'var(--font-mono)', ...(style || {}) }} />
      {suffix && <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>{suffix}</span>}
    </div>
  );
}

// ─── SegmentedControl ──────────────────────────────────────────────────────
function SegmentedControl({ value, onChange, options }) {
  // options: [{ id, label, icon? }]
  return (
    <div style={{ display: 'inline-flex', gap: 4, padding: 3,
      background: 'var(--surface-inset)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius)' }}>
      {options.map(o => {
        const sel = value === o.id;
        const Ic = o.icon ? (I[o.icon] || null) : null;
        return (
          <button key={o.id} onClick={() => onChange?.(o.id)}
            style={{
              display: 'inline-flex', alignItems: 'center', gap: 5,
              padding: '5px 10px', fontSize: 11.5, fontFamily: 'inherit',
              cursor: 'default', borderRadius: 4, border: 0,
              background: sel ? 'var(--surface)' : 'transparent',
              color: sel ? 'var(--text)' : 'var(--text-muted)',
              fontWeight: sel ? 600 : 500,
              boxShadow: sel ? 'var(--shadow-sm)' : 'none',
            }}>
            {Ic && <Ic size={11} />}{o.label}
          </button>
        );
      })}
    </div>
  );
}

// ─── ControlledLocalized — wraps LocalizedStringEditor with explicit value
// ─── (so the editor draft owns the data, not the widget's internal state).
function ControlledLocalized({ label, value, onChange, languages = ['en','it'], multiline }) {
  // `value` is a LocalizedString — a per-language object { [lang]: string }
  // (a legacy flat string is auto-upgraded on first edit). The widget shows a
  // SINGLE input bound to the course's default language; editing writes only
  // that slot and preserves every other language. Translation visibility lives
  // on the Localisation surface, never here (no badges, no language picker).
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  const str = locText(value, lang);
  const emit = (s) => onChange?.(locSet(value, lang, s));
  return (
    <div>
      {label && <div style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text)',
        marginBottom: 6 }}>{label}</div>}
      {multiline
        ? <textarea className="field" value={str}
            onChange={e => emit(e.target.value)}
            style={{ width: '100%', minHeight: 60 }} />
        : <input className="field" value={str}
            onChange={e => emit(e.target.value)}
            style={{ width: '100%' }} />}
    </div>
  );
}

// ─── ControlledRich — wraps RichTextEditor controllably.
// ─── Defers to RichTextEditor for the real B/I/U/list/link behaviour;
// ─── duplicating the toolbar here means dead buttons.
// ─── `value` is a LocalizedString (per-language object of HTML strings, or a
// ─── legacy flat HTML string). Reads/writes the default-language slot exactly
// ─── like ControlledLocalized — RichTextEditor itself only ever sees a string.
function ControlledRich({ value, onChange, placeholder, minHeight = 96, tokens, tokenInsert }) {
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  return (
    <RichTextEditor value={locText(value, lang)}
      onChange={(s) => onChange?.(locSet(value, lang, s))}
      placeholder={placeholder} minHeight={minHeight}
      tokens={tokens} tokenInsert={tokenInsert} />
  );
}

// ─── FieldGrid — a labeled-pair grid used for blocks of color/switch fields.
function FieldGrid({ children, columns = 2 }) {
  return (
    <div style={{ display: 'grid',
      gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: 8 }}>
      {children}
    </div>
  );
}

// ─── LabeledControl — wraps any control with a small label row.
function LabeledControl({ label, hint, required, children }) {
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
        <span style={{ fontSize: 11.5, color: 'var(--text-muted)', fontWeight: 600,
          letterSpacing: '.02em', textTransform: 'uppercase' }}>{label}</span>
        {required && <span style={{ fontSize: 10, color: 'var(--error-text)' }}>·  required</span>}
        {hint && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>· {hint}</span>}
      </div>
      {children}
    </div>
  );
}

// ─── SubBlock — a lighter EditorBlock-style header for nested sections inside
// ─── per-element editors (no full divider line; tighter rhythm). Carries an
// ─── optional inline `action` so colours and toggles can attach to the label.
function SubBlock({ label, action, hint, children }) {
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
        <span style={{ fontSize: 11.5, color: 'var(--text-muted)', fontWeight: 600,
          letterSpacing: '.02em', textTransform: 'uppercase', whiteSpace: 'nowrap',
          flexShrink: 0 }}>{label}</span>
        {hint && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>· {hint}</span>}
        <div style={{ flex: 1 }} />
        {action}
      </div>
      {children}
    </div>
  );
}

// ─── EditorTabs — underline tab strip that organises a single editor body
// ─── into chunks (e.g. quiz_gaming → Questions / Scoring / Screens). This is
// ─── editor chrome, NOT a layout-variant toggle (§23 forbids those). Tabs:
// ─── [{ id, label, icon?, badge?, warn? }]. `warn` shows a red dot for a tab
// ─── that has a validation problem so the author notices it from any tab.
function EditorTabs({ tabs, active, onChange }) {
  return (
    <div role="tablist" style={{ display: 'flex', gap: 22,
      borderBottom: '1px solid var(--border)' }}>
      {tabs.map(t => {
        const sel = t.id === active;
        const Ic = t.icon ? (I[t.icon] || null) : null;
        return (
          <button key={t.id} role="tab" aria-selected={sel} onClick={() => onChange?.(t.id)}
            style={{
              position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 7,
              padding: '9px 1px', border: 0, background: 'transparent', cursor: 'default',
              fontFamily: 'inherit', fontSize: 13, fontWeight: sel ? 600 : 500,
              color: sel ? 'var(--text)' : 'var(--text-muted)',
              transition: 'color 120ms',
            }}>
            {Ic && <Ic size={14} style={{ color: sel ? 'var(--accent)' : 'var(--text-faint)' }} />}
            {t.label}
            {t.badge != null && (
              <span style={{ fontSize: 10.5, fontFamily: 'var(--font-mono)',
                color: 'var(--text-faint)', background: 'var(--surface-inset)',
                padding: '1px 5px', borderRadius: 3 }}>{t.badge}</span>
            )}
            {t.warn && <span title="Needs attention" style={{ width: 6, height: 6,
              borderRadius: '50%', background: 'var(--error)' }} />}
            {sel && <span style={{ position: 'absolute', left: 0, right: 0, bottom: -1,
              height: 2, background: 'var(--accent)', borderRadius: 2 }} />}
          </button>
        );
      })}
    </div>
  );
}

// ─── SettingRow — a single setting laid out as label (+ hint) on the left and
// ─── its control on the right. Clearer than cramming pill-toggles into a
// ─── flex-wrap row; used for boolean/threshold settings in the quiz editor.
function SettingRow({ label, hint, control, children }) {
  return (
    <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '2px 0' }}>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)' }}>{label}</div>
        {hint && <div style={{ fontSize: 11.5, color: 'var(--text-muted)', marginTop: 1,
          lineHeight: 1.4 }}>{hint}</div>}
      </div>
      <div style={{ flex: '0 0 auto', paddingTop: 1 }}>{control || children}</div>
    </div>
  );
}

// ─── HeaderSwatch — tiny inline colour swatch + hex, designed to slot into
// ─── an EditorBlock's `action` so the colour visually attaches to the
// ─── label of the field it styles (e.g. Title colour ↔ "Title block").
function HeaderSwatch({ value = '#000000', onChange, title }) {
  const [open, setOpen] = React.useState(false);
  const hex = normaliseHex(value);
  return (
    <div style={{ position: 'relative', display: 'inline-flex',
      alignItems: 'center', gap: 4 }}>
      <div style={{ position: 'relative', display: 'inline-flex' }}>
        <button type="button" onClick={() => setOpen(o => !o)}
          title={title || 'Pick colour'}
          style={{
            width: 18, height: 18, borderRadius: 3,
            background: value, border: '1px solid var(--border-strong)',
            padding: 0, cursor: 'default',
          }} />
        <input type="color" value={hex}
          onChange={e => onChange?.(e.target.value)}
          style={{ position: 'absolute', inset: 0, width: 18, height: 18,
            opacity: 0, cursor: 'default' }} />
      </div>
      <input className="field" value={value}
        onChange={e => onChange?.(e.target.value)}
        style={{ width: 86, height: 22, padding: '0 6px',
          fontFamily: 'var(--font-mono)', fontSize: 11 }} />
      {open && (
        <>
          <div onClick={() => setOpen(false)}
            style={{ position: 'fixed', inset: 0, zIndex: 30 }} />
          <div style={{
            position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 31,
            padding: 8, background: 'var(--surface)', border: '1px solid var(--border)',
            borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-lg)',
            display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4, width: 240,
          }}>
            {DEFAULT_PALETTE.map(c => (
              <button key={c} type="button"
                onClick={() => { onChange?.(c); setOpen(false); }}
                title={c}
                style={{
                  width: 24, height: 24, borderRadius: 4, background: c,
                  border: '1px solid var(--border-strong)', cursor: 'default',
                }} />
            ))}
          </div>
        </>
      )}
    </div>
  );
}

// ─── HeaderSwatchSet — multiple HeaderSwatches in one action slot, each
// ─── with a tiny floating label so authors know which colour is which.
function HeaderSwatchSet({ swatches }) {
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
      {swatches.map((s, i) => (
        <div key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
          {s.label && <span style={{ fontSize: 10.5, color: 'var(--text-faint)',
            fontWeight: 500, letterSpacing: '.02em',
            textTransform: 'uppercase' }}>{s.label}</span>}
          <HeaderSwatch value={s.value} onChange={s.onChange} title={s.label} />
        </div>
      ))}
    </div>
  );
}

// ─── InlineColorRow — full-width row: label-left, swatch + hex right.
// ─── For free-standing colours (Content background, Hotspot icon colour…).
function InlineColorRow({ label, value, onChange }) {
  return (
    <div style={{
      display: 'flex', alignItems: 'center', gap: 10,
      padding: '8px 12px', background: 'var(--surface)',
      border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
    }}>
      <span style={{ flex: 1, fontSize: 12, color: 'var(--text)',
        lineHeight: 1.3 }}>{label}</span>
      <HeaderSwatch value={value} onChange={onChange} title={label} />
    </div>
  );
}

// ─── GateBlock — convenience: the blockingSection gate in one tidy block,
// ─── used by every editor. Per packages/schema BlockingSectionSchema the
// ─── field is a nested object { state, color?, textColor? } — the colours
// ─── live inside it, not as sibling top-level fields.
function GateBlock({ draft, setField }) {
  const gate = draft.blockingSection || {};
  const state = gate.state ?? '';
  const active = state === 'true' || state === 'false';
  const write = (patch) => setField('blockingSection', { ...gate, ...patch });
  return (
    <EditorBlock label="Progress gate"
      action={active ? <HeaderSwatchSet swatches={[
        { label: 'Bg', value: gate.color,
          onChange: v => write({ color: v }) },
        { label: 'Text', value: gate.textColor,
          onChange: v => write({ textColor: v }) },
      ]} /> : <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
        blockingSection
      </span>}>
      <GateControl value={state}
        onChange={v => write({ state: v })} />
    </EditorBlock>
  );
}

// ─── ToolIcon — a single 30×30 icon button with a rich hover tooltip.
// ─── Used by VideoMediaBlock's toolbar. None preselected at rest; clicking
// ─── toggles the active state so the toolbar can surface contextual controls.
// ─── Tooltip is portalled to <body> so it escapes the VideoMediaBlock
// ─── card's `overflow:hidden` (the icons sit at the very bottom edge).
function ToolIcon({ icon: Ic, title, hint, active, disabled, onClick }) {
  const [hover, setHover] = React.useState(false);
  const btnRef = React.useRef(null);
  const [rect, setRect] = React.useState(null);
  React.useEffect(() => {
    if (hover && btnRef.current) setRect(btnRef.current.getBoundingClientRect());
  }, [hover]);
  return (
    <div
      onMouseEnter={() => !disabled && setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{ position: 'relative', display: 'inline-flex' }}>
      <button
        ref={btnRef}
        onClick={disabled ? undefined : onClick}
        disabled={disabled}
        aria-pressed={!!active}
        aria-label={title}
        style={{
          width: 30, height: 30, padding: 0,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          background: active ? 'var(--accent-bg)' : 'var(--surface)',
          color: active ? 'var(--accent-text)' : 'var(--text-muted)',
          border: '1px solid',
          borderColor: active ? 'var(--accent-border)' : 'var(--border-strong)',
          borderRadius: 'var(--radius)',
          cursor: 'default', fontFamily: 'inherit',
          opacity: disabled ? 0.45 : 1,
          transition: 'background 120ms, color 120ms, border-color 120ms',
        }}>
        <Ic size={14} />
      </button>
      {hover && !disabled && rect && ReactDOM.createPortal(
        <div role="tooltip" style={{
          position: 'fixed',
          top: rect.bottom + 8,
          left: rect.left,
          zIndex: 1000, width: 240,
          padding: '8px 10px',
          background: 'var(--text-strong)', color: '#fff',
          border: '1px solid rgba(255,255,255,.08)',
          borderRadius: 'var(--radius)',
          fontSize: 11.5, lineHeight: 1.45,
          boxShadow: '0 8px 20px rgba(15,23,42,.18)',
          pointerEvents: 'none',
        }}>
          <div style={{ fontWeight: 600, marginBottom: 2 }}>{title}</div>
          <div style={{ color: 'rgba(255,255,255,.78)' }}>{hint}</div>
          <div style={{
            position: 'absolute', top: -5, left: 10,
            width: 8, height: 8, transform: 'rotate(45deg)',
            background: 'var(--text-strong)',
            borderLeft: '1px solid rgba(255,255,255,.08)',
            borderTop: '1px solid rgba(255,255,255,.08)',
          }} />
        </div>,
        document.body
      )}
    </div>
  );
}

// ─── VideoMediaBlock — compact stack for the video-related slots.
// ─── Used by fullscreen_video, small_video, sequence/horizontal_tabs video
// ─── tabs, text_and_image video rows.
// ─── Layout:
// ─── • 132-px preview at top (status pill + filename strip + scrubber).
// ─── • Toolbar: three icon buttons (Capture poster · Upload · AI generate),
// ─── none preselected on first view. Hovering an icon shows a tooltip
// ─── describing its purpose; clicking surfaces the action's controls
// ─── inline. Trash on the far right is always available once a video
// ─── exists.
// ─── • Capture lives inline — no separate Poster row; the captured frame
// ─── becomes the poster on click.
function VideoMediaBlock({ videoUrl, videoThumbUrl, subtitlesUrl, transcriptUrl,
  duration = '00:00', generatedBy, defaultMode, defaultPrompt,
  onChange, hasTranscript = true, courseId }) {
  const cid = courseId || window.dynamoCourseId;
  const [activeAction, setActiveAction] = React.useState(null); // null | 'capture' | 'upload' | 'ai'
  const [prompt, setPrompt] = React.useState(defaultPrompt || '');
  const [aiModel, setAiModel] = React.useState(generatedBy || 'HeyGen');
  const [status, setStatus] = React.useState(videoUrl ? 'ready' : 'idle');
  const [captureStatus, setCaptureStatus] = React.useState('idle'); // idle | capturing | done
  const fileInputRef = React.useRef(null);
  const filename = (videoUrl || '').split('/').pop();
  const isAiVideo = (videoUrl || '').includes('ai-vid') || (generatedBy && videoUrl);

  const triggerFile = () => fileInputRef.current?.click();
  // Real presigned upload → asset://<id> ref the layout draft stores.
  const uploadVideo = async (f) => {
    if (!f) return;
    setStatus('uploading');
    setActiveAction('upload');
    try {
      const ref = await window.uploadAsset(cid, f, 'video');
      onChange?.('videoUrl', ref);
      setStatus('ready');
    } catch (err) {
      console.error('video upload failed', err);
      setStatus('error');
    }
  };
  const handleFile = (e) => { uploadVideo(e.target.files?.[0]); };
  const handleDrop = (e) => {
    e.preventDefault(); e.stopPropagation();
    uploadVideo(e.dataTransfer.files?.[0]);
  };
  const handleClear = () => {
    setStatus('idle');
    setActiveAction(null);
    onChange?.('videoUrl', '');
  };
  // Capture a still from the video at the current playhead and set it as the
  // poster. The Poster surface used to live in a CompanionSlot below — now
  // the captured frame just becomes the poster inline.
  const handleCaptureStill = () => {
    if (!videoUrl || status !== 'ready') return;
    setCaptureStatus('capturing');
    setTimeout(() => {
      setCaptureStatus('done');
      onChange?.('videoThumbUrl', `placeholder:still-${Date.now().toString(36)}`);
      setTimeout(() => setCaptureStatus('idle'), 1100);
    }, 700);
  };
  const toggleAction = (k) => setActiveAction(prev => (prev === k ? null : k));

  // Preview background depends on what's happening.
  const previewBg = status === 'generating'
    ? 'linear-gradient(135deg, var(--ai-bg) 0%, var(--surface-inset) 100%)'
    : videoUrl
      ? 'linear-gradient(135deg, #475569 0%, #0f172a 100%)'
      : 'var(--surface-inset)';

  // Status pill text — derived from the video's actual state, not from any
  // preselected toolbar action.
  const pillText = status === 'generating' ? 'Generating'
    : status === 'uploading' ? 'Uploading'
    : status === 'error' ? 'Upload failed'
    : !videoUrl ? 'No video'
    : isAiVideo ? 'AI-generated'
    : 'Uploaded';

  const aiModels = ['HeyGen', 'Synthesia', 'Sora'];

  return (
    <div className="card" style={{ overflow: 'hidden' }}>
      {/* Compact preview — drag-drop always enabled */}
      <div
        onDragOver={(e) => { e.preventDefault(); }}
        onDrop={handleDrop}
        style={{
          height: 132, position: 'relative', overflow: 'hidden',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          background: previewBg,
        }}>
        {/* Status pill (top-left) */}
        <span style={{
          position: 'absolute', top: 8, left: 10,
          padding: '2px 7px', fontSize: 10, fontWeight: 600,
          borderRadius: 3, letterSpacing: '.04em', textTransform: 'uppercase',
          background: 'rgba(15,23,42,.55)', color: '#fff',
          backdropFilter: 'blur(4px)', fontFamily: 'inherit',
          display: 'inline-flex', alignItems: 'center', gap: 4,
        }}>{pillText}</span>

        {/* AI service badge (top-right, when applicable) */}
        {videoUrl && status === 'ready' && isAiVideo && (
          <span style={{ position: 'absolute', top: 8, right: 10 }}>
            <AiBadge label={generatedBy || aiModel} />
          </span>
        )}

        {/* Centre content — varies by state */}
        {status === 'ready' && videoUrl && (
          <I.Film size={28} style={{ color: 'rgba(255,255,255,.45)' }} />
        )}
        {status === 'generating' && (
          <span style={{ color: 'rgba(255,255,255,.75)', fontSize: 12,
            display: 'inline-flex', alignItems: 'center', gap: 6 }}>
            <I.Sparkle size={13} />Generating with {aiModel}…
          </span>
        )}
        {status === 'uploading' && (
          <span style={{ color: 'rgba(255,255,255,.75)', fontSize: 12,
            display: 'inline-flex', alignItems: 'center', gap: 6 }}>
            <I.Upload size={13} />Uploading…
          </span>
        )}
        {status === 'error' && (
          <span style={{ color: 'rgba(255,255,255,.85)', fontSize: 12,
            display: 'inline-flex', alignItems: 'center', gap: 6 }}>
            <I.AlertTriangle size={13} />Upload failed — try again
          </span>
        )}
        {status === 'idle' && !videoUrl && (
          <span style={{ color: 'var(--text-muted)', fontSize: 12,
            display: 'inline-flex', alignItems: 'center', gap: 6 }}>
            <I.Upload size={13} />Drop a video here, or pick an action below
          </span>
        )}

        {/* Capture-still flash overlay */}
        {captureStatus === 'capturing' && (
          <div style={{ position: 'absolute', inset: 0,
            background: 'rgba(255,255,255,0.55)', display: 'flex',
            alignItems: 'center', justifyContent: 'center',
            color: '#0f172a', fontSize: 12, fontWeight: 600,
            gap: 6 }}>
            <I.Camera size={14} />Capturing still…
          </div>
        )}
        {captureStatus === 'done' && (
          <div style={{ position: 'absolute', top: 8, left: '50%',
            transform: 'translateX(-50%)',
            padding: '4px 9px', fontSize: 10.5, fontWeight: 600,
            background: 'rgba(15,23,42,.7)', color: '#fff',
            borderRadius: 3, letterSpacing: '.04em', textTransform: 'uppercase',
            display: 'inline-flex', alignItems: 'center', gap: 5 }}>
            <I.Check size={11} />Still saved as poster
          </div>
        )}

        {/* Filename + scrubber strip (bottom) — only when a video is ready */}
        {status === 'ready' && videoUrl && (
          <div style={{ position: 'absolute', bottom: 8, left: 10, right: 10,
            display: 'flex', alignItems: 'center', gap: 8, fontSize: 11,
            color: 'rgba(255,255,255,.9)' }}>
            <I.Play size={13} />
            <div style={{ flex: 1, height: 3, background: 'rgba(255,255,255,.2)',
              borderRadius: 2 }}>
              <div style={{ width: '34%', height: '100%', background: 'rgba(255,255,255,.9)',
                borderRadius: 2 }} />
            </div>
            <span style={{ fontFamily: 'var(--font-mono)' }}>{duration}</span>
          </div>
        )}
        {status === 'ready' && videoUrl && (
          <span style={{
            position: 'absolute', top: 32, left: 10, maxWidth: 260,
            fontSize: 11, color: 'rgba(255,255,255,.85)',
            fontFamily: 'var(--font-mono)',
            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          }}>{filename}</span>
        )}
      </div>

      {/* Hidden file picker so triggerFile() works from the contextual Replace
          button without bloating the toolbar with an inline <input>. */}
      <input ref={fileInputRef} type="file" accept="video/*" hidden onChange={handleFile} />

      {/* Toolbar — three icon buttons (none preselected) + contextual right
          column + always-available trash. */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: 8, borderTop: '1px solid var(--border)',
        background: 'var(--surface-2)',
        position: 'relative',
      }}>
        <div style={{ display: 'inline-flex', gap: 6 }}>
          <ToolIcon icon={I.Camera}
            title="Capture poster"
            hint="Scrub the video to any moment, then click to save that frame as the poster image."
            disabled={!videoUrl || status !== 'ready'}
            active={activeAction === 'capture'}
            onClick={() => toggleAction('capture')} />
          <ToolIcon icon={I.Upload}
            title="Upload video"
            hint="Replace this video with one from your computer (MP4 / WebM)."
            active={activeAction === 'upload'}
            onClick={() => toggleAction('upload')} />
          <ToolIcon icon={I.Sparkle}
            title="Generate with AI"
            hint="Describe the video and generate a new one with HeyGen, Synthesia, or Sora."
            active={activeAction === 'ai'}
            onClick={() => toggleAction('ai')} />
        </div>

        {/* Contextual controls for the active icon */}
        <div style={{ flex: 1, display: 'flex', alignItems: 'center',
          gap: 8, minWidth: 0 }}>
          {activeAction === 'capture' && (
            <>
              <div style={{ fontSize: 11.5, color: 'var(--text-muted)',
                flex: 1, minWidth: 0 }}>
                Pick a moment on the video timeline to set it as the poster placeholder.
              </div>
              <button className="btn sm primary"
                onClick={handleCaptureStill}
                disabled={!videoUrl || status !== 'ready' || captureStatus === 'capturing'}>
                <I.Camera size={11} />Capture current frame
              </button>
            </>
          )}

          {activeAction === 'upload' && (
            <>
              <div className="truncate" style={{
                flex: 1, fontSize: 12,
                color: filename ? 'var(--text)' : 'var(--text-faint)',
                fontFamily: filename ? 'var(--font-mono)' : 'inherit',
              }}>{filename || 'No file selected'}</div>
              <button className="btn sm" onClick={triggerFile}
                disabled={status === 'uploading'}>
                <I.Upload size={11} />{videoUrl ? 'Replace' : 'Choose file'}
              </button>
            </>
          )}

          {activeAction === 'ai' && (
            <>
              <input className="field" value={prompt}
                onChange={e => setPrompt(e.target.value)}
                placeholder="Describe the video you want…"
                style={{ flex: 1, height: 30, fontSize: 12.5, minWidth: 0 }} />
              <select value={aiModel} onChange={e => setAiModel(e.target.value)}
                className="field select-elegant"
                style={{ height: 30, fontSize: 12, width: 116, paddingRight: 26 }}>
                {aiModels.map(m => <option key={m}>{m}</option>)}
              </select>
              {/* AI generation is not wired yet — trigger is inert, prompt stays. */}
              <button className="btn sm primary" disabled title="AI video generation is coming soon">
                <I.Sparkle size={11} />Coming soon
              </button>
            </>
          )}
        </div>

        {/* Trash — always available once a video exists */}
        {videoUrl && (
          <button className="btn sm ghost danger" title="Remove this video"
            onClick={handleClear}
            style={{ width: 28, height: 28, padding: 0,
              justifyContent: 'center', gap: 0 }}>
            <I.Trash size={12} />
          </button>
        )}
      </div>
    </div>
  );
}

// ─── CompanionSlot — a thin (~52px) inline slot used by VideoMediaBlock.
// ─── One row: icon + label + filename or "Add" + tiny action button.
function CompanionSlot({ kind = 'image', label, value, onChange, courseId }) {
  const cid = courseId || window.dynamoCourseId;
  const Icon = kind === 'image' ? I.Image
    : kind === 'subs' ? I.Captions
    : I.FileText;
  const accept = kind === 'image' ? 'image/*'
    : kind === 'subs' ? '.vtt,.srt,text/vtt'
    : '.txt,.md,text/plain';
  const has = !!value;
  const fileInputRef = React.useRef(null);
  const triggerFile = () => fileInputRef.current?.click();
  // Map the slot kind to the gateway asset kind (subtitles → 'subtitle',
  // poster/image → 'image', transcript → 'document').
  const assetKind = kind === 'subs' ? 'subtitle' : kind === 'image' ? 'image' : 'document';
  const handleFile = async (e) => {
    const f = e.target.files?.[0];
    e.target.value = ''; // allow re-selecting the same file
    if (!f) return;
    try {
      const ref = await window.uploadAsset(cid, f, assetKind);
      onChange?.(ref);
    } catch (err) {
      console.error('companion upload failed', err);
    }
  };
  return (
    <div style={{
      display: 'flex', alignItems: 'center', gap: 10,
      padding: '10px 12px', background: 'var(--surface)',
      minWidth: 0,
    }}>
      <input ref={fileInputRef} type="file" accept={accept} hidden onChange={handleFile} />
      <div style={{
        width: 28, height: 28, borderRadius: 4,
        background: has ? 'var(--accent-bg)' : 'var(--surface-inset)',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        color: has ? 'var(--accent-text)' : 'var(--text-muted)',
        flexShrink: 0,
      }}>
        <Icon size={13} />
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 11.5, color: 'var(--text-muted)', fontWeight: 500,
          letterSpacing: '.02em', textTransform: 'uppercase' }}>{label}</div>
        <div className="truncate" style={{ fontSize: 11.5,
          color: has ? 'var(--text)' : 'var(--text-faint)',
          fontFamily: has ? 'var(--font-mono)' : 'inherit' }}>
          {has ? (value || '').replace('placeholder:', '').split('/').pop() : 'Not set'}
        </div>
      </div>
      {has && (
        <button className="btn sm ghost danger" title="Remove"
          onClick={() => onChange?.('')}
          style={{ width: 24, height: 24, padding: 0 }}>
          <I.Trash size={11} />
        </button>
      )}
      <button className="btn sm ghost" title={has ? 'Replace' : 'Add'}
        onClick={triggerFile}
        style={{ width: 24, height: 24, padding: 0 }}>
        <I.Upload size={11} />
      </button>
    </div>
  );
}

// ─── InteractionEmptyState — collapsed call-to-action shown when the layout
// ─── has zero in-video interactions. The full timeline is only rendered once
// ─── the author adds one.
function InteractionEmptyState({ onAdd }) {
  const [open, setOpen] = React.useState(false);
  return (
    <div style={{
      padding: 16, background: 'var(--surface-inset)',
      border: '1px dashed var(--border-strong)', borderRadius: 'var(--radius-md)',
      display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
      textAlign: 'center',
    }}>
      <div style={{
        width: 36, height: 36, borderRadius: '50%',
        background: 'var(--surface)', border: '1px solid var(--border)',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        color: 'var(--text-muted)',
      }}>
        <I.Clock size={16} />
      </div>
      <div>
        <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', marginBottom: 2 }}>
          No in-video interactions
        </div>
        <p style={{ margin: 0, fontSize: 11.5, color: 'var(--text-muted)',
          lineHeight: 1.5, maxWidth: 360 }}>
          Pause the video at a moment and overlay a reveal, a question, or a
          mandatory checkpoint.
        </p>
      </div>
      {!open ? (
        <button className="btn sm primary" onClick={() => setOpen(true)}>
          <I.Plus size={12} />Add in-video interaction
        </button>
      ) : (
        <div style={{ display: 'flex', gap: 4 }}>
          <button className="btn sm" onClick={() => onAdd('discover')}>
            <I.Eye size={11} />Discover
          </button>
          <button className="btn sm" onClick={() => onAdd('question')}>
            <I.AlertCircle size={11} />Question
          </button>
          <button className="btn sm" onClick={() => onAdd('mandatoryQuestion')}>
            <I.Lock size={11} />Mandatory
          </button>
          <button className="btn sm ghost" onClick={() => setOpen(false)}>
            <I.X size={11} />
          </button>
        </div>
      )}
    </div>
  );
}

// ─── InlineTimeInput — compact MM : SS : mmm header variant.
// ─── Used inside an interaction-inspector header where timing colocates
// ─── with the type label, not the focal control. Three small monospaced
// ─── cells separated by colons; no min/sec/ms caps, no inset wrapper. The
// ─── full-size TimeInput remains for layouts where time IS the focal point.
function InlineTimeInput({ value = 0, onChange, max = 9999 }) {
  const totalMs = Math.max(0, Math.round((+value || 0) * 1000));
  const mm = Math.floor(totalMs / 60000);
  const ss = Math.floor((totalMs % 60000) / 1000);
  const ms = totalMs % 1000;
  const update = (m, s, milli) => {
    const next = Math.min(max, Math.max(0, m * 60 + s + milli / 1000));
    onChange?.(next);
  };
  const cell = {
    width: 42, height: 30, padding: '0 4px',
    fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 500,
    textAlign: 'center',
    border: '1px solid var(--border-strong)', borderRadius: 'var(--radius)',
    background: 'var(--surface)', color: 'var(--text)',
    appearance: 'textfield', MozAppearance: 'textfield',
  };
  const wide = { ...cell, width: 54 };
  const sep = {
    fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 500,
    color: 'var(--text-faint)', padding: '0 4px',
  };
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center' }}>
      <style>{`
        .om-inlinetime-cell::-webkit-outer-spin-button,
        .om-inlinetime-cell::-webkit-inner-spin-button {
          -webkit-appearance: none; margin: 0;
        }
      `}</style>
      <input className="om-inlinetime-cell" style={cell} type="number" min="0" max="59"
        value={String(mm).padStart(2, '0')}
        onChange={e => update(Math.min(59, Math.max(0, +e.target.value || 0)), ss, ms)} />
      <span style={sep}>:</span>
      <input className="om-inlinetime-cell" style={cell} type="number" min="0" max="59"
        value={String(ss).padStart(2, '0')}
        onChange={e => update(mm, Math.min(59, Math.max(0, +e.target.value || 0)), ms)} />
      <span style={sep}>:</span>
      <input className="om-inlinetime-cell" style={wide} type="number" min="0" max="999"
        value={String(ms).padStart(3, '0')}
        onChange={e => update(mm, ss, Math.min(999, Math.max(0, +e.target.value || 0)))} />
    </div>
  );
}

// ─── TimeInput — minutes : seconds . milliseconds, large enough to read.
// ─── Stores as a float (seconds with decimal milliseconds). Designed to be
// ─── the focal control inside an interaction inspector, so the digits read
// ─── like a stopwatch — generously sized, monospaced, with quiet unit
// ─── labels under each pair so the format never has to be guessed.
function TimeInput({ value = 0, onChange, max = 9999, compact }) {
  if (compact) return <InlineTimeInput value={value} onChange={onChange} max={max} />;
  const totalMs = Math.max(0, Math.round((+value || 0) * 1000));
  const mm = Math.floor(totalMs / 60000);
  const ss = Math.floor((totalMs % 60000) / 1000);
  const ms = totalMs % 1000;
  const update = (m, s, milli) => {
    const next = Math.min(max, Math.max(0, m * 60 + s + milli / 1000));
    onChange?.(next);
  };
  const cell = {
    width: 60, height: 40, padding: '0 6px',
    fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 500,
    letterSpacing: '.01em', textAlign: 'center',
    border: '1px solid var(--border-strong)', borderRadius: 'var(--radius)',
    background: 'var(--surface)', color: 'var(--text)',
    /* Hide native number spinners so the cell reads as a clean digit slot. */
    appearance: 'textfield', MozAppearance: 'textfield',
  };
  const sep = {
    fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 500,
    color: 'var(--text-faint)', padding: '0 2px', lineHeight: '40px',
  };
  const cap = {
    fontSize: 10, color: 'var(--text-faint)',
    letterSpacing: '.06em', textTransform: 'uppercase',
    textAlign: 'center', marginTop: 4, fontFamily: 'var(--font-mono)',
  };
  return (
    <div style={{
      display: 'inline-flex', alignItems: 'flex-start', gap: 2,
      padding: '8px 10px',
      background: 'var(--surface-inset)',
      border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)',
    }}>
      <style>{`
        .om-timeinput-cell::-webkit-outer-spin-button,
        .om-timeinput-cell::-webkit-inner-spin-button {
          -webkit-appearance: none; margin: 0;
        }
      `}</style>
      <div>
        <input className="om-timeinput-cell" style={cell} type="number" min="0" max="59"
          value={String(mm).padStart(2, '0')}
          onChange={e => update(Math.min(59, Math.max(0, +e.target.value || 0)), ss, ms)} />
        <div style={cap}>min</div>
      </div>
      <span style={sep}>:</span>
      <div>
        <input className="om-timeinput-cell" style={cell} type="number" min="0" max="59"
          value={String(ss).padStart(2, '0')}
          onChange={e => update(mm, Math.min(59, Math.max(0, +e.target.value || 0)), ms)} />
        <div style={cap}>sec</div>
      </div>
      <span style={sep}>.</span>
      <div>
        <input className="om-timeinput-cell" style={{ ...cell, width: 76 }}
          type="number" min="0" max="999"
          value={String(ms).padStart(3, '0')}
          onChange={e => update(mm, ss, Math.min(999, Math.max(0, +e.target.value || 0)))} />
        <div style={cap}>ms</div>
      </div>
    </div>
  );
}

// ─── Helpers exposed to the editor draft hook ───────────────────────────────
function setAtPath(obj, path, value) {
  // path: array of keys/indices. Returns a new object with value set at path.
  if (!path.length) return value;
  const [k, ...rest] = path;
  const isArr = Array.isArray(obj);
  const next = isArr ? obj.slice() : { ...(obj || {}) };
  next[k] = setAtPath(obj?.[k], rest, value);
  return next;
}

function getAtPath(obj, path) {
  let cur = obj;
  for (const k of path) {
    if (cur == null) return undefined;
    cur = cur[k];
  }
  return cur;
}

// ─── TooltipParamsForm — the Author's per-use tooltip-text form (Phase 2) ────
// Rendered below a chosen HTML template when that template exposes tooltip
// slots (`tooltipParams`). One row per slot: the slot's label + helper text,
// and a ControlledLocalized editor (the canonical LocalizedString widget — we
// reuse it, never reimplement; CLAUDE.md §26) bound to that slot's value.
// Shared by the live quiz HTML-media editor and the (legacy) HtmlTemplateSlot.
//   · params  : TooltipParam[]  — { name, label:LocalizedString, description:LocalizedString }
//   · values  : { [name]: LocalizedString }
//   · onChange : (name, localizedValue) => void
function TooltipParamsForm({ params, values = {}, onChange }) {
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  if (!params || params.length === 0) return null;
  return (
    <div className="tooltip-params-form" style={{
      display: 'grid', gap: 12, padding: 12, marginTop: 2,
      background: 'var(--surface-inset)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)' }}>
      <div>
        <div className="section-header" style={{ display: 'flex', alignItems: 'center', gap: 7,
          fontSize: 12.5, fontWeight: 600, color: 'var(--text)' }}>
          <I.MessageSquare size={13} style={{ color: 'var(--text-muted)' }} />Tooltip text
        </div>
        <div className="section-help" style={{ fontSize: 11.5, color: 'var(--text-muted)',
          lineHeight: 1.5, marginTop: 4 }}>
          Fill in the tooltip text for each highlighted term. Each tooltip can be translated
          in the Localisation surface.
        </div>
      </div>
      {params.map(param => (
        <div key={param.name} className="param-row" style={{ display: 'grid', gap: 6 }}>
          <label style={{ display: 'grid', gap: 2 }}>
            <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>
              {locText(param.label, lang) || locText(param.label, 'en')}
            </span>
            <span className="hint" style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.45 }}>
              {locText(param.description, lang) || locText(param.description, 'en')}
            </span>
          </label>
          <ControlledLocalized value={values[param.name] || {}} multiline
            onChange={v => onChange?.(param.name, v)} />
        </div>
      ))}
    </div>
  );
}

Object.assign(window, {
  ColorField, GateControl, Toggle, TextField, NumberField, SegmentedControl,
  ControlledLocalized, ControlledRich, FieldGrid, LabeledControl, GateBlock,
  VideoMediaBlock, CompanionSlot, InteractionEmptyState, TimeInput, InlineTimeInput,
  HeaderSwatch, HeaderSwatchSet, InlineColorRow, SubBlock, EditorTabs, SettingRow,
  setAtPath, getAtPath, DEFAULT_PALETTE, TooltipParamsForm,
});
