/* Runner de séance — colonne vertébrale (CHARTE II.13).
 *
 * Déroule le plan (LangPlanner) bloc par bloc via LangSessionRunner : la
 * révision de cartes est un rappel actif noté (FSRS), les autres familles sont
 * des blocs focalisés (contenu du contrat + audio + contrôle de complétion).
 * Chaque complétion journalise via session-log ; un bilan clôt la séance.
 *
 * L'UI ne décide rien : elle rend runner.current() et appelle submit()/skip().
 */

const RUNNER_RATINGS = [
  { id: "rate", label: "Raté", color: "var(--warn)", tint: "var(--warn-tint)", correct: false },
  { id: "hard", label: "Difficile", color: "var(--ochre)", tint: "var(--warn-tint)", correct: true },
  { id: "good", label: "Bon", color: "var(--good)", tint: "var(--good-tint)", correct: true },
  { id: "easy", label: "Facile", color: "var(--accent)", tint: "var(--accent-tint)", correct: true },
];

const STRAND_LABEL = { study: "Étude", input: "Input", output: "Production", fluency: "Fluidité", meta: "Méta" };
const DRILL_LABEL = { mixte: "Mixte", rappel: "Rappel", reconnaissance: "Reconnaissance", dictee: "Dictée", fluidite: "Fluidité" };
const ACTIVITY_LABEL = {
  cards_srs: "Cartes",
  vocab_freq: "Vocabulaire",
  dialogue_listening: "Écoute",
  dialogue_comprehension: "Compréhension",
  source_listening: "Fluidité",
  guided_production: "Production",
  dialogue_reply: "Dialogue",
  dictation: "Dictée",
  error_repair: "Réparation",
};

function runnerDrillLabel(mode) {
  return DRILL_LABEL[mode] || (mode ? String(mode).replace(/[_-]/g, " ") : "Mixte");
}

function runnerActivityLabel(kind) {
  return ACTIVITY_LABEL[kind] || (kind ? polishRunnerText(String(kind).replace(/[_-]/g, " ")) : "Exercice");
}

const TAG_EXPLANATIONS = {
  sound: "Réécoutez le modèle et isolez le son qui change.",
  son: "Réécoutez le modèle et isolez le son qui change.",
  batchim: "Vérifiez la consonne finale : elle change souvent la syllabe entendue.",
  space: "L'espacement coréen se travaille par groupes de mots, pas mot à mot.",
  espace: "L'espacement coréen se travaille par groupes de mots, pas mot à mot.",
  particle: "Repérez la particule : elle marque le rôle du mot dans la phrase.",
  particule: "Repérez la particule : elle marque le rôle du mot dans la phrase.",
  ending: "La terminaison porte le temps, la politesse et l'attitude.",
  terminaison: "La terminaison porte le temps, la politesse et l'attitude.",
  form: "Comparez la forme attendue et la forme produite avant de continuer.",
  forme: "Comparez la forme attendue et la forme produite avant de continuer.",
  meaning: "Revenez au sens précis, pas seulement à une traduction proche.",
  sens: "Revenez au sens précis, pas seulement à une traduction proche.",
  order: "Gardez l'ordre coréen attendu avant de traduire mentalement.",
  ordre: "Gardez l'ordre coréen attendu avant de traduire mentalement.",
  grammar: "Nommez la règle en une phrase courte, puis réutilisez-la.",
  grammaire: "Nommez la règle en une phrase courte, puis réutilisez-la.",
  politeness: "Vérifiez le niveau de politesse avant la terminaison.",
  politesse: "Vérifiez le niveau de politesse avant la terminaison.",
};

function cleanTag(tag) {
  return String(tag || "").trim().replace(/[_-]/g, " ");
}

function explanationForTags(tags, fallback) {
  const list = (Array.isArray(tags) ? tags : []).map(cleanTag).filter(Boolean);
  const found = list.map(t => TAG_EXPLANATIONS[t.toLowerCase()]).filter(Boolean);
  const base = found[0] || fallback || "Reprenez le modèle, nommez l'écart, puis refaites une phrase courte.";
  return list.length ? `${base} (${list.join(" · ")})` : base;
}

function repairSpecificHint(drill, fallback) {
  if (!drill || !Array.isArray(drill.choices)) return fallback;
  const choices = drill.choices.map(String);
  if (!(choices.includes("이에요") && choices.includes("예요"))) return fallback;
  const stem = String(drill.cloze || "").split("____")[0].trim();
  const targetEnding = String(drill.answer || "").trim();
  const yoursEnding = choices.find((choice) => choice !== targetEnding) || "";
  if (window.LangValidate && window.LangValidate.copulaEndingHint) {
    const hint = window.LangValidate.copulaEndingHint({ stem, targetEnding, yoursEnding });
    if (hint) return hint;
  }
  return fallback;
}

// Contraste item-spécifique : « ta forme / forme cible / pourquoi » sur l'item exact
// (AUDIT_PEDAGOGIQUE §6). S'appuie sur LangValidate.contrast (morphème + rôle réel).
function contrastFor(model, answer, dictation) {
  if (!model || !window.LangValidate || !window.LangValidate.contrast) return null;
  const c = window.LangValidate.contrast(model, answer, { ignoreSpaces: false, ignorePunct: !!dictation });
  return c && !c.correct ? c : null;
}

function ContrastFeedback({ contrast }) {
  if (!contrast) return null;
  const scopeLabel = contrast.dominant === "ordre" ? "ordre des mots"
    : contrast.scope === "morpheme" && contrast.dominant === "particule" ? "particule"
    : contrast.scope === "morpheme" && contrast.dominant === "terminaison" ? "terminaison"
    : contrast.scope === "morpheme" ? "morphème"
    : contrast.scope === "phrase" ? "découpage des mots" : "mot";
  return (
    <div className="fade-up" style={{ display: "grid", gap: 6 }}>
      <div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "3px 12px", alignItems: "baseline" }}>
        <span className="faint" style={{ fontSize: 12 }}>Ta forme</span>
        <span className="kr" style={{ fontSize: 16, color: "var(--warn)", textDecoration: "line-through", textDecorationColor: "var(--warn)" }}>{contrast.yours || "—"}</span>
        <span className="faint" style={{ fontSize: 12 }}>Forme cible</span>
        <span className="kr" style={{ fontSize: 16, color: "var(--good)", fontWeight: 700 }}>{contrast.target || "—"}</span>
      </div>
      <div style={{ fontSize: 13.5, color: "var(--ink)" }}><span className="faint">Pourquoi ({scopeLabel}) : </span>{contrast.reason}</div>
    </div>
  );
}

function inputProofText(prompt, expected, feedback, line) {
  const values = [
    expected && expected.fr,
    expected && expected.en,
    feedback && feedback.meaning,
    prompt && prompt.meaning,
    line && line.fr,
    line && line.en,
    prompt && prompt.fr,
    prompt && prompt.en,
  ];
  return String(values.find(v => typeof v === "string" && v.trim()) || "").trim();
}

function polishRunnerPrompt(text) {
  return polishRunnerText(String(text || "").replace(/^Quelle replique correspond a ce sens\s*:/i, "Quelle réplique correspond à ce sens :"));
}

// Délègue à la couche partagée polishLabel (ui.jsx, chargé avant runner.jsx).
function polishRunnerText(text) {
  return window.polishLabel ? window.polishLabel(text) : text;
}

/* ----- Blocs focalisés (un par grande famille) ----- */

// Palier de qualité de contexte (#2) d'une phrase porteuse, avec repli depuis la source.
const CONTEXT_TIER_FROM_SOURCE = { dialogue: "reel", ecoute: "reel", carte: "reel", ecrit: "ecrit", lexique: "collocation", patron: "patron" };
const CONTEXT_TIER_LABEL = { reel: "contexte réel", ecrit: "phrase validée", collocation: "collocation", patron: "patron généré" };
function carrierTier(s) {
  if (s && s.contextQuality) return s.contextQuality;
  if (s && s.quality === "generated_scaffold") return "patron";
  return (s && CONTEXT_TIER_FROM_SOURCE[s.source]) || "autre";
}

function hangulToken(text) {
  return String(text || "").replace(/^[^\uac00-\ud7a3]+|[^\uac00-\ud7a3]+$/g, "");
}

function contextualGlossFor(glossFor, focusKr, focusMeaning) {
  const target = hangulToken(focusKr);
  const meaning = String(focusMeaning || "").trim();
  if (!target || !meaning) return glossFor;
  const stem = target.endsWith("다") ? target.slice(0, -1) : "";
  return (token) => {
    const clean = hangulToken(token);
    if (clean === target) return meaning;
    if (target === "하다" && /^해(요)?$/.test(clean)) return meaning;
    if (stem.length >= 2 && clean.startsWith(stem)) return meaning;
    return glossFor ? glossFor(token) : undefined;
  };
}

function CarrierSentences({ carriers, glossFor, contextStatus, focusKr, focusMeaning }) {
  if (!carriers || !carriers.length) return null;
  const topTier = carrierTier(carriers[0]);
  const tierLabel = CONTEXT_TIER_LABEL[topTier] || "contexte relié";
  const label = contextStatus === "linked"
    ? (topTier === "patron" ? "Phrase porteuse · patron généré" : `Dans une phrase · ${tierLabel}`)
    : "Dans une phrase";
  const localGlossFor = contextualGlossFor(glossFor, focusKr, focusMeaning);
  return (
    <div className="fade-up" style={{ display: "grid", gap: 8 }}>
      <div className="eyebrow">{label}</div>
      {carriers.map((s, i) => (
        <div key={s.id || i} style={{ padding: "10px 13px", borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px solid var(--rule)", display: "grid", gap: 4 }}>
          <PhraseWords text={s.kr} glossFor={localGlossFor} audioUrl={s.audioUrl} audioRef={s.kr} size={22} justify="flex-start" />
          {s.fr && <span className="faint" style={{ fontSize: 12.5 }}>{s.fr}</span>}
        </div>
      ))}
    </div>
  );
}

function VocabContext({ exercise, carriers, glossFor }) {
  const sourceKind = exercise && exercise.source && exercise.source.kind;
  if (sourceKind !== "vocab_freq") return null;
  const meta = exercise.meta || {};
  if (carriers && carriers.length) {
    return <CarrierSentences carriers={carriers} glossFor={glossFor} contextStatus={meta.contextStatus} focusKr={exercise.expected && exercise.expected.kr} focusMeaning={exercise.expected && exercise.expected.fr} />;
  }
  if (meta.contextStatus !== "missing") return null;
  return (
    <div className="fade-up" style={{ padding: "10px 13px", borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px dashed var(--rule)", display: "grid", gap: 3 }}>
      <div className="eyebrow">Contexte</div>
      <span className="faint" style={{ fontSize: 12.5 }}>Contexte à enrichir dans la banque de phrases.</span>
    </div>
  );
}

function hasHangul(text) {
  return /[\uac00-\ud7a3]/.test(String(text || ""));
}

function glossPairs(text, glossFor) {
  if (!glossFor) return [];
  return String(text || "").trim().split(/\s+/).filter(Boolean).map((token) => {
    const clean = token.replace(/^[^\uac00-\ud7a3]+|[^\uac00-\ud7a3]+$/g, "");
    const gloss = clean ? glossFor(clean) : "";
    return gloss ? { token: clean, gloss } : null;
  }).filter(Boolean).slice(0, 10);
}

function MixedGlossText({ text, glossFor, style, glossRevealed = true }) {
  const parts = String(text || "").split(/(\s+)/);
  return (
    <div style={style}>
      {parts.map((part, i) => {
        if (!hasHangul(part)) return <React.Fragment key={i}>{part}</React.Fragment>;
        const clean = part.replace(/^[^\uac00-\ud7a3]+|[^\uac00-\ud7a3]+$/g, "");
        const gloss = clean && glossFor ? glossFor(clean) : undefined;
        return <Speakable key={i} text={clean || part} audioRef={clean || part} gloss={gloss} glossRevealed={glossRevealed} frame={false} className="kr" style={{ padding: "0 2px", color: "inherit" }}>{part}</Speakable>;
      })}
    </div>
  );
}

function GlossAid({ text, glossFor }) {
  const [open, setOpen] = useState(false);
  const pairs = glossPairs(text, glossFor);
  if (!pairs.length) return null;
  return (
    <div style={{ display: "grid", gap: 6, marginTop: 2 }}>
      <button type="button" aria-expanded={open} onClick={() => setOpen(o => !o)} className="chip" style={{ justifySelf: "start", cursor: "pointer", background: open ? "var(--accent-tint)" : "var(--card)", color: open ? "var(--accent-deep)" : "var(--ink-soft)", borderColor: open ? "transparent" : "var(--rule)" }}>
        <Icon name={open ? "eyeoff" : "eye"} size={13} /> Gloses
      </button>
      {open && (
        <div className="fade-up" style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
          {pairs.map((p, i) => (
            <span key={`${p.token}_${i}`} className="chip" style={{ fontSize: 12, background: "var(--card-2)" }}>
              <span className="kr">{p.token}</span><span className="faint">{p.gloss}</span>
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

// QCM de reconnaissance explicite : correct + distracteurs.
function buildRunnerQcm(choices, correct, n) {
  const others = Array.from(new Set(choices)).filter(c => c && c !== correct);
  const sel = [correct].concat(others).slice(0, Math.max(2, n));
  return sel.sort(() => Math.random() - 0.5);
}

function RunnerCardBlock({ exercise, glossFor, carriers, onGrade }) {
  const [revealed, setRevealed] = useState(false);
  const [qcm, setQcm] = useState(null);
  const [picked, setPicked] = useState(null);
  const [answer, setAnswer] = useState("");
  const [checked, setChecked] = useState(null);
  const [introDone, setIntroDone] = useState(false);
  const [choicesShown, setChoicesShown] = useState(false);
  const p = exercise.prompt || {};
  const e = exercise.expected || {};
  const f = exercise.feedback || {};
  const kr = p.kr || p.front || e.kr || "";
  const fr = e.fr || e.back || p.fr || "";
  const settings = window.LangStore ? window.LangStore.settings.get() : {};
  const nChoices = Math.max(2, Math.min(6, Number(settings.qcmChoices) || 3));
  const choices = (f.choices && f.choices.length ? f.choices : (e.choices || []));
  const mode = p.mode || "active_recall";
  const qcmMode = mode === "qcm";
  const dictationMode = mode === "audio_to_hangeul";
  const listeningMode = mode === "audio_to_meaning";
  const productionMode = mode === "fr_to_kr";
  const presentationMode = mode === "new_presentation";
  const canQcm = choices.length >= 2 && !!fr;
  useEffect(() => {
    setRevealed(false);
    setPicked(null);
    setAnswer("");
    setChecked(null);
    setIntroDone(false);
    setChoicesShown(false);
    setQcm(canQcm ? { options: buildRunnerQcm(choices, fr, nChoices), immediate: qcmMode } : null);
  }, [exercise.id, mode, qcmMode, canQcm, fr, nChoices]);

  const onRate = (r) => {
    onGrade({ rating: r.id, correct: r.correct, errorTags: r.correct ? [] : (exercise.errorTags || []) });
  };

  if (dictationMode) {
    const done = checked != null;
    const tags = done && checked.tags && checked.tags.length ? checked.tags : (exercise.errorTags || []);
    return (
      <div style={{ display: "grid", gap: 16 }}>
        <div className="card" style={{ padding: 24, display: "grid", gap: 14 }}>
          <Eyebrow>Dictée</Eyebrow>
          <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
            <SpeakBtn text={kr} audioRef={p.audioRef || kr} audioUrl={p.audioUrl} size={44} title="Écouter" />
          </div>
          <textarea value={answer} onChange={ev => setAnswer(ev.target.value)} rows={2} placeholder="Ce que vous entendez..." className="kr" lang="ko" autoCapitalize="off" autoCorrect="off" spellCheck={false}
            style={{ width: "100%", padding: "11px 13px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: "var(--card-2)", color: "var(--ink)", fontSize: 20, resize: "vertical" }} />
          <KoreanKeyboard value={answer} onChange={(next) => { setAnswer(next); setChecked(null); }} compact />
          {done && (
            <div className="fade-up" style={{ display: "grid", gap: 8, padding: "12px 14px", borderRadius: "var(--radius-sm)", background: checked.correct ? "var(--good-tint)" : "var(--warn-tint)" }}>
              <div style={{ fontWeight: 700, color: checked.correct ? "var(--good)" : "var(--warn)" }}>{dictationFeedbackLabel(checked)}</div>
              <div>Modèle : <span className="kr" style={{ fontSize: 18 }}>{kr}</span></div>
              {p.reading && <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 12.5 }}>Prononciation : {p.reading}</div>}
              {fr && <div className="faint" style={{ fontSize: 13 }}>{fr}</div>}
              {!checked.correct && <ContrastFeedback contrast={contrastFor(kr, answer, true)} />}
              {!checked.correct && tags.length > 0 && <div className="faint" style={{ fontSize: 12 }}>Tags : {tags.join(" · ")}</div>}
            </div>
          )}
          {done && <VocabContext exercise={exercise} carriers={carriers} glossFor={glossFor} />}
        </div>
        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
          {!done
            ? <Btn kind="secondary" icon="eye" disabled={!answer.trim()} onClick={() => setChecked(compareDictationAnswer(kr, answer))}>Vérifier</Btn>
            : <Btn kind="primary" icon="check" onClick={() => onGrade({ rating: checked.correct ? "good" : "rate", correct: checked.correct, answer, errorTags: checked.correct ? [] : tags, feedbackShown: true })}>Continuer</Btn>}
        </div>
      </div>
    );
  }

  if (presentationMode && !introDone) {
    return (
      <div style={{ display: "grid", gap: 16 }}>
        <div className="card" style={{ padding: 24, display: "grid", gap: 14 }}>
          <Eyebrow>Présentation</Eyebrow>
          <PhraseWords text={kr} glossFor={glossFor} audioUrl={p.audioUrl} audioRef={p.audioRef} size={48} justify="flex-start" glossRevealed />
          {p.reading && <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 13 }}>{p.reading}</div>}
          <div style={{ fontFamily: "var(--serif)", fontSize: 24, color: "var(--ink)" }}>{fr}</div>
          <VocabContext exercise={exercise} carriers={carriers} glossFor={glossFor} />
        </div>
        <div style={{ display: "flex", justifyContent: "flex-end" }}>
          <Btn kind="primary" icon="eyeoff" onClick={() => setIntroDone(true)}>Masquer et rappeler</Btn>
        </div>
      </div>
    );
  }

  if (listeningMode) {
    const selected = picked == null || !qcm ? null : qcm.options[picked];
    const correct = selected === fr;
    return (
      <div style={{ display: "grid", gap: 16 }}>
        <div className="card" style={{ padding: 24, display: "grid", gap: 14 }}>
          <Eyebrow>Écoute active</Eyebrow>
          <div style={{ display: "grid", gap: 8 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
              <SpeakBtn text={kr} audioRef={p.audioRef || kr} audioUrl={p.audioUrl} size={46} title="Écouter sans lire" />
              <span className="faint" style={{ fontSize: 13.5 }}>Écoutez d'abord, puis choisissez le sens.</span>
            </div>
          </div>
          {choicesShown && qcm && (
            <div className="fade-up" style={{ display: "grid", gap: 8 }}>
              {qcm.options.map((c, idx) => {
                const reveal = picked != null;
                let bg = "var(--card)", col = "var(--ink)";
                if (reveal && c === fr) { bg = "var(--good-tint)"; col = "var(--good)"; }
                else if (reveal && picked === idx && c !== fr) { bg = "var(--warn-tint)"; col = "var(--warn)"; }
                return <button key={idx} onClick={() => { if (picked == null) { setPicked(idx); setRevealed(true); } }} disabled={reveal} style={{ padding: "12px 15px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: bg, color: col, fontFamily: "var(--serif)", fontSize: 17, textAlign: "left", cursor: reveal ? "default" : "pointer" }}>{c}</button>;
              })}
            </div>
          )}
          {revealed && (
            <div className="fade-up" style={{ display: "grid", gap: 8, padding: "12px 14px", borderRadius: "var(--radius-sm)", background: picked == null || correct ? "var(--good-tint)" : "var(--warn-tint)" }}>
              <div style={{ fontWeight: 700, color: picked == null || correct ? "var(--good)" : "var(--warn)" }}>{picked == null ? "Modèle" : (correct ? "Juste" : "À revoir")}</div>
              <PhraseWords text={kr} glossFor={glossFor} audioUrl={p.audioUrl} audioRef={p.audioRef} size={30} justify="flex-start" glossRevealed />
              <div style={{ fontFamily: "var(--serif)", fontSize: 20, color: "var(--ink)" }}>{fr}</div>
              <VocabContext exercise={exercise} carriers={carriers} glossFor={glossFor} />
            </div>
          )}
        </div>
        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }}>
          {!choicesShown && qcm && <Btn kind="secondary" icon="ear" onClick={() => setChoicesShown(true)}>Choisir le sens</Btn>}
          {!qcm && !revealed && <Btn kind="secondary" icon="eye" onClick={() => setRevealed(true)}>Afficher le modèle</Btn>}
          {picked != null && <Btn kind="primary" icon="check" onClick={() => onGrade({ rating: correct ? "good" : "rate", correct, answer: selected || "", errorTags: correct ? [] : (exercise.errorTags || []), feedbackShown: true })}>Continuer</Btn>}
          {!qcm && revealed && (
            <>
              <Btn kind="secondary" onClick={() => onGrade({ rating: "rate", correct: false, errorTags: exercise.errorTags || [], feedbackShown: true })}>À revoir</Btn>
              <Btn kind="primary" icon="check" onClick={() => onGrade({ rating: "good", correct: true, feedbackShown: true })}>Compris</Btn>
            </>
          )}
        </div>
      </div>
    );
  }

  if (productionMode) {
    const done = checked != null;
    const tags = done && checked.tags && checked.tags.length ? checked.tags : (exercise.errorTags || []);
    return (
      <div style={{ display: "grid", gap: 16 }}>
        <div className="card" style={{ padding: 24, display: "grid", gap: 14 }}>
          <Eyebrow>Production</Eyebrow>
          <div style={{ fontFamily: "var(--serif)", fontSize: 24, color: "var(--accent-deep)" }}>{fr}</div>
          <textarea value={answer} onChange={ev => { setAnswer(ev.target.value); setChecked(null); }} rows={2} placeholder="Produisez en coréen..." className="kr" lang="ko" autoCapitalize="off" autoCorrect="off" spellCheck={false}
            style={{ width: "100%", padding: "11px 13px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: "var(--card-2)", color: "var(--ink)", fontSize: 20, resize: "vertical" }} />
          <KoreanKeyboard value={answer} onChange={(next) => { setAnswer(next); setChecked(null); }} compact />
          {done && (
            <div className="fade-up" style={{ display: "grid", gap: 8, padding: "12px 14px", borderRadius: "var(--radius-sm)", background: checked.correct ? "var(--good-tint)" : "var(--warn-tint)" }}>
              <div style={{ fontWeight: 700, color: checked.correct ? "var(--good)" : "var(--warn)" }}>{checked.correct ? "Correct" : "À revoir"}</div>
              <div>Modèle : <span className="kr" style={{ fontSize: 18 }}>{kr}</span></div>
              {p.reading && <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 12.5 }}>Prononciation : {p.reading}</div>}
              {!checked.correct && <ContrastFeedback contrast={contrastFor(kr, answer, false)} />}
              {!checked.correct && tags.length > 0 && <div className="faint" style={{ fontSize: 12 }}>Tags : {tags.join(" · ")}</div>}
              <VocabContext exercise={exercise} carriers={carriers} glossFor={glossFor} />
            </div>
          )}
        </div>
        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
          {!done
            ? <Btn kind="secondary" icon="eye" disabled={!answer.trim()} onClick={() => setChecked(compareDictationAnswer(kr, answer))}>Vérifier</Btn>
            : <Btn kind="primary" icon="check" onClick={() => onGrade({ rating: checked.correct ? "good" : "rate", correct: checked.correct, answer, errorTags: checked.correct ? [] : tags, feedbackShown: true })}>Continuer</Btn>}
        </div>
      </div>
    );
  }

  if (qcmMode && qcm) {
    const selected = picked == null ? null : qcm.options[picked];
    const correct = selected === fr;
    return (
      <div style={{ display: "grid", gap: 16 }}>
        <div className="card" style={{ padding: 24, display: "grid", gap: 14 }}>
          <Eyebrow>QCM</Eyebrow>
          <PhraseWords text={kr} glossFor={glossFor} audioUrl={p.audioUrl} audioRef={p.audioRef} size={46} justify="flex-start" />
          {p.reading && <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 13 }}>{p.reading}</div>}
          <div style={{ display: "grid", gap: 8 }}>
            {qcm.options.map((c, idx) => {
              const reveal = picked != null;
              let bg = "var(--card)", col = "var(--ink)";
              if (reveal && c === fr) { bg = "var(--good-tint)"; col = "var(--good)"; }
              else if (reveal && picked === idx && c !== fr) { bg = "var(--warn-tint)"; col = "var(--warn)"; }
              return <button key={idx} onClick={() => picked == null && setPicked(idx)} disabled={reveal} style={{ padding: "12px 15px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: bg, color: col, fontFamily: "var(--serif)", fontSize: 17, textAlign: "left", cursor: reveal ? "default" : "pointer" }}>{c}</button>;
            })}
          </div>
          {picked != null && <VocabContext exercise={exercise} carriers={carriers} glossFor={glossFor} />}
        </div>
        <div style={{ display: "flex", justifyContent: "flex-end" }}>
          <Btn kind="primary" icon="check" disabled={picked == null} onClick={() => onGrade({ rating: correct ? "good" : "rate", correct, answer: selected || "", errorTags: correct ? [] : (exercise.errorTags || []), feedbackShown: true })}>Continuer</Btn>
        </div>
      </div>
    );
  }

  // Rappel d'abord (réponse cachée), PUIS vérification objective par reconnaissance
  // du sens (réutilise expected.choices) : juste/faux pilote la boucle + FSRS, plus
  // d'auto-déclaration. Repli sur l'auto-note seulement si aucun distracteur.
  const recognize = !!qcm;
  const selectedChoice = recognize && picked != null ? qcm.options[picked] : null;
  const recogCorrect = selectedChoice != null && selectedChoice === fr;
  const eyebrowText = revealed
    ? (recognize ? (recogCorrect ? "Juste" : "À revoir") : "Vérifiez")
    : (choicesShown ? "Choisissez le sens" : "Rappel actif");
  return (
    <div style={{ display: "grid", gap: 18 }}>
      <div className="card" style={{ padding: 30, display: "grid", placeItems: "center", minHeight: 200 }}>
        <div style={{ textAlign: "center" }}>
          <Eyebrow>{eyebrowText}</Eyebrow>
          <div style={{ margin: "14px 0" }}>
            <PhraseWords text={kr} glossFor={glossFor} audioUrl={p.audioUrl} audioRef={p.audioRef} size={56} glossRevealed={revealed} />
          </div>
          {revealed
            ? <div className="fade-up" style={{ fontFamily: "var(--serif)", fontSize: 30, color: "var(--ink)" }}>{fr}</div>
            : <p className="faint" style={{ fontSize: 14 }}>{choicesShown ? "Rappel fait ? Choisissez le bon sens — sans deviner." : "Cliquez un mot pour l'écouter, rappelez le sens de tête, puis vérifiez."}</p>}
        </div>
      </div>
      {revealed && <VocabContext exercise={exercise} carriers={carriers} glossFor={glossFor} />}
      {recognize ? (
        !choicesShown
          ? <div style={{ display: "flex", justifyContent: "center" }}><Btn kind="secondary" icon="eye" aria-label="Vérifier" title="Vérifier mon rappel" onClick={() => setChoicesShown(true)}>Vérifier <span aria-hidden="true" className="faint" style={{ fontSize: 12, fontFamily: "var(--mono)" }}>espace</span></Btn></div>
          : (
            <div className="fade-up" style={{ display: "grid", gap: 10 }}>
              <div className="eyebrow">Choisissez le bon sens</div>
              {qcm.options.map((c, idx) => {
                const correct = c === fr, reveal = picked != null;
                let bg = "var(--card)", col = "var(--ink)";
                if (reveal && correct) { bg = "var(--good-tint)"; col = "var(--good)"; }
                else if (reveal && picked === idx && !correct) { bg = "var(--warn-tint)"; col = "var(--warn)"; }
                return <button key={idx} onClick={() => { if (picked == null) { setPicked(idx); setRevealed(true); } }} disabled={picked != null} style={{ padding: "12px 15px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: bg, color: col, fontFamily: "var(--serif)", fontSize: 17, textAlign: "left", cursor: picked == null ? "pointer" : "default" }}>{c}</button>;
              })}
              {picked != null && (
                <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
                  {recogCorrect && <Btn kind="ghost" size="sm" onClick={() => onGrade({ rating: "easy", correct: true, feedbackShown: true })}>C'était facile</Btn>}
                  <Btn kind="primary" icon="check" onClick={() => onGrade({ rating: recogCorrect ? "good" : "rate", correct: recogCorrect, answer: selectedChoice || "", errorTags: recogCorrect ? [] : (exercise.errorTags || []), feedbackShown: true })}>Continuer</Btn>
                </div>
              )}
            </div>
          )
      ) : !revealed
        ? <div style={{ display: "flex", justifyContent: "center" }}><Btn kind="secondary" icon="eye" aria-label="Vérifier" title="Vérifier" onClick={() => setRevealed(true)}>Vérifier <span aria-hidden="true" className="faint" style={{ fontSize: 12, fontFamily: "var(--mono)" }}>espace</span></Btn></div>
        : (
          <div className="fade-up" style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 10 }}>
            {RUNNER_RATINGS.map(r => (
              <button key={r.id} onClick={() => onRate(r)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4, padding: "14px 8px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: "var(--card)", cursor: "pointer", transition: "all .15s" }}
                onMouseEnter={ev => { ev.currentTarget.style.background = r.tint; ev.currentTarget.style.borderColor = "transparent"; }}
                onMouseLeave={ev => { ev.currentTarget.style.background = "var(--card)"; ev.currentTarget.style.borderColor = "var(--rule)"; }}>
                <span style={{ fontFamily: "var(--serif)", fontSize: 18, color: r.color }}>{r.label}</span>
              </button>
            ))}
          </div>
        )}
    </div>
  );
}

function RunnerInputBlock({ exercise, glossFor, onDone }) {
  const p = exercise.prompt || {};
  const e = exercise.expected || {};
  const f = exercise.feedback || {};
  const rawChoices = (p.choices && p.choices.length) ? p.choices : null;
  const [picked, setPicked] = useState(null);
  const [proofShown, setProofShown] = useState(false);
  const [heard, setHeard] = useState(false);
  // Les choix arrivent avec la bonne réponse en position 0 : on mélange (stable par
  // exercice) et on suit la bonne réponse, sinon « toujours cliquer la 1re » suffit.
  const [choices, setChoices] = useState(null);
  const line = p.firstLine || {};
  const lineKr = p.kr || line.kr || p.title || "";
  const audioUrl = p.audioUrl || (line.audio && line.audio.url) || "";
  const audioRef = p.audioRef || (line.audio && (line.audio.ref || line.audio.id)) || lineKr || p.title || "";
  const promptText = polishRunnerPrompt(p.q);
  const proof = inputProofText(p, e, f, line);
  const revealGlosses = proofShown || picked != null;
  const audioFirst = p.mode === "audio_to_meaning" || p.mode === "listen_repeat";
  useEffect(() => {
    setPicked(null);
    setProofShown(false);
    setHeard(false);
    if (!rawChoices) { setChoices(null); return; }
    const arr = rawChoices.map((c, i) => ({ c, correct: typeof e.answer === "number" ? i === e.answer : c === e.answer }));
    for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = arr[i]; arr[i] = arr[j]; arr[j] = t; }
    setChoices(arr);
  }, [exercise.id]);
  if (audioFirst) {
    const selected = picked == null || !choices ? null : choices[picked];
    const correct = !!(selected && selected.correct);
    const revealed = picked != null || proofShown;
    const reviewTags = (exercise.errorTags && exercise.errorTags.length) ? exercise.errorTags : ["comprehension"];
    return (
      <div style={{ display: "grid", gap: 16 }}>
        <div className="card" style={{ padding: 24, display: "grid", gap: 14 }}>
          <Eyebrow>{choices ? "Écoute active" : "Écoute guidée"}</Eyebrow>
          {p.title && <div style={{ fontFamily: "var(--serif)", fontSize: 22, color: "var(--accent-deep)" }}>{polishRunnerText(p.title)}</div>}
          <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
            <SpeakBtn text={lineKr} audioRef={audioRef} audioUrl={audioUrl} size={46} title="Écouter sans lire" onPlayed={() => setHeard(true)} />
            <span className="faint" style={{ fontSize: 13.5 }}>{heard ? (choices ? "Choisissez le sens avant de voir le texte." : "Répétez, puis révélez le modèle.") : "Écoutez d'abord, sans lire le coréen."}</span>
          </div>
          {heard && choices && picked == null && (
            <div className="fade-up" style={{ display: "grid", gap: 8 }}>
              {choices.map((opt, idx) => (
                <button key={idx} onClick={() => setPicked(idx)} style={{ padding: "12px 15px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: "var(--card)", color: "var(--ink)", fontFamily: "var(--serif)", fontSize: 17, textAlign: "left", cursor: "pointer" }}>{opt.c}</button>
              ))}
            </div>
          )}
          {picked != null && choices && (
            <div className="fade-up" style={{ display: "grid", gap: 8 }}>
              {choices.map((opt, idx) => {
                let bg = "var(--card)", col = "var(--ink)";
                if (opt.correct) { bg = "var(--good-tint)"; col = "var(--good)"; }
                else if (picked === idx) { bg = "var(--warn-tint)"; col = "var(--warn)"; }
                return <div key={idx} style={{ padding: "10px 13px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: bg, color: col, fontFamily: "var(--serif)", fontSize: 16 }}>{opt.c}</div>;
              })}
            </div>
          )}
          {heard && !choices && !revealed && <Btn kind="secondary" icon="eye" onClick={() => setProofShown(true)} style={{ justifySelf: "start" }}>Afficher le modèle</Btn>}
          {revealed && (
            <div className="fade-up" style={{ display: "grid", gap: 8, padding: "12px 14px", borderRadius: "var(--radius-sm)", background: choices ? (correct ? "var(--good-tint)" : "var(--warn-tint)") : "var(--card-2)", border: "1px solid var(--rule)" }}>
              {choices && <div style={{ fontWeight: 700, color: correct ? "var(--good)" : "var(--warn)" }}>{correct ? "Juste" : "À revoir"}</div>}
              {lineKr && <PhraseWords text={lineKr} glossFor={glossFor} glossRevealed audioUrl={audioUrl} audioRef={audioRef} size={30} justify="flex-start" />}
              {p.reading && <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 13 }}>{p.reading}</div>}
              {proof && <div style={{ fontFamily: "var(--serif)", fontSize: 19, color: "var(--ink)" }}>{proof}</div>}
            </div>
          )}
        </div>
        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }}>
          {choices && picked != null && <Btn kind="primary" icon="check" onClick={() => onDone({ correct, answer: selected ? selected.c : "", errorTags: correct ? [] : reviewTags, feedbackShown: true })}>Continuer</Btn>}
          {!choices && revealed && (
            <>
              <Btn kind="secondary" icon="refresh" onClick={() => onDone({ correct: false, answer: proof || lineKr, errorTags: reviewTags, feedbackShown: true })}>À revoir · continuer</Btn>
              <Btn kind="primary" icon="check" onClick={() => onDone({ correct: true, answer: proof || lineKr, feedbackShown: true })}>Répété · continuer</Btn>
            </>
          )}
        </div>
      </div>
    );
  }
  return (
    <div style={{ display: "grid", gap: 16 }}>
      <div className="card" style={{ padding: 24, display: "grid", gap: 12 }}>
        {p.title && <Eyebrow>{polishRunnerText(p.title)}</Eyebrow>}
        {promptText
          ? (hasHangul(promptText)
              ? <MixedGlossText text={promptText} glossFor={glossFor} glossRevealed={revealGlosses} style={{ fontFamily: "var(--serif)", fontSize: 22, color: "var(--accent-deep)" }} />
              : <div style={{ fontFamily: "var(--serif)", fontSize: 22, color: "var(--accent-deep)" }}>{promptText}</div>)
          : (
            <>
              <PhraseWords text={lineKr} glossFor={glossFor} glossRevealed={revealGlosses} audioUrl={p.audioUrl} audioRef={p.audioRef} size={40} justify="flex-start" />
              <GlossAid text={lineKr} glossFor={glossFor} />
            </>
          )}
        {promptText && hasHangul(promptText) && <GlossAid text={promptText} glossFor={glossFor} />}
        {p.reading && <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 14 }}>{p.reading}</div>}
        {choices && (
          <div style={{ display: "grid", gap: 8, marginTop: 6 }}>
            {choices.map((opt, idx) => {
              const reveal = picked != null;
              let bg = "var(--card)", col = "var(--ink)";
              if (reveal && opt.correct) { bg = "var(--good-tint)"; col = "var(--good)"; }
              else if (reveal && picked === idx && !opt.correct) { bg = "var(--warn-tint)"; col = "var(--warn)"; }
              return <button key={idx} onClick={() => picked == null && setPicked(idx)} disabled={reveal} style={{ padding: "12px 15px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: bg, color: col, fontFamily: "var(--serif)", fontSize: 17, textAlign: "left", cursor: reveal ? "default" : "pointer" }}>{opt.c}</button>;
            })}
          </div>
        )}
        {!choices && proofShown && (
          <div className="fade-up" style={{ display: "grid", gap: 6, padding: "12px 14px", borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px solid var(--rule)" }}>
            <div className="eyebrow">Preuve de compréhension</div>
            {proof
              ? <div style={{ fontFamily: "var(--serif)", fontSize: 18, color: "var(--ink)" }}>{proof}</div>
              : <p className="muted" style={{ fontSize: 14, margin: 0 }}>Reformulez le sens mentalement avant de valider.</p>}
          </div>
        )}
      </div>
      <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }}>
        {choices
          ? <Btn kind="primary" icon="check" disabled={picked == null} onClick={() => {
              const sel = choices[picked] || {};
              onDone({ correct: !!sel.correct, answer: sel.c, errorTags: sel.correct ? [] : ["comprehension"], feedbackShown: true });
            }}>Valider et continuer</Btn>
          : proofShown
            ? <>
                <Btn kind="secondary" icon="refresh" onClick={() => onDone({ correct: false, answer: proof, errorTags: ["comprehension"], feedbackShown: true })}>À revoir · continuer</Btn>
                <Btn kind="primary" icon="check" onClick={() => onDone({ correct: true, answer: proof, feedbackShown: true })}>Compris · continuer</Btn>
              </>
            : <Btn kind="secondary" icon="eye" onClick={() => setProofShown(true)}>Vérifier le sens</Btn>}
      </div>
    </div>
  );
}

function RunnerProductionBlock({ exercise, glossFor, grade, onDone }) {
  const p = exercise.prompt || {};
  const dictation = exercise.type === "dictation_line" || (exercise.source && exercise.source.kind === "dictation_scene");
  const [answer, setAnswer] = useState("");
  const [dictationLevelId, setDictationLevelId] = useState("blocks");
  const [unitAnswersByLevel, setUnitAnswersByLevel] = useState({});
  const [unitChecked, setUnitChecked] = useState(null);
  const [checked, setChecked] = useState(null);
  // Boucle de production (AUDIT_PEDAGOGIQUE §6/levier #4) : produire -> corriger ->
  // RETRY immédiat -> réapparition différée si l'item a d'abord été raté.
  const [attempts, setAttempts] = useState(1);
  const [revealModel, setRevealModel] = useState(false);
  const [firstTags, setFirstTags] = useState([]);
  const [retryHint, setRetryHint] = useState("");
  useEffect(() => { setAnswer(""); setDictationLevelId("blocks"); setUnitAnswersByLevel({}); setUnitChecked(null); setChecked(null); setAttempts(1); setRevealModel(false); setFirstTags([]); setRetryHint(""); }, [exercise.id]);
  const must = p.must || [];
  const ex = exercise.expected || {};
  const model = dictation ? dictationExpectedForExercise(exercise) : (ex.model || ex.kr || "");
  const dictationLevels = dictation ? dictationLevelsForExercise(exercise) : [];
  const activeDictationLevel = dictationLevels.find(level => level.id === dictationLevelId) || dictationLevels.find(level => level.id === "blocks") || dictationLevels[0] || null;
  const activeDictationUnits = activeDictationLevel ? activeDictationLevel.units : [];
  const activeUnitAnswers = unitAnswersByLevel[activeDictationLevel && activeDictationLevel.id] || [];
  const dictationTags = Array.isArray(exercise.errorTags) && exercise.errorTags.length
    ? exercise.errorTags
    : ["son", "batchim", "espace", "particule", "terminaison"];
  const checkUnits = () => {
    const res = compareDictationUnits(activeDictationUnits, activeUnitAnswers);
    setUnitChecked(res);
  };
  const setUnitAnswer = (index, value) => {
    const levelId = (activeDictationLevel && activeDictationLevel.id) || "blocks";
    setUnitAnswersByLevel(prev => {
      const next = (prev[levelId] || []).slice();
      next[index] = value;
      return Object.assign({}, prev, { [levelId]: next });
    });
    setUnitChecked(null);
  };
  const check = () => {
    const expected = dictationExpectedForExercise(exercise);
    const res = dictation && expected ? compareDictationAnswer(expected, answer) : grade(answer);
    setChecked(res);
    if (res && !res.correct && attempts === 1) setFirstTags(Array.isArray(res.tags) ? res.tags : []);
  };
  const feedbackTags = checked && Array.isArray(checked.tags) ? checked.tags : [];
  const contrast = checked && !checked.correct ? contrastFor(model, answer, dictation) : null;
  const feedbackHint = checked && !checked.correct && !contrast ? explanationForTags(feedbackTags) : "";

  const wrong = !!checked && !checked.correct;
  const correctedAfterRetry = !!checked && checked.correct && attempts >= 2;
  const canRetry = wrong && attempts === 1 && !revealModel; // 1er essai raté : on rejoue
  const finalWrong = wrong && (attempts >= 2 || revealModel);
  const showModel = !!checked && (checked.correct || finalWrong); // le modèle n'apparaît qu'à la fin

  const retry = () => {
    setRetryHint(contrast ? contrast.reason : explanationForTags(firstTags));
    setAnswer(""); setChecked(null); setAttempts(2);
  };
  const finishCorrect = () => onDone({ answer, correct: true, correctedAfterRetry, errorTags: correctedAfterRetry ? firstTags : [], attempts, feedbackShown: true });
  const finishWrong = () => onDone({ answer, correct: false, errorTags: feedbackTags.length ? feedbackTags : firstTags, attempts, feedbackShown: true });

  const statusLabel = !checked ? ""
    : checked.correct
      ? (correctedAfterRetry ? "Corrigé après reprise" : (dictation ? dictationFeedbackLabel(checked) : "Correct"))
      : (dictation ? dictationFeedbackLabel(checked) : "À revoir");
  return (
    <div style={{ display: "grid", gap: 16 }}>
      <div className="card" style={{ padding: 24, display: "grid", gap: 12 }}>
        <Eyebrow>{dictation ? "Dictée — écrivez ce que vous entendez" : (p.mode === "dialogue_reply" ? "Réponse de dialogue" : "Production guidée")}{attempts > 1 ? " · reprise" : ""}</Eyebrow>
        {!dictation && p.contextKr && (
          <div style={{ display: "grid", gap: 5, padding: "10px 12px", borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px solid var(--rule)" }}>
            <div className="eyebrow">Réplique précédente</div>
            <PhraseWords text={p.contextKr} glossFor={glossFor} audioUrl={p.audioUrl} audioRef={p.audioRef || p.contextKr} size={22} justify="flex-start" />
            <GlossAid text={p.contextKr} glossFor={glossFor} />
            {p.contextFr && <span className="faint" style={{ fontSize: 12.5 }}>{p.contextFr}</span>}
          </div>
        )}
        {dictation
          ? (
            <div style={{ display: "grid", gap: 12 }}>
              <div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
                <span className="chip">1 · écouter</span>
                <SpeakBtn text="" audioRef={(p.audio && (p.audio.ref || p.audio.text)) || ""} audioUrl={(p.audio && p.audio.url) || ""} size={42} title="Réécouter la ligne" />
                {p.speaker && <span className="faint" style={{ fontSize: 12.5 }}>locuteur {p.speaker}</span>}
              </div>
              {dictationLevels.length > 0 && (
                <div style={{ display: "grid", gap: 10, padding: "12px 14px", borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px solid var(--rule)" }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                    <span className="chip">2 · niveaux</span>
                    <span className="faint" style={{ fontSize: 12.5 }}>Choisissez un niveau, puis reconstruisez avant la phrase entière.</span>
                  </div>
                  <div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
                    {dictationLevels.map((level) => (
                      <button key={level.id} onClick={() => { setDictationLevelId(level.id); setUnitChecked(null); }} className="chip" style={{ cursor: "pointer", background: activeDictationLevel && activeDictationLevel.id === level.id ? "var(--accent-tint)" : "var(--card)", color: activeDictationLevel && activeDictationLevel.id === level.id ? "var(--accent-deep)" : "var(--ink-soft)", borderColor: activeDictationLevel && activeDictationLevel.id === level.id ? "transparent" : "var(--rule)" }}>
                        {level.order} · {polishRunnerText(level.label)} <span className="faint" style={{ fontSize: 11 }}>{level.units.length}</span>
                      </button>
                    ))}
                  </div>
                  {activeDictationLevel && activeDictationLevel.audioTags && activeDictationLevel.audioTags.length > 0 && (
                    <div className="faint" style={{ fontSize: 12 }}>À écouter ici : {activeDictationLevel.audioTags.join(" · ")}</div>
                  )}
                  {activeDictationLevel && activeDictationLevel.kind === "line" ? (
                    <div className="faint" style={{ fontSize: 12.5 }}>La phrase entière se valide dans le champ principal ci-dessous.</div>
                  ) : (
                    <>
                      <div style={{ display: "grid", gap: 8 }}>
                        {activeDictationUnits.map((unit, index) => {
                          const result = unitChecked && unitChecked.results && unitChecked.results[index];
                          const bg = result ? (result.correct ? "var(--good-tint)" : "var(--warn-tint)") : "var(--card)";
                          return (
                            <div key={unit.id || index} style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr) auto", gap: 8, alignItems: "center" }}>
                              <SpeakBtn text={unit.text} audioRef={(unit.audio && (unit.audio.ref || unit.audio.id)) || unit.text} audioUrl={unit.audio && unit.audio.url} size={32} title={`Écouter ${polishRunnerText(activeDictationLevel.label || activeDictationLevel.kind || "niveau").toLowerCase()} ${index + 1}`} />
                              <input value={activeUnitAnswers[index] || ""} onChange={ev => setUnitAnswer(index, ev.target.value)} className="kr" lang="ko" autoCapitalize="off" autoCorrect="off" spellCheck={false} placeholder={`${activeDictationLevel.kind} ${index + 1}`} style={{ minWidth: 0, padding: "9px 11px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: bg, color: "var(--ink)", fontSize: 18 }} />
                              {result && <span className="faint" style={{ fontSize: 12 }}>{result.correct ? "OK" : dictationFeedbackLabel(result)}</span>}
                            </div>
                          );
                        })}
                      </div>
                      <div style={{ display: "flex", justifyContent: "space-between", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
                        {unitChecked && !unitChecked.correct && unitChecked.tags.length > 0 && <span className="faint" style={{ fontSize: 12 }}>Tags niveau : {unitChecked.tags.join(" · ")}</span>}
                        <span style={{ flex: 1 }} />
                        <Btn kind="secondary" size="sm" icon="check" disabled={!activeUnitAnswers.some(v => String(v || "").trim())} onClick={checkUnits}>Vérifier ce niveau</Btn>
                      </div>
                    </>
                  )}
                </div>
              )}
              <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                <span className="chip">3 · phrase entière</span>
                <span className="faint" style={{ fontSize: 12.5 }}>Le résultat final est noté sur cette phrase.</span>
              </div>
            </div>
          )
          : <div style={{ fontFamily: "var(--serif)", fontSize: 24, color: "var(--accent-deep)" }}>{p.fr}</div>}
        {p.pattern && <div className="faint" style={{ fontSize: 13 }}>Patron : <span className="kr">{p.pattern}</span></div>}
        {must.length > 0 && <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>{must.map((m, i) => <span key={i} className="chip kr" style={{ fontSize: 12 }}>{m}</span>)}</div>}
        {attempts > 1 && !checked && retryHint && (
          <div className="fade-up" style={{ fontSize: 13, padding: "8px 12px", borderRadius: "var(--radius-sm)", background: "var(--accent-tint)", color: "var(--accent-deep)" }}>Indice : {retryHint}</div>
        )}
        <textarea value={answer} onChange={ev => { setAnswer(ev.target.value); setChecked(null); setRevealModel(false); }} rows={2} placeholder={attempts > 1 ? "Reprenez en corrigeant l'écart…" : "Votre réponse en coréen…"} className="kr" lang="ko" autoCapitalize="off" autoCorrect="off" spellCheck={false}
          style={{ width: "100%", padding: "11px 13px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: "var(--card-2)", color: "var(--ink)", fontSize: 20, resize: "vertical" }} />
        <KoreanKeyboard value={answer} onChange={(next) => { setAnswer(next); setChecked(null); setRevealModel(false); }} compact />
        {checked && (
          <div className="fade-up" style={{ display: "grid", gap: 8, padding: "12px 14px", borderRadius: "var(--radius-sm)", background: checked.correct ? "var(--good-tint)" : "var(--warn-tint)" }}>
            <div style={{ fontWeight: 700, color: checked.correct ? "var(--good)" : "var(--warn)" }}>{statusLabel}</div>
            {showModel && <div>Modèle : <span className="kr" style={{ fontSize: 18 }}>{checked.expected}</span></div>}
            {dictation && !checked.correct && <div className="faint" style={{ fontSize: 13 }}>À écouter : {dictationTags.join(" · ")}</div>}
            {wrong && contrast && <ContrastFeedback contrast={contrast} />}
            {wrong && !contrast && feedbackHint && <div className="faint" style={{ fontSize: 13 }}>À retenir : {feedbackHint}</div>}
            {wrong && feedbackTags.length > 0 && <div className="faint" style={{ fontSize: 12 }}>Tags : {feedbackTags.join(" · ")}</div>}
            {canRetry && <div className="faint" style={{ fontSize: 12.5 }}>Reprends tout de suite en corrigeant juste cet écart — le modèle reste caché.</div>}
            {finalWrong && <div className="faint" style={{ fontSize: 12.5 }}>Ce bloc n'est pas validé : la séance le remettra en reprise.</div>}
          </div>
        )}
      </div>
      <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }}>
        {!checked && <Btn kind="secondary" icon="eye" disabled={!answer.trim()} onClick={check}>Vérifier</Btn>}
        {checked && checked.correct && <Btn kind="primary" icon="check" onClick={finishCorrect}>Continuer</Btn>}
        {canRetry && <>
          <Btn kind="secondary" icon="eye" onClick={() => setRevealModel(true)}>Voir le modèle</Btn>
          <Btn kind="primary" icon="refresh" disabled={!answer.trim()} onClick={retry}>Réessayer</Btn>
        </>}
        {finalWrong && <Btn kind="primary" icon="refresh" onClick={finishWrong}>Continuer · à reprendre</Btn>}
      </div>
    </div>
  );
}

// Réparation = vrai mini-exercice objectif (AUDIT §réparation), plus de « J'ai
// compris » auto-déclaré. Deux modes pilotés par prompt.drill :
//  - choose_form : cloze à compléter en choisissant la bonne forme (이에요/예요, 이/가…).
//  - produce     : produire une micro-phrase qui réutilise l'ancre du patron.
function RunnerRepairBlock({ exercise, onDone }) {
  const p = exercise.prompt || {};
  const f = exercise.feedback || {};
  const drill = (p.drill && p.drill.mode) ? p.drill : { mode: "produce", anchor: "" };
  const tags = Array.isArray(exercise.errorTags) ? exercise.errorTags : [];
  const examples = (exercise.expected && Array.isArray(exercise.expected.examples) ? exercise.expected.examples : [])
    .map(ex => typeof ex === "string" ? { kr: ex } : { kr: ex && (ex.kr || ex.text || ex.answer || ""), fr: ex && (ex.fr || ex.en || ex.meaning || "") })
    .filter(ex => ex.kr || ex.fr)
    .slice(0, 2);
  const warning = polishRunnerText(p.warning || f.warning);
  const hint = repairSpecificHint(drill, explanationForTags(tags, warning));
  const [order, setOrder] = useState([]);   // choix mélangés (choose_form)
  const [picked, setPicked] = useState(null);
  const [answer, setAnswer] = useState("");  // produce
  const [checked, setChecked] = useState(null);
  useEffect(() => {
    setPicked(null); setAnswer(""); setChecked(null);
    if ((drill.mode === "choose_form" || drill.mode === "contrast") && Array.isArray(drill.choices)) {
      const arr = drill.choices.slice();
      for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = arr[i]; arr[i] = arr[j]; arr[j] = t; }
      setOrder(arr);
    } else setOrder([]);
  }, [exercise.id]);

  const Header = (
    <>
      <Eyebrow>Réparation ciblée</Eyebrow>
      <div style={{ fontFamily: "var(--serif)", fontSize: 20, color: "var(--accent-deep)" }}>{polishRunnerText(p.label)}</div>
      {warning && <p className="muted" style={{ fontSize: 14.5, margin: 0 }}>{warning}</p>}
      {p.pattern && <div className="faint" style={{ fontSize: 13 }}>Patron : <span className="kr">{p.pattern}</span></div>}
    </>
  );

  if (drill.mode === "contrast") {
    const answered = picked != null;
    const correct = picked === drill.answer;
    const glosses = drill.glosses || {};
    return (
      <div style={{ display: "grid", gap: 16 }}>
        <div className="card" style={{ padding: 24, display: "grid", gap: 12, borderLeft: "3px solid var(--warn)" }}>
          {Header}
          <div className="eyebrow">Quelle forme pour ce sens ?</div>
          <div style={{ fontFamily: "var(--serif)", fontSize: 22, color: "var(--accent-deep)" }}>{drill.question}</div>
          <div style={{ display: "grid", gap: 8 }}>
            {order.map((c, idx) => {
              let bg = "var(--card)", col = "var(--ink)";
              if (answered && c === drill.answer) { bg = "var(--good-tint)"; col = "var(--good)"; }
              else if (answered && c === picked && !correct) { bg = "var(--warn-tint)"; col = "var(--warn)"; }
              return <button key={idx} onClick={() => picked == null && setPicked(c)} disabled={answered} className="kr" style={{ padding: "12px 15px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: bg, color: col, fontSize: 22, textAlign: "center", cursor: answered ? "default" : "pointer" }}>{c}</button>;
            })}
          </div>
          {answered && (
            <div className="fade-up" style={{ display: "grid", gap: 8, padding: "12px 14px", borderRadius: "var(--radius-sm)", background: correct ? "var(--good-tint)" : "var(--warn-tint)" }}>
              <div style={{ fontWeight: 700, color: correct ? "var(--good)" : "var(--warn)" }}>{correct ? "Juste" : "À revoir"}</div>
              <div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "4px 12px", alignItems: "baseline" }}>
                <span className="kr" style={{ fontSize: 18, fontWeight: 700, color: "var(--good)" }}>{drill.answer}</span>
                <span style={{ fontSize: 13.5 }}>{glosses[drill.answer] || drill.question}</span>
                <span className="kr" style={{ fontSize: 18, color: "var(--warn)", textDecoration: "line-through", textDecorationColor: "var(--warn)" }}>{drill.distractor}</span>
                <span className="faint" style={{ fontSize: 13 }}>{glosses[drill.distractor] || "autre sens"}</span>
              </div>
            </div>
          )}
        </div>
        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
          {answered && <Btn kind="primary" icon="check" onClick={() => onDone({ correct, answer: picked || "", errorTags: correct ? [] : tags, feedbackShown: true })}>Continuer</Btn>}
        </div>
      </div>
    );
  }

  if (drill.mode === "choose_form") {
    const answered = picked != null;
    const correct = picked === drill.answer;
    const parts = String(drill.cloze || "").split("____");
    const filled = answered ? picked : "____";
    return (
      <div style={{ display: "grid", gap: 16 }}>
        <div className="card" style={{ padding: 24, display: "grid", gap: 12, borderLeft: "3px solid var(--warn)" }}>
          {Header}
          <div className="eyebrow">Complète avec la bonne forme</div>
          <div className="kr" style={{ fontSize: 26, color: "var(--ink)" }}>
            {parts[0]}<span style={{ padding: "0 8px", borderBottom: `2px solid ${answered ? (correct ? "var(--good)" : "var(--warn)") : "var(--accent)"}`, color: answered ? (correct ? "var(--good)" : "var(--warn)") : "var(--accent)", fontWeight: 700 }}>{filled}</span>{parts[1] || ""}
          </div>
          <div style={{ display: "grid", gap: 8 }}>
            {order.map((c, idx) => {
              let bg = "var(--card)", col = "var(--ink)";
              if (answered && c === drill.answer) { bg = "var(--good-tint)"; col = "var(--good)"; }
              else if (answered && c === picked && !correct) { bg = "var(--warn-tint)"; col = "var(--warn)"; }
              return <button key={idx} onClick={() => picked == null && setPicked(c)} disabled={answered} className="kr" style={{ padding: "12px 15px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: bg, color: col, fontSize: 20, textAlign: "center", cursor: answered ? "default" : "pointer" }}>{c}</button>;
            })}
          </div>
          {answered && (
            <div className="fade-up" style={{ display: "grid", gap: 6, padding: "12px 14px", borderRadius: "var(--radius-sm)", background: correct ? "var(--good-tint)" : "var(--warn-tint)" }}>
              <div style={{ fontWeight: 700, color: correct ? "var(--good)" : "var(--warn)" }}>{correct ? "Juste" : "À revoir"}</div>
              <div>Forme correcte : <span className="kr" style={{ fontSize: 18, fontWeight: 700 }}>{drill.full}</span></div>
              <div className="faint" style={{ fontSize: 13 }}>{hint}</div>
            </div>
          )}
        </div>
        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
          {answered && <Btn kind="primary" icon="check" onClick={() => onDone({ correct, answer: picked || "", errorTags: correct ? [] : tags, feedbackShown: true })}>Continuer</Btn>}
        </div>
      </div>
    );
  }

  // Mode produire : micro-phrase qui réutilise l'ancre du patron (contrôle objectif).
  const reuse = answer.trim();
  const need = (drill.anchor || "").trim();
  const ok = !!checked && checked.ok;
  const check = () => {
    const passes = need ? (reuse.indexOf(need) >= 0 && reuse.length > need.length) : reuse.length >= 2;
    setChecked({ ok: passes, reason: passes ? "" : (need ? `Réutilise « ${need} » dans une phrase à toi.` : "Écris une micro-phrase complète.") });
  };
  return (
    <div style={{ display: "grid", gap: 16 }}>
      <div className="card" style={{ padding: 24, display: "grid", gap: 10, borderLeft: "3px solid var(--warn)" }}>
        {Header}
        {examples.length > 0 && (
          <div style={{ display: "grid", gap: 6 }}>
            <div className="eyebrow">Modèles</div>
            {examples.map((ex, i) => (
              <div key={i} style={{ display: "grid", gap: 3, padding: "9px 11px", borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px solid var(--rule)" }}>
                {ex.kr && <span className="kr" style={{ fontSize: 17 }}>{ex.kr}</span>}
                {(ex.fr || ex.en) && <span className="faint" style={{ fontSize: 12.5 }}>{ex.fr || ex.en}</span>}
              </div>
            ))}
          </div>
        )}
        <div className="eyebrow">{need ? <>Écris une micro-phrase avec <span className="kr">{need}</span></> : "Écris une micro-phrase qui applique le patron"}</div>
        <textarea value={answer} onChange={ev => { setAnswer(ev.target.value); setChecked(null); }} rows={2} placeholder="Ta micro-phrase en coréen…" className="kr" lang="ko" autoCapitalize="off" autoCorrect="off" spellCheck={false}
          style={{ width: "100%", padding: "11px 13px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: "var(--card-2)", color: "var(--ink)", fontSize: 18, resize: "vertical" }} />
        <KoreanKeyboard value={answer} onChange={(next) => { setAnswer(next); setChecked(null); }} compact />
        {checked && (
          <div className="fade-up" style={{ display: "grid", gap: 4, padding: "10px 12px", borderRadius: "var(--radius-sm)", background: ok ? "var(--good-tint)" : "var(--warn-tint)", color: ok ? "var(--good)" : "var(--warn)", fontSize: 13.5 }}>
            <span style={{ fontWeight: 700 }}>{ok ? "Patron réutilisé — bien" : "Pas encore"}</span>
            {!ok && <span className="faint" style={{ fontSize: 12.5 }}>{checked.reason} À retenir : {hint}</span>}
          </div>
        )}
      </div>
      <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }}>
        {!ok && <Btn kind="secondary" icon="eye" disabled={!reuse} onClick={check}>Vérifier</Btn>}
        {checked && !ok && <Btn kind="ghost" size="sm" onClick={() => onDone({ correct: false, answer: reuse, errorTags: tags, feedbackShown: true })}>Continuer (fragile)</Btn>}
        {ok && <Btn kind="primary" icon="check" onClick={() => onDone({ correct: true, answer: reuse, feedbackShown: true })}>Continuer</Btn>}
      </div>
    </div>
  );
}

function RunnerGenericBlock({ exercise, onDone }) {
  return (
    <div style={{ display: "grid", gap: 16 }}>
      <div className="card" style={{ padding: 24 }}>
        <Eyebrow>{(exercise.type || "exercice").replace(/_/g, " ")}</Eyebrow>
        <p className="muted" style={{ fontSize: 14.5, margin: "10px 0 0" }}>Bloc sans surface dédiée pour l'instant — marquez-le fait pour continuer la séance.</p>
      </div>
      <div style={{ display: "flex", justifyContent: "flex-end" }}>
        <Btn kind="primary" icon="check" onClick={() => onDone({ correct: true })}>Fait · continuer</Btn>
      </div>
    </div>
  );
}

const INPUT_KINDS = { dialogue_listening: 1, dialogue_comprehension: 1, source_listening: 1 };
const PROD_KINDS = { guided_production: 1, dialogue_reply: 1, dictation: 1 };
const CARD_KINDS = { cards_srs: 1, vocab_freq: 1 };

// Bilan honnête : titre + cadre qui reflètent ce qui a VRAIMENT été acquis (pas
// l'accuracy brute, qui peut rester haute alors que tout a été passé). Un bloc
// passé n'est pas un bloc acquis ; trop de passages => séance « survolée ».
// Profil débutant absolu → ton franc mais jamais décourageant.
function bilanTone({ ratio, passedShare }) {
  if (ratio == null) return { title: "Séance terminée.", note: "", color: "var(--ink)" };
  if (passedShare >= 0.5) return { title: "Séance survolée.", note: "Beaucoup de blocs passés : reviens-y pour les acquérir pour de vrai.", color: "var(--warn)" };
  if (ratio >= 0.8) return { title: "Bien joué.", note: "La plupart des items sont acquis — continue à ce rythme.", color: "var(--good)" };
  if (ratio >= 0.5) return { title: "Séance bouclée.", note: "Du solide, et des écarts à reprendre dès la prochaine fois.", color: "var(--ochre)" };
  return { title: "Séance difficile.", note: "C'est normal au début : ce sont justement ces écarts qui font progresser.", color: "var(--warn)" };
}

function RunnerBilan({ session, go }) {
  const s = session.summary || {};
  const counts = s.counts || {};
  const strands = s.strands || {};
  const tags = Object.keys(s.errorTags || {}).map(k => ({ tag: k, n: s.errorTags[k] })).sort((a, b) => b.n - a.n);
  const plannedMinutes = Math.round(Number(session.plannedMinutes || s.plannedMinutes || 0));
  const activeMinutes = Math.round(Number(s.minutesActive || 0));
  const durationText = plannedMinutes > 0 ? `${plannedMinutes} min prévues · ${activeMinutes} min actives` : `${activeMinutes} min actives`;

  // Unités explicites (cf. audit §passages) : acquis ≠ à reprendre ≠ passé.
  const total = s.blocksTotal || 0;
  const mastered = s.blocksMastered != null ? s.blocksMastered : (s.blocksCompleted || 0);
  const fragile = s.blocksPartial || 0;   // gradué sans maîtrise = à reprendre
  const passed = s.blocksSkipped || 0;    // passé = non-acquis explicite
  const ratio = total ? mastered / total : null;
  const passedShare = total ? passed / total : 0;
  const tone = bilanTone({ ratio, passedShare });

  // Réussite réelle = accuracy au niveau des tentatives (les passages en sont exclus).
  const graded = (counts.correct || 0) + (counts.errors || 0);
  const accuracy = s.accuracy != null ? s.accuracy : (graded ? (counts.correct || 0) / graded : null);
  const accPct = accuracy != null ? Math.round(accuracy * 100) : null;
  const corrected = counts.corrected || 0;
  const countLabel = (n, one, many) => `${n} ${n === 1 ? one : many}`;

  return (
    <div style={{ maxWidth: 620, margin: "30px auto 0", textAlign: "center" }} className="fade-up">
      <Eyebrow>Séance terminée</Eyebrow>
      <h1 style={{ fontSize: 40, margin: "10px 0 6px", color: tone.color }}>{tone.title}</h1>
      <p className="muted" style={{ fontSize: 16 }}>{mastered}/{total} items acquis · {durationText}.</p>
      {tone.note && <p className="faint" style={{ fontSize: 14, margin: "4px 0 0" }}>{tone.note}</p>}
      <div className="card" style={{ padding: 20, margin: "22px 0", display: "grid", gap: 16, textAlign: "left" }}>
        <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14 }}>
          {[
            { v: `${mastered}/${total}`, l: "Items acquis", color: tone.color },
            { v: fragile, l: "À reprendre", color: fragile > 0 ? "var(--ochre)" : "var(--ink-faint)" },
            { v: passed, l: "Passés", color: passed > 0 ? "var(--warn)" : "var(--ink-faint)" },
          ].map((x, i) => (
            <div key={i}><div style={{ fontFamily: "var(--serif)", fontSize: 26, color: x.color }}>{x.v}</div><div className="faint" style={{ fontSize: 12 }}>{x.l}</div></div>
          ))}
        </div>
        <div className="faint" style={{ fontSize: 12.5, display: "grid", gap: 3 }}>
          <span>Réussite réelle {accPct != null ? `${accPct}%` : "—"} · {countLabel(counts.reviewed || 0, "carte notée", "cartes notées")} · {countLabel(counts.correct || 0, "bonne", "bonnes")} / {countLabel(counts.errors || 0, "ratée", "ratées")}</span>
          {corrected > 0 && <span>{corrected} reprise{corrected > 1 ? "s" : ""} corrigée{corrected > 1 ? "s" : ""} après erreur</span>}
          {(counts.produced || 0) > 0 && <span>Productions : {counts.produced || 0} tentées → {counts.producedOk || 0} validées</span>}
        </div>
        {Object.keys(strands).some(k => strands[k] > 0) && (
          <div>
            <div className="eyebrow" style={{ marginBottom: 8 }}>Minutes par brin</div>
            <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
              {Object.keys(strands).filter(k => strands[k] > 0).map(k => (
                <span key={k} className="chip" style={{ fontSize: 12 }}>{STRAND_LABEL[k] || k} · {strands[k]}′</span>
              ))}
            </div>
          </div>
        )}
        {tags.length > 0 && (
          <div>
            <div className="eyebrow" style={{ marginBottom: 8 }}>Erreurs nommées</div>
            <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>{tags.map(t => <span key={t.tag} className="chip" style={{ fontSize: 12, background: "var(--warn-tint)", color: "var(--warn)", borderColor: "transparent" }}>{t.tag} ×{t.n}</span>)}</div>
          </div>
        )}
      </div>
      <div style={{ display: "flex", gap: 10, justifyContent: "center" }}>
        <Btn kind="primary" icon="today" onClick={() => go("today")}>Retour à Aujourd'hui</Btn>
        <Btn kind="secondary" icon="chart" onClick={() => go("pilotage")}>Voir le pilotage</Btn>
      </div>
    </div>
  );
}

function RunnerPage({ go, routeParams = {} }) {
  const [phase, setPhase] = useState("loading"); // loading | error | running | done
  const [, setTick] = useState(0);
  const force = () => setTick(t => t + 1);
  const [glossMap, setGlossMap] = useState(null);
  const [chunkIndex, setChunkIndex] = useState(null);
  const runnerRef = useRef(null);
  const sessionRef = useRef(null);

  useEffect(() => {
    if (!window.LangData) return undefined;
    let alive = true;
    if (window.LangData.loadGloss) window.LangData.loadGloss().then(p => { if (alive) setGlossMap(p.map || {}); }).catch(() => {});
    if (window.LangData.loadChunkIndex) window.LangData.loadChunkIndex().then(idx => { if (alive) setChunkIndex(idx); }).catch(() => {});
    return () => { alive = false; };
  }, []);
  const glossFor = (s) => (window.resolveGloss ? window.resolveGloss(glossMap, s) : null) || undefined;

  // Phrases porteuses d'un mot (approche lexicale) : on apprend le mot dans un chunk réel.
  const carriersFor = (exercise) => {
    const metaCarriers = Array.isArray(exercise && exercise.meta && exercise.meta.contextCarriers)
      ? exercise.meta.contextCarriers
      : [];
    if (!chunkIndex) return metaCarriers;
    const wid = exercise && exercise.source && exercise.source.id;
    const sids = (wid && chunkIndex.word_to_sentences[wid]) || [];
    const resolved = sids.slice(0, 2).map(sid => ({ id: sid, ...(chunkIndex.sentences[sid] || {}) })).filter(s => s && s.kr);
    return resolved.length ? resolved : metaCarriers;
  };

  useEffect(() => {
    let alive = true;
    if (!window.LangSessionRunner) { setPhase("error"); return undefined; }

    const startWith = (plan, runnerOptions) => {
      if (!alive) return;
      if (!plan || !plan.blocks || !plan.blocks.length) { setPhase("error"); return; }
      const runner = window.LangSessionRunner.createRunner({ plan, ...(runnerOptions || {}) });
      runnerRef.current = runner;
      if (runner.isFinished && runner.isFinished()) {
        sessionRef.current = runner.finish();
        setPhase("done");
      } else {
        setPhase("running");
      }
    };

    if (routeParams.resume) {
      const draft = window.LangSessionRunner.readDraft && window.LangSessionRunner.readDraft(window.LangStore);
      if (!draft) setPhase("error");
      else startWith(draft.plan, { session: draft.session, mastery: draft.mastery, index: draft.index, finished: !!draft.finished, blockStartedAt: draft.blockStartedAt });
      return () => { alive = false; };
    }
    if (routeParams.plan && routeParams.plan.blocks && routeParams.plan.blocks.length) {
      startWith(routeParams.plan);
      return () => { alive = false; };
    }
    if (!window.LangData || !window.LangPlanner) { setPhase("error"); return undefined; }
    Promise.all([
      window.LangData.loadCardsP0(), window.LangData.loadVocabFreq(), window.LangData.loadPrompts(),
      window.LangData.loadDialogues(), window.LangData.loadExercises(), window.LangData.loadErrorBank(),
      window.LangData.loadSessionTemplates(), window.LangData.loadListening(),
      window.LangData.loadChunkIndex ? window.LangData.loadChunkIndex() : Promise.resolve(null),
    ]).then(([cardsP0, vocabFreq, prompts, dialogues, exercises, errorBank, sessionTemplates, listening, chunkIndex]) => {
      const storeSettings = window.LangStore && window.LangStore.settings && window.LangStore.settings.get ? window.LangStore.settings.get() : {};
      startWith(window.LangPlanner.buildPlan({
        banks: { cardsP0, vocabFreq, prompts, dialogues, exercises, errorBank, sessionTemplates, listening, chunkIndex },
        minutes: routeParams.duration || 30,
        charge: routeParams.charge || "moderee",
        intention: routeParams.intention || "reviser",
        drillMode: routeParams.drillMode || storeSettings.todayDrillMode || "mixte",
        source: routeParams.source || "",
        state: (typeof planState === "function" ? planState() : undefined),
        budget: (typeof planBudget === "function" ? planBudget() : undefined),
        learnerState: (typeof planLearnerState === "function" ? planLearnerState(typeof planBudget === "function" ? planBudget() : undefined) : undefined),
      }));
    }).catch(() => { if (alive) setPhase("error"); });
    return () => { alive = false; };
  }, []);

  // Coupe tout audio (fichier + synthèse) avant de quitter ou de changer de bloc :
  // sans ça, une voix lancée puis un skip rapide laissait l'audio jouer dans le vide.
  const stopAudio = () => {
    if (window.LangAudio && window.LangAudio.stop) { try { window.LangAudio.stop(); } catch (e) {} }
  };

  const finishSession = () => {
    stopAudio();
    const runner = runnerRef.current;
    if (runner) sessionRef.current = runner.finish();
    setPhase("done");
  };

  const handle = (partial) => {
    const runner = runnerRef.current;
    if (!runner) return;
    stopAudio();
    const out = runner.submit(partial);
    if (out.finished) finishSession();
    else force();
  };
  const handleSkip = () => {
    const runner = runnerRef.current;
    if (!runner) return;
    stopAudio();
    const out = runner.skip();
    if (out.finished) finishSession();
    else force();
  };

  // Filet de sécurité : si le runner est démonté (navigation hors séance via le
  // menu, retour arrière…), on coupe l'audio même si la sortie n'est pas passée
  // par les handlers ci-dessus.
  useEffect(() => () => {
    if (window.LangAudio && window.LangAudio.stop) { try { window.LangAudio.stop(); } catch (e) {} }
  }, []);

  if (phase === "loading") {
    return <div style={{ maxWidth: 720, margin: "40px auto 0" }} className="fade-up"><LoadingCard lines={4} /></div>;
  }
  if (phase === "error") {
    return (
      <div style={{ maxWidth: 620, margin: "40px auto 0" }} className="fade-up">
        <EmptyState icon="today" title="Séance indisponible" body="Le plan du jour n'a pas pu être préparé. Revenez à Aujourd'hui et relancez." action={<Btn kind="secondary" size="sm" icon="today" onClick={() => go("today")}>Aujourd'hui</Btn>} />
      </div>
    );
  }
  if (phase === "done") {
    return <RunnerBilan session={sessionRef.current || { summary: {} }} go={go} />;
  }

  const runner = runnerRef.current;
  const ctx = runner && runner.current();
  if (!ctx) { return <RunnerBilan session={sessionRef.current || { summary: {} }} go={go} />; }
  const prog = runner.progress();
  const ex = ctx.exercise;
  const kind = ctx.block.kind;
  const drillMode = ctx.block.drillMode || "mixte";
  const blockInstanceKey = `${ctx.block.id || ex.id || kind}:${ctx.attempt || 1}`;
  const blockTitle = polishRunnerText(ctx.block.label || (ex.type || kind).replace(/_/g, " "));

  return (
    <div style={{ maxWidth: 760, margin: "0 auto", display: "grid", gap: "var(--gap)" }}>
      <div className="fade-up" style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <Eyebrow>Séance · bloc {ctx.index + 1}/{ctx.total} · {STRAND_LABEL[ctx.block.strand] || ctx.block.strand}</Eyebrow>
          <h1 style={{ fontSize: 28, margin: "3px 0 0" }}>{blockTitle}</h1>
        </div>
        {ctx.isRetry && <span className="chip" style={{ fontSize: 12, background: "var(--warn-tint)", color: "var(--warn)", borderColor: "transparent" }} title="Item raté plus tôt : il revient jusqu'à ce qu'il tienne.">Reprise · essai {ctx.attempt}</span>}
        <span className="chip" style={{ fontSize: 12 }}>Activité · {runnerActivityLabel(kind)}</span>
        <span className="chip" style={{ fontSize: 12 }}>Mode · {runnerDrillLabel(drillMode)}</span>
        <Btn kind="ghost" size="sm" onClick={finishSession}>Quitter</Btn>
      </div>

      <div className="fade-up" style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <Progress value={prog.total ? prog.index / prog.total : 0} />
        <span className="faint" style={{ fontSize: 13, fontFamily: "var(--mono)", whiteSpace: "nowrap" }}>{prog.index} / {prog.total}</span>
      </div>

      <div className="fade-up">
        {CARD_KINDS[kind] && <RunnerCardBlock key={blockInstanceKey} exercise={ex} glossFor={glossFor} carriers={ex.source && ex.source.kind === "vocab_freq" ? carriersFor(ex) : []} onGrade={handle} />}
        {INPUT_KINDS[kind] && <RunnerInputBlock key={blockInstanceKey} exercise={ex} glossFor={glossFor} onDone={handle} />}
        {PROD_KINDS[kind] && <RunnerProductionBlock key={blockInstanceKey} exercise={ex} glossFor={glossFor} grade={(a) => runner.gradeFromAnswer(a)} onDone={handle} />}
        {kind === "error_repair" && <RunnerRepairBlock key={blockInstanceKey} exercise={ex} onDone={handle} />}
        {!CARD_KINDS[kind] && !INPUT_KINDS[kind] && !PROD_KINDS[kind] && kind !== "error_repair" && <RunnerGenericBlock key={blockInstanceKey} exercise={ex} onDone={handle} />}
      </div>

      <div className="fade-up" style={{ display: "flex", justifyContent: "center", paddingTop: 4 }}>
        <Btn kind="ghost" size="sm" onClick={handleSkip}>Passer ce bloc</Btn>
      </div>
    </div>
  );
}

Object.assign(window, { RunnerPage });
