// Editor-style components: MediaSlot, FourAnswerEditor, FreeFormQuestionEditor,
// LocalizedStringEditor, RichTextEditor (Tiptap-lookalike).

// ── MediaSlot ───────────────────────────────────────────────────────────────
// Three states: bound (thumbnail + actions), unbound-with-hint (AI prompt + Generate),
// unbound-no-hint (manual entry).
function MediaSlot({
  kind = 'image',         // 'image' | 'video' | 'audio' | 'object3d'
  bound,                  // { url, alt, generatedBy? }
  hintPrompt,             // string — pre-filled AI prompt
  generators = ['Gemini'],
  label,
  compact = false,
  // Action callbacks — only buttons with a handler are rendered. Dead
  // decorative buttons were lying to authors ("delete the 3D model
  // does nothing"); now if you want the user to be able to clear the
  // slot, you must wire `onRemove` explicitly.
  onReplace,
  onRegenerate,
  onRemove,
  courseId,
}) {
  const [editingPrompt, setEditingPrompt] = React.useState(hintPrompt || '');
  const [busy, setBusy] = React.useState(false);
  const cid = courseId || window.dynamoCourseId;
  const fileInputRef = React.useRef(null);
  const acceptFor = { image: 'image/*', video: 'video/*', audio: 'audio/*',
    object3d: '.glb,.gltf,model/gltf-binary' }[kind] || '*/*';
  const triggerUpload = () => fileInputRef.current?.click();
  // Real presigned upload → asset://<id> ref handed to the bind callback.
  const handleUpload = async (e) => {
    const f = e.target.files?.[0];
    e.target.value = ''; // allow re-selecting the same file
    if (!f) return;
    setBusy(true);
    try {
      const ref = await window.uploadAsset(cid, f, kind);
      onReplace?.(ref);
    } catch (err) {
      console.error('media upload failed', err);
    } finally {
      setBusy(false);
    }
  };

  const KindIcon = { image: I.Image, video: I.Film, audio: I.Mic, object3d: I.Cube }[kind] || I.Image;

  if (bound) {
    return (
      <div className="card" style={{ overflow: 'hidden' }}>
        {label && <div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)',
          fontSize: 12, fontWeight: 500, color: 'var(--text-muted)', display: 'flex',
          alignItems: 'center', gap: 8 }}>
          <KindIcon size={13} />{label}
          {bound.generatedBy && <AiBadge label={bound.generatedBy} />}
        </div>}
        <div style={{
          height: compact ? 120 : 168, position: 'relative',
          background: bound.previewBg || 'linear-gradient(135deg, #475569 0%, #1e293b 100%)',
          display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,.7)',
        }}>
          {/* Thumbnail content. The kind='video' branch used to draw a placeholder
              play glyph and a hardcoded 34%-filled scrubber — a progress bar that
              could never be scrubbed and a position that meant nothing. Removed
              2026-07-28 with the same chrome in VideoMediaBlock (Omar). This slot
              has no real <video>, so it shows the film glyph and the length it was
              given, and claims nothing it cannot do. Reaching for a REAL video
              preview? Use VideoMediaBlock, which mounts an actual player. */}
          {kind === 'video' && <>
            <KindIcon size={28} style={{ opacity: 0.7 }} />
            {bound.duration && (
              <span style={{ position: 'absolute', bottom: 8, right: 10, fontSize: 11,
                fontFamily: 'var(--font-mono)', color: 'rgba(255,255,255,.85)' }}>
                {bound.duration}
              </span>
            )}
          </>}
          {kind === 'image' && <I.Image size={28} style={{ opacity: 0.7 }} />}
          {kind === 'audio' && <I.Mic size={28} style={{ opacity: 0.7 }} />}
          {kind === 'object3d' && <I.Cube size={28} style={{ opacity: 0.7 }} />}
        </div>
        <div style={{ padding: '8px 12px', display: 'flex', alignItems: 'center', gap: 6 }}>
          <span style={{ flex: 1, fontSize: 12, color: 'var(--text-muted)' }} className="truncate">
            {bound.alt || bound.filename || 'Untitled media'}
          </span>
          {onReplace && (
            <button className="btn sm ghost" title="Replace" onClick={triggerUpload} disabled={busy}>
              <I.RefreshCw size={12} />
            </button>
          )}
          {onRegenerate && (
            <button className="btn sm ghost" title="Regenerate" onClick={onRegenerate}>
              <I.Sparkle size={12} />
            </button>
          )}
          {onRemove && (
            <button className="btn sm ghost danger" title="Remove" onClick={onRemove}>
              <I.Trash size={12} />
            </button>
          )}
          <input ref={fileInputRef} type="file" accept={acceptFor} hidden onChange={handleUpload} />
        </div>
      </div>
    );
  }

  // Unbound: with or without hint
  return (
    <div className="card" style={{
      borderStyle: 'dashed', borderColor: 'var(--border-strong)',
      padding: 14, display: 'flex', flexDirection: 'column', gap: 10,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <KindIcon size={14} style={{ color: 'var(--text-muted)' }} />
        <span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text)' }}>
          {label || `Add ${kind}`}
        </span>
        {hintPrompt && <AiBadge label="prompt ready" />}
      </div>
      <textarea
        className="field"
        value={editingPrompt}
        onChange={e => setEditingPrompt(e.target.value)}
        placeholder={`Describe the ${kind} you want to generate…`}
        style={{ minHeight: 56, fontSize: 12.5 }}
      />
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
        {generators.map(g => (
          <button key={g} className="btn sm primary" disabled
            title={`AI ${kind} generation is coming soon`}>
            <I.Sparkle size={12} />Coming soon
          </button>
        ))}
        <input ref={fileInputRef} type="file" accept={acceptFor} hidden onChange={handleUpload} />
        <button className="btn sm" onClick={triggerUpload} disabled={busy}>
          <I.Upload size={12} />{busy ? 'Uploading…' : 'Upload'}
        </button>
      </div>
    </div>
  );
}

// ── RichTextEditor (Tiptap-lookalike) ───────────────────────────────────────
function RichTextEditor({ value, defaultValue, onChange, placeholder, minHeight = 96, tokens, tokenInsert = true }) {
  // ── Protected tokens (e.g. {correct} / {total}) ──────────────────────────
  // When `tokens` is passed, those {key} placeholders render as atomic,
  // non-editable chips (contenteditable=false) so authors can insert or
  // delete them whole but never edit the characters inside. Storage stays
  // the plain {key} string the Player expects — chips serialise back to
  // {key} on every emit (toStored) and expand to chips when seeding the
  // DOM (toDom). No-op when `tokens` is undefined, so existing callers
  // are unaffected.
  const tokenList = tokens || [];
  const tokenChip = (key, label) =>
    `<span class="rt-token" contenteditable="false" data-token="${key}">${'{' + (label || key) + '}'}</span>`;
  // ── Sanitisation boundary ────────────────────────────────────────────────
  // Author rich text is untrusted input. It arrives by paste from arbitrary
  // web pages, from machine translation, and — since the authoring state became
  // a server-side copy — from whoever last saved the course. `clean` applies
  // the allowlist in rich-text-sanitize.js; it is a no-op on the B/I/U/list/
  // link markup this toolbar produces, so ordinary authoring is unaffected.
  //
  // It runs on the way IN (seed, paste, drop) so the visible DOM and the stored
  // value always agree, and on the way OUT (emit) so nothing hostile can reach
  // the draft even if it got into the DOM by a route we did not anticipate.
  // FAIL CLOSED. An earlier draft of this let the value through unchanged when
  // the sanitiser was missing, reasoning that the preview path escapes anyway.
  // refute-fullapp.harness.mjs disproved that: the contentEditable seed is its
  // OWN live sink, so a course whose stored text carries <img onerror> executed
  // it the moment the editor opened. A security control that degrades to "off"
  // is not a control, so the editor refuses to open instead — the author's
  // stored words are untouched either way (see the guard on `sanitiserReady`).
  const clean = (html) => window.sanitizeRichText(html == null ? '' : String(html));
  const toDom = (stored) => {
    let out = clean(stored);
    tokenList.forEach(t => { out = out.split('{' + t.key + '}').join(tokenChip(t.key, t.label)); });
    return out;
  };
  const toStored = (html) => {
    if (!tokenList.length) return clean(html);
    const tmp = document.createElement('div');
    // clean() first: assigning innerHTML on a DETACHED element still builds the
    // elements, and a browser will still fetch an <img src> and fire its
    // onerror from one.
    tmp.innerHTML = clean(html);
    tmp.querySelectorAll('span[data-token]').forEach(s => {
      s.replaceWith(document.createTextNode('{' + s.dataset.token + '}'));
    });
    return tmp.innerHTML;
  };
  // Without the sanitiser there is no safe way to open this field: seeding the
  // surface means writing stored HTML into a live DOM. Say so instead of doing
  // it. No hook has run at this point, so this early return cannot reorder
  // hooks — and the branch is fixed for the lifetime of a page load, because a
  // classic script either loaded or it did not.
  // Both live in `rich-text-sanitize.js`, so either one missing means the same
  // thing: that classic script did not load. Naming both keeps a paste from
  // throwing a TypeError on a half-deployed build.
  if (!window.sanitizeRichText || !window.sanitizeRichTextForPaste) {
    return (
      <div style={{
        padding: '10px 12px', border: '1px solid var(--border)',
        borderRadius: 'var(--radius-md)', background: 'var(--surface-2)',
        color: 'var(--text-muted)', fontSize: 13, lineHeight: 1.5,
      }}>
        <strong style={{ color: 'var(--text)' }}>Rich-text editing is unavailable.</strong>{' '}
        The HTML sanitiser did not load, so this field cannot be opened safely.
        Reload the page — if it keeps happening the deploy is incomplete. Your
        saved text is unchanged.
      </div>
    );
  }

  // contentEditable surface so B/I/U/lists/links are real — execCommand
  // wraps the selection in proper HTML, and the editor emits innerHTML
  // to the parent. (The panel renderer uses dangerouslySetInnerHTML, so
  // HTML round-trips losslessly.)
  //
  // CRITICAL: we never use React's dangerouslySetInnerHTML here. If we
  // did, every parent re-render after a keystroke would rewrite the
  // editor's innerHTML and destroy the caret + selection — making
  // execCommand a no-op on the second keystroke. Instead we seed the
  // innerHTML imperatively on mount and only re-sync when an EXTERNAL
  // value change (not one we just emitted) shows up.
  const editorRef = React.useRef(null);
  const initialRef = React.useRef(toDom(value ?? defaultValue ?? ''));
  const lastEmittedRef = React.useRef(value ?? defaultValue ?? '');
  const [active, setActive] = React.useState({ bold: false, italic: false, underline: false });
  // Link modal — opening a custom dialog drops the editor's selection,
  // so we snapshot the range up-front and restore it before createLink.
  const [linkModal, setLinkModal] = React.useState(null); // { range, suggested } | null
  // A COUNTER, not a boolean: pasting twice in a row must restart the note's
  // four seconds, and a boolean already true would not change and so would not
  // re-run the effect below.
  const [pasteNote, setPasteNote] = React.useState(0);
  React.useEffect(() => {
    if (!pasteNote) return;
    const t = setTimeout(() => setPasteNote(0), 4000);
    return () => clearTimeout(t);
  }, [pasteNote]);

  React.useEffect(() => {
    if (editorRef.current && editorRef.current.innerHTML !== initialRef.current) {
      editorRef.current.innerHTML = initialRef.current;
    }
  }, []);

  React.useEffect(() => {
    if (!editorRef.current) return;
    if (value != null && value !== lastEmittedRef.current) {
      editorRef.current.innerHTML = toDom(value);
      lastEmittedRef.current = value;
    }
  }, [value]);

  const refreshActive = () => {
    try {
      setActive({
        bold: document.queryCommandState('bold'),
        italic: document.queryCommandState('italic'),
        underline: document.queryCommandState('underline'),
      });
    } catch (_) { /* queryCommandState can throw with no selection */ }
  };

  const emit = () => {
    const stored = toStored(editorRef.current?.innerHTML ?? '');
    lastEmittedRef.current = stored;
    onChange?.(stored);
    refreshActive();
  };

  const exec = (cmd, arg) => {
    document.execCommand(cmd, false, arg);
    emit();
  };

  const onInput = () => { emit(); };

  // Paste and drop are the only routes by which markup this editor did not
  // generate gets into the document. Intercept both, sanitise the HTML flavour,
  // and insert that — so what the author sees is exactly what will be stored.
  // Without this, pasting from a web page carries its <script>, <img onerror>
  // and <iframe> in verbatim and the author has no way to notice.
  //
  // Since 2026-08-10 the paste ALSO arrives unformatted, at Omar's request: the
  // source document's bold, colours, fonts, sizes, alignment and heading styles
  // are stripped so the toolbar is the only thing that formats this field. Words,
  // paragraphs, line breaks, lists, tables and links survive — see
  // `sanitizeRichTextForPaste`, which layers on top of `clean` rather than
  // replacing it. Only the paste path uses it; a stored value is never restyled
  // behind the author's back.
  const insertSanitised = (e, html, text) => {
    e.preventDefault();
    editorRef.current?.focus();
    const result = html ? window.sanitizeRichTextForPaste(html) : null;
    const safe = result ? result.html : '';
    if (safe) document.execCommand('insertHTML', false, safe);
    else if (text) document.execCommand('insertText', false, text);
    // Say it out loud. A paste that quietly comes out plain is indistinguishable
    // from a field that failed to take the formatting, and guessing which is
    // exactly the confusion this note removes.
    if (result && result.changed) {
      setPasteNote(n => n + 1);
    }
    emit();
  };
  const onPaste = (e) => {
    const dt = e.clipboardData || window.clipboardData;
    if (!dt) return;                       // no clipboard data → let the browser do it
    insertSanitised(e, dt.getData('text/html'), dt.getData('text/plain'));
  };
  const onDrop = (e) => {
    const dt = e.dataTransfer;
    if (!dt) return;
    insertSanitised(e, dt.getData('text/html'), dt.getData('text/plain'));
  };

  // Insert a protected token chip at the caret, then a trailing nbsp so the
  // caret lands clear of the atomic span.
  const insertToken = (t) => {
    editorRef.current?.focus();
    document.execCommand('insertHTML', false, tokenChip(t.key, t.label) + '\u00A0');
    emit();
  };

  const openLinkModal = () => {
    const sel = window.getSelection();
    if (!sel || sel.rangeCount === 0) return;
    // Only allow links when the selection lives inside this editor.
    if (!editorRef.current?.contains(sel.anchorNode)) return;
    const range = sel.getRangeAt(0).cloneRange();
    setLinkModal({ range, selectedText: sel.toString() });
  };

  const applyLink = (url) => {
    const lm = linkModal;
    setLinkModal(null);
    if (!url || !lm) return;
    // Restore the original selection — opening the modal moved focus.
    editorRef.current?.focus();
    const sel = window.getSelection();
    if (sel) {
      sel.removeAllRanges();
      sel.addRange(lm.range);
    }
    exec('createLink', url);
  };

  const btn = (key, label, style, onClick, ariaLabel) => {
    const isOn = active[key];
    return (
      <button key={label} className="btn sm ghost" aria-label={ariaLabel || label}
        aria-pressed={isOn}
        onMouseDown={e => e.preventDefault()}
        onClick={onClick}
        style={{
          width: 26, height: 24, padding: 0, ...style, fontSize: 12,
          background: isOn ? 'var(--accent-bg)' : undefined,
          color: isOn ? 'var(--accent-text)' : undefined,
        }}>{label}</button>
    );
  };

  return (
    <div style={{
      border: '1px solid var(--border-strong)', borderRadius: 'var(--radius-md)',
      background: 'var(--surface)',
    }}>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap',
        padding: '4px 6px', borderBottom: '1px solid var(--border)',
      }}>
        {btn('bold', 'B', { fontWeight: 700 }, () => exec('bold'), 'Bold')}
        {btn('italic', 'I', { fontStyle: 'italic' }, () => exec('italic'), 'Italic')}
        {btn('underline', 'U', { textDecoration: 'underline' }, () => exec('underline'), 'Underline')}
        <div style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 4px' }} />
        <button className="btn sm ghost" aria-label="Bulleted list"
          onMouseDown={e => e.preventDefault()}
          onClick={() => exec('insertUnorderedList')}
          style={{ width: 26, height: 24, padding: 0 }}>
          <I.ListChecks size={13} />
        </button>
        <button className="btn sm ghost" aria-label="Link"
          onMouseDown={e => e.preventDefault()}
          onClick={openLinkModal}
          style={{ width: 26, height: 24, padding: 0 }}>
          <I.Link size={13} />
        </button>
        {tokenList.length > 0 && tokenInsert ? (
          <>
            <div style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 4px' }} />
            {tokenList.map(t => (
              <button key={t.key} className="btn sm ghost" type="button"
                aria-label={`Insert ${t.label || t.key} token`}
                title={`Insert {${t.label || t.key}} — the value is filled in automatically at runtime and can't be edited`}
                onMouseDown={e => e.preventDefault()}
                onClick={() => insertToken(t)}
                style={{ height: 24, padding: '0 7px', gap: 3,
                  fontFamily: 'var(--font-mono)', fontSize: 11,
                  color: 'var(--accent-text)' }}>
                <I.Plus size={10} />{'{' + (t.label || t.key) + '}'}
              </button>
            ))}
          </>
        ) : (
          <>
            <div style={{ flex: 1 }} />
            <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>Rich text</span>
          </>
        )}
      </div>
      {/* Shown for four seconds after a paste that actually lost formatting.
          `role="status"` rather than `alert`: it reports what happened, it does
          not need interrupting. It replaces nothing and shifts nothing above it,
          so the caret the author is about to type at does not move. */}
      {pasteNote > 0 && (
        <div role="status" style={{
          display: 'flex', alignItems: 'center', gap: 6,
          padding: '5px 10px', fontSize: 11.5, lineHeight: 1.4,
          color: 'var(--text-muted)', background: 'var(--surface-2)',
          borderBottom: '1px solid var(--border)',
        }}>
          <I.Info size={12} style={{ flexShrink: 0 }} />
          <span>Pasted as unformatted text — use <strong style={{ color: 'var(--text)' }}>B</strong>,{' '}
            <strong style={{ color: 'var(--text)' }}>I</strong>,{' '}
            <strong style={{ color: 'var(--text)' }}>U</strong> or the list button to style it.</span>
        </div>
      )}
      <div
        ref={editorRef}
        contentEditable
        suppressContentEditableWarning
        role="textbox"
        aria-multiline="true"
        data-placeholder={placeholder}
        onInput={onInput}
        onPaste={onPaste}
        onDrop={onDrop}
        onKeyUp={refreshActive}
        onMouseUp={refreshActive}
        onFocus={refreshActive}
        style={{
          width: '100%', minHeight, padding: '10px 12px',
          outline: 'none', resize: 'vertical',
          fontSize: 13.5, lineHeight: 1.55,
          background: 'transparent', color: 'var(--text)',
          fontFamily: 'inherit', overflow: 'auto',
          borderRadius: '0 0 var(--radius-md) var(--radius-md)',
          whiteSpace: 'pre-wrap', wordBreak: 'break-word',
        }}
      />
      <style>{`
        [contenteditable][data-placeholder]:empty::before {
          content: attr(data-placeholder);
          color: var(--text-faint);
          pointer-events: none;
        }
        .rt-token {
          display: inline-block;
          padding: 0 6px; margin: 0 1px;
          border-radius: 4px;
          background: var(--accent-bg);
          color: var(--accent-text);
          border: 1px solid var(--accent-border);
          font-family: var(--font-mono);
          font-size: 0.84em; font-weight: 500;
          line-height: 1.5; white-space: nowrap;
          user-select: all; cursor: default;
        }
      `}</style>
      {linkModal && (
        <LinkModal selectedText={linkModal.selectedText}
          onCancel={() => setLinkModal(null)}
          onConfirm={applyLink} />
      )}
    </div>
  );
}

// ── LinkModal — styled in-app replacement for window.prompt('URL') ───────────
// ─── linkSchemeError — refuse a link the store would silently strip ──────────
// <input type="url"> accepts any well-formed scheme, and createLink writes it
// into the DOM verbatim, but the sanitiser only allows http/https/mailto/tel and
// drops the href while keeping the <a>. So typing `ftp://…` or `file:///…` gave
// the author a link on screen and stored dead text — no warning, nothing in the
// export report, and the learner got unclickable words. Refuse it at the point of
// entry instead, using the SAME list the sanitiser enforces rather than a second
// copy of it (one rule, one place).
function linkSchemeError(url) {
  const allowed = (window.RICH_TEXT_POLICY && window.RICH_TEXT_POLICY.urlSchemes)
    || ['http', 'https', 'mailto', 'tel'];
  const m = /^([a-zA-Z][a-zA-Z0-9+.\-]*):/.exec(String(url).trim());
  if (!m) return null;                                  // relative or anchor — fine
  if (allowed.indexOf(m[1].toLowerCase()) !== -1) return null;
  return `Links can only use ${allowed.slice(0, -1).join(', ')} or ${allowed[allowed.length - 1]}`
    + ` — “${m[1]}:” cannot be stored, so the link would be lost.`;
}

function LinkModal({ selectedText, onCancel, onConfirm }) {
  const [url, setUrl] = React.useState('https://');
  const [error, setError] = React.useState(null);
  const inputRef = React.useRef(null);
  React.useEffect(() => {
    // Focus + select the placeholder so typing replaces it immediately.
    inputRef.current?.focus();
    inputRef.current?.select();
    const onKey = (e) => {
      if (e.key === 'Escape') onCancel?.();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);
  const submit = (e) => {
    e.preventDefault();
    const trimmed = url.trim();
    if (!trimmed || trimmed === 'https://' || trimmed === 'http://') {
      onCancel?.();
      return;
    }
    const problem = linkSchemeError(trimmed);
    if (problem) { setError(problem); return; }
    onConfirm?.(trimmed);
  };
  return (
    <>
      <div onClick={onCancel} style={{
        position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.4)',
        backdropFilter: 'blur(2px)', zIndex: 60,
      }} />
      <form onSubmit={submit} style={{
        position: 'fixed', top: '30%', left: '50%', transform: 'translateX(-50%)',
        width: 'min(440px, 92vw)', zIndex: 61,
        background: 'var(--surface)', border: '1px solid var(--border)',
        borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-xl)',
        padding: 18,
      }}>
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 14 }}>
          <div style={{
            width: 32, height: 32, borderRadius: '50%', background: 'var(--accent-bg)',
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
            color: 'var(--accent-text)', flexShrink: 0,
          }}>
            <I.Link size={16} />
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>Add link</h3>
            {selectedText ? (
              <p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-muted)',
                lineHeight: 1.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                Link “<span style={{ color: 'var(--text)', fontWeight: 500 }}>{selectedText}</span>” to…
              </p>
            ) : (
              <p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-muted)',
                lineHeight: 1.5 }}>
                Enter the destination URL.
              </p>
            )}
          </div>
        </div>
        <input
          ref={inputRef}
          className="field"
          type="url"
          value={url}
          onChange={e => { setUrl(e.target.value); if (error) setError(null); }}
          placeholder="https://example.com"
          aria-invalid={error ? 'true' : undefined}
          aria-describedby={error ? 'rt-link-error' : undefined}
          style={{ width: '100%', marginBottom: error ? 6 : 14 }} />
        {error && (
          <div id="rt-link-error" role="alert" style={{
            marginBottom: 12, fontSize: 12, lineHeight: 1.45, color: '#fca5a5',
          }}>{error}</div>
        )}
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <button type="button" className="btn sm ghost" onClick={onCancel}>Cancel</button>
          <button type="submit" className="btn sm primary">Add link</button>
        </div>
      </form>
    </>
  );
}

// ── LocalizedStringEditor ───────────────────────────────────────────────────
function LocalizedStringEditor({ label, values, languages = ['en','it'], onChange, multiline = false }) {
  const [active, setActive] = React.useState(languages[0]);
  // Own the per-language text internally so the input is editable even when
  // the parent doesn't pass a wired onChange (common in the prototype).
  const [drafts, setDrafts] = React.useState(() => {
    const seed = {};
    languages.forEach(l => { seed[l] = values?.[l]?.value || ''; });
    return seed;
  });
  const update = (v) => {
    setDrafts(d => ({ ...d, [active]: v }));
    onChange?.(active, v);
  };
  return (
    <div>
      {label && <div style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text)',
        marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
        {label}
        <span style={{ fontSize: 10.5, color: 'var(--text-faint)', fontWeight: 400 }}>
          · {languages.length} languages
        </span>
      </div>}
      <div style={{
        display: 'flex', gap: 2, marginBottom: 6,
        background: 'var(--surface-inset)', padding: 3, borderRadius: 'var(--radius)',
        width: 'fit-content', border: '1px solid var(--border)',
      }}>
        {languages.map(l => {
          const isActive = active === l;
          const status = values?.[l]?.status; // 'needs-review' | undefined
          return (
            <button key={l} className="focusable" onClick={() => setActive(l)}
              style={{
                display: 'inline-flex', alignItems: 'center', gap: 5,
                padding: '3px 8px', borderRadius: 4, border: 0, cursor: 'default',
                background: isActive ? 'var(--surface)' : 'transparent',
                color: isActive ? 'var(--text)' : 'var(--text-muted)',
                fontSize: 11.5, fontWeight: 500,
                boxShadow: isActive ? 'var(--shadow-sm)' : 'none',
                fontFamily: 'inherit',
              }}>
              <span style={{ fontSize: 13, lineHeight: 1 }}>{LANG_FLAGS[l]}</span>
              <span style={{ textTransform: 'uppercase', letterSpacing: '.04em' }}>{l}</span>
              {status === 'needs-review' && (
                <span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--warning)' }} />
              )}
            </button>
          );
        })}
      </div>
      {multiline ? (
        <textarea className="field" value={drafts[active] || ''}
          onChange={e => update(e.target.value)}
          style={{ width: '100%', minHeight: 72 }} />
      ) : (
        <input className="field" value={drafts[active] || ''}
          onChange={e => update(e.target.value)}
          style={{ width: '100%' }} />
      )}
    </div>
  );
}

// ── AnswersEditor ────────────────────────────────────────────────────────────
// Standard question-answers editor: a flat list of options, each with a
// "Correct" toggle and the answer text. Feedback shape is DERIVED, not chosen
// (see video-media-patterns.md §27): when ≤ 1 answer is ticked correct each
// row shows its own per-answer feedback input; when ≥ 2 are ticked the rows'
// inputs hide and a single shared "Correct / incorrect" pair appears below
// (→ stored in `feedbacks.{correct,wrong}`). Generic feedback is controlled
// when `onFeedbacksChange` is passed; otherwise it's kept in local state
// (mock/gallery contexts). Accepts an array of
// { id?, text, isCorrect, feedback? } or the legacy four-answer object.
//
// `sharedFeedbackOnly` — for the QUIZ layouts, whose Player template renders
// exactly ONE message per question (`components.js:5675`): the shared pair
// collapses to a single field, so no box is offered for a second message the
// runtime cannot show. Sequence steps keep both (their XML carries
// `<text for="correct">` / `<text for="wrong">` on the paired feedback tab).
function AnswersEditor({ answers, onChange, allowFeedback = true,
  feedbacks, onFeedbacksChange, sharedFeedbackOnly = false }) {
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  const list = normalizeAnswers(answers);
  const [fbLocal, setFbLocal] = React.useState(feedbacks || {});
  const fb = feedbacks != null ? feedbacks : fbLocal;
  const setFb = (patch) => {
    const next = { ...fb, ...patch };
    onFeedbacksChange ? onFeedbacksChange(next) : setFbLocal(next);
  };
  // Feedback shape is DERIVED, not chosen (§27). Single-correct questions
  // (≤ 1 ticked) carry per-answer feedback on each row; multi-correct
  // (≥ 2 ticked) collapse to one shared correct / incorrect pair below.
  const correctCount = list.filter(a => a.isCorrect).length;
  const isMultiCorrect = correctCount >= 2;
  // …EXCEPT that a shared message which already has text keeps its box, even
  // after the author unticks an answer and the question stops being
  // multi-correct. Hiding it stranded the text: still in the data, still
  // emitted as the question's ONE `<feedbackText>` (which suppresses the
  // per-answer consolidation), but no longer visible or clearable — so the
  // author would edit per-answer feedback that could never ship. Editor and
  // package now agree in every state (2026-07-28 review).
  const sharedHasText = sharedFeedbackOnly && locHasText(fb.correct);
  const showShared = allowFeedback && (isMultiCorrect || sharedHasText);
  const perAnswer = !isMultiCorrect && !sharedHasText;
  const updRow = (i, patch) => onChange?.(list.map((a, j) => j === i ? { ...a, ...patch } : a));
  const addRow = () => onChange?.([...list,
    { id: String(list.length + 1), text: '', isCorrect: false, feedback: '' }]);
  const removeRow = (i) => onChange?.(list.filter((_, j) => j !== i));
  return (
    <div>
      {/* Answers header — hint on the left, "Add answer" pinned top-right.
          Mirrors QuizQuestionBody's Answers header (small_video) so the
          add affordance lives in the same place on every quiz layout (§26). */}
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 8 }}>
        <span style={{ fontSize: 11.5, color: 'var(--text-faint)' }}>
          {list.length} option{list.length === 1 ? '' : 's'} · tick the correct one
        </span>
        <div style={{ flex: 1 }} />
        <button className="btn sm" onClick={addRow}>
          <I.Plus size={11} />Add answer
        </button>
      </div>
      {/* No Feedback toggle — the surface below is derived from how many
          answers are ticked correct (§27). */}
      {/* Reuse AnswerRow (the small_video row) so the answer text and its
          per-answer feedback share one column and stay vertically aligned. */}
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        {list.map((a, i) => (
          <AnswerRow key={a.id || i}
            option={{ ...a, text: locText(a.text, lang), feedback: locText(a.feedback, lang) }}
            index={i} count={list.length}
            type="question" interactionId={a.id || i}
            showFeedback={allowFeedback && perAnswer}
            onText={v => updRow(i, { text: locSet(a.text, lang, v) })}
            onFeedback={v => updRow(i, { feedback: locSet(a.feedback, lang, v) })}
            onCorrect={() => updRow(i, { isCorrect: !a.isCorrect })}
            onRemove={() => removeRow(i)} />
        ))}
      </div>
      {showShared && (
        <div style={{ marginTop: 12, display: 'grid', gap: 8 }}>
          <label style={{ display: 'grid', gap: 4 }}>
            <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>
              {sharedFeedbackOnly ? 'Question feedback' : 'Correct feedback'}
            </span>
            <input className="field" value={locText(fb.correct, lang)}
              onChange={e => setFb({ correct: locSet(fb.correct, lang, e.target.value) })}
              placeholder={sharedFeedbackOnly
                ? 'Shown once this question is answered'
                : 'Shown when the learner picks the correct answer'}
              style={{ height: 30, fontSize: 12.5 }} />
            {/* Plain JSX text, not a template literal: a multi-line template
                keeps its newline and indentation and renders as a long gap. */}
            {sharedFeedbackOnly && isMultiCorrect && (
              <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
                Several answers are ticked correct, so this question shows one message to
                everyone — this layout has no separate “incorrect” message.
              </span>
            )}
            {sharedFeedbackOnly && !isMultiCorrect && (
              <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
                This one message is shown to everyone and replaces the per-answer feedback.
                Clear it to give each answer its own feedback again.
              </span>
            )}
          </label>
          {!sharedFeedbackOnly && (
            <label style={{ display: 'grid', gap: 4 }}>
              <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>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={{ height: 30, fontSize: 12.5 }} />
            </label>
          )}
        </div>
      )}
    </div>
  );
}

function normalizeAnswers(answers) {
  if (Array.isArray(answers)) {
    return answers.map((a, i) => ({ id: a.id || String(i + 1), text: a.text || '',
      isCorrect: !!a.isCorrect, feedback: a.feedback || '' }));
  }
  if (answers && typeof answers === 'object') {
    const out = [];
    if (answers.correct != null) out.push({ id: 'c', text: answers.correct, isCorrect: true, feedback: '' });
    ['wrong-1', 'wrong-2', 'playful'].forEach((k, j) => {
      if (answers[k] != null) out.push({ id: 'w' + j, text: answers[k], isCorrect: false, feedback: '' });
    });
    return out.length ? out : [{ id: '1', text: '', isCorrect: true, feedback: '' }];
  }
  return [
    { id: '1', text: '', isCorrect: true, feedback: '' },
    { id: '2', text: '', isCorrect: false, feedback: '' },
  ];
}

// Back-compat alias: the editor used to be the rigid 1-correct + 2-reflective
// + 1-playful "FourAnswerEditor". It is now the standard AnswersEditor above.
const FourAnswerEditor = AnswersEditor;

// ── FreeFormQuestionEditor (legacy / hand-authored) ────────────────────────
function FreeFormQuestionEditor({ answers = [], correctIdx = 0, onChange }) {
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
        <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)',
          letterSpacing: '.04em', textTransform: 'uppercase' }}>Free-form question editor</span>
        <span style={{ fontSize: 11.5, color: 'var(--text-faint)' }}>Any count · any distribution</span>
      </div>
      <div style={{ display: 'grid', gap: 6 }}>
        {answers.map((a, i) => (
          <div key={i} style={{
            display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 8, alignItems: 'center',
            padding: '8px 10px', border: '1px solid var(--border)', borderRadius: 'var(--radius)',
            background: i === correctIdx ? 'var(--success-bg)' : 'var(--surface)',
          }}>
            <input type="radio" name="freeform-correct" defaultChecked={i === correctIdx}
              style={{ accentColor: 'var(--success)' }} />
            <input className="field" value={a} style={{ border: 0, background: 'transparent', height: 28 }} />
            <button className="btn sm ghost"><I.Trash size={12} /></button>
          </div>
        ))}
        <button className="btn sm" style={{ alignSelf: 'flex-start' }}><I.Plus size={12} />Add answer</button>
      </div>
    </div>
  );
}

// ── BackgroundPicker ────────────────────────────────────────────────────────
// Compact three-way background source picker. One preview + one toolbar.
// The mode selector is a tiny segmented control of icons; the active mode's
// control sits inline next to it; the primary action button is on the right.
// Solid color uses the native picker + a hex input — no palette grid.
// `onCaptureFrame` / `captureReady` add a SECOND way to fill the same field —
// they do not add a second field. Supplying `onCaptureFrame` puts a "Capture
// frame" button beside "Choose file"; the result goes through the very same
// `uploadImage` as a chosen file, so the preview, the status pill, the filename
// strip and the `onImageChange` payload are identical either way. Only the video
// slots pass it (field-widgets.jsx + the quiz question editor); every other
// picker call site is untouched and shows no such button.
// ── fullscreen_text_and_image background: ONE rule, three readers ───────────
// The Player decides which background you see purely by whether an IMAGE is
// set. The image goes on `.contentContainer`; the colour goes on its parent
// `.layoutContentContainer`; a child always covers its parent. So:
//
//     an image is set  →  you see the image (a colour behind it is invisible)
//     no image         →  you see the colour
//
// This predicate is the single home of that rule. The editor's mode toggle, the
// preview renderer and the export warning all call it instead of each restating
// it, and there is deliberately no stored `backgroundMode` field — a stored mode
// is a second copy of a fact the data already carries, and copies drift
// (`feedback_one_rule_one_place`). A `placeholder:` value counts as "an image is
// set": the author chose one and has not uploaded it yet, which the export gate
// reports separately.
const FS_TEXT_IMAGE_DEFAULT_BG = '#0f172a';
function fsBackgroundShowsColor(layout) {
  const url = layout && layout.backgroundImageUrl;
  return !url || (typeof url === 'string' && !url.trim());
}
window.fsBackgroundShowsColor = fsBackgroundShowsColor;
window.FS_TEXT_IMAGE_DEFAULT_BG = FS_TEXT_IMAGE_DEFAULT_BG;

function BackgroundPicker({ defaultPrompt, defaultColor = '#0f172a',
  defaultFilename, defaultMode, colorOnly, imageOnly, label, previewHeight = 120,
  onColorChange, onModeChange, onImageChange, courseId,
  onCaptureFrame, captureReady = false }) {
  const cid = courseId || window.dynamoCourseId;
  // The 'ai' mode is gone (Omar, 2026-07-26), so it must never be the INITIAL
  // mode either — a stored defaultMode:'ai' or a leftover defaultPrompt would
  // otherwise select a mode with no toolbar and no preview branch, leaving an
  // empty control strip. Anything AI-ish now starts on 'upload'.
  const initialMode = colorOnly ? 'color'
    : imageOnly
      ? 'upload'
      : (defaultMode === 'ai' ? 'upload'
        : defaultMode
        || (defaultFilename ? 'upload' : 'color'));
  const [mode, _setMode] = React.useState(initialMode);
  const setMode = (v) => { _setMode(v); onModeChange?.(v); };
  const [prompt, setPrompt] = React.useState(defaultPrompt || '');
  const [color, _setColor] = React.useState(defaultColor);
  const setColor = (v) => { _setColor(v); onColorChange?.(v); };
  const [filename, _setFilename] = React.useState(defaultFilename || '');
  const setFilename = (v) => { _setFilename(v); onImageChange?.(v); };
  const [status, setStatus] = React.useState(defaultFilename ? 'ready' : 'idle');
  const fileInputRef = React.useRef(null);

  // The chosen image, as something the preview box can actually display.
  // `defaultFilename` is the STORED field value, so for an uploaded image it is
  // the opaque `asset://<id>` — resolved below to a signed/blob URL. Without
  // this the box only ever showed a generic icon, so authors had no way to tell
  // whether their upload had landed (fixed 2026-07-26).
  const [imageRef, setImageRef] = React.useState(
    typeof defaultFilename === 'string' && defaultFilename.startsWith('asset://')
      ? defaultFilename : null);
  const [imageSrc, setImageSrc] = React.useState(null);
  // Resync when the PARENT swaps the underlying record. The hotspot / 360 /
  // object inspectors reuse one picker instance across selection changes (no
  // `key`), so mount-time state alone showed the PREVIOUS pin's photograph under
  // the next pin's heading — a convincing false preview that made authors skip a
  // real upload (fixed 2026-07-27).
  const lastDefault = React.useRef(defaultFilename);
  React.useEffect(() => {
    if (lastDefault.current === defaultFilename) return;
    lastDefault.current = defaultFilename;
    const isRef = typeof defaultFilename === 'string' && defaultFilename.startsWith('asset://');
    setImageRef(isRef ? defaultFilename : null);
    if (!isRef) setImageSrc(null);
    _setFilename(defaultFilename || '');
    setStatus(defaultFilename ? 'ready' : 'idle');
    // A capture failure describes the OLD record; carrying it onto the next one
    // would accuse a video that was never tried.
    setCaptureError('');
  }, [defaultFilename]);
  React.useEffect(() => {
    let cancelled = false;
    if (!imageRef || !window.resolveAssetUrl) { setImageSrc(null); return undefined; }
    window.resolveAssetUrl(imageRef).then((url) => {
      if (!cancelled) setImageSrc(url || null);
    }).catch(() => { /* leave the box empty rather than lie */ });
    return () => { cancelled = true; };
  }, [imageRef]);

  const triggerFile = () => fileInputRef.current?.click();
  // Real presigned upload. Display name updates immediately; onImageChange
  // fires with the returned asset://<id> ref (not the raw filename).
  const uploadImage = async (f) => {
    if (!f) return;
    _setFilename(f.name); setStatus('uploading'); setMode('upload');
    try {
      const ref = await window.uploadAsset(cid, f, 'image');
      onImageChange?.(ref);
      setImageRef(ref); // show the real thing, not just a tick
      setStatus('ready');
    } catch (err) {
      console.error('background upload failed', err);
      setStatus('error');
    }
  };
  const handleFile = (e) => { uploadImage(e.target.files?.[0]); };
  const handleDrop = (e) => {
    e.preventDefault(); e.stopPropagation();
    uploadImage(e.dataTransfer.files?.[0]);
  };

  // ── Capture frame ─────────────────────────────────────────────────────────
  // Reuses `uploadImage` rather than re-implementing the upload: a captured
  // frame IS a chosen file from this component's point of view, so both routes
  // share one already-proven path to the draft.
  //
  // A capture can fail for a reason an upload cannot (the browser refusing to
  // read the video's pixels), and that reason has its own remedy — "use Choose
  // file". So it gets its own message instead of being folded into
  // status='error', whose text is the unhelpful "Upload failed — try again".
  const [capturing, setCapturing] = React.useState(false);
  const [captureError, setCaptureError] = React.useState('');
  const runCapture = async () => {
    setCaptureError('');
    setCapturing(true);
    try {
      const file = await onCaptureFrame();
      // A caller may legitimately decline (nothing loaded yet) — not an error.
      if (file) await uploadImage(file);
    } catch (err) {
      console.error('frame capture failed', err);
      setCaptureError((err && err.message) || 'Could not capture a frame from this video.');
    } finally {
      setCapturing(false);
    }
  };

  const previewStyle = (() => {
    if (mode === 'color') return { background: color };
    return { background: 'linear-gradient(135deg, #475569 0%, #1e293b 100%)' };
  })();

  const modes = [
    { id: 'upload', label: 'Upload', icon: 'Upload' },
    ...(imageOnly ? [] : [{ id: 'color',  label: 'Color',  icon: 'Droplet' }]),
  ];

  return (
    <div className="card" style={{ overflow: 'hidden' }}>
      {label && (
        <div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)',
          fontSize: 12, fontWeight: 500, color: 'var(--text-muted)',
          display: 'flex', alignItems: 'center', gap: 8 }}>
          <I.Image size={13} />{label}
        </div>
      )}      {/* Compact preview */}
      <div
        onDragOver={mode === 'upload' ? (e) => { e.preventDefault(); } : undefined}
        onDrop={mode === 'upload' ? handleDrop : undefined}
        style={{
          height: previewHeight, position: 'relative', overflow: 'hidden',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          ...previewStyle,
        }}>
        {/* The real uploaded image fills the box when we can load it. */}
        {mode !== 'color' && imageSrc && (
          <img src={imageSrc} alt={filename || 'Selected image'}
            style={{ position: 'absolute', inset: 0, width: '100%', height: '100%',
              objectFit: 'cover' }} />
        )}
        {mode !== 'color' && status === 'ready' && !imageSrc && (
          <I.Image size={26} style={{ color: 'rgba(255,255,255,.5)' }} />
        )}
        {mode !== 'color' && status === 'uploading' && (
          <span style={{ color: 'rgba(255,255,255,.75)', fontSize: 12,
            display: 'flex', alignItems: 'center', gap: 6 }}>
            <I.Upload size={13} />Uploading…
          </span>
        )}
        {mode !== 'color' && status === 'error' && !captureError && (
          <span style={{ color: 'rgba(255,255,255,.85)', fontSize: 12,
            display: 'flex', alignItems: 'center', gap: 6 }}>
            <I.AlertTriangle size={13} />Upload failed — try again
          </span>
        )}
        {/* Wraps, unlike the one-liners above: this message has to name the
            remedy ("use Choose file"), and a truncated instruction is no
            instruction. Sits over the preview so it can't be missed. */}
        {mode !== 'color' && captureError && (
          <span style={{ color: 'rgba(255,255,255,.9)', fontSize: 11.5,
            display: 'flex', alignItems: 'flex-start', gap: 6,
            maxWidth: '88%', lineHeight: 1.35, textAlign: 'left',
            background: 'rgba(15,23,42,.72)', padding: '7px 9px',
            borderRadius: 'var(--radius-sm)', backdropFilter: 'blur(4px)' }}>
            <I.AlertTriangle size={13} style={{ flexShrink: 0, marginTop: 1 }} />
            {captureError}
          </span>
        )}
        {mode === 'upload' && status === 'idle' && !imageSrc && (
          <span style={{ color: 'rgba(255,255,255,.7)', fontSize: 12,
            display: 'flex', alignItems: 'center', gap: 6 }}>
            <I.Upload size={13} />Drop an image here, or use the bar below
          </span>
        )}
        {/* Status pill */}
        <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',
        }}>
          {mode === 'upload' && ((filename || imageSrc) ? 'Uploaded' : 'No image')}
          {mode === 'color' && 'Solid'}
        </span>
        {/* Filename strip */}
        {mode !== 'color' && filename && status === 'ready' && (
          <span style={{
            position: 'absolute', bottom: 8, left: 10, right: 10,
            fontSize: 11, color: 'rgba(255,255,255,.85)',
            fontFamily: 'var(--font-mono)',
            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          }}>{filename}</span>
        )}
        {mode === 'color' && (
          <span style={{
            position: 'absolute', bottom: 8, left: 10,
            fontSize: 11, color: 'rgba(255,255,255,.85)',
            fontFamily: 'var(--font-mono)',
          }}>{color}</span>
        )}
      </div>

      {/* Inline toolbar */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: 8, borderTop: '1px solid var(--border)',
        background: 'var(--surface-2)',
      }}>
        {/* Mode segmented (icons only) */}
        {!colorOnly && (
        <div style={{ display: 'inline-flex', gap: 2, padding: 2,
          background: 'var(--surface-inset)', border: '1px solid var(--border)',
          borderRadius: 'var(--radius-sm)' }}>
          {modes.map(m => {
            const Ic = I[m.icon] || I.Hash;
            const sel = mode === m.id;
            return (
              <button key={m.id} onClick={() => setMode(m.id)}
                title={m.label}
                style={{
                  width: 26, height: 22, padding: 0, border: 0,
                  background: sel ? 'var(--surface)' : 'transparent',
                  color: sel ? 'var(--accent-text)' : 'var(--text-muted)',
                  borderRadius: 3, cursor: 'default', fontFamily: 'inherit',
                  display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                  boxShadow: sel ? 'var(--shadow-sm)' : 'none',
                }}>
                <Ic size={12} />
              </button>
            );
          })}
        </div>
        )}

        {/* Active mode's control + action */}
        {mode === 'upload' && (
          <>
            <input ref={fileInputRef} type="file" accept="image/*" hidden onChange={handleFile} />
            {/* The action sits FIRST — immediately right of the mode icons —
                so picking the upload mode puts the button where the eye already
                is, instead of a dead "No file selected" label with the control
                stranded at the far right. */}
            <button className="btn sm" onClick={triggerFile}
              disabled={status === 'uploading' || capturing}>
              <I.Upload size={11} />{filename ? 'Replace' : 'Choose file'}
            </button>
            {/* Second door onto the same field — only rendered where a video
                exists to capture FROM. Disabled rather than hidden while no
                video is loaded, so the author can see the option exists and
                the tooltip can say what unlocks it. */}
            {onCaptureFrame && (
              <button className="btn sm" onClick={runCapture}
                disabled={!captureReady || capturing || status === 'uploading'}
                title={captureReady
                  ? 'Use the frame the video is paused on as the poster. Scrub the video first to choose the moment.'
                  : 'Upload a video first — the poster is captured from the frame on screen.'}>
                <I.Camera size={11} />{capturing ? 'Capturing…' : 'Capture frame'}
              </button>
            )}
            <div style={{ flex: 1, fontSize: 12, color: filename ? 'var(--text)' : 'var(--text-faint)',
              fontFamily: filename ? 'var(--font-mono)' : 'inherit',
              overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
              {filename}
            </div>
            {filename && (
              <button className="btn sm ghost danger"
                onClick={() => { setFilename(''); setImageRef(null); setStatus('idle'); }}
                title="Remove"><I.Trash size={11} /></button>
            )}
          </>
        )}

        {mode === 'color' && (
          <>
            <div style={{ flex: 1 }} />
            <div style={{ position: 'relative', width: 32, height: 28 }}>
              <button type="button"
                style={{
                  width: 32, height: 28, padding: 0,
                  borderRadius: 4, border: '1px solid var(--border-strong)',
                  background: color, cursor: 'default',
                }} />
              <input type="color" value={(color && color.startsWith('#') && (color.length === 7 || color.length === 4)) ? (color.length === 4 ? '#' + color.slice(1).split('').map(c => c+c).join('') : color) : '#000000'}
                onChange={e => setColor(e.target.value)}
                style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'default' }} />
            </div>
            <input className="field" value={color}
              onChange={e => setColor(e.target.value)}
              style={{ width: 110, height: 28,
                fontFamily: 'var(--font-mono)', fontSize: 12 }} />
          </>
        )}
      </div>
    </div>
  );
}

Object.assign(window, {
  MediaSlot, RichTextEditor, LocalizedStringEditor, BackgroundPicker,
  AnswersEditor, FourAnswerEditor, FreeFormQuestionEditor,
});
