// exercises.jsx — exercise body renderers.
// Each receives { theme, mascotOn, ex, picked, setPicked, feedback, setExternal }.
// `setExternal({ pickedValue, answerValue })` lets exercises with free input feed the grader.

// Pretty pinyin "audio" button — triggers TTS for the underlying CN/pinyin string.
function PlayPinyin({ theme, text, size = 88, label = 'TAP TO HEAR' }) {
  const [playing, setPlaying] = React.useState(false);
  const onTap = () => {
    primeSpeech();
    speakZh(text, { onend: () => setPlaying(false), onerror: () => setPlaying(false) });
    setPlaying(true);
  };
  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
      <button onClick={onTap} style={{
        all: 'unset', cursor: 'pointer',
        width: size, height: size, borderRadius: '50%',
        background: theme.primary, border: `2.5px solid ${theme.fg}`,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        boxShadow: `0 4px 0 0 ${theme.yellow}`,
        animation: playing ? 'speakPulse 0.6s ease-in-out 1' : 'none',
      }}>
        <Speaker theme={theme} size={size * 0.5} color={theme.mode === 'dark' ? '#fff' : theme.fg}/>
      </button>
      <div style={{
        fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute,
        textTransform: 'uppercase', letterSpacing: '0.12em',
      }}>{label}</div>
    </div>
  );
}

// ─── Exercise: intro ────────────────────────────────────────
function ExIntro({ theme, mascotOn, ex }) {
  return (
    <div style={{ padding: '16px 4px', display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center', textAlign: 'center' }}>
      {mascotOn && <Mascot theme={theme} mood="excited" size={90} />}
      <H theme={theme} size={24} align="center" style={{ lineHeight: 1.1 }}>{ex.title}</H>
      <div style={{ fontFamily: theme.fontBody, fontSize: 14, color: theme.fgMute, lineHeight: 1.45, maxWidth: 300 }}>
        {ex.body}
      </div>
      {ex.tones && (
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginTop: 6, width: '100%' }}>
          {ex.tones.map((t, i) => (
            <Card key={i} theme={theme} padded={false} style={{ padding: 12 }} accent={theme.primary}>
              <button onClick={() => { primeSpeech(); speakZh(t); }} style={{
                all: 'unset', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10, width: '100%',
              }}>
                <ToneCurve tone={i} theme={theme} size={32} color={theme.primary}/>
                <div style={{ textAlign: 'left' }}>
                  <Pinyin theme={theme} size={20}>{t}</Pinyin>
                  <div style={{ fontFamily: theme.fontBody, fontSize: 10, color: theme.fgMute, marginTop: 2 }}>
                    {ex.names && ex.names[i]}
                  </div>
                </div>
                <div style={{ marginLeft: 'auto' }}>
                  <Speaker theme={theme} size={18} color={theme.fgMute}/>
                </div>
              </button>
            </Card>
          ))}
        </div>
      )}
      {ex.cards && (
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginTop: 6, width: '100%' }}>
          {ex.cards.map((card, i) => (
            <Card key={i} theme={theme} padded={false} accent={[theme.primary, theme.cyan, theme.yellow, theme.green][i % 4]} style={{ padding: 12 }}>
              <button onClick={() => { primeSpeech(); speakZh(card.cn || card.py); }} style={{
                all: 'unset', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 4, width: '100%',
              }}>
                {card.cn && <CN theme={theme} size={24}>{card.cn}</CN>}
                <Pinyin theme={theme} size={14} color={theme.primary}>{card.py}</Pinyin>
                <div style={{ fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute, marginTop: 2 }}>{card.en}</div>
              </button>
            </Card>
          ))}
        </div>
      )}
    </div>
  );
}

// ─── Exercise: tone ─────────────────────────────────────────
function ExTone({ theme, ex, picked, setPicked, feedback }) {
  return (
    <div style={{ padding: '12px 0', display: 'flex', flexDirection: 'column', gap: 18 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      {ex.audio && (
        <div style={{ display: 'flex', justifyContent: 'center' }}>
          <PlayPinyin theme={theme} text={ex.audio} size={72} label="HEAR IT"/>
        </div>
      )}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
        {ex.options.map((opt, i) => {
          const isPicked = picked === i;
          const right = feedback === 'right' && isPicked;
          const wrong = feedback === 'wrong' && isPicked;
          return (
            <button key={i} onClick={feedback ? null : () => { setPicked(i); primeSpeech(); speakZh(opt); }}
              style={{
                all: 'unset', cursor: feedback ? 'default' : 'pointer',
                background: right ? theme.green : wrong ? theme.red : isPicked ? theme.primary : theme.surface,
                border: `2.5px solid ${theme.fg}`, borderRadius: theme.radius,
                padding: '20px 16px',
                boxShadow: isPicked ? '' : `0 4px 0 0 ${theme.fg}`,
                transform: isPicked ? 'translateY(2px)' : '',
                display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
                transition: 'all .1s',
              }}>
              <ToneCurve tone={i} theme={theme} size={48}
                color={isPicked && (right || wrong) ? '#fff' : theme.fg} />
              <Pinyin theme={theme} size={42}
                color={isPicked && (right || wrong) ? '#fff' : isPicked ? (theme.mode === 'dark' ? '#fff' : theme.fg) : theme.fg}>
                {opt}
              </Pinyin>
              <div style={{
                fontFamily: theme.fontBody, fontSize: 10,
                color: isPicked && (right || wrong) ? '#fff' : theme.fgMute,
                textTransform: 'uppercase', letterSpacing: '0.1em',
              }}>TONE {i+1}</div>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Exercise: pick-listen ──────────────────────────────────
function ExPickListen({ theme, ex, picked, setPicked, feedback }) {
  // Auto-play once on mount, then user can tap to replay.
  React.useEffect(() => {
    const t = setTimeout(() => { primeSpeech(); speakZh(ex.heard); }, 250);
    return () => clearTimeout(t);
    // eslint-disable-next-line
  }, [ex.heard]);

  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 16 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      <Card theme={theme} padded={false} style={{ padding: 20, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, background: theme.surface }} accent={theme.cyan}>
        <PlayPinyin theme={theme} text={ex.heard} size={88} label="TAP TO REPLAY"/>
      </Card>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
        {ex.options.map((opt, i) => {
          const isPicked = picked === i;
          const right = feedback === 'right' && isPicked;
          const wrong = feedback === 'wrong' && isPicked;
          return (
            <button key={i} onClick={feedback ? null : () => { setPicked(i); primeSpeech(); speakZh(opt); }}
              style={{
                all: 'unset', cursor: feedback ? 'default' : 'pointer',
                background: right ? theme.green : wrong ? theme.red : isPicked ? theme.cyan : theme.surface,
                border: `2px solid ${theme.fg}`, borderRadius: theme.radius,
                padding: '16px 8px', textAlign: 'center',
                transform: isPicked ? 'translateY(2px)' : '',
                boxShadow: isPicked ? '' : `0 3px 0 0 ${theme.fg}`,
                transition: 'all .1s',
              }}>
              <Pinyin theme={theme} size={28} color={isPicked && (right || wrong) ? '#fff' : theme.fg}>{opt}</Pinyin>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Exercise: pick-initial / pick-final ────────────────────
function ExPickPart({ theme, ex, picked, setPicked, feedback }) {
  const highlight = ex.initial || ex.final;
  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 16 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      <div style={{ textAlign: 'center', padding: '12px 0' }}>
        <span style={{
          fontFamily: theme.fontDisplay, fontSize: 80, fontWeight: 900,
          color: theme.primary,
          textShadow: `3px 3px 0 ${theme.yellow}`,
        }}>{highlight}</span>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
        {ex.options.map((opt, i) => {
          const isPicked = picked === i;
          const right = feedback === 'right' && isPicked;
          const wrong = feedback === 'wrong' && isPicked;
          return (
            <button key={i} onClick={feedback ? null : () => { setPicked(i); primeSpeech(); speakZh(opt); }}
              style={{
                all: 'unset', cursor: feedback ? 'default' : 'pointer',
                background: right ? theme.green : wrong ? theme.red : isPicked ? theme.yellow : theme.surface,
                border: `2px solid ${theme.fg}`, borderRadius: theme.radius,
                padding: '14px 8px', textAlign: 'center',
                transform: isPicked ? 'translateY(2px)' : '',
                boxShadow: isPicked ? '' : `0 3px 0 0 ${theme.fg}`,
                transition: 'all .1s',
              }}>
              <Pinyin theme={theme} size={26} color={isPicked && (right || wrong) ? '#fff' : isPicked ? (theme.mode === 'dark' ? '#0d0d10' : theme.fg) : theme.fg}>
                {opt}
              </Pinyin>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Exercise: match-tone (display only) ────────────────────
function ExMatchTone({ theme, ex }) {
  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 14 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        {ex.pairs.map((p, i) => (
          <Card key={i} theme={theme} padded={false} style={{ padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 14 }}>
            <button onClick={() => { primeSpeech(); speakZh(p.word); }} style={{
              all: 'unset', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12, flex: 1,
            }}>
              <ToneCurve tone={i} theme={theme} size={28} color={theme.primary}/>
              <Pinyin theme={theme} size={28}>{p.word}</Pinyin>
            </button>
            <div style={{ flex: 1, height: 2, borderTop: `2px dashed ${theme.fgDim}` }}/>
            <div style={{
              fontFamily: theme.fontBody, fontSize: 13, fontWeight: 700,
              color: theme.fg, background: theme.surfaceAlt,
              border: `2px solid ${theme.fg}`,
              borderRadius: theme.radius * 0.7,
              padding: '4px 10px',
            }}>{p.meaning}</div>
          </Card>
        ))}
      </div>
    </div>
  );
}

// ─── Exercise: match-meaning (CN/pinyin → English) ──────────
function ExMatchMeaning({ theme, ex, picked, setPicked, feedback }) {
  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 16 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      <Card theme={theme} padded={false} style={{ padding: 18, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }} accent={theme.primary}>
        <button onClick={() => { primeSpeech(); speakZh(ex.cn || ex.py); }} style={{
          all: 'unset', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
        }}>
          {ex.cn && <CN theme={theme} size={52}>{ex.cn}</CN>}
          <Pinyin theme={theme} size={18} color={theme.primary}>{ex.py}</Pinyin>
          <Speaker theme={theme} size={20} color={theme.fgMute}/>
        </button>
      </Card>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
        {ex.options.map((opt, i) => {
          const isPicked = picked === i;
          const right = feedback === 'right' && isPicked;
          const wrong = feedback === 'wrong' && isPicked;
          return (
            <button key={i} onClick={feedback ? null : () => setPicked(i)}
              style={{
                all: 'unset', cursor: feedback ? 'default' : 'pointer',
                background: right ? theme.green : wrong ? theme.red : isPicked ? theme.cyan : theme.surface,
                border: `2px solid ${theme.fg}`, borderRadius: theme.radius,
                padding: '14px 10px', textAlign: 'center',
                fontFamily: theme.fontHead, fontSize: 14, fontWeight: 700,
                color: isPicked && (right || wrong) ? '#fff' : isPicked ? (theme.mode === 'dark' ? '#0d0d10' : theme.fg) : theme.fg,
                transform: isPicked ? 'translateY(2px)' : '',
                boxShadow: isPicked ? '' : `0 3px 0 0 ${theme.fg}`,
                transition: 'all .1s',
              }}>{opt}</button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Exercise: pick-cn (English → CN) ───────────────────────
// If `ex.context` is provided (an array of {cn, py, en, speaker}), renders it
// as a multi-line dialogue card above the prompt — this powers `dialogue-complete`.
function ExPickCN({ theme, ex, picked, setPicked, feedback }) {
  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 16 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      {ex.context && (
        <Card theme={theme} padded={false} style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }} accent={theme.cyan}>
          {ex.context.map((line, i) => (
            <div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
              {line.speaker && (
                <div style={{
                  fontFamily: theme.fontDisplay, fontSize: 11,
                  background: theme.primary, color: theme.mode === 'dark' ? '#fff' : theme.fg,
                  border: `1.5px solid ${theme.fg}`, padding: '2px 6px', borderRadius: 999,
                  flexShrink: 0, marginTop: 4,
                }}>{line.speaker}</div>
              )}
              <button onClick={() => { primeSpeech(); speakZh(line.cn || line.py); }} style={{
                all: 'unset', cursor: 'pointer', flex: 1, display: 'flex', flexDirection: 'column', gap: 2,
              }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                  <CN theme={theme} size={20}>{line.cn}</CN>
                  <Speaker theme={theme} size={14} color={theme.fgMute}/>
                </div>
                <Pinyin theme={theme} size={12} color={theme.primary}>{line.py}</Pinyin>
                {line.en && <div style={{ fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute, marginTop: 2 }}>{line.en}</div>}
              </button>
            </div>
          ))}
        </Card>
      )}
      <Card theme={theme} padded={false} style={{ padding: 16, textAlign: 'center' }} accent={theme.yellow}>
        <div style={{ fontFamily: theme.fontHead, fontSize: 22, fontWeight: 700 }}>
          “{ex.en}”
        </div>
      </Card>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 8 }}>
        {ex.options.map((opt, i) => {
          const isPicked = picked === i;
          const right = feedback === 'right' && isPicked;
          const wrong = feedback === 'wrong' && isPicked;
          return (
            <button key={i} onClick={feedback ? null : () => { setPicked(i); primeSpeech(); speakZh(opt.cn || opt.py); }}
              style={{
                all: 'unset', cursor: feedback ? 'default' : 'pointer',
                background: right ? theme.green : wrong ? theme.red : isPicked ? theme.cyan : theme.surface,
                border: `2px solid ${theme.fg}`, borderRadius: theme.radius,
                padding: '12px 14px',
                display: 'flex', alignItems: 'center', gap: 12,
                transform: isPicked ? 'translateY(2px)' : '',
                boxShadow: isPicked ? '' : `0 3px 0 0 ${theme.fg}`,
                transition: 'all .1s',
              }}>
              <CN theme={theme} size={26} color={isPicked && (right || wrong) ? '#fff' : isPicked ? (theme.mode === 'dark' ? '#0d0d10' : theme.fg) : theme.fg}>
                {opt.cn}
              </CN>
              <Pinyin theme={theme} size={13}
                color={isPicked && (right || wrong) ? '#fff' : isPicked ? (theme.mode === 'dark' ? '#0d0d10' : theme.fgMute) : theme.fgMute}>
                {opt.py}
              </Pinyin>
              <div style={{ marginLeft: 'auto', opacity: 0.6 }}>
                <Speaker theme={theme} size={18} color={isPicked && (right || wrong) ? '#fff' : theme.fgMute}/>
              </div>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Exercise: type-pinyin ──────────────────────────────────
function ExTypePinyin({ theme, ex, picked, setPicked, feedback }) {
  // We use the 'picked' slot to hold the typed string. Grader normalizes both.
  React.useEffect(() => {
    if (picked == null) setPicked('');
    // eslint-disable-next-line
  }, []);

  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 16 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      <Card theme={theme} padded={false} style={{ padding: 18, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }} accent={theme.primary}>
        <button onClick={() => { primeSpeech(); speakZh(ex.cn); }} style={{
          all: 'unset', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
        }}>
          <CN theme={theme} size={56}>{ex.cn}</CN>
          <Speaker theme={theme} size={22} color={theme.fgMute}/>
        </button>
      </Card>
      <input
        type="text"
        inputMode="text"
        autoComplete="off"
        autoCorrect="off"
        autoCapitalize="off"
        spellCheck={false}
        value={picked || ''}
        onChange={(e) => setPicked(e.target.value)}
        placeholder="type pinyin… (e.g. nihao)"
        style={{
          fontFamily: theme.fontPinyin, fontSize: 22, fontWeight: 700,
          padding: '14px 16px',
          background: theme.surface, color: theme.fg,
          border: `2.5px solid ${feedback === 'right' ? theme.green : feedback === 'wrong' ? theme.red : theme.fg}`,
          borderRadius: theme.radius, outline: 'none',
          boxShadow: `0 3px 0 0 ${theme.fg}`,
          letterSpacing: 0,
        }}
      />
      {feedback === 'wrong' && (
        <div style={{ fontFamily: theme.fontBody, fontSize: 12, color: theme.fgMute }}>
          Correct: <Pinyin theme={theme} size={16}>{ex.alts?.[0] || ex.answer}</Pinyin>
        </div>
      )}
    </div>
  );
}

// ─── Exercise: speak ────────────────────────────────────────
function ExSpeak({ theme, ex }) {
  const [recording, setRecording] = React.useState(false);
  const tone = ex.tone ?? inferTone(ex.py || ex.word);
  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center', textAlign: 'center' }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      <button onClick={() => { primeSpeech(); speakZh(ex.word); }} style={{
        all: 'unset', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
      }}>
        {/[一-鿿]/.test(ex.word) ? <CN theme={theme} size={80}>{ex.word}</CN>
                                         : <Pinyin theme={theme} size={84} color={theme.primary} style={{ textShadow: `4px 4px 0 ${theme.yellow}` }}>{ex.word}</Pinyin>}
        {ex.py && <Pinyin theme={theme} size={20} color={theme.primary}>{ex.py}</Pinyin>}
        <Speaker theme={theme} size={22} color={theme.fgMute}/>
      </button>
      <ToneCurve tone={tone} theme={theme} size={64} color={theme.cyan}/>
      <button onPointerDown={() => setRecording(true)}
              onPointerUp={() => setRecording(false)}
              onPointerLeave={() => setRecording(false)}
              style={{
        all: 'unset', cursor: 'pointer',
        width: 88, height: 88, borderRadius: '50%',
        background: recording ? theme.red : theme.primary,
        border: `3px solid ${theme.fg}`,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        boxShadow: `0 4px 0 0 ${theme.yellow}`,
        animation: recording ? 'speakPulse 1s infinite' : 'none',
      }}>
        <Mic theme={theme} size={42} color="#fff"/>
      </button>
      <div style={{ fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute, textTransform: 'uppercase', letterSpacing: '0.12em' }}>
        {recording ? 'LISTENING…' : 'HOLD TO SPEAK · BETA'}
      </div>
    </div>
  );
}

// ─── Exercise: arrange (word-order drill) ──────────────────
// Show scrambled word chips. Learner taps them in order to build the sentence.
// `ex.words` = ['我', '叫', '明文'] (correct order)
// `ex.scrambled` (optional) — fixed scramble order for stable rendering. If
//   omitted, the lesson data should list them in the order to display.
// `ex.translation` = English meaning shown above the chips.
function ExArrange({ theme, ex, picked, setPicked, feedback }) {
  // picked is the array of indexes (into ex.scrambled || ex.words) the learner has tapped.
  const display = ex.scrambled || ex.words;
  const chosen = Array.isArray(picked) ? picked : [];

  // Build the current sentence from chosen indexes.
  const currentWords = chosen.map((i) => display[i]);
  // Available = those not yet picked.
  const available = display
    .map((w, i) => ({ word: w, idx: i }))
    .filter(({ idx }) => !chosen.includes(idx));

  const tap = (idx) => {
    if (feedback) return;
    setPicked([...chosen, idx]);
  };
  const undo = (i) => {
    if (feedback) return;
    const next = [...chosen];
    next.splice(i, 1);
    setPicked(next);
  };
  const clear = () => { if (!feedback) setPicked([]); };

  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 16 }}>
      <H theme={theme} size={22}>{ex.prompt || 'Put the words in order'}</H>
      {ex.translation && (
        <Card theme={theme} padded={false} style={{ padding: 12, textAlign: 'center' }} accent={theme.yellow}>
          <div style={{ fontFamily: theme.fontHead, fontSize: 16, fontWeight: 700, fontStyle: 'italic' }}>
            “{ex.translation}”
          </div>
        </Card>
      )}

      {/* Drop zone — what the learner has built so far */}
      <div style={{
        minHeight: 64,
        background: theme.surface,
        border: `2.5px dashed ${feedback === 'right' ? theme.green : feedback === 'wrong' ? theme.red : theme.fg}`,
        borderRadius: theme.radius,
        padding: 10,
        display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center',
        boxShadow: `0 3px 0 0 ${theme.fg}`,
      }}>
        {chosen.length === 0 && (
          <div style={{ fontFamily: theme.fontBody, fontSize: 12, color: theme.fgDim, fontStyle: 'italic' }}>
            tap chips below to build the sentence…
          </div>
        )}
        {chosen.map((idx, i) => (
          <button key={`${idx}-${i}`} onClick={() => undo(i)}
            style={{
              all: 'unset', cursor: feedback ? 'default' : 'pointer',
              background: theme.cyan,
              color: theme.mode === 'dark' ? '#0d0d10' : theme.fg,
              border: `2px solid ${theme.fg}`, borderRadius: theme.radius * 0.6,
              padding: '8px 12px',
              fontFamily: theme.fontCN, fontSize: 20, fontWeight: 700,
            }}>{display[idx]}</button>
        ))}
      </div>

      {/* Source chips */}
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
        {available.map(({ word, idx }) => (
          <button key={idx} onClick={() => tap(idx)}
            style={{
              all: 'unset', cursor: feedback ? 'default' : 'pointer',
              background: theme.surface, color: theme.fg,
              border: `2px solid ${theme.fg}`, borderRadius: theme.radius * 0.6,
              padding: '10px 14px',
              fontFamily: theme.fontCN, fontSize: 20, fontWeight: 700,
              boxShadow: `0 3px 0 0 ${theme.primary}`,
            }}>{word}</button>
        ))}
        {available.length === 0 && chosen.length > 0 && !feedback && (
          <div style={{ fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute, fontStyle: 'italic', padding: '6px 4px' }}>
            (tap a chip above to put it back)
          </div>
        )}
      </div>

      {chosen.length > 0 && !feedback && (
        <button onClick={clear} style={{
          all: 'unset', cursor: 'pointer',
          alignSelf: 'flex-start',
          fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute,
          textTransform: 'uppercase', letterSpacing: '0.1em',
          borderBottom: `1px dashed ${theme.fgMute}`,
        }}>↺ clear</button>
      )}

      {feedback === 'wrong' && (
        <div style={{ fontFamily: theme.fontBody, fontSize: 12, color: theme.fgMute }}>
          Correct: <CN theme={theme} size={18}>{ex.words.join('')}</CN>
        </div>
      )}
    </div>
  );
}

// ─── Exercise: true-false ───────────────────────────────────
function ExTrueFalse({ theme, ex, picked, setPicked, feedback }) {
  const ans = ex.same ? 0 : 1;
  // Inject hidden answer on first render.
  React.useEffect(() => { if (ex.answer == null) ex.answer = ans; }, [ans, ex]);

  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 18 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      <Card theme={theme} padded={false} style={{ padding: '20px 16px', display: 'flex', justifyContent: 'space-around', alignItems: 'center' }} accent={theme.cyan}>
        {[ex.a, ex.b].map((w, i) => (
          <div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
            <button onClick={() => { primeSpeech(); speakZh(w); }} style={{
              all: 'unset', cursor: 'pointer',
              width: 48, height: 48, borderRadius: '50%',
              background: i === 0 ? theme.primary : theme.cyan, border: `2.5px solid ${theme.fg}`,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
            }}>
              <Speaker theme={theme} size={24} color={theme.mode === 'dark' ? '#fff' : theme.fg}/>
            </button>
            <Pinyin theme={theme} size={30}>{w}</Pinyin>
          </div>
        ))}
        <div style={{ position: 'absolute', left: '50%', transform: 'translateX(-50%)', fontFamily: theme.fontDisplay, fontSize: 18, color: theme.fgMute, pointerEvents: 'none' }}>VS</div>
      </Card>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
        {[{ label: 'SAME', val: 0 }, { label: 'DIFFERENT', val: 1 }].map((b) => {
          const isPicked = picked === b.val;
          const right = feedback === 'right' && isPicked;
          const wrong = feedback === 'wrong' && isPicked;
          return (
            <button key={b.val} onClick={feedback ? null : () => setPicked(b.val)}
              style={{
                all: 'unset', cursor: feedback ? 'default' : 'pointer',
                background: right ? theme.green : wrong ? theme.red : isPicked ? theme.primary : theme.surface,
                border: `2px solid ${theme.fg}`, borderRadius: theme.radius,
                padding: '16px 8px', textAlign: 'center',
                fontFamily: theme.fontDisplay, fontSize: 18,
                color: isPicked && (right || wrong) ? '#fff' : isPicked ? (theme.mode === 'dark' ? '#fff' : theme.fg) : theme.fg,
                transform: isPicked ? 'translateY(2px)' : '',
                boxShadow: isPicked ? '' : `0 3px 0 0 ${theme.fg}`,
                transition: 'all .1s',
              }}>{b.label}</button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Exercise: ai-translate ─────────────────────────────────
// AI grades the translation. Stores result in `picked`:
//   null  = not graded yet
//   true  = correct (sets feedback='right')
//   false = wrong   (sets feedback='wrong')
// The grader is invoked by clicking the CHECK button (see player wiring).
function ExAITranslate({ theme, ex, picked, setPicked, feedback, externalState, setExternal }) {
  const [text, setText] = React.useState(externalState?.text || '');
  const grading = externalState?.grading;
  const result = externalState?.result;

  React.useEffect(() => {
    setExternal?.({ ...externalState, text });
    if (text.trim()) setPicked(text);
    else setPicked(null);
    // eslint-disable-next-line
  }, [text]);

  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 14 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      <Card theme={theme} padded={false} style={{ padding: 16, textAlign: 'center' }} accent={theme.yellow}>
        <div style={{ fontFamily: theme.fontHead, fontSize: 22, fontWeight: 700 }}>
          “{ex.en}”
        </div>
        <div style={{ fontFamily: theme.fontBody, fontSize: 10, color: theme.fgMute, marginTop: 6, textTransform: 'uppercase', letterSpacing: '0.14em' }}>
          AI-GRADED · OPENROUTER
        </div>
      </Card>
      <textarea
        rows={3}
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Type or paste Chinese here… (pinyin OK if no IME)"
        disabled={grading || result}
        style={{
          fontFamily: theme.fontCN, fontSize: 22, fontWeight: 600,
          padding: 14, background: theme.surface, color: theme.fg,
          border: `2.5px solid ${feedback === 'right' ? theme.green : feedback === 'wrong' ? theme.red : theme.fg}`,
          borderRadius: theme.radius, outline: 'none', resize: 'none',
          boxShadow: `0 3px 0 0 ${theme.fg}`,
        }}
      />
      {grading && (
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, color: theme.fgMute, fontFamily: theme.fontBody, fontSize: 12 }}>
          <Spinner theme={theme}/> Grading…
        </div>
      )}
      {result && (
        <Card theme={theme} padded={false} style={{ padding: 12, background: theme.surfaceAlt }}>
          <div style={{ fontFamily: theme.fontBody, fontSize: 10, color: theme.fgMute, textTransform: 'uppercase', letterSpacing: '0.14em' }}>
            ★ MODEL ANSWER · SCORE {result.score}
          </div>
          <CN theme={theme} size={26} style={{ marginTop: 6 }}>{result.model_answer}</CN>
          <Pinyin theme={theme} size={13} color={theme.primary} style={{ display: 'block', marginTop: 4 }}>{result.pinyin}</Pinyin>
          <div style={{ fontFamily: theme.fontBody, fontSize: 12, marginTop: 8, lineHeight: 1.45 }}>{result.feedback}</div>
        </Card>
      )}
      {!result && !grading && orHasKey() && (
        <HintHelper theme={theme}
          sourcePrompt={`English to translate to Chinese: "${ex.en}"`}
          externalState={externalState} setExternal={setExternal}/>
      )}
      {!orHasKey() && (
        <div style={{
          padding: 10, fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute,
          background: theme.surfaceAlt, border: `2px dashed ${theme.fgDim}`, borderRadius: theme.radius,
        }}>
          No OpenRouter key set — this exercise will skip grading. Add a key in Settings to enable AI grading + hints.
        </div>
      )}
    </div>
  );
}

function Spinner({ theme, size = 16 }) {
  return (
    <span style={{
      width: size, height: size, display: 'inline-block',
      border: `2px solid ${theme.fgDim}`, borderTopColor: theme.primary,
      borderRadius: '50%', animation: 'spin 0.8s linear infinite',
    }}/>
  );
}

// Reusable hint helper. Tap to fetch a model Chinese answer + speak it.
// Cached per-exercise so re-taps just replay instead of re-fetching.
function HintHelper({ theme, sourcePrompt, externalState, setExternal }) {
  const cached = externalState?.hint;
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(null);

  const fetchAndSpeak = async () => {
    // Already have a hint — just replay.
    if (cached?.cn) {
      primeSpeech(); speakZh(cached.cn);
      return;
    }
    if (!orHasKey()) {
      setError('No OpenRouter key — add one in Settings to use hints.');
      return;
    }
    setLoading(true); setError(null);
    try {
      const { content } = await orChat({
        messages: [
          { role: 'system', content: SAMPLE_ANSWER_SYSTEM },
          { role: 'user', content: sourcePrompt },
        ],
        temperature: 0.2, max_tokens: 200,
      });
      let json = null;
      const m = content.match(/\{[\s\S]*\}/);
      if (m) { try { json = JSON.parse(m[0]); } catch {} }
      if (!json?.cn) throw new Error('Could not parse hint.');
      setExternal({ ...externalState, hint: json });
      // Auto-speak the result.
      primeSpeech(); speakZh(json.cn);
    } catch (e) {
      setError(e.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button onClick={fetchAndSpeak} disabled={loading} style={{
        all: 'unset', cursor: loading ? 'not-allowed' : 'pointer',
        display: 'inline-flex', alignItems: 'center', gap: 6,
        fontFamily: theme.fontHead, fontSize: 11, fontWeight: 700,
        background: cached ? theme.cyan : theme.yellow,
        color: theme.fg,
        border: `2px solid ${theme.fg}`, borderRadius: 999,
        padding: '5px 11px', letterSpacing: '0.08em', textTransform: 'uppercase',
        boxShadow: `0 2px 0 0 ${theme.fg}`,
        opacity: loading ? 0.6 : 1,
      }}>
        {loading ? <Spinner theme={theme} size={12}/> : <span style={{ fontSize: 13 }}>🎧</span>}
        {cached ? 'PLAY HINT AGAIN' : 'HEAR IN CHINESE'}
      </button>
      {cached && (
        <Card theme={theme} padded={false} style={{ padding: 10, marginTop: 8, background: theme.surfaceAlt }}>
          <div style={{ fontFamily: theme.fontBody, fontSize: 9, color: theme.fgMute, textTransform: 'uppercase', letterSpacing: '0.14em', marginBottom: 4 }}>
            ★ HINT — try writing this in your own words
          </div>
          <button onClick={() => { primeSpeech(); speakZh(cached.cn); }} style={{
            all: 'unset', cursor: 'pointer',
            display: 'flex', alignItems: 'center', gap: 8,
          }}>
            <CN theme={theme} size={20}>{cached.cn}</CN>
            <Speaker theme={theme} size={14} color={theme.fgMute}/>
          </button>
          <Pinyin theme={theme} size={12} color={theme.primary} style={{ display: 'block', marginTop: 2 }}>{cached.py}</Pinyin>
          <div style={{ fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute, marginTop: 4, fontStyle: 'italic' }}>
            {cached.en}
          </div>
        </Card>
      )}
      {error && (
        <div style={{ marginTop: 6, fontFamily: theme.fontBody, fontSize: 11, color: theme.red }}>
          ⚠ {error}
        </div>
      )}
    </div>
  );
}

// ─── Exercise: ai-respond ───────────────────────────────────
// AI poses a Chinese question, learner replies in Chinese, AI grades for
// topical relevance + grammar. Companion to ai-translate.
function ExAIRespond({ theme, ex, picked, setPicked, feedback, externalState, setExternal }) {
  const [text, setText] = React.useState(externalState?.text || '');
  const [showEN, setShowEN] = React.useState(false);
  const grading = externalState?.grading;
  const result = externalState?.result;

  React.useEffect(() => {
    setExternal?.({ ...externalState, text });
    if (text.trim()) setPicked(text);
    else setPicked(null);
    // eslint-disable-next-line
  }, [text]);

  return (
    <div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 14 }}>
      <H theme={theme} size={22}>{ex.prompt}</H>
      <Card theme={theme} padded={false} style={{ padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }} accent={theme.primary}>
        <button onClick={() => { primeSpeech(); speakZh(ex.cn); }} style={{
          all: 'unset', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <CN theme={theme} size={28}>{ex.cn}</CN>
            <Speaker theme={theme} size={20} color={theme.fgMute}/>
          </div>
          {ex.py && <Pinyin theme={theme} size={14} color={theme.primary}>{ex.py}</Pinyin>}
        </button>
        {ex.en && (
          showEN ? (
            <div style={{ fontFamily: theme.fontBody, fontSize: 12, color: theme.fgMute, fontStyle: 'italic' }}>
              ({ex.en})
            </div>
          ) : (
            <button onClick={() => setShowEN(true)} style={{
              all: 'unset', cursor: 'pointer',
              fontFamily: theme.fontBody, fontSize: 11, color: theme.fgDim,
              textTransform: 'uppercase', letterSpacing: '0.1em',
              borderBottom: `1px dashed ${theme.fgDim}`,
            }}>tap for english hint</button>
          )
        )}
        <div style={{ fontFamily: theme.fontBody, fontSize: 10, color: theme.fgMute, marginTop: 2, textTransform: 'uppercase', letterSpacing: '0.14em' }}>
          REPLY IN CHINESE · AI-GRADED
        </div>
      </Card>
      <textarea
        rows={3}
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Type your reply in Chinese (pinyin OK)…"
        disabled={grading || result}
        style={{
          fontFamily: theme.fontCN, fontSize: 22, fontWeight: 600,
          padding: 14, background: theme.surface, color: theme.fg,
          border: `2.5px solid ${feedback === 'right' ? theme.green : feedback === 'wrong' ? theme.red : theme.fg}`,
          borderRadius: theme.radius, outline: 'none', resize: 'none',
          boxShadow: `0 3px 0 0 ${theme.fg}`,
        }}
      />
      {grading && (
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, color: theme.fgMute, fontFamily: theme.fontBody, fontSize: 12 }}>
          <Spinner theme={theme}/> Grading…
        </div>
      )}
      {result && (
        <Card theme={theme} padded={false} style={{ padding: 12, background: theme.surfaceAlt }}>
          <div style={{ fontFamily: theme.fontBody, fontSize: 10, color: theme.fgMute, textTransform: 'uppercase', letterSpacing: '0.14em' }}>
            ★ A NATURAL REPLY · SCORE {result.score}
          </div>
          <CN theme={theme} size={24} style={{ marginTop: 6 }}>{result.model_answer}</CN>
          <Pinyin theme={theme} size={13} color={theme.primary} style={{ display: 'block', marginTop: 4 }}>{result.pinyin}</Pinyin>
          <div style={{ fontFamily: theme.fontBody, fontSize: 12, marginTop: 8, lineHeight: 1.45 }}>{result.feedback}</div>
          {result.follow_up && (
            <div style={{
              marginTop: 10, padding: 10,
              background: theme.surface, border: `2px dashed ${theme.fgDim}`,
              borderRadius: theme.radius * 0.7,
              fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute,
              textTransform: 'uppercase', letterSpacing: '0.1em',
            }}>
              ↻ TRY NEXT: <span style={{ textTransform: 'none', color: theme.fg, letterSpacing: 0, fontSize: 13 }}>{result.follow_up}</span>
            </div>
          )}
        </Card>
      )}
      {!result && !grading && orHasKey() && (
        <HintHelper theme={theme}
          sourcePrompt={`A Chinese question: "${ex.cn}" (${ex.py || ''}). Give the learner one good natural Chinese reply they could use.`}
          externalState={externalState} setExternal={setExternal}/>
      )}
      {!orHasKey() && (
        <div style={{
          padding: 10, fontFamily: theme.fontBody, fontSize: 11, color: theme.fgMute,
          background: theme.surfaceAlt, border: `2px dashed ${theme.fgDim}`, borderRadius: theme.radius,
        }}>
          No OpenRouter key — this exercise will skip grading. Add a key in Settings to enable AI grading + hints.
        </div>
      )}
    </div>
  );
}

// ─── Dispatch ───────────────────────────────────────────────
function ExerciseBody(props) {
  const { ex } = props;
  if (ex.kind === 'intro')             return <ExIntro {...props}/>;
  if (ex.kind === 'tone')              return <ExTone {...props}/>;
  if (ex.kind === 'pick-listen')       return <ExPickListen {...props}/>;
  if (ex.kind === 'pick-initial')      return <ExPickPart {...props}/>;
  if (ex.kind === 'pick-final')        return <ExPickPart {...props}/>;
  if (ex.kind === 'match-tone')        return <ExMatchTone {...props}/>;
  if (ex.kind === 'match-meaning')     return <ExMatchMeaning {...props}/>;
  if (ex.kind === 'pick-cn')           return <ExPickCN {...props}/>;
  if (ex.kind === 'dialogue-complete') return <ExPickCN {...props}/>;  // pick-cn with `context`
  if (ex.kind === 'type-pinyin')       return <ExTypePinyin {...props}/>;
  if (ex.kind === 'speak')             return <ExSpeak {...props}/>;
  if (ex.kind === 'true-false')        return <ExTrueFalse {...props}/>;
  if (ex.kind === 'arrange')           return <ExArrange {...props}/>;
  if (ex.kind === 'ai-translate')      return <ExAITranslate {...props}/>;
  if (ex.kind === 'ai-respond')        return <ExAIRespond {...props}/>;
  return null;
}

// Display kinds with no scoring (skip the CHECK flow, just CONTINUE).
function isDisplayOnly(kind) {
  return kind === 'intro' || kind === 'match-tone' || kind === 'speak';
}

// Grader. Returns Promise<{ right: bool, info?: string, gradeResult? }>.
async function gradeExercise(ex, picked, externalState, setExternal) {
  if (ex.kind === 'type-pinyin') {
    const norm = normalizePinyin(picked || '');
    const expectAll = [ex.answer, ...(ex.alts || [])].map(normalizePinyin);
    return { right: expectAll.some((e) => e && e === norm) };
  }
  if (ex.kind === 'arrange') {
    // picked is an array of indexes into ex.scrambled (or ex.words if no scramble).
    if (!Array.isArray(picked)) return { right: false, info: 'Tap the chips to build a sentence first.' };
    const display = ex.scrambled || ex.words;
    if (picked.length !== ex.words.length) return { right: false, info: 'Use every word once.' };
    const built = picked.map((i) => display[i]).join('');
    const target = ex.words.join('');
    return { right: built === target };
  }
  if (ex.kind === 'ai-translate') {
    const text = (externalState?.text || picked || '').trim();
    if (!text) return { right: false, info: 'Type something first.' };
    if (!orHasKey()) {
      // Skip grading — accept any non-empty response. User has been notified.
      return { right: true, info: 'Skipped (no API key).' };
    }
    setExternal({ ...externalState, text, grading: true, result: null });
    try {
      const { content } = await orChat({
        messages: [
          { role: 'system', content: TRANSLATE_GRADER_SYSTEM },
          { role: 'user', content: `English: ${ex.en}\nLearner attempt: ${text}` },
        ],
        temperature: 0.1, max_tokens: 350,
      });
      let json = null;
      const m = content.match(/\{[\s\S]*\}/);
      if (m) { try { json = JSON.parse(m[0]); } catch {} }
      if (!json) throw new Error('Could not parse grader response.');
      setExternal({ ...externalState, text, grading: false, result: json });
      return { right: !!json.correct, info: json.feedback };
    } catch (e) {
      setExternal({ ...externalState, text, grading: false, error: e.message });
      return { right: false, info: e.message };
    }
  }
  if (ex.kind === 'ai-respond') {
    const text = (externalState?.text || picked || '').trim();
    if (!text) return { right: false, info: 'Type something first.' };
    if (!orHasKey()) {
      return { right: true, info: 'Skipped (no API key).' };
    }
    setExternal({ ...externalState, text, grading: true, result: null });
    try {
      const { content } = await orChat({
        messages: [
          { role: 'system', content: RESPOND_GRADER_SYSTEM },
          { role: 'user', content: `Chinese question: ${ex.cn}\nPinyin: ${ex.py || '—'}\nEnglish (hidden from learner): ${ex.en || '—'}\nLearner reply: ${text}` },
        ],
        temperature: 0.15, max_tokens: 400,
      });
      let json = null;
      const m = content.match(/\{[\s\S]*\}/);
      if (m) { try { json = JSON.parse(m[0]); } catch {} }
      if (!json) throw new Error('Could not parse grader response.');
      setExternal({ ...externalState, text, grading: false, result: json });
      return { right: !!json.on_topic, info: json.feedback };
    } catch (e) {
      setExternal({ ...externalState, text, grading: false, error: e.message });
      return { right: false, info: e.message };
    }
  }
  // Default: index-equality against ex.answer.
  return { right: picked === ex.answer };
}

Object.assign(window, {
  ExerciseBody, isDisplayOnly, gradeExercise, PlayPinyin, Spinner,
  ExAIRespond,
});
