/* Cartes — Leitner complet. Modes, filtres, formats, mini-dico, segments,
   audio phrase/bloc, signalement, historique, learning map. 3 styles de carte. */

const RATINGS = [
  { id: "rate", label: "Raté", box: 1, color: "var(--warn)", tint: "var(--warn-tint)", key: "1" },
  { id: "hard", label: "Difficile", box: 0, color: "var(--ochre)", tint: "var(--warn-tint)", key: "2" },
  { id: "good", label: "Bon", box: 1, color: "var(--good)", tint: "var(--good-tint)", key: "3" },
  { id: "easy", label: "Facile", box: 2, color: "var(--accent)", tint: "var(--accent-tint)", key: "4" },
];
const NEXT_REVIEW = { rate: "10 min", hard: "1 jour", good: "3 jours", easy: "8 jours" };
function reviewLabelFor(ratingId) {
  // Repli statique seulement si FSRS.preview est indisponible (sinon l'UI montre le vrai intervalle).
  return NEXT_REVIEW[ratingId];
}

/* Intervalle FSRS (jours) -> libellé court honnête. */
function formatInterval(days) {
  const d = Math.round(Number(days));
  if (!Number.isFinite(d) || d <= 0) return "aujourd'hui";
  if (d === 1) return "1 jour";
  if (d < 7) return `${d} jours`;
  if (d < 30) { const w = Math.round(d / 7); return `${w} sem`; }
  if (d < 365) { const m = Math.round(d / 30); return `${m} mois`; }
  const y = Math.round(d / 365 * 10) / 10;
  return `${y} an${y > 1 ? "s" : ""}`;
}

const GRADE_BY_RATING = { rate: 1, hard: 2, good: 3, easy: 4 }; // AGAIN/HARD/GOOD/EASY

/* Vrais intervalles des 4 notes pour la carte courante, via FSRS.preview.
   Renvoie null si FSRS indispo : on retombe alors sur les libellés statiques. */
function reviewPreviewIntervals(prevState, settings, now) {
  if (!window.FSRS || !window.FSRS.preview) return null;
  // FSRS pur : les 4 intervalles découlent de la stabilité et de la rétention cible.
  return window.FSRS.preview(prevState || {}, now || Date.now(), settings.retention || window.FSRS.DEFAULT_RETENTION);
}

/* Phrase coréenne lisible mot par mot, directement sur la carte.
   Chaque mot est encadré (Speakable) : survol = sens (glose), clic = audio du mot.
   Un bouton discret lit la phrase entière (fichier réel si connu). */
function audioKey(value) {
  return String(value || "").normalize("NFC").trim().replace(/^[\s"'`]+|[\s"'`.,!?;:]+$/g, "");
}

function audioBlockForText(blocks, text) {
  const wanted = audioKey(text);
  if (!wanted) return null;
  return ((blocks || []).find(block => block && block.url && audioKey(block.text) === wanted)) || null;
}

function audioRefForBlock(block, fallbackText) {
  return (block && (block.id || block.audioRef || block.ref || block.text)) || audioKey(fallbackText) || fallbackText;
}

function surfaceWordsForText(text) {
  return String(text || "").trim().split(/\s+/).map(audioKey).filter(Boolean);
}

function ttsSegmentsForCard(card) {
  const words = surfaceWordsForText(card && card.kr);
  return words.length ? words : ((card && card.seg) || []).map(audioKey).filter(Boolean);
}

function krFontSize(size) {
  if (typeof size !== "number") return size;
  if (size >= 54) return `clamp(34px, 11vw, ${size}px)`;
  if (size >= 42) return `clamp(28px, 9vw, ${size}px)`;
  if (size >= 28) return `clamp(20px, 7vw, ${size}px)`;
  return size;
}

function serifFontSize(max, min, vw) {
  return `clamp(${min}px, ${vw}vw, ${max}px)`;
}

function PhraseWords({ text, glossFor, audioUrl, audioRef, audioBlocks, size = 48, color = "var(--accent-deep)", justify = "center", glossRevealed = true }) {
  const words = String(text || "").trim().split(/\s+/).filter(Boolean);
  if (!words.length) return null;
  const pad = Math.max(4, Math.round(size * 0.16));
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: Math.round(size * 0.2), alignItems: "center", justifyContent: justify, maxWidth: "100%" }}>
      {words.map((w, i) => {
        const block = audioBlockForText(audioBlocks, w);
        const wordAudioUrl = (block && block.url) || (words.length === 1 ? audioUrl : "");
        const wordAudioRef = block ? audioRefForBlock(block, w) : (words.length === 1 ? (audioRef || w) : w);
        return (
          <Speakable key={i} text={w} audioRef={wordAudioRef} audioUrl={wordAudioUrl} gloss={glossFor ? glossFor(w) : undefined} glossRevealed={glossRevealed} suppressGlossHover={!glossRevealed} className="kr"
            style={{ fontSize: krFontSize(size), color, fontWeight: 500, lineHeight: 1.15, padding: `2px clamp(4px, 2vw, ${pad}px)`, maxWidth: "100%", whiteSpace: "normal", wordBreak: "keep-all", overflowWrap: "anywhere" }}>{w}</Speakable>
        );
      })}
      {audioUrl && words.length > 1 && (
        <SpeakBtn text={text} audioRef={audioRef} audioUrl={audioUrl} size={Math.max(30, Math.round(size * 0.5))} title="Écouter la phrase entière" style={{ alignSelf: "center" }} />
      )}
    </div>
  );
}

function KoreanCueWithImage({ card, glossFor, image, size = 42, color = "var(--accent-deep)", imageStyle }) {
  return (
    <div style={{ display: "grid", gap: 10, justifyItems: "center", minWidth: 0, maxWidth: "100%" }}>
      <PhraseWords text={card.kr} glossFor={glossFor} audioUrl={card.audioUrl} audioRef={card.audioRef} audioBlocks={card.audio && card.audio.blocks} size={size} color={color} />
      {image && <LearningImage image={image} compact style={imageStyle} />}
    </div>
  );
}

function uniqueExamples(card) {
  const seen = Object.create(null);
  return ([]).concat((card && card.examples) || [], card && card.back).filter(Boolean).flatMap(v => String(v).split("/"))
    .map(v => v.trim()).filter(Boolean).filter(v => {
      if (seen[v]) return false;
      seen[v] = true;
      return true;
    });
}

function ProductionBackDetails({ card, glossFor, modelSize = 42, compact = false, image = null }) {
  const examples = uniqueExamples(card).filter(v => v !== card.kr);
  const objective = card.meaning_fr || card.fr || card.front || "";
  return (
    <div style={{ display: "grid", gap: compact ? 8 : 10, justifyItems: "center", maxWidth: 560, margin: "0 auto", minWidth: 0 }}>
      {objective && (
        <div style={{ display: "grid", gap: 3, justifyItems: "center" }}>
          <div className="faint" style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0 }}>Objectif</div>
          <div style={{ fontFamily: "var(--serif)", fontSize: compact ? serifFontSize(22, 18, 6) : serifFontSize(28, 21, 7), color: "var(--ink)", lineHeight: 1.2, overflowWrap: "anywhere" }}>{objective}</div>
        </div>
      )}
      <div style={{ display: "grid", gap: 5, justifyItems: "center" }}>
        <div className="faint" style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0 }}>Modele</div>
        <KoreanCueWithImage card={card} glossFor={glossFor} image={image} size={modelSize} color="var(--accent-deep)" imageStyle={{ marginTop: 3 }} />
      </div>
      {examples.length > 0 && (
        <div style={{ display: "grid", gap: 6, justifyItems: "center" }}>
          <div className="faint" style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0 }}>Variantes</div>
          <div style={{ display: "flex", gap: 7, flexWrap: "wrap", justifyContent: "center" }}>
            {examples.slice(0, 5).map((ex, idx) => (
              <Speakable key={idx} text={ex} audioRef={ex} className="kr" style={{ fontSize: compact ? 18 : 21, padding: "3px 10px", color: "var(--ink)" }}>{ex}</Speakable>
            ))}
          </div>
        </div>
      )}
      {(card.pattern || card.note) && (
        <div className="faint" style={{ fontSize: 13, lineHeight: 1.35, maxWidth: 500 }}>
          {card.pattern && <span style={{ fontFamily: "var(--mono)", color: "var(--ink-soft)" }}>{card.pattern}</span>}
          {card.pattern && card.note ? " - " : ""}{card.note || ""}
        </div>
      )}
    </div>
  );
}

function BoxLadder({ box, compact }) {
  return (
    <div style={{ display: "flex", gap: 5, alignItems: "flex-end" }}>
      {[1, 2, 3, 4, 5].map(b => {
        const on = b <= box, cur = b === box;
        return (
          <div key={b} style={{ flex: 1, textAlign: "center" }}>
            <div style={{ height: compact ? 6 + b * 3 : 10 + b * 6, borderRadius: 4, background: cur ? "var(--accent)" : on ? "var(--accent-line)" : "var(--rule)", transition: "all .3s" }} />
            {!compact && <div className="faint" style={{ fontSize: 11, marginTop: 5, color: cur ? "var(--accent)" : "var(--ink-faint)", fontWeight: cur ? 700 : 400 }}>{b}</div>}
          </div>
        );
      })}
    </div>
  );
}

function CardClassique({ card, revealed, qcm, format, onReveal, onReset, glossFor, image }) {
  const production = format === "Production";
  const onCardClick = qcm ? undefined : revealed ? onReset : onReveal;
  const cardMinHeight = revealed && image ? "clamp(420px, 68vh, 520px)" : 300;
  const facePadding = "clamp(18px, 5vw, 30px)";
  return (
    <div onClick={onCardClick} style={{ position: "relative", minHeight: cardMinHeight, cursor: !qcm ? "pointer" : "default", perspective: 1200 }}>
      <div style={{ position: "relative", minHeight: cardMinHeight, borderRadius: "var(--radius)", transition: "transform .55s cubic-bezier(.2,.8,.2,1)", transformStyle: "preserve-3d", transform: revealed ? "rotateX(180deg)" : "none" }}>
        <div aria-hidden={revealed ? "true" : "false"} style={{ position: "absolute", inset: 0, backfaceVisibility: "hidden", background: "var(--card)", border: "1px solid var(--rule)", borderRadius: "var(--radius)", boxShadow: "var(--shadow-card)", display: "grid", placeItems: "center", padding: facePadding }}>
          <div style={{ textAlign: "center", minWidth: 0, maxWidth: "100%" }}>
            <Eyebrow>{production ? "Production" : "Recto"}</Eyebrow>
            <div style={{ margin: "14px 0" }}>
              {production
                ? <div style={{ fontFamily: "var(--serif)", fontSize: serifFontSize(42, 28, 8), color: "var(--accent-deep)", lineHeight: 1.12, overflowWrap: "anywhere" }}>{card.fr}</div>
                : <PhraseWords text={card.kr} glossFor={glossFor} audioUrl={card.audioUrl} audioRef={card.audioRef} audioBlocks={card.audio && card.audio.blocks} size={60} glossRevealed={revealed} />}
            </div>
            {!qcm && <p className="faint" style={{ fontSize: 14, marginTop: 6 }}>{production ? "Produisez en coréen de mémoire, puis vérifiez." : "Cliquez le mot pour l'écouter · rappel actif, puis vérifiez."}</p>}
          </div>
        </div>
        <div aria-hidden={revealed ? "false" : "true"} style={{ position: "absolute", inset: 0, backfaceVisibility: "hidden", transform: "rotateX(180deg)", background: "var(--card-2)", border: "1px solid var(--accent-line)", borderRadius: "var(--radius)", boxShadow: "var(--shadow-card)", display: "grid", placeItems: "center", padding: facePadding }}>
          {revealed && (
            <div style={{ textAlign: "center", minWidth: 0, maxWidth: "100%" }}>
              <Eyebrow>Verso</Eyebrow>
              {production
                ? <ProductionBackDetails card={card} glossFor={glossFor} modelSize={42} image={image} />
                : <>
                  <KoreanCueWithImage card={card} glossFor={glossFor} image={image} size={42} />
                  <div style={{ fontFamily: "var(--serif)", fontSize: serifFontSize(38, 25, 7), color: "var(--accent-deep)", margin: "12px 0 6px", overflowWrap: "anywhere" }}>{card.fr}</div>
                  <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 16 }}>{card.ro} - {card.pos}</div>
                </>}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}
function CardMinimal({ card, revealed, format, onReveal, onReset, glossFor, image }) {
  const production = format === "Production";
  return (
    <div onClick={revealed ? onReset : onReveal} style={{ minHeight: 300, cursor: "pointer", display: "grid", placeItems: "center", textAlign: "center", padding: 20 }}>
      <div style={{ minWidth: 0, maxWidth: "100%" }}>
        {production
          ? <div style={{ fontFamily: "var(--serif)", fontSize: serifFontSize(44, 28, 8), color: "var(--accent-deep)", lineHeight: 1.12, overflowWrap: "anywhere" }}>{card.fr}</div>
          : <PhraseWords text={card.kr} glossFor={glossFor} audioUrl={card.audioUrl} audioRef={card.audioRef} audioBlocks={card.audio && card.audio.blocks} size={60} glossRevealed={revealed} />}
        <div style={{ height: 26 }} />
        {revealed ? <div className="fade-up" style={{ display: "grid", gap: 12, justifyItems: "center", minWidth: 0 }}>{production ? <ProductionBackDetails card={card} glossFor={glossFor} modelSize={36} compact image={image} /> : <><KoreanCueWithImage card={card} glossFor={glossFor} image={image} size={40} color="var(--ink)" /><div style={{ fontFamily: "var(--serif)", fontSize: serifFontSize(36, 24, 7), color: "var(--ink)", overflowWrap: "anywhere" }}>{card.fr}</div><div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 15, marginTop: 4 }}>{card.ro} - {card.pos}</div></>}</div> : <p className="faint" style={{ fontSize: 14 }}>{production ? "Produisez le coreen, puis cliquez." : "Rappelez le sens, puis cliquez."}</p>}
      </div>
    </div>
  );
}
function CardBoxes({ card, revealed, format, onReveal, onReset, glossFor, image }) {
  const production = format === "Production";
  return (
    <div onClick={revealed ? onReset : onReveal} style={{ minHeight: 300, cursor: "pointer", display: "flex", flexDirection: "column" }}>
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 14 }}>
        <span className="chip" style={{ background: "var(--accent-tint)", color: "var(--accent-deep)", borderColor: "transparent" }}>Boîte {card.box}</span>
        <div style={{ flex: 1 }}><BoxLadder box={card.box} compact /></div>
      </div>
      <div className="card" style={{ flex: 1, display: "grid", placeItems: "center", padding: 26 }}>
        <div style={{ textAlign: "center", minWidth: 0, maxWidth: "100%" }}>
          {production
            ? <div style={{ fontFamily: "var(--serif)", fontSize: serifFontSize(36, 25, 7), color: "var(--accent-deep)", lineHeight: 1.12, overflowWrap: "anywhere" }}>{card.fr}</div>
            : <PhraseWords text={card.kr} glossFor={glossFor} audioUrl={card.audioUrl} audioRef={card.audioRef} audioBlocks={card.audio && card.audio.blocks} size={54} glossRevealed={revealed} />}
          {revealed ? <div className="fade-up" style={{ marginTop: 14, display: "grid", gap: 12, justifyItems: "center", minWidth: 0 }}>{production ? <ProductionBackDetails card={card} glossFor={glossFor} modelSize={34} compact image={image} /> : <><KoreanCueWithImage card={card} glossFor={glossFor} image={image} size={38} color="var(--ink)" /><div style={{ fontFamily: "var(--serif)", fontSize: serifFontSize(32, 23, 7), color: "var(--ink)", overflowWrap: "anywhere" }}>{card.fr}</div><div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 15, marginTop: 4 }}>{card.ro}</div></>}</div> : <p className="faint" style={{ fontSize: 14, marginTop: 14 }}>Cliquez pour verifier</p>}
        </div>
      </div>
    </div>
  );
}

function CardDictation({ card, answer, checked, onAnswer, onCheck, glossFor, image }) {
  const done = checked != null;
  const correct = done && checked.correct;
  return (
    <div className="card" style={{ minHeight: 300, padding: 26, display: "grid", gap: 15 }}>
      <div>
        <Eyebrow>Dictée</Eyebrow>
        <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", marginTop: 12 }}>
          <SpeakBtn text={card.kr} audioRef={card.audioRef || card.kr} audioUrl={card.audioUrl} size={44} title="Écouter" />
          {card.ro && <span className="faint" style={{ fontFamily: "var(--mono)", fontSize: 13 }}>{card.ro}</span>}
        </div>
      </div>
      <textarea value={answer} onChange={e => onAnswer(e.target.value)} className="kr" lang="ko" autoCapitalize="off" autoCorrect="off" spellCheck={false} placeholder="한국어로 받아쓰기..."
        style={{ width: "100%", minHeight: 92, resize: "vertical", padding: 14, borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: "var(--card-2)", color: "var(--ink)", fontSize: 23, lineHeight: 1.45 }} />
      <KoreanKeyboard value={answer} onChange={onAnswer} />
      {done && (
        <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)" }}>{dictationFeedbackLabel(checked)}</div>
          <PhraseWords text={card.kr} glossFor={glossFor} audioUrl={card.audioUrl} audioRef={card.audioRef} audioBlocks={card.audio && card.audio.blocks} size={28} color="var(--ink)" justify="flex-start" />
          {image && <LearningImage image={image} compact style={{ justifySelf: "start", maxWidth: 260 }} />}
          <div className="faint" style={{ fontSize: 13 }}>{card.fr}</div>
          {!correct && checked.tags && checked.tags.length > 0 && <div className="faint" style={{ fontSize: 12.5 }}>Erreurs : {checked.tags.join(" · ")}</div>}
        </div>
      )}
      {!done && (
        <div style={{ display: "flex", justifyContent: "flex-end" }}>
          <Btn kind="secondary" icon="eye" disabled={!answer.trim()} onClick={onCheck}>Vérifier</Btn>
        </div>
      )}
    </div>
  );
}

function CardTransformation({ card, transform, answer, checked, onAnswer, onCheck, onPick, glossFor }) {
  const done = checked != null;
  const correct = done && checked.correct;
  const chooseForm = transform && transform.mode === "choose_form";
  if (!transform) {
    return (
      <div className="card" style={{ minHeight: 300, padding: 26, display: "grid", gap: 14, alignContent: "center", textAlign: "center" }}>
        <Eyebrow>Transformation</Eyebrow>
        <PhraseWords text={card.kr} glossFor={glossFor} audioUrl={card.audioUrl} audioRef={card.audioRef} audioBlocks={card.audio && card.audio.blocks} size={42} />
        <p className="muted" style={{ margin: 0 }}>Pas encore de transformation sûre pour cette carte.</p>
      </div>
    );
  }
  return (
    <div className="card" style={{ minHeight: 300, padding: 26, display: "grid", gap: 16 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
        <Eyebrow>Transformation</Eyebrow>
        <span className="chip" style={{ background: "var(--accent-tint)", color: "var(--accent-deep)", borderColor: "transparent" }}>{transform.label}</span>
      </div>
      <div style={{ display: "grid", gap: 10 }}>
        <div className="faint" style={{ fontSize: 13 }}>{transform.instruction}</div>
        {chooseForm ? (
          <div className="kr" style={{ fontSize: 34, lineHeight: 1.25, color: "var(--accent-deep)", fontFamily: "var(--serif)" }}>{transform.cloze}</div>
        ) : (
          <div style={{ display: "grid", gap: 8 }}>
            <div style={{ display: "grid", gap: 5 }}>
              <div className="faint" style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0 }}>Source</div>
              <PhraseWords text={transform.sourceKr} glossFor={glossFor} size={30} color="var(--ink)" justify="flex-start" />
              <div className="faint" style={{ fontSize: 13 }}>{transform.sourceFr}</div>
            </div>
            <div style={{ display: "grid", gap: 5 }}>
              <div className="faint" style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0 }}>Cible</div>
              <div style={{ fontFamily: "var(--serif)", fontSize: 24, color: "var(--accent-deep)", lineHeight: 1.2 }}>{transform.targetFr}</div>
            </div>
          </div>
        )}
      </div>
      {chooseForm ? (
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
          {transform.choices.map(choice => {
            const picked = done && checked.given === choice;
            const isAnswer = done && choice === transform.answer;
            const bg = isAnswer ? "var(--good-tint)" : picked ? "var(--warn-tint)" : "var(--card-2)";
            const color = isAnswer ? "var(--good)" : picked ? "var(--warn)" : "var(--ink)";
            return (
              <button key={choice} disabled={done} onClick={() => onPick(choice)} className="kr" style={{ padding: "10px 14px", borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: bg, color, cursor: done ? "default" : "pointer", fontSize: 22 }}>
                {choice}
              </button>
            );
          })}
        </div>
      ) : (
        <div style={{ display: "grid", gap: 10 }}>
          <textarea value={answer} onChange={e => onAnswer(e.target.value)} className="kr" lang="ko" autoCapitalize="off" autoCorrect="off" spellCheck={false} placeholder="한국어로 변형..."
            style={{ width: "100%", minHeight: 92, resize: "vertical", padding: 14, borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: "var(--card-2)", color: "var(--ink)", fontSize: 23, lineHeight: 1.45 }} />
          <KoreanKeyboard value={answer} onChange={onAnswer} />
          {!done && <div style={{ display: "flex", justifyContent: "flex-end" }}><Btn kind="secondary" icon="eye" disabled={!answer.trim()} onClick={() => onCheck(answer)}>Vérifier</Btn></div>}
        </div>
      )}
      {done && (
        <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 ? "Correct." : "À reprendre."}</div>
          <PhraseWords text={transform.targetKr} glossFor={glossFor} size={28} color="var(--ink)" justify="flex-start" />
          {transform.targetFr && <div className="faint" style={{ fontSize: 13 }}>{transform.targetFr}</div>}
          {transform.pattern && <div className="faint" style={{ fontSize: 12.5 }}>Patron : <span className="kr">{transform.pattern}</span></div>}
          {!correct && checked.tags && checked.tags.length > 0 && <div className="faint" style={{ fontSize: 12.5 }}>Erreurs : {checked.tags.join(" · ")}</div>}
        </div>
      )}
    </div>
  );
}

function QcmRow({ card, picked, onPick }) {
  return (
    <div style={{ display: "grid", gap: 8, marginTop: 16 }}>
      {card.choices.map((c, i) => {
        const correct = c === card.fr, reveal = picked != null;
        let bg = "var(--card)", bd = "var(--rule)", col = "var(--ink)";
        if (reveal && correct) { bg = "var(--good-tint)"; bd = "transparent"; col = "var(--good)"; }
        else if (picked === i && !correct) { bg = "var(--warn-tint)"; bd = "transparent"; col = "var(--warn)"; }
        return <button key={i} onClick={() => onPick(i)} disabled={reveal} style={{ padding: "13px 16px", borderRadius: "var(--radius-sm)", border: `1px solid ${bd}`, background: bg, color: col, fontFamily: "var(--serif)", fontSize: 18, textAlign: "left", cursor: reveal ? "default" : "pointer", transition: "all .15s" }}>{c}</button>;
      })}
    </div>
  );
}

function settingsFromStore() {
  return (window.LangStore && window.LangStore.settings ? window.LangStore.settings.get() : {}) || {};
}

function qcmChoiceCountFromSettings(settings) {
  if (window.LangExerciseRouter && window.LangExerciseRouter.qcmChoiceCount) {
    return window.LangExerciseRouter.qcmChoiceCount(settings || settingsFromStore());
  }
  settings = settings || settingsFromStore();
  const n = Number(settings.qcmChoices);
  return Math.max(2, Math.min(6, Number.isFinite(n) ? Math.round(n) : 3));
}

function questionModeFromSettings(settings) {
  const mode = String((settings || {}).questionMode || "rappel").toLowerCase();
  return mode === "qcm" || mode === "dictee" ? mode : "rappel";
}

function buildCardQcmChoices(card, deck, count) {
  if (window.LangExerciseRouter && window.LangExerciseRouter.buildChoices) {
    return window.LangExerciseRouter.buildChoices(card, deck, {
      correct: card.fr,
      count,
      seed: card.id,
    });
  }
  const seen = Object.create(null);
  const out = [];
  function push(value) {
    const text = String(value || "").trim();
    const key = text.toLowerCase();
    if (!text || seen[key]) return;
    seen[key] = true;
    out.push(text);
  }
  push(card.fr);
  (card.choices || []).forEach(push);
  (deck || []).forEach((candidate) => push(candidate && candidate.fr));
  return out.slice(0, count);
}

function filterCardsBySettings(deck, settings) {
  const list = Array.isArray(deck) ? deck : [];
  if (window.LangExerciseRouter && window.LangExerciseRouter.filterBySettings) {
    return window.LangExerciseRouter.filterBySettings(list, settings || {});
  }
  const kind = String((settings && settings.itemType) || "mixte").toLowerCase();
  if (!kind || kind === "mixte" || kind === "tous") return list;
  const filtered = list.filter(card => {
    const text = String([card.pos, card.type, card.focus, (card.tags || []).join(" ")].filter(Boolean).join(" ")).toLowerCase();
    if (kind === "nom") return /(^|\W)(nom|noun)(\W|$)/.test(text);
    if (kind === "verbe") return /(^|\W)(verbe|verb)(\W|$)/.test(text);
    return true;
  });
  return filtered.length ? filtered : list;
}

function normalizeLookupToken(token) {
  let text = String(token || "").normalize("NFC").replace(/[^\u3130-\u318F\uAC00-\uD7A3]/g, "");
  if (!text) return "";
  const suffixes = ["입니다", "이에요", "예요", "습니다", "어요", "아요", "해요", "에서", "에게", "한테", "으로", "처럼", "부터", "까지", "은", "는", "이", "가", "을", "를", "에", "도", "만", "의", "와", "과", "로", "요"];
  for (const suffix of suffixes) {
    if (text.length > suffix.length + 1 && text.endsWith(suffix)) return text.slice(0, -suffix.length);
  }
  return text;
}

function lookupQueryForCard(card) {
  const blocked = { 저는: 1, 제가: 1, 제: 1, 이: 1, 그: 1, 저: 1 };
  const tokens = []
    .concat((card && card.seg) || [])
    .concat(String((card && card.kr) || "").split(/\s+/));
  const cleaned = tokens.map(normalizeLookupToken).filter(token => token.length >= 2 && !blocked[token]);
  return cleaned[0] || normalizeLookupToken(card && card.kr) || String((card && card.fr) || "").trim();
}

function SideRow({ label, value }) {
  return <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 10 }}><span className="faint" style={{ fontSize: 13 }}>{label}</span><span style={{ fontSize: 13.5, fontWeight: 600, textAlign: "right" }}>{value}</span></div>;
}

function dayStartMs(now) {
  if (window.LangDailyBudget && window.LangDailyBudget.dayStart) return window.LangDailyBudget.dayStart(now);
  const d = new Date(now);
  d.setHours(0, 0, 0, 0);
  return d.getTime();
}

function countLabel(n) {
  if (typeof n === "string") return n;
  return Number.isFinite(n) ? String(n) : "-";
}

function CardQueueOverview({ stats, activeMode }) {
  const rows = [
    { key: "jour", label: "File jour", value: stats.dayQueue, detail: `${stats.dayDue} dues + ${stats.dayNew} nouvelles`, srs: true },
    { key: "dues", label: "Dues FSRS", value: stats.due, detail: "revue avec effet SRS", srs: true },
    { key: "nonvues", label: "Nouvelles", value: `${stats.newAllowed}/${stats.fresh}`, detail: stats.newNote, srs: true },
    { key: "faibles", label: "Fragiles", value: stats.weak, detail: "récupérabilité basse", srs: true },
    { key: "vues", label: "Vues aujourd'hui", value: stats.seenToday, detail: `${stats.seen} déjà rencontrées`, srs: true },
    { key: "drill", label: "Drill libre", value: stats.drill, detail: "sans effet SRS", srs: false },
  ];
  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(142px,1fr))", gap: 8 }}>
      {rows.map(row => {
        const active = row.key === activeMode || (activeMode === "jour" && row.key === "jour");
        return (
          <div key={row.key} style={{
            minHeight: 74, padding: "10px 12px", borderRadius: "var(--radius-sm)",
            border: `1px solid ${active ? "var(--accent-line)" : "var(--rule)"}`,
            background: active ? "var(--accent-tint)" : "var(--card)",
            display: "grid", gap: 4,
          }}>
            <div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "baseline" }}>
              <span className="faint" style={{ fontSize: 11.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0 }}>{row.label}</span>
              <span className="chip" style={{ fontSize: 10.5, padding: "2px 7px", background: row.srs ? "var(--card-2)" : "var(--good-tint)" }}>{row.srs ? "SRS" : "libre"}</span>
            </div>
            <div style={{ fontFamily: "var(--serif)", fontSize: 24, color: active ? "var(--accent-deep)" : "var(--ink)", lineHeight: 1 }}>{countLabel(row.value)}</div>
            <div className="faint" style={{ fontSize: 12, lineHeight: 1.25 }}>{row.detail}</div>
          </div>
        );
      })}
    </div>
  );
}

function QueueActionGrid({ actions }) {
  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(165px,1fr))", gap: 10 }}>
      {(actions || []).filter(Boolean).map(action => (
        <Btn key={action.label} kind={action.kind || "secondary"} icon={action.icon} disabled={!!action.disabled} title={action.title} onClick={action.onClick} style={{ width: "100%", justifyContent: "center", minHeight: 40 }}>
          {action.label}
        </Btn>
      ))}
    </div>
  );
}

function emptyQueueBody(mode, queueStats, scopeLabel) {
  const scope = scopeLabel && scopeLabel !== "Tous" ? ` dans ${scopeLabel}` : "";
  if (mode === "dues") return `Aucune carte échue${scope}. C'est le bon moment pour introduire un peu de neuf si le quota le permet, réparer les fragiles, ou faire un drill libre sans SRS.`;
  if (mode === "nonvues") {
    if (queueStats.fresh > 0 && queueStats.newAllowed <= 0) return `Il reste ${queueStats.fresh} nouvelles cartes${scope}, mais le quota du jour est atteint ou suspendu. Consolide plutôt les vues ou passe en drill libre.`;
    return `Aucune nouvelle carte disponible${scope}. Reviens aux dues, aux fragiles ou au drill libre.`;
  }
  if (mode === "faibles") return `Aucune carte fragile${scope}. Continue avec les dues, quelques nouvelles plafonnées, ou un drill libre sans effet SRS.`;
  if (mode === "vues") return `Aucune carte déjà rencontrée${scope}. Introduis du neuf si le quota est ouvert, ou retourne au plan du jour.`;
  if (mode === "drill") return `Aucune carte dans ce filtre${scope}. Élargis le paquet ou retourne au plan du jour.`;
  if (queueStats.fresh > 0 && queueStats.newAllowed <= 0) return `La file prioritaire est vide${scope} parce que le neuf est plafonné aujourd'hui. La suite saine : drill libre, vues déjà rencontrées, ou retour au plan global.`;
  return `La file prioritaire est vide${scope}. Choisis une suite claire : nouvelles plafonnées, fragiles, vues, ou drill libre sans SRS.`;
}

const CARD_MODE_COPY = {
  jour: {
    label: "Aujourd'hui",
    short: "Cartes du jour",
    desc: "Priorite FSRS : dues puis quelques nouvelles si le budget le permet.",
    rest: "file du jour",
    empty: "La file prioritaire est vide.",
  },
  dues: {
    label: "A revoir",
    short: "Cartes dues",
    desc: "Toutes les cartes arrivees a echeance. Effet FSRS apres notation.",
    rest: "file due",
    empty: "Aucune carte n'est due maintenant.",
  },
  faibles: {
    label: "Fragiles",
    short: "Points fragiles",
    desc: "Cartes a faible recuperabilite ou boites basses. Effet FSRS apres notation.",
    rest: "file fragile",
    empty: "Aucune carte fragile dans ce filtre.",
  },
  vues: {
    label: "Deja vues",
    short: "Cartes vues",
    desc: "Cartes deja rencontrees. File finie, utile pour consolider.",
    rest: "file vues",
    empty: "Aucune carte deja vue dans ce filtre.",
  },
  nonvues: {
    label: "Nouvelles",
    short: "Nouvelles cartes",
    desc: "Cartes jamais presentees. Effet FSRS apres notation, dans la limite du budget.",
    rest: "file nouvelles",
    empty: "Aucune carte nouvelle dans ce filtre.",
  },
  drill: {
    label: "Drill libre",
    short: "Drill libre",
    desc: "Entrainement a volonte, sans effet SRS. Les notes servent seulement a avancer.",
    rest: "libre",
    empty: "Aucune carte disponible pour ce drill.",
  },
};

const FOCUS_LABELS = {
  identity_presence: "Identite et presence",
  action_verbs: "Actions de base",
  social_formulas: "Formules sociales",
  connectors: "Connecteurs",
  questions: "Questions utiles",
  context: "Contexte en phrase",
  travel_food: "Situations pratiques",
  time_numbers: "Temps et nombres",
  particles: "Particules",
  negation: "Negation",
  production: "Production",
  sens: "Sens",
  ecoute: "Ecoute",
};

const TYPE_LABELS = {
  PROD: "Produire",
  SUBST: "Remplacer",
  RECO: "Reconnaissance",
  VOCAB: "Mot frequent",
  word: "Mot",
  sentence: "Phrase",
  grammar: "Structure",
  production: "Production",
  substitution: "Substitution",
};

function titleizeToken(value) {
  return String(value || "")
    .replace(/^pack:/i, "")
    .replace(/[_-]+/g, " ")
    .trim()
    .replace(/\b\w/g, c => c.toUpperCase());
}

function focusLabel(value) {
  return FOCUS_LABELS[value] || titleizeToken(value) || "Objectif";
}

function typeLabel(card) {
  return TYPE_LABELS[card && card.kind] || TYPE_LABELS[card && card.pos] || TYPE_LABELS[card && card.type] || titleizeToken((card && (card.pos || card.type)) || "");
}

function packLabel(value) {
  const raw = String(value || "").trim();
  if (!raw || raw === "Tous") return "Tous";
  if (/^Paquet\s*0?1$/i.test(raw) || raw === "01") return "Identite et bases";
  if (/^Paquet\s*0?2$/i.test(raw) || raw === "02") return "Actions et objets";
  if (/^Paquet\s*0?3$/i.test(raw) || raw === "03") return "Questions utiles";
  if (/^Paquet\s*0?4$/i.test(raw) || raw === "04") return "Contexte en phrase";
  if (/^Paquet\s*0?5$/i.test(raw) || raw === "05") return "Connecteurs";
  if (/^Paquet\s*0?6$/i.test(raw) || raw === "06") return "Formules sociales";
  if (/^Paquet\s*0?7$/i.test(raw) || raw === "07") return "Actions de base";
  if (/^Paquet\s*0?8$/i.test(raw) || raw === "08") return "Reprises ciblees";
  if (/^P1-0?1$/i.test(raw)) return "Formules sociales";
  if (/^P1-0?2$/i.test(raw)) return "Questions et contexte";
  if (/^P1-0?3$/i.test(raw)) return "Actions de base";
  if (/^Reconnaissance$/i.test(raw)) return "Comprendre le sens";
  if (/^Contexte$/i.test(raw)) return "Phrases en contexte";
  if (/^Formules$/i.test(raw)) return "Formules utiles";
  return titleizeToken(raw);
}

function resetCardStats() {
  return { rate: 0, hard: 0, good: 0, easy: 0, done: 0 };
}

function fallbackCardsP0() {
  return (window.LANGUES_DATA && Array.isArray(window.LANGUES_DATA.cards)) ? window.LANGUES_DATA.cards : [];
}

function normalizeCardsP0Payload(payload) {
  if (Array.isArray(payload)) return { cards: payload, meta: { total: payload.length } };
  return {
    cards: (payload && Array.isArray(payload.cards)) ? payload.cards : [],
    meta: (payload && payload.meta) || {},
  };
}

const CARD_SOURCE_SCOPES = ["Tous", "Cartes P0", "Vocabulaire"];

function normalizeVocabFreqPayload(payload) {
  return {
    items: (payload && Array.isArray(payload.items)) ? payload.items : [],
    meta: (payload && payload.meta) || {},
  };
}

function vocabLevel(item) {
  return String((item && (item.level || item.niveau_cible)) || "A1").trim() || "A1";
}

function vocabPackLabel(item) {
  return `Vocab ${vocabLevel(item)}`;
}

function vocabItemToCard(item) {
  const kr = String((item && (item.kr || item.script)) || "").trim();
  const fr = String((item && (item.fr || item.meaning_fr || item.answer_primary)) || "").trim();
  const choices = Array.isArray(item && item.choices) && item.choices.length ? item.choices : [fr].filter(Boolean);
  const audioUrl = (item && (item.audioUrl || (item.audio && item.audio.url))) || "";
  const audioRef = (item && (item.audio_ref || item.audioRef || (item.audio && item.audio.ref))) || kr;
  const level = vocabLevel(item);
  const pos = (item && (item.classe || item.pos || item.part_of_speech)) || "";
  return {
    id: item.id,
    language: item.language || "ko",
    type: item.item_type || item.type || "word",
    kind: "VOCAB",
    front: kr,
    back: fr,
    kr,
    ro: item.reading || item.ro || "",
    reading: item.reading || item.ro || "",
    fr,
    meaning_fr: fr,
    answer_primary: fr,
    audio_ref: audioRef,
    audioRef,
    audioUrl,
    audioPhrase: kr,
    audioPhraseRef: audioRef,
    audioPhraseUrl: audioUrl,
    audioPhraseFr: fr,
    audio: item.audio || (audioUrl ? { id: audioRef, ref: kr, url: audioUrl, status: item.audio_status || "generated" } : {}),
    examples: [item.example_target].filter(Boolean),
    choices,
    box: 1,
    pack: vocabPackLabel(item),
    pack_id: `vocab_${level}`,
    pos,
    focus: "sens",
    format: "vocab",
    seg: [kr].filter(Boolean),
    seen: false,
    note: item.theme || item.notes || "",
    tags: []
      .concat(Array.isArray(item.tags) ? item.tags : [])
      .concat([`level:${level}`, pos ? `pos:${pos}` : null])
      .filter(Boolean),
    sourceKind: "vocab_freq",
    sourcePath: "data/vocab_freq.json",
    source: { kind: "vocab_freq", path: "data/vocab_freq.json", item_id: item.id },
  };
}

function vocabItemsToCards(payload) {
  return normalizeVocabFreqPayload(payload).items
    .filter(item => item && item.id && (item.kr || item.script) && (item.fr || item.meaning_fr || item.answer_primary))
    .map(vocabItemToCard);
}

function cardSourceScope(card) {
  return card && card.sourceKind === "vocab_freq" ? "Vocabulaire" : "Cartes P0";
}

function cardSourceLabel(card) {
  return cardSourceScope(card);
}

function cardRepeatKey(card) {
  if (window.LangReviewQueue) return window.LangReviewQueue.keyFor(card);
  return audioKey(card && (card.kr || card.id));
}

function sourceScopeFromRoute(value) {
  const v = String(value || "").toLowerCase();
  if (v === "vocab" || v === "vocab_freq" || v === "vocabulaire") return "Vocabulaire";
  if (v === "p0" || v === "cards_p0" || v === "cartes_p0") return "Cartes P0";
  return "Tous";
}

function CartesPage({ t, go, routeParams = {} }) {
  const D = window.LANGUES_DATA;
  const [cardsLoad, setCardsLoad] = useState(() => ({
    status: window.LangData && (window.LangData.loadCardsP0 || window.LangData.loadVocabFreq) ? "loading" : "ready",
    cards: fallbackCardsP0(),
    vocabCards: [],
    imageBank: null,
    meta: null,
    vocabMeta: null,
    error: null,
  }));
  const [reloadKey, setReloadKey] = useState(0);
  const [mode, setMode] = useState(routeParams.mode || "jour");
  const [format, setFormat] = useState(routeParams.format || "Carte");
  const [sourceScope, setSourceScope] = useState(sourceScopeFromRoute(routeParams.source));
  const [pack, setPack] = useState("Tous");
  const [qcm, setQcm] = useState(false);
  const [i, setI] = useState(0);
  const [revealed, setRevealed] = useState(false);
  const [picked, setPicked] = useState(null);
  const [pendingRating, setPendingRating] = useState(null);
  const [dictAnswer, setDictAnswer] = useState("");
  const [dictChecked, setDictChecked] = useState(null);
  const [transformAnswer, setTransformAnswer] = useState("");
  const [transformChecked, setTransformChecked] = useState(null);
  const [stats, setStats] = useState(resetCardStats());
  const [reviewedIds, setReviewedIds] = useState([]);
  const [lastCardRepeatKey, setLastCardRepeatKey] = useState("");
  const [toast, setToast] = useState(null);
  const [glossMap, setGlossMap] = useState(null);
  // Journalisation de la séance « jour » : alimente Pilotage + la réussite récente du budget.
  const sessionRef = useRef(null);
  const shownAtRef = useRef(Date.now());
  const reset = () => {
    setRevealed(false);
    setPicked(null);
    setPendingRating(null);
    setDictAnswer("");
    setDictChecked(null);
    setTransformAnswer("");
    setTransformChecked(null);
  };

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

  useEffect(() => {
    let alive = true;
    const fallback = fallbackCardsP0();
    if (!window.LangData || (!window.LangData.loadCardsP0 && !window.LangData.loadVocabFreq)) {
      setCardsLoad({ status: "ready", cards: fallback, vocabCards: [], imageBank: null, meta: { total: fallback.length }, vocabMeta: { total: 0 }, error: null });
      return () => { alive = false; };
    }
    setCardsLoad(prev => ({ ...prev, status: "loading", error: null }));
    const cardsPromise = window.LangData.loadCardsP0
      ? window.LangData.loadCardsP0().then(payload => ({ ok: true, payload })).catch(error => ({ ok: false, error }))
      : Promise.resolve({ ok: true, payload: { cards: fallback, meta: { total: fallback.length } } });
    const vocabPromise = window.LangData.loadVocabFreq
      ? window.LangData.loadVocabFreq().then(payload => ({ ok: true, payload })).catch(error => ({ ok: false, error }))
      : Promise.resolve({ ok: true, payload: { items: [], meta: { total: 0 } } });
    const imagePromise = window.LangData.loadImageBank
      ? window.LangData.loadImageBank().then(payload => ({ ok: true, payload })).catch(error => ({ ok: false, error }))
      : Promise.resolve({ ok: true, payload: null });
    Promise.all([cardsPromise, vocabPromise, imagePromise])
      .then(([cardsResult, vocabResult, imageResult]) => {
        if (!alive) return;
        const next = cardsResult.ok ? normalizeCardsP0Payload(cardsResult.payload) : { cards: fallback, meta: { total: fallback.length } };
        const vocabPayload = vocabResult.ok ? normalizeVocabFreqPayload(vocabResult.payload) : { items: [], meta: { total: 0 } };
        const vocabCards = vocabItemsToCards(vocabPayload);
        if (window.LANGUES_DATA) {
          window.LANGUES_DATA.cards = next.cards;
          if (window.LANGUES_DATA.corpus) window.LANGUES_DATA.corpus.cardsP0 = next.meta.total || next.cards.length;
          if (window.LANGUES_DATA.corpus) window.LANGUES_DATA.corpus.vocabFreq = vocabPayload.meta.total || vocabCards.length;
        }
        const anyCards = next.cards.length || vocabCards.length;
        const failed = [cardsResult, vocabResult].filter(r => !r.ok);
        setCardsLoad({
          status: anyCards ? (failed.length ? "partial" : "ready") : "error",
          cards: next.cards,
          vocabCards,
          imageBank: imageResult.ok ? imageResult.payload : null,
          meta: next.meta,
          vocabMeta: vocabPayload.meta,
          error: failed.length ? failed[0].error : null,
        });
        setI(0);
        setLastCardRepeatKey("");
        reset();
      })
      .catch(error => {
        if (!alive) return;
        setCardsLoad({ status: fallback.length ? "fallback" : "error", cards: fallback, vocabCards: [], imageBank: null, meta: { total: fallback.length }, vocabMeta: { total: 0 }, error });
      });
    return () => { alive = false; };
  }, [reloadKey]);
  useEffect(() => {
    if (routeParams.mode) setMode(routeParams.mode);
    if (routeParams.format) setFormat(routeParams.format);
    if (routeParams.source) setSourceScope(sourceScopeFromRoute(routeParams.source));
    setI(0);
    setReviewedIds([]);
    setLastCardRepeatKey("");
    setStats(resetCardStats());
    reset();
  }, [routeParams.mode, routeParams.format, routeParams.focus, routeParams.source]);
  useEffect(() => {
    setPack("Tous");
    setI(0);
    setReviewedIds([]);
    setLastCardRepeatKey("");
    setStats(resetCardStats());
    if (sourceScope === "Vocabulaire" && format === "Transformation") setFormat("Carte");
    reset();
  }, [sourceScope]);

  const allDeckBase = (cardsLoad.cards.length ? cardsLoad.cards : fallbackCardsP0()).concat(cardsLoad.vocabCards || []);
  const allDeck = sourceScope === "Tous" ? allDeckBase : allDeckBase.filter(c => cardSourceScope(c) === sourceScope);
  const settings = settingsFromStore();
  const deck = filterCardsBySettings(allDeck, settings);
  const cardsP0Count = cardsLoad.cards.length || (cardsLoad.meta && cardsLoad.meta.total) || D.corpus.cardsP0 || 0;
  const vocabCount = (cardsLoad.vocabCards && cardsLoad.vocabCards.length) || (cardsLoad.vocabMeta && cardsLoad.vocabMeta.total) || D.corpus.vocabFreq || 0;
  const cardCount = deck.length || cardsP0Count + vocabCount;
  const sourceOptions = CARD_SOURCE_SCOPES.map(scope => {
    const n = scope === "Cartes P0" ? cardsP0Count : scope === "Vocabulaire" ? vocabCount : cardsP0Count + vocabCount;
    return { value: scope, label: `${scope} (${n.toLocaleString("fr-FR")})` };
  });
  const cardPacks = deck.length
    ? ["Tous", ...Array.from(new Set(deck.map(c => c.pack).filter(Boolean)))]
    : D.cardPacks;
  const cardPackOptions = cardPacks.map(p => ({ value: p, label: packLabel(p), title: p === packLabel(p) ? undefined : p }));
  const sessionLimit = Math.max(1, Number(settings.sessionSize || D.settingsDefaults.sessionSize || 20));
  const questionMode = questionModeFromSettings(settings);
  const transformationActive = format === "Transformation";
  const dictationActive = format === "Carte" && questionMode === "dictee";
  const qcmForced = format === "Carte" && questionMode === "qcm";
  // État SRS réel (store) superposé au deck : dues = vraie échéance FSRS, boîte = état vivant.
  const liveCards = window.LangStore && window.LangStore.cards ? window.LangStore.cards : null;
  const now = Date.now();
  const stOf = (c) => (liveCards ? liveCards.state(c.id) : null);
  const liveBox = (c) => { const st = stOf(c); return (st && st.box) || c.box; };
  const deckBoxes = [1, 2, 3, 4, 5].map(box => ({ box, n: deck.filter(c => liveBox(c) === box).length }));
  const scopedDeck = pack === "Tous" ? deck : deck.filter(c => c.pack === pack);
  const byMode = deck.filter(c => {
    const st = stOf(c);
    if (mode === "dues") return !!st && (st.due || 0) <= now;
    if (mode === "faibles") return !!st && (window.FSRS ? window.FSRS.retrievability(st, now) < 0.7 : liveBox(c) <= 2);
    if (mode === "vues") return !!st || c.seen;
    if (mode === "nonvues") return !st && !c.seen;
    return true;
  });
  const byPack = pack === "Tous" ? byMode : byMode.filter(c => c.pack === pack);
  // Budget asservi : la séance « jour » sert d'abord les DUES (triées par priorité),
  // puis du neuf PLAFONNÉ (cap journalier réduit/suspendu selon dette + réussite récente).
  const budget = (window.LangDailyBudget && liveCards)
    ? window.LangDailyBudget.compute({ cards: liveCards.all(), sessions: window.LangStore.sessions ? window.LangStore.sessions.all() : [], now, settings })
    : null;
  const buildJourQueue = () => {
    const prio = (c) => (window.FSRS && stOf(c) ? window.FSRS.priority(stOf(c), now) : 0);
    const dues = scopedDeck.filter(c => { const st = stOf(c); return !!st && (st.due || 0) <= now; }).sort((a, b) => prio(b) - prio(a));
    const fresh = scopedDeck.filter(c => !stOf(c) && !c.seen);
    const newCap = budget ? budget.newAllowed : fresh.length;
    return dues.concat(fresh.slice(0, Math.max(0, newCap))).slice(0, sessionLimit);
  };
  const modeCopy = CARD_MODE_COPY[mode] || CARD_MODE_COPY.jour;
  const finiteQueue = mode !== "drill";
  const reviewedSet = new Set(reviewedIds);
  const todayQueue = buildJourQueue();
  const newQueueLimit = budget ? Math.max(0, budget.newAllowed) : byPack.length;
  const baseQueue = mode === "jour" ? todayQueue : (mode === "nonvues" ? byPack.slice(0, Math.min(sessionLimit, newQueueLimit)) : byPack);
  const baseFiltered = finiteQueue ? baseQueue.filter(c => !reviewedSet.has(c.id)) : byPack;
  const filtered = transformationActive && window.LangCardTransform
    ? baseFiltered.filter(c => !!window.LangCardTransform.buildTransformation(c, allDeck))
    : baseFiltered;
  const rawCardIndex = filtered.length && window.LangReviewQueue
    ? window.LangReviewQueue.findNextIndex(filtered, finiteQueue ? 0 : i, lastCardRepeatKey, { keyFor: cardRepeatKey })
    : (filtered.length ? i % filtered.length : -1);
  const rawCard = rawCardIndex >= 0 ? filtered[rawCardIndex] : null;
  const card = rawCard ? { ...rawCard, box: liveBox(rawCard), seen: !!stOf(rawCard) || rawCard.seen } : rawCard;
  const cardImage = card && window.primaryImageForItem
    ? window.primaryImageForItem(cardsLoad.imageBank, [card.id, card.source_card_id, card.sourceId], "card_back")
    : null;
  const qcmChoiceCount = qcmChoiceCountFromSettings(settings);
  const qcmCard = card ? { ...card, choices: buildCardQcmChoices(card, allDeck, qcmChoiceCount) } : card;
  const transform = transformationActive && card && window.LangCardTransform
    ? window.LangCardTransform.buildTransformation(card, allDeck)
    : null;
  const remaining = filtered.length;
  const sessionTotal = finiteQueue ? stats.done + remaining : null;
  const finished = finiteQueue && stats.done > 0 && remaining === 0;
  const progressValue = finiteQueue && sessionTotal ? stats.done / sessionTotal : 0;
  const todayStart = dayStartMs(now);
  const dueCount = scopedDeck.filter(c => { const st = stOf(c); return !!st && (st.due || 0) <= now; }).length;
  const weakCount = scopedDeck.filter(c => {
    const st = stOf(c);
    return !!st && (window.FSRS ? window.FSRS.retrievability(st, now) < 0.7 : liveBox(c) <= 2);
  }).length;
  const seenCount = scopedDeck.filter(c => !!stOf(c) || c.seen).length;
  const freshCount = scopedDeck.filter(c => !stOf(c) && !c.seen).length;
  const seenTodayCount = scopedDeck.filter(c => {
    const st = stOf(c);
    return !!st && Math.max(st.last_review || 0, st.introduced_at || 0) >= todayStart;
  }).length;
  const dayDueCount = todayQueue.filter(c => { const st = stOf(c); return !!st && (st.due || 0) <= now; }).length;
  const dayNewCount = todayQueue.filter(c => !stOf(c) && !c.seen).length;
  const queueStats = {
    dayQueue: todayQueue.length,
    dayDue: dayDueCount,
    dayNew: dayNewCount,
    due: dueCount,
    weak: weakCount,
    seen: seenCount,
    seenToday: seenTodayCount,
    fresh: freshCount,
    newAllowed: Math.min(freshCount, budget ? Math.max(0, budget.newAllowed) : freshCount),
    newNote: budget && budget.throttle && budget.throttle.level !== "open" ? budget.throttle.reason : "quota du jour",
    drill: scopedDeck.length,
  };
  const openQueue = (nextMode, nextPack) => {
    setMode(nextMode);
    if (nextPack) setPack(nextPack);
    setI(0);
    setReviewedIds([]);
    setLastCardRepeatKey("");
    setStats(resetCardStats());
    reset();
  };
  const nextActions = [
    { label: "File du jour", icon: "today", kind: mode === "jour" ? "secondary" : "primary", disabled: queueStats.dayQueue <= 0, title: queueStats.dayQueue <= 0 ? "Aucune due ni nouvelle autorisée dans ce filtre" : undefined, onClick: () => openQueue("jour") },
    { label: "Introduire du neuf", icon: "plus", disabled: queueStats.fresh <= 0 || queueStats.newAllowed <= 0, title: queueStats.fresh <= 0 ? "Aucune nouvelle carte dans ce filtre" : queueStats.newAllowed <= 0 ? "Quota de nouvelles atteint ou suspendu" : undefined, onClick: () => openQueue("nonvues") },
    { label: "Revoir les fragiles", icon: "wrench", disabled: queueStats.weak <= 0, title: queueStats.weak <= 0 ? "Aucune carte fragile dans ce filtre" : undefined, onClick: () => openQueue("faibles") },
    { label: "Revoir les vues", icon: "refresh", disabled: queueStats.seen <= 0, title: queueStats.seen <= 0 ? "Aucune carte déjà rencontrée dans ce filtre" : undefined, onClick: () => openQueue("vues") },
    { label: "Drill libre", icon: "repeat", disabled: queueStats.drill <= 0, title: queueStats.drill <= 0 ? "Aucune carte dans ce filtre" : undefined, onClick: () => openQueue("drill") },
    pack !== "Tous" && { label: "Tous les paquets", icon: "grid", onClick: () => openQueue(mode, "Tous") },
    { label: "Plan global", icon: "today", kind: "ghost", onClick: () => go("today") },
  ];
  const formatOptions = D.cardFormats.map(f => {
    const supported = ["Carte", "Production", "Transformation"].includes(f);
    const vocabTransform = sourceScope === "Vocabulaire" && f === "Transformation";
    return {
      value: f,
      label: f,
      disabled: !supported || vocabTransform,
      title: !supported ? "À brancher via exercise-router" : vocabTransform ? "Les mots frequentiels n'ont pas encore de patron de transformation" : undefined,
    };
  });
  const srsActive = mode !== "drill";
  const qcmActive = qcm && format === "Carte" && !dictationActive;
  const canRate = transformationActive ? !!transformChecked : (dictationActive ? !!dictChecked : (qcmActive ? picked != null && !pendingRating : revealed));
  const style = t.cardStyle;
  // Intervalles FSRS réels pour la carte courante (état vivant du store superposé).
  const previewIntervals = srsActive && card ? reviewPreviewIntervals(stOf(rawCard) || {}, settings, now) : null;
  const ratingLabel = (r) => {
    if (!srsActive) return "libre";
    if (previewIntervals && previewIntervals[GRADE_BY_RATING[r.id]] != null) return formatInterval(previewIntervals[GRADE_BY_RATING[r.id]]);
    return reviewLabelFor(r.id);
  };

  const rate = (r) => {
    if (window.LangSounds) (r.id === "rate" ? LangSounds.wrong : LangSounds.correct)();
    if (srsActive && window.FSRS && window.LangStore && card) {
      const grades = { rate: FSRS.GRADES.AGAIN, hard: FSRS.GRADES.HARD, good: FSRS.GRADES.GOOD, easy: FSRS.GRADES.EASY };
      const liveSettings = window.LangStore.settings.get();
      const prev = window.LangStore.cards.state(card.id) || {};
      const next = FSRS.review(prev, grades[r.id], Date.now(), liveSettings.retention || FSRS.DEFAULT_RETENTION);
      // Registre du jour : horodate la 1re introduction (sert au cap de nouveauté).
      next.introduced_at = prev.introduced_at || Date.now();
      window.LangStore.cards.setState(card.id, next);
      // Journalise les revues SRS : compte dans Pilotage et la réussite récente.
      if (window.LangSessionLog) {
        const sess = ensureCardSession();
        if (sess) window.LangSessionLog.logResult(sess, {
          exerciseId: "card_" + card.id, itemId: card.id, kind: (card.sourceKind === "vocab_freq" ? "vocab_cards_" : "cards_") + mode, strand: "study",
          startedAt: shownAtRef.current, finishedAt: Date.now(),
          grade: grades[r.id], correct: r.id !== "rate",
          srsUpdates: [{ cardId: card.id, grade: grades[r.id], due: next.due, interval: next.interval }],
        });
      }
    } else if (mode === "drill" && window.LangSessionLog && card) {
      const sess = ensureCardSession();
      if (sess) {
        window.LangSessionLog.logResult(sess, {
          exerciseId: "card_" + card.id, itemId: card.id, kind: card.sourceKind === "vocab_freq" ? "vocab_cards_drill_free" : "cards_drill_free", strand: "fluency",
          startedAt: shownAtRef.current, finishedAt: Date.now(),
          grade: GRADE_BY_RATING[r.id], correct: r.id !== "rate",
          notes: "Drill libre sans effet SRS",
        });
        if (window.LangStore) window.LangSessionLog.finishSession(sess, window.LangStore, Date.now());
        sessionRef.current = null;
      }
    }
    if (card) setLastCardRepeatKey(cardRepeatKey(card));
    if (finiteQueue && card) setReviewedIds(ids => ids.indexOf(card.id) >= 0 ? ids : ids.concat(card.id));
    setStats(s => ({ ...s, [r.id]: s[r.id] + 1, done: s.done + 1 }));
    reset();
    setI(finiteQueue ? 0 : (rawCardIndex >= 0 ? rawCardIndex + 1 : i + 1));
  };
  const ensureCardSession = () => {
    if (!window.LangSessionLog || !window.LangStore) return null;
    if (!sessionRef.current) {
      sessionRef.current = window.LangSessionLog.startSession({
        profile: window.LangStore.profile, planId: "cartes_" + mode, templateId: "cartes",
        intention: mode === "drill" ? "fluidite" : "reviser", charge: "", drillMode: mode,
        plannedMinutes: 0, blocks: [],
      });
    }
    return sessionRef.current;
  };
  // Chrono par carte (pour les minutes journalisées).
  useEffect(() => { shownAtRef.current = Date.now(); }, [card && card.id]);
  // Persiste la séance « jour » dès qu'elle est terminée (une seule fois).
  useEffect(() => {
    if (finished && sessionRef.current && window.LangSessionLog && window.LangStore) {
      window.LangSessionLog.finishSession(sessionRef.current, window.LangStore, Date.now());
      sessionRef.current = null;
    }
  }, [finished]);
  const handleRate = (r) => {
    rate(r);
  };
  const checkDictation = () => {
    if (!card) return;
    setDictChecked(compareDictationAnswer(card.kr, dictAnswer));
    setRevealed(true);
  };
  const checkTransformation = (value) => {
    if (!transform) return;
    let result;
    if (transform.mode === "choose_form") {
      result = window.LangCardTransform.checkChoice(transform, value);
    } else if (window.LangValidate && window.LangValidate.compare) {
      result = window.LangValidate.compare(transform.targetKr, value, { requireQuestion: /\?\s*$/.test(transform.targetKr || "") });
    } else {
      result = window.LangCardTransform.checkChoice(transform, value);
    }
    setTransformChecked(result);
    setRevealed(true);
  };
  useEffect(() => {
    if (qcmForced && revealed && !dictationActive && !qcm) {
      setQcm(true);
      setPicked(null);
      setPendingRating(null);
    }
  }, [qcmForced, revealed, dictationActive, qcm, card && card.id]);
  const reportAudio = () => {
    const api = window.LangAudio;
    if (!api || typeof api.reportLastAudio !== "function") { setToast("Audio indisponible"); setTimeout(() => setToast(null), 2000); return; }
    Promise.resolve(api.reportLastAudio({ card: card.id, fr: card.fr || card.audioPhraseFr || "" })).then((res) => {
      const msg = !res || !res.ok ? "Écoute d'abord le mot/son à corriger, puis re-signale"
        : res.mode === "queue" ? `Audio « ${res.entry.text} » ajouté à la file de régénération`
        : `Audio « ${res.entry.text} » copié — à coller dans la file`;
      setToast(msg); setTimeout(() => setToast(null), 2800);
    });
  };

  useEffect(() => {
    const h = (e) => {
      if (e.key === " " && !revealed && !qcmActive && !dictationActive && !transformationActive) { e.preventDefault(); setRevealed(true); }
      const canKeyboardRate = transformationActive ? !!transformChecked : (dictationActive ? !!dictChecked : (qcmActive ? picked != null && !pendingRating : revealed));
      if (canKeyboardRate) { const r = RATINGS.find(x => x.key === e.key); if (r) handleRate(r); }
    };
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, [revealed, qcmActive, dictationActive, transformationActive, dictChecked, transformChecked, picked, pendingRating, format, qcmCard && qcmCard.id]);


  if (cardsLoad.status === "loading" && deck.length === 0) {
    return (
      <div style={{ maxWidth: 760, margin: "40px auto 0" }} className="fade-up">
        <LoadingCard lines={4} />
      </div>
    );
  }

  if (cardsLoad.status === "error" && deck.length === 0) {
    return (
      <div style={{ maxWidth: 680, margin: "40px auto 0" }} className="fade-up">
        <ErrorState
          title="Cartes indisponibles"
          body="Les banques de cartes ne peuvent pas etre chargees."
          onRetry={() => setReloadKey(x => x + 1)}
        />
      </div>
    );
  }

  if (finished) {
    return (
      <div style={{ maxWidth: 680, margin: "40px auto 0", textAlign: "center" }} className="fade-up">
        <Eyebrow>{modeCopy.short} terminee</Eyebrow>
        <h1 style={{ fontSize: 40, margin: "10px 0 6px" }}>File finie.</h1>
        <p className="muted" style={{ fontSize: 16 }}>{stats.done} cartes traitees. Tu peux arreter ici, ou continuer sans confondre la fin du quota avec la fin du module.</p>
        <div className="card" style={{ padding: 20, margin: "24px 0", display: "flex", justifyContent: "space-around" }}>
          {RATINGS.map(r => <div key={r.id}><div style={{ fontFamily: "var(--serif)", fontSize: 28, color: r.color }}>{stats[r.id]}</div><div className="faint" style={{ fontSize: 12 }}>{r.label}</div></div>)}
        </div>
        <div style={{ margin: "0 0 18px" }}>
          <CardQueueOverview stats={queueStats} activeMode={mode} />
        </div>
        <QueueActionGrid actions={[
          { label: "Recommencer cette file", icon: "refresh", kind: "ghost", onClick: () => { setI(0); setReviewedIds([]); setStats(resetCardStats()); reset(); } },
          ...nextActions,
        ]} />
      </div>
    );
  }

  if (filtered.length === 0) {
    return (
      <div style={{ maxWidth: 680, margin: "40px auto 0" }} className="fade-up">
        <EmptyState
          icon="grid"
          title={modeCopy.empty}
          body={emptyQueueBody(mode, queueStats, packLabel(pack))}
          action={<div style={{ display: "grid", gap: 14, width: "min(100%, 620px)", margin: "0 auto" }}><CardQueueOverview stats={queueStats} activeMode={mode} /><QueueActionGrid actions={nextActions} /></div>}
        />
      </div>
    );
  }

  return (
    <div style={{ maxWidth: 1040, margin: "0 auto", display: "grid", gap: "var(--gap)" }}>
      {/* Header */}
      <div className="fade-up" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, flexWrap: "wrap" }}>
        <div style={{ flexShrink: 0 }}>
          <Eyebrow>Leitner · {cardCount.toLocaleString("fr-FR")} cartes{cardsLoad.status === "fallback" ? " · repli local" : cardsLoad.status === "partial" ? " · charge partielle" : ""}</Eyebrow>
          <h1 style={{ fontSize: 36, margin: "4px 0 0", whiteSpace: "nowrap" }}>Cartes</h1>
          <p className="faint" style={{ fontSize: 13, margin: "5px 0 0" }}>{cardsP0Count.toLocaleString("fr-FR")} cartes P0 structurees + {vocabCount.toLocaleString("fr-FR")} mots frequents avec audio.</p>
        </div>
        <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
          <Segmented value={format} options={formatOptions} onChange={(v) => { setFormat(v); setQcm(false); reset(); }} size="sm" />
          <button onClick={() => { setPendingRating(null); setPicked(null); setQcm(q => !q); }} disabled={format !== "Carte" || dictationActive || !revealed} title={format !== "Carte" ? "QCM disponible seulement en format Carte" : dictationActive ? "Mode dictée actif" : !revealed ? "Vérifiez d'abord" : undefined} className="chip" style={{ cursor: format !== "Carte" || dictationActive || !revealed ? "not-allowed" : "pointer", padding: "7px 13px", background: qcmActive ? "var(--accent-tint)" : "var(--card-2)", color: qcmActive ? "var(--accent-deep)" : "var(--ink-soft)" }}><Icon name="grid" size={14} /> Aide QCM</button>
        </div>
      </div>

      {/* Modes + filters */}
      <div className="fade-up" style={{ display: "grid", gap: 10 }}>
        <Segmented value={mode} options={D.cardModes.map(m => ({ value: m.id, label: (CARD_MODE_COPY[m.id] || m).label, title: (CARD_MODE_COPY[m.id] || m).desc || m.desc }))} onChange={(v) => { setMode(v); reset(); setI(0); setReviewedIds([]); setLastCardRepeatKey(""); setStats(resetCardStats()); }} size="sm" />
        <div className="faint" style={{ fontSize: 13, lineHeight: 1.35 }}>{modeCopy.desc}</div>
        <FilterChips options={sourceOptions} value={sourceScope} onChange={(v) => { setSourceScope(v); }} />
        <FilterChips options={cardPackOptions} value={pack} onChange={(v) => { setPack(v); setI(0); setReviewedIds([]); setLastCardRepeatKey(""); setStats(resetCardStats()); reset(); }} />
        <CardQueueOverview stats={queueStats} activeMode={mode} />
      </div>

      {finiteQueue ? (
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <Progress value={progressValue} />
          <span className="faint" style={{ fontSize: 13, fontFamily: "var(--mono)", whiteSpace: "nowrap" }}>{stats.done} / {sessionTotal}</span>
        </div>
      ) : (
        <div className="chip" style={{ width: "fit-content", background: "var(--card-2)" }}>Drill libre · sans effet SRS · {stats.done} passages journalises</div>
      )}

      {/* Main + side */}
      <div className="cartes-grid" style={{ display: "grid", gridTemplateColumns: "minmax(0,1fr) 252px", gap: "var(--gap)", alignItems: "start" }}>
        <div>
          {transformationActive
            ? <CardTransformation card={card} transform={transform} answer={transformAnswer} checked={transformChecked} onAnswer={(v) => { setTransformAnswer(v); setTransformChecked(null); setRevealed(false); }} onCheck={checkTransformation} onPick={checkTransformation} glossFor={glossFor} />
            : dictationActive
            ? <CardDictation card={card} answer={dictAnswer} checked={dictChecked} onAnswer={(v) => { setDictAnswer(v); setDictChecked(null); setRevealed(false); }} onCheck={checkDictation} glossFor={glossFor} image={cardImage} />
            : (
              <>
                {style === "classique" && <CardClassique card={card} revealed={revealed} qcm={qcmActive} format={format} onReveal={() => setRevealed(true)} onReset={reset} glossFor={glossFor} image={cardImage} />}
                {style === "minimal" && <CardMinimal card={card} revealed={revealed} format={format} onReveal={() => setRevealed(true)} onReset={reset} glossFor={glossFor} image={cardImage} />}
                {style === "boxes" && <CardBoxes card={card} revealed={revealed} format={format} onReveal={() => setRevealed(true)} onReset={reset} glossFor={glossFor} image={cardImage} />}
              </>
            )}

          {qcmActive && <QcmRow card={qcmCard} picked={picked} onPick={(p) => { setPicked(p); setRevealed(true); }} />}

          {qcmActive && pendingRating && picked != null && (
            <div className="fade-up" style={{ display: "flex", justifyContent: "flex-end", marginTop: 14 }}>
              <Btn kind="primary" icon="check" onClick={() => rate(pendingRating)}>Continuer</Btn>
            </div>
          )}

          {!transformationActive && !dictationActive && !qcmActive && !revealed && (
            <div style={{ display: "flex", justifyContent: "center", marginTop: 18 }}>
              <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>
          )}

          {canRate && (
            <div className="fade-up" style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 10, marginTop: 22 }}>
              {RATINGS.map(r => (
                <button key={r.id} onClick={() => handleRate(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={e => { e.currentTarget.style.background = r.tint; e.currentTarget.style.borderColor = "transparent"; }}
                  onMouseLeave={e => { e.currentTarget.style.background = "var(--card)"; e.currentTarget.style.borderColor = "var(--rule)"; }}>
                  <span style={{ fontFamily: "var(--serif)", fontSize: 18, color: r.color }}>{r.label}</span>
                  <span className="faint" style={{ fontSize: 11.5 }}>{ratingLabel(r)}</span>
                </button>
              ))}
            </div>
          )}
        </div>

        {/* Side */}
        <aside className="card cartes-side" style={{ padding: 17, display: "grid", gap: 16, position: "sticky", top: 16 }}>
          <div>
            <Eyebrow style={{ marginBottom: 10 }}>État de la boîte</Eyebrow>
            <BoxLadder box={card.box} />
          </div>
          <div style={{ height: 1, background: "var(--rule)" }} />
          <div style={{ display: "grid", gap: 9 }}>
            <SideRow label="Filtre" value={packLabel(card.pack)} />
            <SideRow label="Banque" value={cardSourceLabel(card)} />
            <SideRow label="Type" value={typeLabel(card)} />
            <SideRow label="Objectif" value={focusLabel(card.focus)} />
            <SideRow label="Prochaine" value={revealed ? (previewIntervals ? `Bon → ${formatInterval(previewIntervals[3])}` : "selon la note") : "—"} />
            <SideRow label="Reste" value={finiteQueue ? `${remaining}` : "drill libre"} />
          </div>
          {revealed && (
            <div className="fade-up" style={{ display: "grid", gap: 12, paddingTop: 4 }}>
              <div style={{ height: 1, background: "var(--rule)" }} />
              <div>
                <div className="eyebrow" style={{ marginBottom: 7 }}>Mots TTS <span className="faint" style={{ fontWeight: 400, textTransform: "none", letterSpacing: 0 }}>· mots de la phrase visible</span></div>
                <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>{ttsSegmentsForCard(card).map((s, k) => {
                  const block = audioBlockForText(card.audio && card.audio.blocks, s);
                  return <Speakable key={k} text={s} audioRef={audioRefForBlock(block, s)} audioUrl={block && block.url} gloss={glossFor(s)} className="kr" style={{ fontSize: 16, padding: "3px 11px" }}>{s}</Speakable>;
                })}</div>
              </div>
              <div>
                <div className="eyebrow" style={{ marginBottom: 6 }}>Phrase audio</div>
                <Speakable text={card.audioPhrase} audioRef={card.audioPhraseRef} audioUrl={card.audioPhraseUrl} gloss={card.audioPhraseFr || undefined} className="kr" style={{ fontSize: 14, padding: "4px 9px" }}>{card.audioPhrase}</Speakable>
                <p className="faint" style={{ fontSize: 12, margin: "7px 0 0" }}>{card.audioPhraseFr}</p>
              </div>
            </div>
          )}
          <div style={{ height: 1, background: "var(--rule)" }} />
          <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
            <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>
            <Btn kind="ghost" size="sm" icon="book" onClick={() => go("pilotage", { view: "liste", query: lookupQueryForCard(card), status: "Tous", source: "card", cardId: card.id })}>Mini-dico</Btn>
          </div>
        </aside>
      </div>

      {/* Learning map / stats / historique */}
      <Collapsible eyebrow="Suivi" title="Learning map & stats" hint={`${cardsP0Count.toLocaleString("fr-FR")} P0 + ${vocabCount.toLocaleString("fr-FR")} vocab`}>
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(220px,1fr))", gap: "var(--gap)" }}>
          <div>
            <div className="eyebrow" style={{ marginBottom: 10 }}>Répartition des boîtes</div>
            <MiniBars data={deckBoxes} labelKey="box" valKey="n" />
          </div>
          <div>
            <div className="eyebrow" style={{ marginBottom: 10 }}>Couverture</div>
            <div style={{ display: "grid", gap: 10 }}>
              <SideRow label="Cartes vues" value={`${deck.filter(c => !!stOf(c) || c.seen).length} / ${deck.length}`} />
              <SideRow label="Boîtes hautes (4-5)" value={`${deckBoxes.filter(b => b.box >= 4).reduce((a, b) => a + b.n, 0)}`} />
              <SideRow label="Profil" value="Sora (sauvegardé)" />
            </div>
          </div>
        </div>
      </Collapsible>
      <Collapsible eyebrow="Historique" title="Historique cartes" hint="dernières séances">
        <div style={{ display: "grid", gap: 8 }}>
          {D.recentSessions.map((s, k) => (
            <div key={k} style={{ display: "flex", alignItems: "center", gap: 14, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px solid var(--rule)" }}>
              <span className="faint" style={{ fontSize: 13, width: 52, fontFamily: "var(--mono)" }}>{s.date}</span>
              <span style={{ flex: 1, fontWeight: 600, fontSize: 14 }}>{s.kind}</span>
              <span className="chip" style={{ fontSize: 11.5 }}>{s.cards} cartes</span>
            </div>
          ))}
        </div>
      </Collapsible>

      {toast && <div className="fade-up" style={{ position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)", background: "var(--ink)", color: "var(--paper)", padding: "11px 18px", borderRadius: 999, fontSize: 14, fontWeight: 600, zIndex: 60, boxShadow: "var(--shadow-pop)" }}>{toast}</div>}
    </div>
  );
}

Object.assign(window, { CartesPage });
