// 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)',
        }}>
          {/* Fake thumbnail content */}
          {kind === 'video' && <>
            <KindIcon size={28} style={{ opacity: 0.7 }} />
            <div style={{ position: 'absolute', bottom: 8, left: 10, right: 10,
              display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'rgba(255,255,255,.85)' }}>
              <I.Play size={14} />
              <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)' }}>{bound.duration || '00:00'}</span>
            </div>
          </>}
          {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>`;
  const toDom = (stored) => {
    let out = stored || '';
    tokenList.forEach(t => { out = out.split('{' + t.key + '}').join(tokenChip(t.key, t.label)); });
    return out;
  };
  const toStored = (html) => {
    if (!tokenList.length) return html;
    const tmp = document.createElement('div');
    tmp.innerHTML = html;
    tmp.querySelectorAll('span[data-token]').forEach(s => {
      s.replaceWith(document.createTextNode('{' + s.dataset.token + '}'));
    });
    return tmp.innerHTML;
  };
  // 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

  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(); };

  // 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>
      <div
        ref={editorRef}
        contentEditable
        suppressContentEditableWarning
        role="textbox"
        aria-multiline="true"
        data-placeholder={placeholder}
        onInput={onInput}
        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') ───────────
function LinkModal({ selectedText, onCancel, onConfirm }) {
  const [url, setUrl] = React.useState('https://');
  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;
    }
    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)}
          placeholder="https://example.com"
          style={{ width: '100%', marginBottom: 14 }} />
        <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.
function AnswersEditor({ answers, onChange, allowFeedback = true,
  feedbacks, onFeedbacksChange }) {
  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;
  const perAnswer = !isMultiCorrect;
  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>
      {allowFeedback && isMultiCorrect && (
        <div style={{ marginTop: 12, display: 'grid', gap: 8 }}>
          <label style={{ display: 'grid', gap: 4 }}>
            <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>Correct feedback</span>
            <input className="field" value={locText(fb.correct, lang)}
              onChange={e => setFb({ correct: locSet(fb.correct, lang, e.target.value) })}
              placeholder="Shown when the learner picks the correct answer"
              style={{ height: 30, fontSize: 12.5 }} />
          </label>
          <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.
function BackgroundPicker({ defaultPrompt, defaultColor = '#0f172a',
  defaultFilename, defaultMode, colorOnly, imageOnly, label, previewHeight = 120,
  onColorChange, onModeChange, onImageChange, courseId }) {
  const cid = courseId || window.dynamoCourseId;
  const initialMode = colorOnly ? 'color'
    : imageOnly
      ? (defaultMode === 'ai' || defaultPrompt ? 'ai' : 'upload')
      : (defaultMode
        || (defaultFilename ? 'upload' : defaultPrompt ? 'ai' : '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 [aiModel, setAiModel] = React.useState('Gemini');
  const [status, setStatus] = React.useState(defaultFilename ? 'ready' : 'idle');
  const fileInputRef = React.useRef(null);

  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);
      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]);
  };

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

  const modes = [
    { id: 'upload', label: 'Upload', icon: 'Upload' },
    { id: 'ai',     label: 'AI',     icon: 'Sparkle' },
    ...(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,
        }}>
        {mode !== 'color' && status === 'ready' && (
          <I.Image size={26} style={{ color: 'rgba(255,255,255,.5)' }} />
        )}
        {mode !== 'color' && status === 'generating' && (
          <span style={{ color: 'rgba(255,255,255,.75)', fontSize: 12,
            display: 'flex', alignItems: 'center', gap: 6 }}>
            <I.Sparkle size={13} />Generating with {aiModel}…
          </span>
        )}
        {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' && (
          <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>
        )}
        {mode === 'upload' && status === 'idle' && (
          <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>
        )}
        {mode === 'ai' && status === 'idle' && (
          <span style={{ color: 'rgba(255,255,255,.7)', fontSize: 12,
            display: 'flex', alignItems: 'center', gap: 6 }}>
            <I.Sparkle size={13} />Type a prompt below, then Generate
          </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 ? 'Uploaded' : 'No image')}
          {mode === 'ai' && (status === 'ready' ? 'AI-generated' : 'AI prompt')}
          {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} />
            <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 || 'No file selected'}
            </div>
            {filename && (
              <button className="btn sm ghost danger"
                onClick={() => { setFilename(''); setStatus('idle'); }}
                title="Remove"><I.Trash size={11} /></button>
            )}
            <button className="btn sm" onClick={triggerFile}
              disabled={status === 'uploading'}>
              <I.Upload size={11} />{filename ? 'Replace' : 'Choose file'}
            </button>
          </>
        )}

        {mode === 'ai' && (
          <>
            <input className="field" value={prompt}
              onChange={e => setPrompt(e.target.value)}
              placeholder="Describe the background you want…"
              style={{ flex: 1, height: 28, fontSize: 12 }} />
            <select value={aiModel} onChange={e => setAiModel(e.target.value)}
              className="field" style={{ height: 28, fontSize: 11.5, width: 100 }}>
              <option>Gemini</option>
              <option>DALL·E</option>
              <option>Midjourney</option>
            </select>
            {/* AI generation is not wired yet — trigger is inert, prompt stays. */}
            <button className="btn sm primary" disabled title="AI image generation is coming soon">
              <I.Sparkle size={11} />Coming soon
            </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,
});
