/* Atelier - lots de vocabulaire accelerables, avec contexte et SRS borne. */

const PRACTICE_SELECTORS = [
  { id: "active", label: "Lot actif", icon: "play" },
  { id: "last", label: "Dernier", icon: "grid" },
  { id: "last2", label: "2 derniers", icon: "layers" },
  { id: "today", label: "Aujourd'hui", icon: "today" },
  { id: "recent", label: "Recents", icon: "refresh" },
  { id: "fragile", label: "Fragiles", icon: "wrench" },
];

const PRACTICE_MODES = [
  { value: "recall", label: "Rappel" },
  { value: "recognition", label: "QCM" },
  { value: "context", label: "Contexte" },
];

const PRACTICE_RATINGS = [
  { id: "rate", label: "Rate", color: "var(--warn)", tint: "var(--warn-tint)" },
  { id: "hard", label: "Difficile", color: "var(--ochre)", tint: "var(--warn-tint)" },
  { id: "good", label: "Bon", color: "var(--good)", tint: "var(--good-tint)" },
  { id: "easy", label: "Facile", color: "var(--accent)", tint: "var(--accent-tint)" },
];

const PRACTICE_TIER_LABELS = {
  reel: "contexte reel",
  ecrit: "phrase validee",
  collocation: "collocation",
  patron: "patron",
  autre: "contexte",
};

const ATELIER_MODES = [
  { value: "numbers", label: "Compter" },
  { value: "vocab", label: "Cardio vocab" },
  { value: "songs", label: "Chansons" },
];

const NUMBER_FILTERS = [
  { value: "mix", label: "Mix" },
  { value: "system", label: "Systemes" },
  { value: "qcm", label: "QCM" },
  { value: "production", label: "Clavier" },
  { value: "audio", label: "Audio" },
  { value: "contrast", label: "Contrastes" },
];

const NUMBER_TYPE_LABELS = {
  choose_system: "Choisir le systeme",
  qcm_number: "Lire le nombre",
  production: "Produire en coreen",
  audio_first: "Audio first",
  contrast_pair: "Serie contrastive",
};

const NUMBER_RUN_LIMIT = 24;

function numberTypeFilter(type) {
  if (type === "choose_system") return "system";
  if (type === "qcm_number") return "qcm";
  if (type === "production") return "production";
  if (type === "audio_first") return "audio";
  if (type === "contrast_pair") return "contrast";
  return "mix";
}

function numberAnswerToken(value) {
  return String(value || "")
    .normalize("NFC")
    .trim()
    .replace(/[.,!?;:'"`()[\]{}]/g, "")
    .replace(/\s+/g, "");
}

function numberMatches(exercise, answer) {
  const got = numberAnswerToken(answer);
  const accepted = (Array.isArray(exercise.answers) && exercise.answers.length ? exercise.answers : [exercise.answer])
    .map(numberAnswerToken);
  return accepted.indexOf(got) >= 0;
}

function numberExerciseKey(id, byId) {
  const ex = byId && byId[id] ? byId[id] : id;
  if (!ex || typeof ex === "string") return String(ex || "");
  return ex.repeatKey || `${ex.type}:${ex.prompt || ex.id}`;
}

function numberChoiceOrder(exercise) {
  const choices = Array.isArray(exercise.choices) ? exercise.choices.slice() : [];
  return choices.sort((a, b) => practiceHash(`${exercise.id}:num:${a}`) - practiceHash(`${exercise.id}:num:${b}`));
}

function numberExerciseSet(exercises, filter) {
  const list = Array.isArray(exercises) ? exercises : [];
  if (!filter || filter === "mix") return list;
  return list.filter((exercise) => numberTypeFilter(exercise.type) === filter);
}

function numberSystemChip(system) {
  if (system === "native") return "Natif";
  if (system === "sino") return "Sino";
  return "Nombre";
}

function numberGeneratedExercises(payload, filter, seed) {
  if (window.LangNumbers && typeof window.LangNumbers.generatedExercises === "function") {
    return window.LangNumbers.generatedExercises(payload, { filter: filter || "mix", seed: seed || "preview" });
  }
  const exercises = Array.isArray(payload) ? payload : (payload && payload.exercises);
  return numberExerciseSet(exercises, filter);
}

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

function playNumberText(text) {
  const value = String(text || "");
  if (value && numberHasKorean(value) && window.LangAudio) window.LangAudio.speak(value, { ref: value });
}

function NumberSpeakText({ text, className, style, audioByText }) {
  const value = String(text || "");
  if (!value) return null;
  if (!numberHasKorean(value)) return <span className={className} style={style}>{value}</span>;
  const alternatives = value.split(/\s+\/\s+/).filter(Boolean);
  if (alternatives.length > 1 && alternatives.every(numberHasKorean)) {
    return <NumberSpeakGroup values={alternatives} style={style} audioByText={audioByText} />;
  }
  const audioUrl = audioByText && audioByText[value] ? audioByText[value] : "";
  return <Speakable text={value} audioRef={value} audioUrl={audioUrl} frame={false} className={className || "kr"} style={style}>{value}</Speakable>;
}

function NumberSpeakGroup({ values, style, audioByText }) {
  const parts = (Array.isArray(values) ? values : [values]).filter(Boolean);
  return (
    <span style={style}>
      {parts.map((part, index) => (
        <React.Fragment key={`${part}:${index}`}>
          {index > 0 && <span> / </span>}
          <NumberSpeakText text={part} className="kr" style={{ fontSize: "inherit", color: "inherit" }} audioByText={audioByText} />
        </React.Fragment>
      ))}
    </span>
  );
}

function NumberRichText({ text, className, style, audioByText }) {
  const parts = String(text || "").split(/([\u3131-\u318e\uac00-\ud7a3]+(?:\s+[\u3131-\u318e\uac00-\ud7a3]+)*)/g);
  return (
    <span className={className} style={style}>
      {parts.map((part, index) => (
        numberHasKorean(part)
          ? <NumberSpeakText key={index} text={part} className="kr" style={{ color: "inherit", fontSize: "inherit" }} audioByText={audioByText} />
          : <React.Fragment key={index}>{part}</React.Fragment>
      ))}
    </span>
  );
}

function startNumbersRun(payload, filter) {
  const seed = `run:${filter || "mix"}:${Date.now()}:${Math.random()}`;
  const pool = numberGeneratedExercises(payload, filter || "mix", seed);
  const selected = pool.slice(0, Math.min(NUMBER_RUN_LIMIT, pool.length));
  const byId = {};
  selected.forEach((exercise) => { byId[exercise.id] = exercise; });
  let queue = selected.map((exercise) => exercise.id);
  if (window.LangReviewQueue) {
    for (let index = 1; index < queue.length; index++) {
      queue = window.LangReviewQueue.avoidImmediateRepeatAtCursor(queue, index, queue[index - 1], {
        keyFor: (id) => numberExerciseKey(id, byId),
      });
    }
  }
  return {
    id: `numbers_${Date.now()}_${filter || "mix"}`,
    filter: filter || "mix",
    queue,
    items: selected,
    poolSize: pool.length,
    pos: 0,
    attempts: {},
    results: [],
    startedAt: Date.now(),
    finished: false,
  };
}

function practiceMeaning(item) {
  return String((item && (item.fr || item.meaning_fr || item.answer_primary)) || "").trim();
}

function practiceKr(item) {
  return String((item && (item.kr || item.script)) || "").trim();
}

function practiceReading(item) {
  return String((item && (item.reading || item.ro || item.romanisation_temporaire)) || "").trim();
}

function practiceAudioRef(item) {
  return (item && (item.audioRef || item.audio_ref || (item.audio && (item.audio.ref || item.audio.id)))) || practiceKr(item);
}

function practiceAudioUrl(item) {
  return (item && (item.audioUrl || (item.audio && item.audio.url))) || "";
}

function practiceHash(text) {
  const s = String(text || "");
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
  return h >>> 0;
}

function practiceChoices(item, allItems, count = 4) {
  const correct = practiceMeaning(item);
  const out = [];
  function add(v) {
    const s = String(v || "").trim();
    if (s && out.indexOf(s) < 0) out.push(s);
  }
  add(correct);
  (Array.isArray(item && item.choices) ? item.choices : []).forEach(add);
  const pool = (allItems || [])
    .map(practiceMeaning)
    .filter(Boolean)
    .filter((x) => out.indexOf(x) < 0)
    .sort((a, b) => (practiceHash(`${item.id}:${a}`) - practiceHash(`${item.id}:${b}`)));
  pool.slice(0, Math.max(0, count - out.length)).forEach(add);
  return out
    .slice(0, count)
    .sort((a, b) => (practiceHash(`${item.id}:choice:${a}`) - practiceHash(`${item.id}:choice:${b}`)));
}

function practiceQueueKey(id) {
  return String(id || "").trim();
}

function practiceLotTitle(lot) {
  if (!lot) return "Lot";
  return lot.title || `Lot ${String(lot.id || "").split("_").slice(-1)[0] || ""}`;
}

function practicePercent(v) {
  return `${Math.round((Number.isFinite(v) ? v : 0) * 100)} %`;
}

function practiceTime(ts) {
  if (!ts) return "jamais";
  const d = new Date(ts);
  return d.toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}

function practiceTierLabel(tier) {
  return PRACTICE_TIER_LABELS[tier] || PRACTICE_TIER_LABELS.autre;
}

function practiceTierStyle(tier) {
  if (tier === "reel" || tier === "ecrit") return { background: "var(--good-tint)", color: "var(--good)", borderColor: "transparent" };
  if (tier === "collocation") return { background: "var(--accent-tint)", color: "var(--accent-deep)", borderColor: "transparent" };
  if (tier === "patron") return { background: "var(--warn-tint)", color: "var(--warn)", borderColor: "transparent" };
  return { background: "var(--card-2)" };
}

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

function PracticePhrase({ sentence, focusKr }) {
  if (!sentence || !sentence.kr) return null;
  const parts = String(sentence.kr).split(/(\s+)/);
  const focus = normalizePracticeTextToken(focusKr);
  const stem = focus && focus.endsWith("다") ? focus.slice(0, -1) : "";
  return (
    <div style={{ display: "grid", gap: 8 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
        {sentence.audioUrl
          ? <SpeakBtn text={sentence.kr} audioRef={sentence.audioRef || sentence.kr} audioUrl={sentence.audioUrl} size={28} title="Ecouter la phrase" />
          : <span className="chip" style={{ fontSize: 11 }}>texte seul</span>}
        <span className="chip" style={{ fontSize: 11, ...practiceTierStyle(sentence.contextQuality) }}>{practiceTierLabel(sentence.contextQuality)}</span>
      </div>
      <div className="kr" style={{ fontSize: 22, lineHeight: 1.65, color: "var(--ink)" }}>
        {parts.map((part, index) => {
          if (!part.trim()) return <span key={index}>{part}</span>;
          const clean = normalizePracticeTextToken(part);
          const hit = clean && (clean === focus || (stem.length >= 2 && clean.startsWith(stem)));
          if (!clean) return <span key={index}>{part}</span>;
          return (
            <Speakable key={index} text={clean} audioRef={clean} frame={false} className="kr"
              style={{ padding: "1px 3px", background: hit ? "var(--accent-tint)" : "transparent", color: hit ? "var(--accent-deep)" : "inherit", fontWeight: hit ? 700 : 500 }}>
              {part}
            </Speakable>
          );
        })}
      </div>
      {(sentence.fr || sentence.en) && <div className="muted" style={{ fontSize: 14 }}>{sentence.fr || sentence.en}</div>}
    </div>
  );
}

function PracticeContext({ item, chunkIndex }) {
  const carriers = window.LangPracticeLots ? window.LangPracticeLots.contextForItem(item, chunkIndex, 2) : [];
  const kr = practiceKr(item);
  if (!carriers.length) {
    return <div className="muted" style={{ fontSize: 14 }}>Aucune phrase porteuse reliee pour ce mot.</div>;
  }
  return (
    <div style={{ display: "grid", gap: 10 }}>
      {carriers.map((sentence) => (
        <div key={sentence.id} style={{ border: "1px solid var(--rule)", background: "var(--card-2)", borderRadius: "var(--radius-sm)", padding: 14 }}>
          <PracticePhrase sentence={sentence} focusKr={kr} />
        </div>
      ))}
    </div>
  );
}

function PracticeStatsStrip({ lots, passes, activeWorkset }) {
  const activeTotal = activeWorkset && activeWorkset.stats ? activeWorkset.stats.total : 0;
  const mastery = activeWorkset && activeWorkset.stats ? practicePercent(activeWorkset.stats.mastery) : "0 %";
  return (
    <StatStrip items={[
      { value: lots.length, label: "Lots" },
      { value: activeTotal, label: "Items selectionnes", accent: true },
      { value: mastery, label: "Solides dans la selection" },
      { value: passes.length, label: "Passes" },
    ]} />
  );
}

function LotRow({ lot, active, summary, onSelect, onStart }) {
  const mastered = !!lot.masteredAt;
  return (
    <div style={{ padding: "12px 0", borderTop: "1px solid var(--rule)", display: "grid", gap: 8 }}>
      <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
        <button onClick={onSelect} className="chip" style={{
          cursor: "pointer",
          background: active ? "var(--accent-tint)" : "var(--card)",
          color: active ? "var(--accent-deep)" : "var(--ink-soft)",
          borderColor: active ? "var(--accent-line)" : "var(--rule)",
          fontWeight: 800,
        }}>
          {practiceLotTitle(lot)}
        </button>
        <span className="chip" style={{ fontSize: 11 }}>{lot.wordIds.length} mots</span>
        <span className="chip" style={{ fontSize: 11, ...(mastered ? { background: "var(--good-tint)", color: "var(--good)", borderColor: "transparent" } : {}) }}>
          {mastered ? "maitrise" : practicePercent(summary.stats.mastery)}
        </span>
      </div>
      <div className="faint" style={{ fontSize: 12 }}>
        Cree {practiceTime(lot.createdAt)} · derniere passe {practiceTime(lot.lastPracticedAt)} · fragile {summary.stats.fragile}
      </div>
      <div>
        <Btn kind="ghost" size="sm" icon="play" onClick={onStart}>Travailler ce lot</Btn>
      </div>
    </div>
  );
}

function WorksetButtons({ selected, worksets, onSelect, onStart }) {
  return (
    <div style={{ display: "grid", gap: 8 }}>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(135px, 1fr))", gap: 8 }}>
        {PRACTICE_SELECTORS.map((s) => {
          const ws = worksets[s.id];
          const disabled = !ws || !ws.wordIds.length;
          const on = selected === s.id;
          return (
            <button key={s.id} disabled={disabled} onClick={() => onSelect(s.id)} title={disabled ? "Aucun item dans cette selection" : undefined} style={{
              border: `1px solid ${on ? "var(--accent-line)" : "var(--rule)"}`,
              background: on ? "var(--accent-tint)" : "var(--card)",
              color: on ? "var(--accent-deep)" : "var(--ink-soft)",
              opacity: disabled ? .48 : 1,
              borderRadius: "var(--radius-sm)",
              padding: 12,
              cursor: disabled ? "not-allowed" : "pointer",
              display: "grid",
              gap: 5,
              textAlign: "left",
            }}>
              <span style={{ display: "flex", alignItems: "center", gap: 7, fontWeight: 800 }}><Icon name={s.icon} size={16} />{s.label}</span>
              <span className="faint" style={{ fontSize: 12 }}>{ws ? ws.wordIds.length : 0} items</span>
            </button>
          );
        })}
      </div>
      <div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }}>
        <Btn kind="primary" icon="play" disabled={!worksets[selected] || !worksets[selected].wordIds.length} onClick={() => onStart(selected)}>Lancer la selection</Btn>
      </div>
    </div>
  );
}

function PracticeRun({ run, setRun, data, practice, persistPractice, refreshPractice }) {
  const [mode, setMode] = useState(run.mode || "recall");
  const [revealed, setRevealed] = useState(mode === "context");
  const [picked, setPicked] = useState(null);
  const [tick, setTick] = useState(0);
  const [audioMsg, setAudioMsg] = useState(null);
  const sessionRef = useRef(null);
  const shownAtRef = useRef(Date.now());

  const vocabMap = data.vocabMap;
  const allItems = data.items;
  const currentId = run.queue[run.pos];
  const currentItem = vocabMap[currentId];
  const currentAttempt = (run.attempts[currentId] || 0) + 1;
  const choices = currentItem ? practiceChoices(currentItem, allItems, 4) : [];
  const selectedMeaning = picked == null ? "" : choices[picked];
  const selectedCorrect = picked == null ? null : selectedMeaning === practiceMeaning(currentItem);

  useEffect(() => {
    setMode(run.mode || "recall");
    setRevealed((run.mode || "recall") === "context");
    setPicked(null);
    shownAtRef.current = Date.now();
  }, [run.id, run.pos]);

  function ensureSession() {
    if (!window.LangSessionLog || !window.LangStore) return null;
    if (!sessionRef.current) {
      sessionRef.current = window.LangSessionLog.startSession({
        profile: window.LangStore.profile,
        planId: `practice_${run.selector}`,
        templateId: "practice_lots",
        intention: "maitrise_vocab",
        charge: "acceleree",
        drillMode: mode,
        plannedMinutes: 0,
        blocks: run.workset.wordIds.map((id) => ({ id: `practice_${id}`, kind: "practice_vocab", strand: mode === "context" ? "input" : "study", required: true })),
      });
    }
    return sessionRef.current;
  }

  function finishPass(nextRun) {
    const now = Date.now();
    const recorded = window.LangPracticeLots.recordPass(practice, {
      startedAt: run.startedAt,
      finishedAt: now,
      mode,
      selector: run.selector,
      lotIds: run.workset.lotIds,
      itemIds: run.workset.wordIds,
      results: nextRun.results,
    });
    persistPractice(recorded.state);
    if (sessionRef.current && window.LangSessionLog && window.LangStore) {
      window.LangSessionLog.finishSession(sessionRef.current, window.LangStore, now);
      sessionRef.current = null;
    }
    setRun(Object.assign({}, nextRun, { finished: true, pass: recorded.pass }));
  }

  function submitRating(ratingId) {
    if (!currentItem) return;
    const now = Date.now();
    const rating = selectedCorrect === false ? "rate" : ratingId;
    const grade = window.LangPracticeLots.GRADE_BY_RATING[rating];
    const correct = grade >= 2;
    const prev = window.LangStore && window.LangStore.cards ? window.LangStore.cards.state(currentId) : null;
    const settings = window.LangStore && window.LangStore.settings ? window.LangStore.settings.get() : {};
    const bounded = window.LangPracticeLots.boundedSrsReview({
      itemId: currentId,
      rating,
      prev,
      now,
      scheduler: window.FSRS,
      retention: settings.retention || (window.FSRS && window.FSRS.DEFAULT_RETENTION) || 0.9,
      errorTags: correct ? [] : ["sens"],
    });
    if (bounded.card && window.LangStore && window.LangStore.cards) {
      window.LangStore.cards.setState(currentId, bounded.card);
    }
    const result = {
      itemId: currentId,
      rating,
      grade,
      correct,
      answer: selectedMeaning,
      attempt: currentAttempt,
      startedAt: shownAtRef.current,
      finishedAt: now,
      srsPolicy: bounded.policy,
      srsUpdated: !!bounded.update,
    };
    const session = ensureSession();
    if (session && window.LangSessionLog) {
      window.LangSessionLog.logResult(session, {
        exerciseId: `practice_${currentId}`,
        itemId: currentId,
        kind: "practice_vocab",
        strand: mode === "context" ? "input" : "study",
        startedAt: shownAtRef.current,
        finishedAt: now,
        grade,
        correct,
        answer: selectedMeaning,
        errorTags: correct ? [] : ["sens"],
        feedbackShown: true,
        srsUpdates: bounded.update ? [bounded.update] : [],
        notes: bounded.policy,
      });
    }

    const attempts = Object.assign({}, run.attempts, { [currentId]: currentAttempt });
    let queue = run.queue.slice();
    const shouldRequeue = rating !== "easy" && currentAttempt < 3;
    const nextPos = run.pos + 1;
    if (shouldRequeue) {
      if (window.LangReviewQueue) {
        queue = window.LangReviewQueue.requeueAfterGap(queue, currentId, nextPos, { keyFor: practiceQueueKey, gap: 2 });
      } else {
        const insertAt = Math.min(queue.length, nextPos + 2);
        queue.splice(insertAt, 0, currentId);
      }
    }
    if (window.LangReviewQueue) {
      queue = window.LangReviewQueue.avoidImmediateRepeatAtCursor(queue, nextPos, currentId, { keyFor: practiceQueueKey });
    }
    const nextRun = Object.assign({}, run, {
      mode,
      attempts,
      queue,
      pos: nextPos,
      results: run.results.concat(result),
    });
    setPicked(null);
    setRevealed(mode === "context");
    shownAtRef.current = Date.now();
    if (nextPos >= queue.length) finishPass(nextRun);
    else setRun(nextRun);
    setTick(tick + 1);
    refreshPractice();
  }

  if (!currentItem && !run.finished) {
    return <EmptyState icon="grid" title="Selection vide" body="Cree un lot ou choisis une autre selection." />;
  }

  if (run.finished) {
    const sum = run.pass && run.pass.summary ? run.pass.summary : window.LangPracticeLots.summarizeResults(run.results);
    return (
      <section className="card fade-up" style={{ padding: "var(--pad-card)", display: "grid", gap: 18 }}>
        <div>
          <Eyebrow>Passage termine</Eyebrow>
          <h2 style={{ fontSize: 34, marginTop: 4 }}>{sum.perfect ? "Lot maitrise" : "A consolider"}</h2>
          <p className="muted" style={{ margin: "8px 0 0", maxWidth: 620 }}>
            {sum.easy} faciles sur {sum.total}. {sum.errors ? `${sum.errors} erreur(s) ou reprise(s) : relance une passe ciblee.` : "Aucune erreur dans cette passe."}
          </p>
        </div>
        <StatStrip items={[
          { value: sum.total, label: "Items" },
          { value: sum.easy, label: "Faciles", accent: true },
          { value: sum.attempts, label: "Tentatives" },
          { value: practicePercent(sum.easyRatio || 0), label: "Maitrise" },
        ]} />
        <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
          <Btn kind="primary" icon="refresh" onClick={() => setRun(startPracticeRun(run.workset, run.selector, mode))}>Refaire la meme selection</Btn>
          <Btn kind="secondary" icon="wrench" onClick={() => setRun(startPracticeRun(window.LangPracticeLots.selectWorkset(practice, data.payload, "fragile", { cardStates: window.LangStore.cards.all(), now: Date.now() }), "fragile", mode))}>Reprendre les fragiles</Btn>
          <Btn kind="ghost" icon="grid" onClick={() => setRun(null)}>Retour atelier</Btn>
        </div>
      </section>
    );
  }

  const progress = run.queue.length ? run.pos / run.queue.length : 0;
  const meaning = practiceMeaning(currentItem);
  const kr = practiceKr(currentItem);
  const reading = practiceReading(currentItem);
  const canRate = mode === "recognition" ? picked != null : revealed;
  const itemImage = currentItem && window.primaryImageForItem
    ? window.primaryImageForItem(data.imageBank, [currentItem.id], mode === "context" ? "vocab_context" : "card_back")
    : null;
  const showItemImage = itemImage && (revealed || picked != null || mode === "context");
  const reportAudio = () => {
    const api = window.LangAudio;
    if (!api || typeof api.reportLastAudio !== "function") { setAudioMsg("Audio indisponible"); setTimeout(() => setAudioMsg(null), 2200); return; }
    Promise.resolve(api.reportLastAudio({ card: currentItem && currentItem.id, fr: meaning || "" })).then((res) => {
      const msg = !res || !res.ok ? "Écoute d'abord le mot, puis re-signale"
        : res.mode === "queue" ? `« ${res.entry.text} » → file de régénération`
        : `« ${res.entry.text} » copié — à coller dans la file`;
      setAudioMsg(msg); setTimeout(() => setAudioMsg(null), 2800);
    });
  };

  return (
    <section className="card fade-up" style={{ padding: "var(--pad-card)", display: "grid", gap: 18 }}>
      <div style={{ display: "flex", gap: 12, justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap" }}>
        <div>
          <Eyebrow>{run.workset.label}</Eyebrow>
          <h2 style={{ fontSize: 32, marginTop: 4 }}>Atelier vocabulaire</h2>
        </div>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }}>
          <Segmented value={mode} options={PRACTICE_MODES} onChange={(v) => { setMode(v); setRevealed(v === "context"); setPicked(null); }} size="sm" />
          <span className="chip" style={{ fontSize: 12 }}>{run.pos + 1} / {run.queue.length}</span>
          {currentAttempt > 1 && <span className="chip" style={{ fontSize: 12, background: "var(--warn-tint)", color: "var(--warn)", borderColor: "transparent" }}>essai {currentAttempt}</span>}
          <Btn kind="ghost" size="sm" icon="speaker" onClick={reportAudio} title="Signaler le dernier audio écouté comme mauvais (file de régénération)">Audio faux</Btn>
          {audioMsg && <span className="chip" style={{ fontSize: 12, background: "var(--accent-tint)", color: "var(--accent-deep)", borderColor: "transparent" }}>{audioMsg}</span>}
        </div>
      </div>
      <Progress value={progress} />

      <div style={{ display: "grid", gap: 16, justifyItems: "center", textAlign: "center", padding: "8px 0 4px" }}>
        <Speakable text={kr} audioRef={practiceAudioRef(currentItem)} audioUrl={practiceAudioUrl(currentItem)} className="kr" gloss={revealed ? meaning : undefined} glossRevealed={revealed}
          style={{ fontSize: "clamp(42px, 7vw, 76px)", lineHeight: 1.05, padding: "6px 14px", color: "var(--accent-deep)" }}>
          {kr}
        </Speakable>
        <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 14 }}>{reading}</div>
        {showItemImage && <LearningImage image={itemImage} compact style={{ justifySelf: "center" }} />}
      </div>

      {mode === "recognition" && (
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", gap: 8 }}>
          {choices.map((choice, index) => {
            const on = picked === index;
            const correct = choice === meaning;
            const shown = picked != null;
            return (
              <button key={choice} onClick={() => setPicked(index)} style={{
                border: `1px solid ${on ? "var(--accent-line)" : "var(--rule)"}`,
                background: shown && correct ? "var(--good-tint)" : on ? "var(--accent-tint)" : "var(--card-2)",
                color: shown && correct ? "var(--good)" : on ? "var(--accent-deep)" : "var(--ink-soft)",
                borderRadius: "var(--radius-sm)",
                padding: "12px 14px",
                textAlign: "left",
                cursor: "pointer",
                fontWeight: 800,
              }}>{choice}</button>
            );
          })}
        </div>
      )}

      {mode !== "recognition" && !revealed && (
        <div style={{ display: "flex", justifyContent: "center" }}>
          <Btn kind="secondary" icon="eye" onClick={() => setRevealed(true)}>Verifier</Btn>
        </div>
      )}

      {(revealed || picked != null || mode === "context") && (
        <div className="fade-up" style={{ display: "grid", gap: 14 }}>
          <div style={{ border: "1px solid var(--rule)", background: "var(--card-2)", borderRadius: "var(--radius-sm)", padding: 16 }}>
            <div className="eyebrow" style={{ marginBottom: 6 }}>Sens</div>
            <div style={{ fontSize: 22, fontFamily: "var(--serif)", color: "var(--ink)" }}>{meaning}</div>
            {(currentItem.classe || currentItem.pos || currentItem.level) && (
              <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginTop: 10 }}>
                {(currentItem.level || currentItem.niveau_cible) && <span className="chip" style={{ fontSize: 11 }}>{currentItem.level || currentItem.niveau_cible}</span>}
                {(currentItem.classe || currentItem.pos) && <span className="chip" style={{ fontSize: 11 }}>{currentItem.classe || currentItem.pos}</span>}
              </div>
            )}
          </div>
          <PracticeContext item={currentItem} chunkIndex={data.chunkIndex} />
        </div>
      )}

      <div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 8 }}>
        {PRACTICE_RATINGS.map((rating) => {
          const disabled = !canRate || (selectedCorrect === false && rating.id !== "rate");
          return (
            <button key={rating.id} disabled={disabled} onClick={() => submitRating(rating.id)} style={{
              border: "1px solid var(--rule)",
              borderRadius: "var(--radius-sm)",
              padding: "12px 8px",
              background: rating.tint,
              color: rating.color,
              opacity: disabled ? .4 : 1,
              cursor: disabled ? "not-allowed" : "pointer",
              fontWeight: 900,
            }}>{rating.label}</button>
          );
        })}
      </div>
      <div className="faint" style={{ fontSize: 12 }}>
        SRS : une premiere rencontre planifie le mot ; les repetitions faciles le meme jour sont journalisees sans agrandir encore l'intervalle.
      </div>
    </section>
  );
}

function startPracticeRun(workset, selector, mode) {
  const ids = (workset && workset.wordIds ? workset.wordIds : []).slice();
  return {
    id: `run_${Date.now()}_${selector || "active"}`,
    selector: selector || "active",
    mode: mode || "recall",
    workset,
    queue: ids,
    pos: 0,
    attempts: {},
    results: [],
    startedAt: Date.now(),
    finished: false,
  };
}

function NumberRun({ run, setRun, payload }) {
  const [picked, setPicked] = useState(null);
  const [answer, setAnswer] = useState("");
  const [checked, setChecked] = useState(null);

  const byId = {};
  (payload.exercises || []).concat(run.items || []).forEach((exercise) => { byId[exercise.id] = exercise; });
  const currentId = run.queue[run.pos];
  const current = byId[currentId];
  const currentAttempt = (run.attempts[currentId] || 0) + 1;
  const progress = run.queue.length ? run.pos / run.queue.length : 0;

  useEffect(() => {
    setPicked(null);
    setAnswer("");
    setChecked(null);
  }, [run.id, run.pos]);

  function finish(nextRun) {
    setRun(Object.assign({}, nextRun, { finished: true }));
  }

  function submitResult(correct, value) {
    if (!current) return;
    const result = {
      exerciseId: current.id,
      type: current.type,
      system: current.system || "",
      correct,
      answer: value || "",
      expected: current.answer || "",
      attempt: currentAttempt,
      finishedAt: Date.now(),
    };
    const attempts = Object.assign({}, run.attempts, { [current.id]: currentAttempt });
    let queue = run.queue.slice();
    const nextPos = run.pos + 1;
    if (!correct && currentAttempt < 3 && window.LangReviewQueue) {
      queue = window.LangReviewQueue.requeueAfterGap(queue, current.id, nextPos, {
        keyFor: (id) => numberExerciseKey(id, byId),
        gap: 2,
      });
    }
    if (window.LangReviewQueue) {
      queue = window.LangReviewQueue.avoidImmediateRepeatAtCursor(queue, nextPos, current.id, {
        keyFor: (id) => numberExerciseKey(id, byId),
      });
    }
    const nextRun = Object.assign({}, run, {
      attempts,
      queue,
      pos: nextPos,
      results: run.results.concat(result),
    });
    if (nextPos >= queue.length) finish(nextRun);
    else setRun(nextRun);
  }

  function choose(choice) {
    const correct = numberMatches(current, choice);
    setPicked(choice);
    setChecked({ correct, value: choice });
  }

  function checkProduction() {
    const correct = numberMatches(current, answer);
    setChecked({ correct, value: answer });
  }

  if (run.finished) {
    const total = run.results.length;
    const correct = run.results.filter((result) => result.correct).length;
    const bySystem = run.results.reduce((acc, result) => {
      if (result.system) acc[result.system] = (acc[result.system] || 0) + 1;
      return acc;
    }, {});
    return (
      <section className="card fade-up" style={{ padding: "var(--pad-card)", display: "grid", gap: 18 }}>
        <div>
          <Eyebrow>Compter</Eyebrow>
          <h2 style={{ fontSize: 34, marginTop: 4 }}>{correct === total ? "Serie nette" : "A consolider"}</h2>
          <p className="muted" style={{ margin: "8px 0 0", maxWidth: 620 }}>
            {correct} reponses justes sur {total}. Les erreurs peuvent etre relancees dans le meme mode.
          </p>
        </div>
        <StatStrip items={[
          { value: total, label: "Questions" },
          { value: correct, label: "Justes", accent: true },
          { value: bySystem.native || 0, label: "Natif" },
          { value: bySystem.sino || 0, label: "Sino" },
        ]} />
        <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
          <Btn kind="primary" icon="refresh" onClick={() => setRun(startNumbersRun(payload, run.filter))}>Refaire ce mode</Btn>
          <Btn kind="secondary" icon="grid" onClick={() => setRun(null)}>Retour Compter</Btn>
        </div>
      </section>
    );
  }

  if (!current) return <EmptyState icon="grid" title="Serie vide" body="Choisis un autre mode de nombres." />;

  const isProduction = current.type === "production";
  const isAudio = current.type === "audio_first";
  const choices = numberChoiceOrder(current);
  const title = NUMBER_TYPE_LABELS[current.type] || "Nombres";
  const systemLabel = numberSystemChip(current.system);

  return (
    <section className="card fade-up" style={{ padding: "var(--pad-card)", display: "grid", gap: 18 }}>
      <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap", alignItems: "flex-start" }}>
        <div>
          <Eyebrow>{title}</Eyebrow>
          <h2 style={{ fontSize: 32, marginTop: 4 }}>Compter en coreen</h2>
        </div>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }}>
          <span className="chip" style={{ fontSize: 12 }}>{systemLabel}</span>
          <span className="chip" style={{ fontSize: 12 }}>{run.pos + 1} / {run.queue.length}</span>
          {currentAttempt > 1 && <span className="chip" style={{ fontSize: 12, background: "var(--warn-tint)", color: "var(--warn)", borderColor: "transparent" }}>essai {currentAttempt}</span>}
        </div>
      </div>
      <Progress value={progress} />

      <div style={{ display: "grid", gap: 14, justifyItems: "center", textAlign: "center", padding: "4px 0 2px" }}>
        {isAudio ? (
          <>
            <SpeakBtn text={current.audioText} audioRef={current.audioText} size={54} title="Ecouter" />
            <div className="muted" style={{ fontSize: 16 }}>{current.prompt}</div>
          </>
        ) : current.type === "qcm_number" ? (
          <Speakable text={current.prompt} audioRef={current.prompt} className="kr" style={{ fontSize: "clamp(42px, 7vw, 68px)", lineHeight: 1.05, padding: "6px 14px", color: "var(--accent-deep)" }}>
            {current.prompt}
          </Speakable>
        ) : (
          <div style={{ fontFamily: "var(--serif)", fontSize: "clamp(34px, 6vw, 58px)", color: "var(--accent-deep)", lineHeight: 1.08 }}>
            {current.prompt}
          </div>
        )}
        {current.type === "choose_system" && <div className="muted" style={{ fontSize: 15 }}>Quel systeme faut-il utiliser ?</div>}
      </div>

      {isProduction ? (
        <div style={{ display: "grid", gap: 10 }}>
          <textarea value={answer} onChange={(ev) => { setAnswer(ev.target.value); setChecked(null); }} rows={2} placeholder="Tapez la reponse en coreen..." className="kr" lang="ko" autoCapitalize="off" autoCorrect="off" spellCheck={false}
            style={{ width: "100%", resize: "vertical", border: "1px solid var(--rule)", borderRadius: "var(--radius-sm)", padding: 13, background: "var(--card-2)", color: "var(--ink)", fontSize: 22, lineHeight: 1.5, boxSizing: "border-box" }} />
          {window.KoreanKeyboard && <KoreanKeyboard value={answer} onChange={(next) => { setAnswer(next); setChecked(null); }} compact />}
          <div style={{ display: "flex", justifyContent: "flex-end" }}>
            <Btn kind="secondary" icon="check" disabled={!answer.trim()} onClick={checkProduction}>Verifier</Btn>
          </div>
        </div>
      ) : (
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 8 }}>
          {choices.map((choice) => {
            const on = picked === choice;
            const correct = checked && numberMatches(current, choice);
            return (
              <button key={choice} onClick={() => { playNumberText(choice); choose(choice); }} style={{
                border: `1px solid ${on ? "var(--accent-line)" : "var(--rule)"}`,
                background: checked && correct ? "var(--good-tint)" : on ? "var(--accent-tint)" : "var(--card-2)",
                color: checked && correct ? "var(--good)" : on ? "var(--accent-deep)" : "var(--ink-soft)",
                borderRadius: "var(--radius-sm)",
                padding: "12px 14px",
                textAlign: "center",
                cursor: "pointer",
                fontWeight: 850,
                minHeight: 48,
              }}><span className={numberHasKorean(choice) ? "kr" : undefined}>{choice}</span></button>
            );
          })}
        </div>
      )}

      {checked && (
        <div className="fade-up" style={{
          border: "1px solid var(--rule)",
          background: checked.correct ? "var(--good-tint)" : "var(--warn-tint)",
          borderRadius: "var(--radius-sm)",
          padding: 15,
          display: "grid",
          gap: 8,
        }}>
          <div style={{ fontWeight: 900, color: checked.correct ? "var(--good)" : "var(--warn)" }}>
            {checked.correct ? "Juste" : "A reprendre"} - reponse : <NumberSpeakText text={current.answer} className={numberHasKorean(current.answer) ? "kr" : undefined} />
          </div>
          {(current.kr || current.audioText) && (
            <div className="kr" style={{ fontSize: 22, color: "var(--ink)" }}>
              <Speakable text={current.kr || current.audioText} audioRef={current.kr || current.audioText} frame={false}>
                {current.kr || current.audioText}
              </Speakable>
            </div>
          )}
          {current.explanation && <div className="muted" style={{ fontSize: 14 }}>{current.explanation}</div>}
        </div>
      )}

      <div style={{ display: "flex", justifyContent: "space-between", gap: 10, flexWrap: "wrap" }}>
        <Btn kind="ghost" icon="grid" onClick={() => setRun(null)}>Quitter</Btn>
        <Btn kind="primary" icon="next" disabled={!checked} onClick={() => checked && submitResult(checked.correct, checked.value)}>Continuer</Btn>
      </div>
    </section>
  );
}

function numberAudioMapFromManifest(manifest) {
  const map = {};
  const entries = manifest && Array.isArray(manifest.entries) ? manifest.entries : [];
  entries.forEach((entry) => {
    const text = entry && (entry.text || entry.kr);
    const rawUrl = entry && (entry.url || entry.path);
    const hash = entry && entry.hash ? String(entry.hash).replace(/^sha256:/, "").slice(0, 12) : "";
    const url = rawUrl && hash && String(rawUrl).indexOf("/audio/numbers/") === 0
      ? rawUrl + (String(rawUrl).indexOf("?") >= 0 ? "&" : "?") + "h=" + hash
      : rawUrl;
    if (text && url && (entry.cat === "numbers" || String(url).indexOf("/audio/numbers/") === 0)) map[text] = url;
  });
  return map;
}

function useNumberAudioByText() {
  const [audioByText, setAudioByText] = useState({});
  useEffect(() => {
    let alive = true;
    const loader = window.LangData && window.LangData.loadAudioManifest
      ? window.LangData.loadAudioManifest()
      : fetch("/data/audio_manifest.json").then((r) => r.ok ? r.json() : Promise.reject(new Error("manifest indisponible")));
    loader
      .then((manifest) => { if (alive) setAudioByText(numberAudioMapFromManifest(manifest)); })
      .catch(() => { if (alive) setAudioByText({}); });
    return () => { alive = false; };
  }, []);
  return audioByText;
}

function NumbersLesson({ payload, audioByText: providedAudioByText }) {
  const loadedAudioByText = useNumberAudioByText();
  const audioByText = providedAudioByText || loadedAudioByText;
  const lesson = payload.lesson || {};
  const generatedEntries = window.LangNumbers && typeof window.LangNumbers.generatedNumberEntries === "function"
    ? window.LangNumbers.generatedNumberEntries(99)
    : (payload.numbers || []);
  const firstNumbers = generatedEntries.filter((entry) => entry.value >= 0 && entry.value <= 10);
  const extraByValue = {};
  (payload.numbers || []).forEach((entry) => { extraByValue[entry.value] = entry; });
  const firstNumberRows = firstNumbers.map((entry) => Object.assign({}, entry, extraByValue[entry.value] || {}));
  const structureNumbers = (payload.numbers || []).filter((entry) => [20, 30, 40, 50, 60, 70, 80, 90, 100, 1000, 10000, 42000].indexOf(entry.value) >= 0);
  return (
    <section className="card fade-up" style={{ padding: "var(--pad-card)", display: "grid", gap: 18 }}>
      <div>
        <Eyebrow>Cours</Eyebrow>
        <h2 style={{ fontSize: 32, marginTop: 4 }}>{lesson.title || "La base des nombres coreens"}</h2>
        {lesson.lead && <p className="muted" style={{ margin: "8px 0 0", maxWidth: 760, fontSize: 16 }}>{lesson.lead}</p>}
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(230px, 1fr))", gap: 10 }}>
        {(lesson.sections || []).map((section) => (
          <div key={section.id} style={{ border: "1px solid var(--rule)", background: "var(--card-2)", borderRadius: "var(--radius-sm)", padding: 14, display: "grid", gap: 8 }}>
            <div style={{ fontWeight: 900, color: "var(--accent-deep)" }}>{section.title}</div>
            {(section.body || []).map((line, index) => (
              <p key={index} className="muted" style={{ margin: 0, fontSize: 13.5, lineHeight: 1.45 }}><NumberRichText text={line} audioByText={audioByText} /></p>
            ))}
          </div>
        ))}
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 14 }}>
        <div style={{ border: "1px solid var(--rule)", borderRadius: "var(--radius-sm)", overflow: "hidden" }}>
          <div style={{ padding: "10px 12px", background: "var(--card-2)", fontWeight: 900 }}>Premiers nombres</div>
          <div style={{ display: "grid" }}>
            {firstNumberRows.map((entry) => (
              <div key={entry.value} style={{ display: "grid", gridTemplateColumns: "42px 1fr 1fr", gap: 8, padding: "8px 12px", borderTop: "1px solid var(--rule)", alignItems: "baseline" }}>
                <span className="faint" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{entry.value}</span>
                <NumberSpeakText text={entry.sino} className="kr" style={{ fontSize: 18 }} audioByText={audioByText} />
                {entry.native ? (
                  <NumberSpeakGroup values={entry.nativeAttributive && entry.nativeAttributive !== entry.native ? [entry.native, entry.nativeAttributive] : [entry.native]} style={{ fontSize: 18 }} audioByText={audioByText} />
                ) : entry.code ? (
                  <NumberSpeakText text={entry.code} className="kr" style={{ fontSize: 18 }} audioByText={audioByText} />
                ) : (
                  <span className="faint" style={{ fontSize: 18 }}>-</span>
                )}
              </div>
            ))}
          </div>
        </div>

        <div style={{ border: "1px solid var(--rule)", borderRadius: "var(--radius-sm)", overflow: "hidden" }}>
          <div style={{ padding: "10px 12px", background: "var(--card-2)", fontWeight: 900 }}>Structures utiles</div>
          <div style={{ display: "grid" }}>
            {structureNumbers.map((entry) => (
              <div key={entry.value} style={{ display: "grid", gridTemplateColumns: "72px 1fr", gap: 8, padding: "8px 12px", borderTop: "1px solid var(--rule)", alignItems: "baseline" }}>
                <span className="faint" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{entry.value}</span>
                <NumberSpeakGroup values={entry.native ? [entry.sino, entry.nativeAttributive || entry.native] : [entry.sino]} style={{ fontSize: 18 }} audioByText={audioByText} />
              </div>
            ))}
          </div>
        </div>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: 8 }}>
        {(lesson.examples || []).map((example) => (
          <div key={example.label} style={{ border: "1px solid var(--rule)", background: "var(--card-2)", borderRadius: "var(--radius-sm)", padding: 12, display: "grid", gap: 5 }}>
            <div className="faint" style={{ fontSize: 12 }}>{example.label}</div>
            <NumberSpeakText text={example.kr} className="kr" style={{ fontSize: 21, color: "var(--ink)" }} audioByText={audioByText} />
            <div className="muted" style={{ fontSize: 12.5 }}><NumberRichText text={example.note} audioByText={audioByText} /></div>
          </div>
        ))}
      </div>
    </section>
  );
}

function NumbersWorkshop() {
  const [load, setLoad] = useState(() => ({ status: window.LangData && window.LangData.loadNumbers ? "loading" : "error", payload: null, error: null }));
  const [filter, setFilter] = useState("mix");
  const [run, setRun] = useState(null);
  const [showLesson, setShowLesson] = useState(false);
  const audioByText = useNumberAudioByText();

  useEffect(() => {
    let alive = true;
    if (!window.LangData || !window.LangData.loadNumbers) {
      setLoad((s) => ({ ...s, status: "error", error: new Error("loadNumbers missing") }));
      return () => { alive = false; };
    }
    window.LangData.loadNumbers().then((payload) => {
      if (alive) setLoad({ status: "ready", payload, error: null });
    }).catch((error) => {
      if (alive) setLoad((s) => ({ ...s, status: "error", error }));
    });
    return () => { alive = false; };
  }, []);

  if (load.status === "loading") return <div style={{ display: "grid", gap: 16 }}><LoadingCard /><LoadingCard lines={4} /></div>;
  if (load.status === "error") return <ErrorState title="Compter indisponible" body={load.error && load.error.message} onRetry={() => location.reload()} />;

  const payload = load.payload || { systems: [], exercises: [], contrasts: [] };
  const filtered = numberGeneratedExercises(payload, filter, `preview:${filter}`);
  const allGenerated = numberGeneratedExercises(payload, "mix", "preview:mix");
  const systemCount = numberGeneratedExercises(payload, "system", "preview:system").length;
  const productionCount = numberGeneratedExercises(payload, "production", "preview:production").length;
  const audioCount = numberGeneratedExercises(payload, "audio", "preview:audio").length;
  const runSize = Math.min(NUMBER_RUN_LIMIT, filtered.length);

  if (run) return <NumberRun run={run} setRun={setRun} payload={payload} />;

  return (
    <div style={{ display: "grid", gap: 22 }}>
      <section style={{ display: "flex", justifyContent: "space-between", gap: 20, alignItems: "flex-end", flexWrap: "wrap" }}>
        <div>
          <Eyebrow>Compter</Eyebrow>
          <h1 style={{ fontSize: "clamp(36px, 5vw, 58px)", marginTop: 4 }}>Nombres coreens</h1>
          <p className="muted" style={{ maxWidth: 720, margin: "10px 0 0", fontSize: 16 }}>
            Alterner sino-coreen et natif jusqu'a choisir le bon systeme sans hesitation.
          </p>
        </div>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }}>
          <Btn kind={showLesson ? "primary" : "secondary"} icon="book" onClick={() => setShowLesson((value) => !value)}>{showLesson ? "Masquer le cours" : "Cours"}</Btn>
          <Btn kind="primary" icon="play" disabled={!filtered.length} onClick={() => setRun(startNumbersRun(payload, filter))}>Lancer</Btn>
        </div>
      </section>

      {showLesson && <NumbersLesson payload={payload} audioByText={audioByText} />}

      <StatStrip items={[
        { value: allGenerated.length, label: "Vivier" },
        { value: systemCount, label: "Choix systeme", accent: true },
        { value: productionCount, label: "Productions" },
        { value: audioCount, label: "Audio-first" },
      ]} />

      <div className="atelier-grid" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) 330px", gap: 22, alignItems: "start" }}>
        <div style={{ display: "grid", gap: 18 }}>
          <section className="card" style={{ padding: "var(--pad-card)", display: "grid", gap: 18 }}>
            <div style={{ display: "flex", justifyContent: "space-between", gap: 14, alignItems: "center", flexWrap: "wrap" }}>
              <div>
                <Eyebrow>Mode</Eyebrow>
                <h2 style={{ fontSize: 30, marginTop: 4 }}>Serie de nombres</h2>
              </div>
              <Segmented value={filter} options={NUMBER_FILTERS} onChange={setFilter} size="sm" />
            </div>
            <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: 8 }}>
              {filtered.slice(0, 8).map((exercise) => (
                <div key={exercise.id} style={{ border: "1px solid var(--rule)", background: "var(--card-2)", borderRadius: "var(--radius-sm)", padding: 12, display: "grid", gap: 6 }}>
                  <span className="chip" style={{ fontSize: 11, justifySelf: "start" }}>{numberSystemChip(exercise.system)}</span>
                  <div style={{ fontWeight: 850 }}>{NUMBER_TYPE_LABELS[exercise.type] || "Nombre"}</div>
                  <div className={numberHasKorean(exercise.prompt) ? "kr faint" : "faint"} style={{ fontSize: 13 }}>
                    {numberHasKorean(exercise.prompt)
                      ? <NumberSpeakText text={exercise.prompt} className="kr" style={{ fontSize: "inherit", color: "inherit" }} audioByText={audioByText} />
                      : exercise.prompt}
                  </div>
                </div>
              ))}
            </div>
            <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }}>
              <span className="chip" style={{ fontSize: 12 }}>{runSize} tirees / {filtered.length} dans ce mode</span>
              <Btn kind="primary" icon="play" disabled={!filtered.length} onClick={() => setRun(startNumbersRun(payload, filter))}>Commencer</Btn>
            </div>
          </section>
        </div>

        <aside className="atelier-side" style={{ position: "sticky", top: 22, display: "grid", gap: 16 }}>
          <section className="card" style={{ padding: 18, display: "grid", gap: 12 }}>
            <div>
              <Eyebrow>Systemes</Eyebrow>
              <h2 style={{ fontSize: 24, marginTop: 4 }}>Repere rapide</h2>
            </div>
            {(payload.systems || []).map((system) => (
              <div key={system.id} style={{ borderTop: "1px solid var(--rule)", paddingTop: 10, display: "grid", gap: 4 }}>
                <div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
                  <strong>{system.label}</strong>
                  <span className="kr chip" style={{ fontSize: 12 }}>{system.krLabel}</span>
                </div>
                <div className="muted" style={{ fontSize: 13.5 }}>{system.cue}</div>
              </div>
            ))}
          </section>

          <section className="card" style={{ padding: 18, display: "grid", gap: 10 }}>
            <Eyebrow>Contrastes</Eyebrow>
            {(payload.contrasts || []).slice(0, 4).map((item) => (
              <div key={item.value} style={{ display: "grid", gridTemplateColumns: "42px 1fr", gap: 8, alignItems: "baseline" }}>
                <span className="faint" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{item.value}</span>
                <NumberSpeakGroup values={[item.sino, item.native, item.attributive]} style={{ fontSize: 18 }} audioByText={audioByText} />
              </div>
            ))}
          </section>
        </aside>
      </div>
    </div>
  );
}

function VocabWorkshop({ go }) {
  const [load, setLoad] = useState(() => ({ status: window.LangData && window.LangData.loadVocabFreq ? "loading" : "error", payload: null, items: [], vocabMap: {}, chunkIndex: null, imageBank: null, error: null }));
  const [practice, setPractice] = useState(() => window.LangPracticeLots && window.LangStore && window.LangStore.practice ? window.LangPracticeLots.normalizePractice(window.LangStore.practice.all()) : { lots: [], passes: [], activeLotId: null });
  const [selector, setSelector] = useState("active");
  const [mode, setMode] = useState("recall");
  const [run, setRun] = useState(null);
  const [notice, setNotice] = useState("");

  function refreshPractice() {
    if (!window.LangPracticeLots || !window.LangStore || !window.LangStore.practice) return;
    setPractice(window.LangPracticeLots.normalizePractice(window.LangStore.practice.all()));
  }

  function persistPractice(next) {
    const normalized = window.LangPracticeLots.normalizePractice(next);
    if (window.LangStore && window.LangStore.practice) window.LangStore.practice.set(normalized);
    setPractice(normalized);
    return normalized;
  }

  useEffect(() => {
    let alive = true;
    if (!window.LangData || !window.LangData.loadVocabFreq) {
      setLoad((s) => ({ ...s, status: "error", error: new Error("loadVocabFreq missing") }));
      return () => { alive = false; };
    }
    Promise.all([
      window.LangData.loadVocabFreq(),
      window.LangData.loadChunkIndex ? window.LangData.loadChunkIndex() : Promise.resolve(null),
      window.LangData.loadImageBank ? window.LangData.loadImageBank().catch(() => null) : Promise.resolve(null),
    ]).then(([payload, chunkIndex, imageBank]) => {
      if (!alive) return;
      const items = window.LangPracticeLots ? window.LangPracticeLots.orderedVocab(payload) : (payload.items || []);
      const vocabMap = {};
      items.forEach((item) => { vocabMap[item.id] = item; });
      setLoad({ status: "ready", payload, items, vocabMap, chunkIndex, imageBank, error: null });
    }).catch((error) => {
      if (alive) setLoad((s) => ({ ...s, status: "error", error }));
    });
    return () => { alive = false; };
  }, []);

  if (load.status === "loading") return <div style={{ display: "grid", gap: 16 }}><LoadingCard /><LoadingCard lines={4} /></div>;
  if (load.status === "error") return <ErrorState title="Atelier indisponible" body={load.error && load.error.message} onRetry={() => location.reload()} />;

  const cardStates = window.LangStore && window.LangStore.cards ? window.LangStore.cards.all() : {};
  const worksets = {};
  PRACTICE_SELECTORS.forEach((s) => {
    worksets[s.id] = window.LangPracticeLots.selectWorkset(practice, load.payload, s.id, { cardStates, now: Date.now() });
  });
  const activeWorkset = worksets[selector] || worksets.active;
  const lotSummaries = window.LangPracticeLots.summarizeLots(practice, cardStates, Date.now()).slice().sort((a, b) => b.createdAt - a.createdAt);
  const coverage = activeWorkset ? window.LangPracticeLots.contextCoverage(activeWorkset.wordIds, load.chunkIndex) : null;

  function createLot(count) {
    const created = window.LangPracticeLots.createLot(practice, load.payload, { count, now: Date.now(), cardStates });
    if (!created.lot) {
      setNotice("Aucun nouveau mot disponible pour ce lot.");
      return;
    }
    const saved = persistPractice(created.state);
    setSelector("active");
    setNotice(`${practiceLotTitle(created.lot)} cree avec ${created.lot.wordIds.length} mots.`);
    const ws = window.LangPracticeLots.selectWorkset(saved, load.payload, "active", { cardStates: window.LangStore.cards.all(), now: Date.now() });
    setRun(startPracticeRun(ws, "active", mode));
  }

  function setActiveLot(lotId) {
    const next = Object.assign({}, practice, { activeLotId: lotId });
    persistPractice(next);
    setSelector("active");
  }

  function startSelector(sel, nextMode) {
    const ws = window.LangPracticeLots.selectWorkset(practice, load.payload, sel, { cardStates: window.LangStore.cards.all(), now: Date.now() });
    if (!ws.wordIds.length) {
      setNotice("Cette selection est vide pour l'instant.");
      return;
    }
    setRun(startPracticeRun(ws, sel, nextMode || mode));
  }

  return (
    <div className="fade-up" style={{ display: "grid", gap: 24 }}>
      <section style={{ display: "flex", justifyContent: "space-between", gap: 20, alignItems: "flex-end", flexWrap: "wrap" }}>
        <div>
          <Eyebrow>Atelier</Eyebrow>
          <h1 style={{ fontSize: "clamp(36px, 5vw, 58px)", marginTop: 4 }}>Lots de vocabulaire</h1>
          <p className="muted" style={{ maxWidth: 720, margin: "10px 0 0", fontSize: 16 }}>
            Avancer plus vite, reprendre les lots vus plus tot, puis passer aux phrases porteuses quand le rappel tient.
          </p>
        </div>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }}>
          <Btn kind="secondary" icon="grid" onClick={() => go("cartes", { source: "vocabulaire", mode: "jour" })}>Cartes vocab</Btn>
          <Btn kind="ghost" icon="today" onClick={() => go("today")}>Aujourd'hui</Btn>
        </div>
      </section>

      <PracticeStatsStrip lots={practice.lots} passes={practice.passes} activeWorkset={activeWorkset} />

      {notice && <div className="chip" style={{ justifySelf: "start", background: "var(--accent-tint)", color: "var(--accent-deep)", borderColor: "transparent" }}>{notice}</div>}

      <div className="atelier-grid" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) 330px", gap: 22, alignItems: "start" }}>
        <div style={{ display: "grid", gap: 18 }}>
          {run ? (
            <PracticeRun run={run} setRun={setRun} data={load} practice={practice} persistPractice={persistPractice} refreshPractice={refreshPractice} />
          ) : (
            <>
              <section className="card" style={{ padding: "var(--pad-card)", display: "grid", gap: 18 }}>
                <div style={{ display: "flex", justifyContent: "space-between", gap: 14, flexWrap: "wrap", alignItems: "center" }}>
                  <div>
                    <Eyebrow>Creation</Eyebrow>
                    <h2 style={{ fontSize: 30, marginTop: 4 }}>Nouveau lot frequentiel</h2>
                  </div>
                  <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                    {[5, 10, 20].map((n) => <Btn key={n} kind={n === 20 ? "primary" : "secondary"} icon="plus" onClick={() => createLot(n)}>{n} mots</Btn>)}
                  </div>
                </div>
                <div className="muted" style={{ fontSize: 14 }}>
                  Les mots deja places dans un lot ou deja introduits en SRS sont evites lors de la creation automatique.
                </div>
              </section>

              <section className="card" style={{ padding: "var(--pad-card)", display: "grid", gap: 18 }}>
                <div style={{ display: "flex", justifyContent: "space-between", gap: 14, alignItems: "center", flexWrap: "wrap" }}>
                  <div>
                    <Eyebrow>Workset</Eyebrow>
                    <h2 style={{ fontSize: 30, marginTop: 4 }}>Recombiner les lots</h2>
                  </div>
                  <Segmented value={mode} options={PRACTICE_MODES} onChange={setMode} size="sm" />
                </div>
                <WorksetButtons selected={selector} worksets={worksets} onSelect={setSelector} onStart={(sel) => startSelector(sel)} />
                {coverage && activeWorkset && activeWorkset.wordIds.length > 0 && (
                  <div style={{ borderTop: "1px solid var(--rule)", paddingTop: 14, display: "flex", gap: 7, flexWrap: "wrap" }}>
                    <span className="chip" style={{ fontSize: 11 }}>Contexte {coverage.total - coverage.missing}/{coverage.total}</span>
                    <span className="chip" style={{ fontSize: 11 }}>Audio phrase {coverage.audio}</span>
                    <span className="chip" style={{ fontSize: 11, ...practiceTierStyle("reel") }}>reel {coverage.reel + coverage.ecrit}</span>
                    <span className="chip" style={{ fontSize: 11, ...practiceTierStyle("collocation") }}>collocation {coverage.collocation}</span>
                    <span className="chip" style={{ fontSize: 11, ...practiceTierStyle("patron") }}>patron {coverage.patron}</span>
                  </div>
                )}
              </section>
            </>
          )}
        </div>

        <aside className="atelier-side" style={{ position: "sticky", top: 22, display: "grid", gap: 16 }}>
          <section className="card" style={{ padding: 18, display: "grid", gap: 12 }}>
            <div>
              <Eyebrow>Lots</Eyebrow>
              <h2 style={{ fontSize: 24, marginTop: 4 }}>Historique</h2>
            </div>
            {lotSummaries.length ? (
              <div>
                {lotSummaries.slice(0, 8).map((lot) => (
                  <LotRow key={lot.id} lot={lot} active={practice.activeLotId === lot.id} summary={lot} onSelect={() => setActiveLot(lot.id)} onStart={() => {
                    setActiveLot(lot.id);
                    const next = Object.assign({}, practice, { activeLotId: lot.id });
                    const ws = window.LangPracticeLots.selectWorkset(next, load.payload, "active", { cardStates, now: Date.now() });
                    setRun(startPracticeRun(ws, "active", mode));
                  }} />
                ))}
              </div>
            ) : (
              <EmptyState icon="grid" title="Aucun lot" body="Cree un premier lot pour commencer l'entrainement accelere." />
            )}
          </section>

          <section className="card" style={{ padding: 18, display: "grid", gap: 10 }}>
            <Eyebrow>Regle SRS</Eyebrow>
            <div className="muted" style={{ fontSize: 13.5 }}>
              Les tentatives sont toutes journalisees. FSRS est mis a jour a l'introduction, aux vraies echeances, et sur une erreur significative ; les repetitions faciles le meme jour restent en entrainement.
            </div>
          </section>
        </aside>
      </div>
    </div>
  );
}

function songSections(song) {
  return Array.isArray(song && song.sections) ? song.sections : [];
}

function songLines(song) {
  return songSections(song).flatMap((section) => (section.lines || []).map((line) => ({ ...line, section: section.label })));
}

function songLineCount(song) {
  return songLines(song).length;
}

function songSubstitutionCount(song) {
  return (Array.isArray(song && song.substitutionSets) ? song.substitutionSets : [])
    .reduce((total, set) => total + ((set.items && set.items.length) || 0), 0);
}

function songMasteredState(state) {
  if (!state || typeof state !== "object") return false;
  const box = Number(state.box || state.bucket || 0);
  const stability = Number(state.stability || 0);
  const reps = Number(state.reps || state.reviewCount || state.seenCount || 0);
  const lapses = Number(state.lapses || state.errors || 0);
  return box >= 3 || stability >= 2.5 || (reps >= 2 && lapses === 0);
}

function songItemMastered(item, cardStates) {
  const ids = Array.isArray(item && item.sourceIds) ? item.sourceIds : [];
  return ids.some((id) => songMasteredState(cardStates && cardStates[id]));
}

function songAvailableSubstitutions(set, cardStates) {
  const items = Array.isArray(set && set.items) ? set.items : [];
  const mastered = items.filter((item) => songItemMastered(item, cardStates));
  const fallback = items.filter((item) => item.fromSong);
  const out = [];
  const seen = Object.create(null);
  mastered.concat(fallback.length ? fallback : items).forEach((item) => {
    const key = item.kr;
    if (!key || seen[key]) return;
    seen[key] = true;
    out.push(item);
  });
  return out;
}

function songPatternText(set, item) {
  const pattern = String((set && set.pattern) || "");
  const slot = set && set.slot ? `{${set.slot}}` : "{}";
  return pattern.replace(slot, item && item.kr ? item.kr : "___");
}

function songExerciseFr(set, item) {
  if (!set || !item) return "";
  if (set.id === "where-is-place") return `Où est ${item.fr} ?`;
  if (set.id === "where-exists-object") return `Où est ${item.fr} ?`;
  if (set.id === "here-there-exists") return `Il y en a ${item.fr}.`;
  return item.fr;
}

function SongSpeakButton({ text, children, gloss, onSpeak, className, style, title = "Ecouter" }) {
  const [open, setOpen] = useState(false);
  const speak = (event) => {
    event.stopPropagation();
    if (typeof onSpeak === "function") onSpeak(text);
    else if (window.LangAudio) window.LangAudio.speak(text, { ref: text, rate: 0.94 });
    if (gloss) setOpen((value) => !value);
  };
  return (
    <span style={{ position: "relative", display: "inline-block" }}>
      <button type="button" onClick={speak} onMouseEnter={() => gloss && setOpen(true)} onMouseLeave={() => gloss && setOpen(false)}
        className={className} title={gloss ? `${text} - ${gloss}` : title} style={{
          border: "none",
          borderBottom: "1.5px dotted var(--accent-line)",
          background: "transparent",
          color: "inherit",
          cursor: "pointer",
          padding: "1px 2px",
          borderRadius: 5,
          font: "inherit",
          ...style,
        }}>
        {children || text}
      </button>
      {open && gloss && (
        <span role="tooltip" className="fade-up" style={{
          position: "absolute",
          bottom: "calc(100% + 6px)",
          left: "50%",
          transform: "translateX(-50%)",
          width: "max-content",
          maxWidth: 230,
          zIndex: 70,
          background: "var(--ink)",
          color: "var(--paper)",
          borderRadius: 8,
          padding: "5px 9px",
          fontFamily: "var(--sans)",
          fontSize: 12.5,
          fontWeight: 700,
          lineHeight: 1.25,
          textAlign: "center",
          boxShadow: "var(--shadow-pop)",
          pointerEvents: "none",
        }}>{gloss}</span>
      )}
    </span>
  );
}

function SongLineText({ line, onSpeak }) {
  const tokens = Array.isArray(line && line.tokens) && line.tokens.length
    ? line.tokens
    : String((line && line.kr) || "").split(/\s+/).filter(Boolean).map((kr) => ({ kr, fr: "" }));
  return (
    <span>
      {tokens.map((token, index) => (
        <React.Fragment key={`${line.id}:${token.kr}:${index}`}>
          {index > 0 && " "}
          <SongSpeakButton text={token.kr} gloss={token.fr} onSpeak={onSpeak} className="kr">
            {token.kr}
          </SongSpeakButton>
        </React.Fragment>
      ))}
    </span>
  );
}

function SongLineRow({ line, index, active, shadowed, onSelect, onSpeak }) {
  return (
    <div onClick={() => onSelect(line.id)} role="button" tabIndex={0}
      onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onSelect(line.id); } }}
      style={{
        border: `1px solid ${active ? "var(--accent-line)" : "var(--rule)"}`,
        background: active ? "var(--accent-tint)" : "var(--card)",
        borderRadius: "var(--radius-sm)",
        padding: 14,
        display: "grid",
        gridTemplateColumns: "34px minmax(0, 1fr)",
        gap: 12,
        alignItems: "start",
        cursor: "pointer",
      }}>
      <div style={{ display: "grid", gap: 7, justifyItems: "center" }}>
        <span className="faint" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{String(index + 1).padStart(2, "0")}</span>
        <button type="button" onClick={(event) => { event.stopPropagation(); onSpeak(line.kr); }} title="Ecouter la ligne parlee" style={{
          width: 30,
          height: 30,
          borderRadius: 999,
          border: "none",
          background: active ? "var(--accent)" : "var(--accent-tint)",
          color: active ? "#fff" : "var(--accent-deep)",
          cursor: "pointer",
          display: "grid",
          placeItems: "center",
        }}>
          <Icon name="speaker" size={16} />
        </button>
      </div>
      <div style={{ minWidth: 0, display: "grid", gap: 5 }}>
        <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
          <span className="chip" style={{ fontSize: 11 }}>{line.section}</span>
          {shadowed > 0 && <span className="chip" style={{ fontSize: 11, background: "var(--good-tint)", color: "var(--good)", borderColor: "transparent" }}>{shadowed}x</span>}
        </div>
        <div className="kr" style={{ fontSize: "clamp(20px, 4vw, 30px)", lineHeight: 1.45, color: "var(--ink)" }}>
          <SongLineText line={line} onSpeak={onSpeak} />
        </div>
        <div className="muted" style={{ fontSize: 14.5 }}>{line.fr}</div>
      </div>
    </div>
  );
}

function SongShadowingPanel({ lines, activeLineId, setActiveLineId, shadowed, markShadowed, onSpeak }) {
  const index = Math.max(0, lines.findIndex((line) => line.id === activeLineId));
  const line = lines[index] || lines[0];
  if (!line) return <EmptyState icon="speaker" title="Aucune ligne" body="Ce morceau n'a pas encore de paroles." />;

  const goLine = (delta) => {
    const next = lines[Math.max(0, Math.min(lines.length - 1, index + delta))];
    if (next) setActiveLineId(next.id);
  };

  return (
    <section className="card" style={{ padding: 18, display: "grid", gap: 14 }}>
      <div>
        <Eyebrow>Shadowing</Eyebrow>
        <h2 style={{ fontSize: 24, marginTop: 4 }}>Ligne active</h2>
      </div>
      <div style={{ border: "1px solid var(--rule)", borderRadius: "var(--radius-sm)", background: "var(--card-2)", padding: 14, display: "grid", gap: 8 }}>
        <span className="chip" style={{ justifySelf: "start", fontSize: 11 }}>{line.section} · {index + 1}/{lines.length}</span>
        <div className="kr" style={{ fontSize: 27, lineHeight: 1.45, color: "var(--accent-deep)" }}>{line.kr}</div>
        <div className="muted" style={{ fontSize: 14 }}>{line.fr}</div>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 8 }}>
        <Btn kind="ghost" icon="prev" disabled={index <= 0} onClick={() => goLine(-1)}>Avant</Btn>
        <Btn kind="secondary" icon="speaker" onClick={() => onSpeak(line.kr)}>Parler</Btn>
        <Btn kind="ghost" iconR="next" disabled={index >= lines.length - 1} onClick={() => goLine(1)}>Suite</Btn>
      </div>
      <Btn kind="primary" icon="check" onClick={() => markShadowed(line.id)}>J'ai répété</Btn>
      <div className="faint" style={{ fontSize: 12 }}>Répétitions : {shadowed[line.id] || 0}</div>
    </section>
  );
}

function SongSubstitutionExercise({ song, cardStates, onSpeak }) {
  const sets = Array.isArray(song && song.substitutionSets) ? song.substitutionSets : [];
  const [setId, setSetId] = useState(() => (sets[0] && sets[0].id) || "");
  const [picked, setPicked] = useState("");
  const currentSet = sets.find((set) => set.id === setId) || sets[0];
  const items = songAvailableSubstitutions(currentSet, cardStates);
  const selected = items.find((item) => item.kr === picked) || items[0];
  const phrase = selected ? songPatternText(currentSet, selected) : "";
  const masteredCount = items.filter((item) => songItemMastered(item, cardStates)).length;

  useEffect(() => {
    if (currentSet && currentSet.id !== setId) setSetId(currentSet.id);
    if (items.length && !items.some((item) => item.kr === picked)) setPicked(items[0].kr);
  }, [setId, song && song.id]);

  if (!sets.length) return <EmptyState icon="spark" title="Aucune substitution" body="Ce morceau n'a pas encore d'exercices." />;

  return (
    <section className="card" style={{ padding: "var(--pad-card)", display: "grid", gap: 16 }}>
      <div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
        <div>
          <Eyebrow>Substitution</Eyebrow>
          <h2 style={{ fontSize: 30, marginTop: 4 }}>Remplacer sans casser la phrase</h2>
        </div>
        <Segmented value={currentSet && currentSet.id} options={sets.map((set) => ({ value: set.id, label: set.title }))} onChange={(value) => { setSetId(value); setPicked(""); }} size="sm" />
      </div>

      <div style={{ border: "1px solid var(--rule)", background: "var(--card-2)", borderRadius: "var(--radius-sm)", padding: 16, display: "grid", gap: 10 }}>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
          <span className="chip" style={{ fontSize: 11 }}>{items.length} choix</span>
          <span className="chip" style={{ fontSize: 11 }}>{masteredCount} solides SRS</span>
        </div>
        <div className="kr" style={{ fontSize: "clamp(28px, 6vw, 44px)", lineHeight: 1.25, color: "var(--accent-deep)" }}>
          {phrase || "___"}
        </div>
        {selected && <div className="muted" style={{ fontSize: 15 }}>{songExerciseFr(currentSet, selected)}</div>}
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
          <Btn kind="primary" icon="speaker" disabled={!phrase} onClick={() => onSpeak(phrase)}>Parler</Btn>
          <Btn kind="secondary" icon="refresh" disabled={!items.length} onClick={() => {
            if (!items.length) return;
            const currentIndex = Math.max(0, items.findIndex((item) => item.kr === (selected && selected.kr)));
            const next = items[(currentIndex + 1) % items.length];
            setPicked(next.kr);
          }}>Changer</Btn>
        </div>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(145px, 1fr))", gap: 8 }}>
        {items.map((item) => {
          const on = selected && selected.kr === item.kr;
          const mastered = songItemMastered(item, cardStates);
          return (
            <button key={item.kr} type="button" onClick={() => setPicked(item.kr)} style={{
              border: `1px solid ${on ? "var(--accent-line)" : "var(--rule)"}`,
              background: on ? "var(--accent-tint)" : "var(--card)",
              color: on ? "var(--accent-deep)" : "var(--ink)",
              borderRadius: "var(--radius-sm)",
              padding: 12,
              cursor: "pointer",
              textAlign: "left",
              display: "grid",
              gap: 4,
            }}>
              <span className="kr" style={{ fontSize: 20, fontWeight: 800 }}>{item.kr}</span>
              <span className="muted" style={{ fontSize: 12.5 }}>{item.fr}</span>
              <span className="chip" style={{ fontSize: 10, justifySelf: "start", padding: "2px 7px" }}>{mastered ? "SRS" : "morceau"}</span>
            </button>
          );
        })}
      </div>
    </section>
  );
}

function SongLibrary({ songs, selectedId, onSelect }) {
  return (
    <section className="card" style={{ padding: 18, display: "grid", gap: 12 }}>
      <div>
        <Eyebrow>Bibliothèque</Eyebrow>
        <h2 style={{ fontSize: 24, marginTop: 4 }}>Morceaux</h2>
      </div>
      <div style={{ display: "grid", gap: 8 }}>
        {songs.map((song, index) => {
          const on = song.id === selectedId;
          return (
            <button key={song.id} type="button" onClick={() => onSelect(song.id)} style={{
              border: `1px solid ${on ? "var(--accent-line)" : "var(--rule)"}`,
              background: on ? "var(--accent-tint)" : "var(--card-2)",
              borderRadius: "var(--radius-sm)",
              padding: 12,
              textAlign: "left",
              cursor: "pointer",
              display: "grid",
              gap: 5,
            }}>
              <span className="faint" style={{ fontSize: 11, fontFamily: "var(--mono)" }}>Morceau {index + 1}</span>
              <span className="kr" style={{ fontWeight: 900, color: on ? "var(--accent-deep)" : "var(--ink)", fontSize: 18 }}>{song.title}</span>
              <span className="muted" style={{ fontSize: 12.5 }}>{song.titleFr}</span>
            </button>
          );
        })}
      </div>
    </section>
  );
}

function SongsWorkshop() {
  const [load, setLoad] = useState(() => ({ status: window.LangData && window.LangData.loadSongs ? "loading" : "error", payload: null, error: null }));
  const [songId, setSongId] = useState("");
  const [activeLineId, setActiveLineId] = useState("");
  const [shadowed, setShadowed] = useState({});
  const audioRef = useRef(null);

  useEffect(() => {
    let alive = true;
    if (!window.LangData || !window.LangData.loadSongs) {
      setLoad((state) => ({ ...state, status: "error", error: new Error("loadSongs missing") }));
      return () => { alive = false; };
    }
    window.LangData.loadSongs().then((payload) => {
      if (alive) setLoad({ status: "ready", payload, error: null });
    }).catch((error) => {
      if (alive) setLoad((state) => ({ ...state, status: "error", error }));
    });
    return () => { alive = false; };
  }, []);

  const songs = load.payload && Array.isArray(load.payload.songs) ? load.payload.songs : [];
  const selectedSong = songs.find((song) => song.id === songId) || songs[0];
  const lines = songLines(selectedSong);
  const activeLine = lines.find((line) => line.id === activeLineId) || lines[0];
  const cardStates = window.LangStore && window.LangStore.cards ? window.LangStore.cards.all() : {};

  useEffect(() => {
    if (!songId && songs[0]) setSongId(songs[0].id);
  }, [load.status, songs.length, songId]);

  useEffect(() => {
    if (selectedSong && lines[0] && !lines.some((line) => line.id === activeLineId)) setActiveLineId(lines[0].id);
  }, [selectedSong && selectedSong.id, activeLineId]);

  function pauseSongAudio() {
    if (!audioRef.current) return;
    try { audioRef.current.pause(); } catch (e) {}
  }

  function speakSpoken(text, rate) {
    pauseSongAudio();
    if (window.LangAudio) window.LangAudio.speak(text, { ref: text, rate: rate || 0.92 });
  }

  function markShadowed(lineId) {
    setShadowed((state) => ({ ...state, [lineId]: (state[lineId] || 0) + 1 }));
  }

  if (load.status === "loading") return <div style={{ display: "grid", gap: 16 }}><LoadingCard /><LoadingCard lines={4} /></div>;
  if (load.status === "error") return <ErrorState title="Chansons indisponibles" body={load.error && load.error.message} onRetry={() => location.reload()} />;
  if (!selectedSong) return <EmptyState icon="spark" title="Aucun morceau" body="La bibliothèque de chansons est vide." />;

  return (
    <div className="fade-up" style={{ display: "grid", gap: 24 }}>
      <section style={{ display: "flex", justifyContent: "space-between", gap: 20, alignItems: "flex-end", flexWrap: "wrap" }}>
        <div>
          <Eyebrow>Atelier musical</Eyebrow>
          <h1 style={{ fontSize: "clamp(36px, 5vw, 58px)", marginTop: 4 }}>Chansons originales</h1>
          <p className="muted" style={{ maxWidth: 760, margin: "10px 0 0", fontSize: 16 }}>
            Morceaux Suno, paroles cliquables, voix parlée et substitutions guidées par le vocabulaire solide.
          </p>
        </div>
      </section>

      <StatStrip items={[
        { value: songs.length, label: "Morceaux" },
        { value: songLineCount(selectedSong), label: "Lignes", accent: true },
        { value: (selectedSong.substitutionSets || []).length, label: "Patrons" },
        { value: songSubstitutionCount(selectedSong), label: "Substitutions" },
      ]} />

      <div className="atelier-grid" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) 330px", gap: 22, alignItems: "start" }}>
        <div style={{ display: "grid", gap: 18 }}>
          <section className="card" style={{ padding: "var(--pad-card)", display: "grid", gap: 16 }}>
            <div style={{ display: "flex", justifyContent: "space-between", gap: 14, alignItems: "start", flexWrap: "wrap" }}>
              <div style={{ minWidth: 0 }}>
                <Eyebrow>Morceau 1</Eyebrow>
                <h2 className="kr" style={{ fontSize: "clamp(34px, 6vw, 54px)", marginTop: 4 }}>{selectedSong.title}</h2>
                <div className="muted" style={{ fontSize: 15, marginTop: 6 }}>{selectedSong.titleFr}</div>
              </div>
              <div style={{ display: "flex", gap: 7, flexWrap: "wrap", justifyContent: "flex-end" }}>
                {(selectedSong.focus || []).slice(0, 4).map((tag) => <span key={tag} className="chip" style={{ fontSize: 11 }}>{tag}</span>)}
              </div>
            </div>
            <audio ref={audioRef} controls src={selectedSong.audioUrl} preload="metadata" onPlay={() => { if (window.LangAudio) window.LangAudio.stop(); }}
              style={{ width: "100%", accentColor: "var(--accent)" }} />
            {activeLine && (
              <div style={{ border: "1px solid var(--rule)", borderRadius: "var(--radius-sm)", background: "var(--card-2)", padding: 14, display: "grid", gap: 8 }}>
                <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
                  <span className="chip" style={{ fontSize: 11 }}>Ligne active</span>
                  <Btn kind="ghost" size="sm" icon="speaker" onClick={() => speakSpoken(activeLine.kr)}>Voix parlée</Btn>
                </div>
                <div className="kr" style={{ fontSize: 28, lineHeight: 1.35, color: "var(--accent-deep)" }}>{activeLine.kr}</div>
                <div className="muted" style={{ fontSize: 14.5 }}>{activeLine.fr}</div>
              </div>
            )}
          </section>

          <section style={{ display: "grid", gap: 12 }}>
            {songSections(selectedSong).map((section) => (
              <div key={section.id} style={{ display: "grid", gap: 8 }}>
                <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <Eyebrow>{section.label}</Eyebrow>
                  <span style={{ flex: 1, borderTop: "1px solid var(--rule)" }} />
                </div>
                {(section.lines || []).map((line) => {
                  const lineIndex = lines.findIndex((entry) => entry.id === line.id);
                  return (
                    <SongLineRow key={line.id} line={{ ...line, section: section.label }} index={lineIndex} active={activeLine && activeLine.id === line.id}
                      shadowed={shadowed[line.id] || 0} onSelect={setActiveLineId} onSpeak={speakSpoken} />
                  );
                })}
              </div>
            ))}
          </section>

          <SongSubstitutionExercise song={selectedSong} cardStates={cardStates} onSpeak={speakSpoken} />
        </div>

        <aside className="atelier-side" style={{ position: "sticky", top: 22, display: "grid", gap: 16 }}>
          <SongLibrary songs={songs} selectedId={selectedSong.id} onSelect={(id) => { setSongId(id); setActiveLineId(""); }} />
          <SongShadowingPanel lines={lines} activeLineId={activeLine && activeLine.id} setActiveLineId={setActiveLineId}
            shadowed={shadowed} markShadowed={markShadowed} onSpeak={speakSpoken} />
        </aside>
      </div>
    </div>
  );
}

function AtelierPage({ go }) {
  const [atelierMode, setAtelierMode] = useState("numbers");
  const atelierChip = atelierMode === "numbers" ? "Sino-coreen / natif" : atelierMode === "songs" ? "Chansons Suno" : "Lots frequentiels";
  return (
    <div className="fade-up" style={{ display: "grid", gap: 20 }}>
      <section style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
        <Segmented value={atelierMode} options={ATELIER_MODES} onChange={setAtelierMode} />
        <span className="chip" style={{ fontSize: 12 }}>{atelierChip}</span>
      </section>
      {atelierMode === "numbers" ? <NumbersWorkshop /> : atelierMode === "songs" ? <SongsWorkshop /> : <VocabWorkshop go={go} />}
    </div>
  );
}

Object.assign(window, { AtelierPage });
