/* Dialogues — hub de 20 + lecteur complet.
   Variantes lecteur : 'transcript' / 'ligne'. Modes : lecture seule / entraînement.
   Tokens glosés cliquables, evidence lines, reconstruction à tuiles, shadowing,
   signalement audio par ligne et par token. */

const SPEAKER_TONE = {
  rose:  { bar: "var(--accent)", tint: "var(--accent-tint)", text: "var(--accent-deep)" },
  ochre: { bar: "var(--ochre)",  tint: "var(--warn-tint)",   text: "var(--warn)" },
};

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

function sentenceEnd(text) {
  const s = String(text || "").trim();
  if (!s) return "";
  return /[.!?。！？]$/.test(s) ? s : `${s}.`;
}

function levelLabel(level) {
  if (level === "A0") return "Débutant";
  if (level === "A1") return "Élémentaire";
  return level || "Débutant";
}

const DIALOGUE_KIND_ORDER = { progressive: 0, micro: 1, micro_p1_04: 2 };
const DIALOGUE_KIND_LABEL = {
  progressive: "Progressif",
  micro: "Micro",
  micro_p1_04: "Situations",
};

function dialogueSortKey(d) {
  const kindRank = Object.prototype.hasOwnProperty.call(DIALOGUE_KIND_ORDER, d && d.kind) ? DIALOGUE_KIND_ORDER[d.kind] : 9;
  const order = Number.isFinite(d && d.order) ? d.order : 9999;
  return { kindRank, order, id: String((d && d.id) || "") };
}

function sortDialoguesForHub(items) {
  return (items || []).slice().sort((a, b) => {
    const aa = dialogueSortKey(a);
    const bb = dialogueSortKey(b);
    if (aa.kindRank !== bb.kindRank) return aa.kindRank - bb.kindRank;
    if (aa.order !== bb.order) return aa.order - bb.order;
    return aa.id.localeCompare(bb.id);
  });
}

function canonicalHubItem(d, displayIndex) {
  return {
    id: d.id,
    day: Number.isFinite(displayIndex) ? displayIndex + 1 : (d.order || 1),
    series: DIALOGUE_KIND_LABEL[d.kind] || "Dialogue",
    title: polishFrenchText(d.title || d.id),
    titleKr: d.kind === "micro" ? "짧은 대화" : "대화",
    summary: polishFrenchText(d.summary || ""),
    lines: d.line_count || (d.lines ? d.lines.length : 0),
    level: levelLabel(d.level),
    done: false,
    progress: Number.isFinite(d.progress) ? d.progress : null,
    audioReady: !!d.audio_ready,
    locked: false,
    canonical: d,
  };
}

function readerFromCanonical(d) {
  if (!d || d.turns) return d || window.LANGUES_DATA.dialogueCafe;
  const tones = ["rose", "ochre"];
  const speakers = {};
  (d.speakers || []).forEach((speaker, index) => {
    speakers[speaker] = { name: speaker, kr: speaker, tone: tones[index % tones.length] };
  });
  const turns = (d.lines || []).map(line => {
    if (!speakers[line.speaker]) speakers[line.speaker] = { name: line.speaker, kr: line.speaker, tone: tones[Object.keys(speakers).length % tones.length] };
    return {
      s: line.speaker,
      kr: line.kr,
      ro: line.reading || "",
      fr: polishFrenchText(line.fr),
      audio: line.audio || null,
      tokens: (line.tokens || []).map(token => ({ t: token.kr, g: polishFrenchText(token.fr || token.reading || token.kr), audio: token.audio || null })),
    };
  });
  return {
    id: d.id,
    title: polishFrenchText(d.title || d.id),
    titleKr: d.kind === "micro" ? "짧은 대화" : "대화",
    level: levelLabel(d.level),
    turns,
    speakers,
    grammar: [],
    vocabNew: Array.from(new Map(turns.flatMap(turn => (turn.tokens || []).map(token => [token.t, { kr: token.t, fr: token.g, audio: token.audio }]))).values()).slice(0, 14),
    vocabSeen: [],
    questions: (d.questions || []).map(q => ({
      q: polishFrenchText(q.q),
      choices: q.choices,
      a: q.answer,
      evidence: Math.max(0, (d.lines || []).findIndex(line => line.id === q.evidence_line_id)),
      explain: polishFrenchText(q.explain || ""),
    })),
    reconstruct: d.reconstruction ? {
      fr: polishFrenchText(d.reconstruction.fr),
      tiles: d.reconstruction.tiles || [],
      answer: d.reconstruction.answer || [],
    } : null,
    shadowing: (d.shadowing || []).map(item => ({ kr: item.kr, ro: item.reading || "", fr: polishFrenchText(item.fr || ""), audio: item.audio || null })),
  };
}

/* ---------- Hub ---------- */
function DialoguesHub({ onOpen, loading, error, onRetry, items, stats, imageBank }) {
  const list = items && items.length ? sortDialoguesForHub(items).map((d, i) => canonicalHubItem(d, i)) : window.LANGUES_DATA.dialogueList;
  const [filter, setFilter] = useState("Tous");
  const levels = ["Tous", ...Array.from(new Set(list.map(d => d.level)))];
  const shown = filter === "Tous" ? list : list.filter(d => d.level === filter);
  const corpus = stats || window.LANGUES_DATA.corpus;
  return (
    <div style={{ maxWidth: 1040, margin: "0 auto", display: "grid", gap: "var(--gap-lg)" }}>
      <header className="fade-up">
        <Eyebrow>Apprendre par l'oral</Eyebrow>
        <h1 style={{ fontSize: 44, margin: "6px 0 0" }}>Dialogues</h1>
        <p className="muted" style={{ fontSize: 16, margin: "10px 0 0", maxWidth: 600 }}>
          {corpus.dialogues} dialogues · {corpus.dialogueLines} lignes. Le coréen au centre, la traduction reste volontaire.
        </p>
      </header>

      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
        <FilterChips options={levels} value={filter} onChange={setFilter} />
        <span className="faint" style={{ fontSize: 13 }}>{shown.length} dialogues</span>
      </div>

      {error ? (
        <ErrorState body={error} onRetry={onRetry} />
      ) : loading ? (
        <div style={{ display: "grid", gap: "var(--gap)" }}>{[0, 1, 2].map(i => <LoadingCard key={i} lines={2} />)}</div>
      ) : shown.length === 0 ? (
        <EmptyState title="Aucun dialogue à ce niveau" body="Changez de filtre pour voir les autres dialogues." />
      ) : (
        <div style={{ display: "grid", gap: 12 }}>
          {shown.map((d, i) => {
            const scene = window.primaryImageForItem ? window.primaryImageForItem(imageBank, [d.id], "dialogue_context") : null;
            return (
            <article key={d.id} className="card fade-up" style={{ padding: "16px var(--pad-card)", display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap", animationDelay: `${Math.min(i, 8) * 35}ms`, opacity: d.locked ? .6 : 1 }}>
              {scene ? (
                <LearningImage image={scene} compact style={{ width: 96, flexShrink: 0 }} />
              ) : (
                <div style={{ width: 50, height: 50, flexShrink: 0, borderRadius: 13, background: d.done ? "var(--good-tint)" : "var(--accent-tint)", display: "grid", placeItems: "center", color: d.done ? "var(--good)" : "var(--accent-deep)", fontFamily: "var(--serif)", fontSize: 21 }}>
                  {d.locked ? <Icon name="lock" size={20} /> : d.day}
                </div>
              )}
              <div style={{ flex: "1 1 220px", minWidth: 200 }}>
                <div style={{ display: "flex", alignItems: "baseline", gap: 9, flexWrap: "wrap" }}>
                  <span className="kr" style={{ fontSize: 19, color: "var(--accent-deep)" }}>{d.titleKr}</span>
                  <span style={{ fontFamily: "var(--serif)", fontSize: 18 }}>{d.title}</span>
                </div>
                <div style={{ display: "flex", gap: 7, marginTop: 8, flexWrap: "wrap" }}>
                  <span className="chip" style={{ fontSize: 11.5 }}>{d.level}</span>
                  {d.series && <span className="chip" style={{ fontSize: 11.5 }}>{d.series}</span>}
                  <span className="chip" style={{ fontSize: 11.5 }}>{d.lines} lignes</span>
                  {d.audioReady && <span className="chip" style={{ fontSize: 11.5 }}><Icon name="speaker" size={12} /> audio</span>}
                  {d.done && <span className="chip" style={{ fontSize: 11.5, background: "var(--good-tint)", color: "var(--good)", borderColor: "transparent" }}><Icon name="check" size={12} /> terminé</span>}
                </div>
              </div>
              <div style={{ width: 120, flexShrink: 0 }}>
                {d.done || d.progress > 0 ? (
                  <>
                    <div className="faint" style={{ fontSize: 11.5, marginBottom: 5 }}>{Math.round((d.done ? 1 : d.progress) * 100)}% lu</div>
                    <Progress value={d.done ? 1 : d.progress} />
                  </>
                ) : (
                  <div className="faint" style={{ fontSize: 12 }}>Lecture libre</div>
                )}
              </div>
              <div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
                <Btn kind="primary" size="sm" icon="play" aria-label={`Lire ${d.title}`} title={`Lire ${d.title}`} onClick={() => onOpen(d.id, "lecture")} disabled={d.locked}>Lire</Btn>
                <Btn kind="secondary" size="sm" aria-label={`S'entrainer sur ${d.title}`} title={`S'entrainer sur ${d.title}`} onClick={() => onOpen(d.id, "entrainement")} disabled={d.locked}>S'entraîner</Btn>
              </div>
            </article>
          );})}
        </div>
      )}
    </div>
  );
}

/* ---------- Audio bar ---------- */
function AudioBar({ idx, total, playing, speed, onFirst, onPrev, onPlay, onRepeat, onNext, onSpeed }) {
  const Ctrl = ({ icon, label, onClick, big }) => (
    <button onClick={onClick} aria-label={label} title={label} style={{
      width: big ? 50 : 38, height: big ? 50 : 38, borderRadius: 999, cursor: "pointer",
      border: `1px solid ${big ? "transparent" : "var(--rule)"}`, background: big ? "var(--accent)" : "var(--card)",
      color: big ? "#fdf3f0" : "var(--ink-soft)", display: "grid", placeItems: "center", transition: "all .15s",
      boxShadow: big ? "0 8px 18px -10px var(--accent)" : "none",
    }}
    onMouseEnter={e => { if (!big) e.currentTarget.style.background = "var(--card-2)"; }}
    onMouseLeave={e => { if (!big) e.currentTarget.style.background = "var(--card)"; }}>
      <Icon name={icon} size={big ? 23 : 18} />
    </button>
  );
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 7, justifyContent: "center", flexWrap: "wrap" }}>
      <Ctrl icon="prev" label="Début" onClick={onFirst} />
      <Ctrl icon="chevron" label="Précédent" onClick={onPrev} />
      <Ctrl icon={playing ? "pause" : "play"} label={playing ? "Pause" : "Lire"} onClick={onPlay} big />
      <Ctrl icon="repeat" label="Répéter" onClick={onRepeat} />
      <Ctrl icon="next" label="Suivant" onClick={onNext} />
      <button onClick={onSpeed} style={{ marginLeft: 5, padding: "8px 12px", borderRadius: 999, border: "1px solid var(--rule)", background: "var(--card)", color: "var(--ink-soft)", fontFamily: "var(--mono)", fontSize: 13, fontWeight: 600, cursor: "pointer" }}>×{speed}</button>
      <span className="faint" style={{ marginLeft: 5, fontFamily: "var(--mono)", fontSize: 13 }}>{idx + 1} / {total}</span>
    </div>
  );
}

/* ---------- Ligne lue mot par mot ----------
   On affiche TOUJOURS la ligne complète (turn.kr = ce qui est réellement lu),
   découpée en mots de surface cliquables. La glose vient du token aligné (sens
   en contexte, ex. un prénom) sinon du résolveur partagé. Ceci évite l'écart
   « texte affiché ≠ audio » qd les tokens sont des radicaux (저 vs 저는). */
function LineWords({ turn, glossFor }) {
  const words = String(turn.kr || "").trim().split(/\s+/).filter(Boolean);
  const tokens = Array.isArray(turn.tokens) ? turn.tokens : [];
  const aligned = tokens.length === words.length;
  return (
    <>
      {words.map((w, i) => {
        const tok = aligned ? tokens[i] : null;
        const gloss = (tok && (tok.fr || tok.reading)) || (glossFor ? glossFor(w) : "") || undefined;
        return <React.Fragment key={i}>{i ? " " : ""}<Speakable text={w} gloss={gloss} glossRevealed={false} frame={false} className="kr" style={{ padding: "0 2px" }}>{w}</Speakable></React.Fragment>;
      })}
    </>
  );
}

/* ---------- Turn line (with glossed tokens) ---------- */
function TurnLine({ turn, speaker, showRomaja, active, big, transMode, glossed, glossFor, onReport, onClick }) {
  const tone = SPEAKER_TONE[speaker.tone] || SPEAKER_TONE.rose;
  return (
    <div onClick={onClick} style={{ display: "flex", gap: 14, cursor: onClick ? "pointer" : "default", padding: big ? 0 : "11px 13px", borderRadius: "var(--radius-sm)", background: active && !big ? tone.tint : "transparent", transition: "background .2s" }}>
      <div style={{ width: 4, borderRadius: 4, background: tone.bar, flexShrink: 0, opacity: active || big ? 1 : .35 }} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 5 }}>
          <span style={{ fontSize: 12, fontWeight: 700, color: tone.text }}>{speaker.name}</span>
          <span className="kr faint" style={{ fontSize: 12 }}>{speaker.kr}</span>
          {turn.audio && <span title="Signaler l'audio de cette ligne" onClick={e => { e.stopPropagation(); onReport && onReport("ligne", turn); }} style={{ color: "var(--ink-faint)", cursor: "pointer", display: "inline-flex" }}><Icon name="flag" size={13} /></span>}
        </div>
        <div className="kr" style={{ fontSize: big ? 38 : 22, lineHeight: 1.4, color: "var(--ink)", fontWeight: 500 }}>
          {glossed
            ? <LineWords turn={turn} glossFor={glossFor} />
            : turn.kr}
        </div>
        {showRomaja && <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: big ? 15 : 12.5, marginTop: 6 }}>{turn.ro}</div>}
        <div style={{ marginTop: 8 }}>
          <Hideable mode={transMode} style={{ fontSize: big ? 17 : 14, padding: "2px 6px", display: "inline-block" }}>{turn.fr}</Hideable>
        </div>
      </div>
    </div>
  );
}

/* ---------- Panels ---------- */
function GrammarPanel({ d }) {
  return (
    <div style={{ display: "grid", gap: 10 }}>
      {(!d.grammar || d.grammar.length === 0) && (
        <p className="faint" style={{ fontSize: 12.5, margin: 0 }}>Aucun point dédié pour ce dialogue. Utilisez les gloses et la reconstruction pour fixer les formes.</p>
      )}
      {d.grammar.map((g, i) => (
        <div key={i} style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
          <span className="kr" style={{ fontSize: 16, fontWeight: 600, color: "var(--accent-deep)", minWidth: 92 }}>{g.form}</span>
          <span className="muted" style={{ fontSize: 14 }}>{g.note}</span>
        </div>
      ))}
      <p className="faint" style={{ fontSize: 12, margin: "2px 0 0" }}>{window.LANGUES_DATA.corpus.miniGrammar} points de mini-grammaire au total.</p>
    </div>
  );
}
function VocabPanel({ items, reused }) {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
      {items.map((v, i) => {
        const audioRef = (v.audio && (v.audio.ref || v.audio.id)) || v.kr;
        const audioUrl = (v.audio && v.audio.url) || "";
        return (
          <div key={i} style={{ display: "inline-flex", alignItems: "baseline", gap: 8, padding: "7px 12px", borderRadius: 999, border: "1px solid var(--rule)", background: reused ? "var(--card-2)" : "var(--card)" }}>
            <Speakable text={v.kr} audioRef={audioRef} audioUrl={audioUrl} frame={false} className="kr" style={{ fontSize: 16, fontWeight: 600, color: "var(--accent-deep)", padding: "0 2px" }}>{v.kr}</Speakable>
            <Hideable mode="tap" className="muted" style={{ fontSize: 13, padding: "1px 6px" }}>{v.fr}</Hideable>
          </div>
        );
      })}
    </div>
  );
}
function shuffledQuestionChoices(q, qi) {
  const choices = (q.choices || []).map((text, index) => ({ text, index }));
  if (choices.length <= 1) return choices;
  const shift = (qi + 1) % choices.length;
  return choices.slice(shift).concat(choices.slice(0, shift));
}

function QuestionsPanel({ d, onGoLine }) {
  const [ans, setAns] = useState({});
  return (
    <div style={{ display: "grid", gap: 16 }}>
      {d.questions.map((q, qi) => {
        const selected = ans[qi];
        const reveal = selected != null;
        const answeredCorrectly = selected === q.a;
        const correctChoice = (q.choices || [])[q.a] || "";
        const choices = shuffledQuestionChoices(q, qi);
        return (
          <div key={qi}>
            <div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8 }}>{qi + 1}. {q.q}</div>
            <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
              {choices.map((choice, ci) => {
                const correct = choice.index === q.a;
                let bg = "var(--card)", bd = "var(--rule)", col = "var(--ink-soft)";
                if (reveal && correct) { bg = "var(--good-tint)"; bd = "transparent"; col = "var(--good)"; }
                else if (ans[qi] === choice.index && !correct) { bg = "var(--warn-tint)"; bd = "transparent"; col = "var(--warn)"; }
                return <button key={`${choice.index}_${ci}`} onClick={() => setAns(a => ({ ...a, [qi]: choice.index }))} disabled={reveal} style={{ padding: "8px 14px", borderRadius: 999, border: `1px solid ${bd}`, background: bg, color: col, fontFamily: "var(--sans)", fontWeight: 600, fontSize: 14, cursor: reveal ? "default" : "pointer", transition: "all .15s" }}>{choice.text}</button>;
              })}
            </div>
            {reveal && (
              <div className="fade-up" style={{ marginTop: 8, padding: "9px 12px", borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px solid var(--rule)", display: "flex", gap: 10, alignItems: "center" }}>
                <Icon name={answeredCorrectly ? "check" : "spark"} size={15} style={{ color: answeredCorrectly ? "var(--good)" : "var(--ochre)", flexShrink: 0 }} />
                <span className="muted" style={{ fontSize: 13.5, flex: 1 }}>
                  <strong style={{ color: answeredCorrectly ? "var(--good)" : "var(--warn)" }}>{answeredCorrectly ? "Correct." : "Pas encore."}</strong>{" "}
                  {answeredCorrectly ? (
                    <>{q.explain || "Reliez la réponse à la ligne du dialogue."}</>
                  ) : (
                    <>Bonne réponse : <span className="kr" style={{ color: "var(--ink)" }}>{sentenceEnd(correctChoice)}</span> {q.explain || "Relisez la ligne citée, puis continuez."}</>
                  )}
                </span>
                <button onClick={() => onGoLine(q.evidence)} className="chip" style={{ cursor: "pointer", fontSize: 11.5 }}>Voir la ligne {q.evidence + 1}</button>
              </div>
            )}
          </div>
        );
      })}
      <p className="faint" style={{ fontSize: 12, margin: 0 }}>{window.LANGUES_DATA.corpus.comprehension} questions de compréhension dans le corpus.</p>
    </div>
  );
}

// Première tuile mal placée : les tuiles disponibles SONT exactement les mots du
// modèle, donc une reconstruction fausse est toujours un problème d'ordre. On
// localise la première position qui diverge pour un retour précis, pas générique.
function reconstructionMismatch(built, answer) {
  for (let i = 0; i < answer.length; i++) {
    if (built[i] !== answer[i]) return { pos: i + 1, expected: answer[i], got: built[i] };
  }
  return null;
}

function ReconstructionPanel({ d }) {
  const r = d.reconstruct;
  const [pool, setPool] = useState(() => [...r.tiles]);
  const [built, setBuilt] = useState([]);
  const [done, setDone] = useState(null);
  const [miss, setMiss] = useState(null);
  const place = (tile, i) => { setBuilt(b => [...b, tile]); setPool(p => p.filter((_, j) => j !== i)); setDone(null); setMiss(null); };
  const remove = (i) => { const tile = built[i]; setBuilt(b => b.filter((_, j) => j !== i)); setPool(p => [...p, tile]); setDone(null); setMiss(null); };
  const check = () => {
    const ok = built.join(" ") === r.answer.join(" ");
    setDone(ok);
    setMiss(ok ? null : reconstructionMismatch(built, r.answer));
  };
  const reset = () => { setPool([...r.tiles]); setBuilt([]); setDone(null); setMiss(null); };
  return (
    <div style={{ display: "grid", gap: 12 }}>
      <p className="muted" style={{ fontSize: 14, margin: 0 }}>Remettez les tuiles dans l'ordre pour reconstruire : <strong style={{ color: "var(--ink)" }}>« {r.fr} »</strong></p>
      <div style={{ minHeight: 52, display: "flex", flexWrap: "wrap", gap: 8, padding: 12, borderRadius: "var(--radius-sm)", border: `1.5px dashed ${done === true ? "var(--good)" : done === false ? "var(--warn)" : "var(--rule)"}`, background: "var(--card-2)" }}>
        {built.length === 0 && <span className="faint" style={{ fontSize: 13, alignSelf: "center" }}>Touchez les tuiles ci-dessous…</span>}
        {built.map((tile, i) => <button key={i} onClick={() => remove(i)} className="kr" style={{ padding: "8px 13px", borderRadius: 9, border: "1px solid var(--accent-line)", background: "var(--accent-tint)", color: "var(--accent-deep)", fontSize: 17, cursor: "pointer" }}>{tile}</button>)}
      </div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
        {pool.map((tile, i) => <button key={i} onClick={() => place(tile, i)} className="kr" style={{ padding: "8px 13px", borderRadius: 9, border: "1px solid var(--rule)", background: "var(--card)", color: "var(--ink)", fontSize: 17, cursor: "pointer" }}>{tile}</button>)}
      </div>
      <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
        <Btn kind="primary" size="sm" icon="check" onClick={check} disabled={pool.length > 0}>Vérifier</Btn>
        <Btn kind="ghost" size="sm" icon="refresh" onClick={reset}>Recommencer</Btn>
        {done === true && <span className="chip fade-up" style={{ background: "var(--good-tint)", color: "var(--good)", borderColor: "transparent" }}>Correct !</span>}
        {done === false && (
          <span className="chip fade-up" style={{ background: "var(--warn-tint)", color: "var(--warn)", borderColor: "transparent" }}>
            {miss
              ? <>Position {miss.pos} : le modèle attend <span className="kr">« {miss.expected} »</span> ici, pas <span className="kr">« {miss.got} »</span>.</>
              : "Pas encore — vérifiez l'ordre et les particules."}
          </span>
        )}
      </div>
      <p className="faint" style={{ fontSize: 12, margin: 0 }}>{window.LANGUES_DATA.corpus.reconstruction} exercices de reconstruction au total.</p>
    </div>
  );
}

function ShadowingPanel({ d }) {
  const [active, setActive] = useState(null);
  const play = (s, i) => {
    if (active === i) {
      setActive(null);
      if (window.LangAudio) window.LangAudio.stop();
      return;
    }
    setActive(i);
    if (window.LangAudio) window.LangAudio.speak(s.kr, { ref: s.audio && (s.audio.ref || s.audio.id), url: s.audio && s.audio.url });
  };
  return (
    <div style={{ display: "grid", gap: 10 }}>
      <p className="muted" style={{ fontSize: 14, margin: 0 }}>Écoutez, puis répétez juste après le modèle, en rythme.</p>
      {d.shadowing.map((s, i) => (
        <div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px solid var(--rule)" }}>
          <button onClick={() => play(s, i)} aria-label={active === i ? `Pause shadowing ${i + 1}` : `Écouter shadowing ${i + 1}`} title={active === i ? "Pause" : "Écouter"} style={{ width: 38, height: 38, borderRadius: 999, border: "none", background: active === i ? "var(--accent)" : "var(--accent-tint)", color: active === i ? "#fdf3f0" : "var(--accent-deep)", cursor: "pointer", display: "grid", placeItems: "center", flexShrink: 0 }}><Icon name={active === i ? "pause" : "play"} size={18} /></button>
          <div style={{ flex: 1 }}>
            <div className="kr" style={{ fontSize: 18 }}>{s.kr}</div>
            {s.fr && <div className="muted" style={{ fontSize: 13.5, marginTop: 3 }}>{s.fr}</div>}
            <div className="faint" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{s.ro}</div>
          </div>
          <Btn kind="ghost" size="sm" icon="repeat" aria-label={`Répéter shadowing ${i + 1}`} onClick={() => { setActive(i); if (window.LangAudio) window.LangAudio.speak(s.kr, { ref: s.audio && (s.audio.ref || s.audio.id), url: s.audio && s.audio.url }); }}>Répéter</Btn>
        </div>
      ))}
      <p className="faint" style={{ fontSize: 12, margin: 0 }}>{window.LANGUES_DATA.corpus.shadowing} blocs de shadowing au total.</p>
    </div>
  );
}

function normalizeKoreanAnswer(text) {
  return String(text || "")
    .replace(/[.,!?;:。！？…]/g, "")
    .replace(/\s+/g, " ")
    .trim();
}

function productionTargetFromDialogue(d) {
  const r = d && d.reconstruct;
  if (r && r.fr && Array.isArray(r.answer) && r.answer.length) {
    return {
      fr: r.fr,
      model: r.answer.join(" "),
      source: "Réplique reconstruite",
    };
  }
  const turn = d && Array.isArray(d.turns) ? d.turns.find(item => item && item.fr && item.kr) : null;
  return {
    fr: turn ? turn.fr : "Produisez une réplique courte.",
    model: turn ? turn.kr : "",
    source: "Ligne du dialogue",
  };
}

function ProductionGuidedPanel({ d }) {
  const [text, setText] = useState("");
  const [compared, setCompared] = useState(false);
  const target = productionTargetFromDialogue(d);
  const normalizedText = normalizeKoreanAnswer(text);
  const normalizedModel = normalizeKoreanAnswer(target.model);
  const hasAnswer = normalizedText.length > 0;
  const correct = hasAnswer && normalizedModel && normalizedText === normalizedModel;
  // Nomme l'erreur principale (morphème + rôle, ou ordre) plutôt qu'un rappel générique.
  const contrast = compared && !correct && normalizedModel && window.LangValidate && window.LangValidate.contrast
    ? window.LangValidate.contrast(target.model, text, { ignorePunct: true })
    : null;
  const updateText = (value) => { setText(value); setCompared(false); };
  return (
    <div style={{ display: "grid", gap: 12 }}>
      <p className="muted" style={{ fontSize: 14, margin: 0 }}>Produisez une réplique à partir du sens français.</p>
      <div style={{ padding: 14, borderRadius: "var(--radius-sm)", background: "var(--card-2)", border: "1px solid var(--rule)" }}>
        <div className="faint" style={{ fontSize: 12, marginBottom: 4 }}>{target.source}</div>
        <div style={{ fontSize: 16, fontWeight: 600 }}>« {target.fr} »</div>
      </div>
      <textarea value={text} onChange={e => updateText(e.target.value)} placeholder={`${(target.model || "").split(/\s+/)[0] || "한국어"}…`} className="kr" lang="ko" autoCapitalize="off" autoCorrect="off" spellCheck={false} style={{ width: "100%", minHeight: 60, resize: "vertical", padding: 14, borderRadius: "var(--radius-sm)", border: "1px solid var(--rule)", background: "var(--card)", fontSize: 18, color: "var(--ink)" }} />
      <KoreanKeyboard value={text} onChange={updateText} compact />
      <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
        <Btn kind="secondary" size="sm" icon="check" onClick={() => setCompared(true)} disabled={!hasAnswer || !normalizedModel}>Comparer au modèle</Btn>
        <span className="faint" style={{ fontSize: 13 }}>
          Modèle : {compared ? <span className="kr" style={{ fontSize: 16, color: "var(--ink)", marginLeft: 4 }}>{target.model}</span> : <span style={{ marginLeft: 4 }}>caché jusqu'à l'essai</span>}
        </span>
      </div>
      {compared && <div className="fade-up" style={{ display: "grid", gap: 8, padding: 10, borderRadius: "var(--radius-sm)", background: correct ? "var(--good-tint)" : "var(--warn-tint)", fontSize: 13.5 }}>
        <div style={{ fontWeight: 700, color: correct ? "var(--good)" : "var(--warn)" }}>{correct ? "Correct" : "À ajuster"}</div>
        {correct
          ? <span style={{ color: "var(--good)" }}>Réécoutez la ligne puis répétez-la à voix haute.</span>
          : (contrast && !contrast.correct && typeof ContrastFeedback === "function"
              ? <ContrastFeedback contrast={contrast} />
              : <span style={{ color: "var(--warn)" }}>Comparez l'ordre, les particules et la terminaison avec le modèle, puis réessayez.</span>)}
      </div>}
    </div>
  );
}

/* ---------- Reader ---------- */
function DialogueReader({ variant, transMode, initialMode, onBack, dialogue, imageBank }) {
  const d = readerFromCanonical(dialogue);
  const scene = window.primaryImageForItem ? window.primaryImageForItem(imageBank, [dialogue && dialogue.id], "dialogue_context") : null;
  const [idx, setIdx] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [speed, setSpeed] = useState(1);
  const [showRomaja, setShowRomaja] = useState(false);
  const [glossed, setGlossed] = useState(true);
  const [mode, setMode] = useState(initialMode === "entrainement" ? "entrainement" : "lecture");
  const [toast, setToast] = useState(null);
  const timer = useRef(null);
  const scrollRef = useRef(null);
  const [glossMap, setGlossMap] = useState(null);
  const total = d.turns.length;

  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) => (window.resolveGloss ? window.resolveGloss(glossMap, s) : "") || undefined;

  // Lecture pilotee par la FIN reelle de chaque audio : le texte affiche reste
  // synchronise avec ce qui est lu. Pas de saut automatique si le son ne demarre pas.
  useEffect(() => {
    if (!playing || !window.LangAudio) return undefined;
    let cancelled = false;
    const turn = d.turns[idx] || {};
    const audio = turn.audio || {};
    const advance = () => {
      if (cancelled) return;
      setIdx(i => { if (i + 1 >= total) { setPlaying(false); return i; } return i + 1; });
    };
    const onEnd = () => { advance(); };
    const result = window.LangAudio.speak(turn.kr, { rate: speed, ref: audio.ref || audio.id, url: audio.url, onEnd });
    Promise.resolve(result).then(ok => { if (!cancelled && ok === false) setPlaying(false); }).catch(() => { if (!cancelled) setPlaying(false); });
    return () => { cancelled = true; if (window.LangAudio) window.LangAudio.stop(); };
  }, [playing, idx, speed]);

  // Auto-défilement : garder la ligne active visible dans le cadre transcript.
  useEffect(() => {
    const box = scrollRef.current;
    if (!box) return;
    const el = box.querySelector(`[data-line="${idx}"]`);
    if (!el) return;
    const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - 12;
    box.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
  }, [idx]);

  const cycleSpeed = () => setSpeed(s => (s === 1 ? 1.25 : s === 1.25 ? 0.75 : 1));
  const go = (n) => setIdx(Math.max(0, Math.min(total - 1, n)));
  const report = (kind, obj) => { setToast(kind === "token" ? `Son signalé : « ${obj.t} »` : "Audio de la ligne signalé"); setTimeout(() => setToast(null), 2200); };

  const audioBar = (
    <AudioBar idx={idx} total={total} playing={playing} speed={speed}
      onFirst={() => go(0)} onPrev={() => go(idx - 1)} onPlay={() => setPlaying(p => { if (p && window.LangAudio) window.LangAudio.stop(); return !p; })}
      onRepeat={() => { setPlaying(false); setTimeout(() => setPlaying(true), 50); }} onNext={() => go(idx + 1)} onSpeed={cycleSpeed} />
  );

  return (
    <div style={{ maxWidth: 900, margin: "0 auto", display: "grid", gap: "var(--gap)" }}>
      <div className="fade-up" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, flexWrap: "wrap" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 14, flexShrink: 0 }}>
          <Btn kind="ghost" size="sm" icon="prev" onClick={onBack}>Hub</Btn>
          <div>
            <div style={{ display: "flex", alignItems: "baseline", gap: 10, whiteSpace: "nowrap" }}>
              <span className="kr" style={{ fontSize: 22, color: "var(--accent-deep)" }}>{d.titleKr}</span>
              <span style={{ fontFamily: "var(--serif)", fontSize: 20 }}>{d.title}</span>
            </div>
            <div className="faint" style={{ fontSize: 13, whiteSpace: "nowrap" }}>{d.level} · {total} répliques</div>
          </div>
        </div>
        <Segmented value={mode} options={[{ value: "lecture", label: "Lecture seule" }, { value: "entrainement", label: "Entraînement" }]} onChange={setMode} />
      </div>

      <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", flexWrap: "wrap" }}>
        <button onClick={() => setGlossed(g => !g)} className="chip" style={{ cursor: "pointer", background: glossed ? "var(--accent-tint)" : "var(--card-2)", color: glossed ? "var(--accent-deep)" : "var(--ink-soft)" }}><Icon name="spark" size={14} /> Tokens glosés</button>
        <button onClick={() => setShowRomaja(r => !r)} className="chip" style={{ cursor: "pointer", background: showRomaja ? "var(--accent-tint)" : "var(--card-2)", color: showRomaja ? "var(--accent-deep)" : "var(--ink-soft)" }}><Icon name="eye" size={14} /> Romanisation</button>
      </div>

      {scene && <LearningImage image={scene} wide style={{ maxWidth: 520, justifySelf: "center" }} />}

      <div className="card fade-up" style={{ padding: "calc(var(--pad-card) + 4px)" }}>
        {variant === "ligne" ? (
          <div style={{ display: "grid", gap: 24 }}>
            <div style={{ minHeight: 210, display: "grid", alignContent: "center" }}>
              <TurnLine turn={d.turns[idx]} speaker={d.speakers[d.turns[idx].s]} showRomaja={showRomaja} big transMode={transMode} glossed={glossed} glossFor={glossFor} onReport={report} />
            </div>
            <div style={{ height: 1, background: "var(--rule)" }} />
            {audioBar}
            <div style={{ display: "flex", gap: 5, justifyContent: "center" }}>
              {d.turns.map((_, i) => <button key={i} onClick={() => go(i)} style={{ width: i === idx ? 22 : 8, height: 8, borderRadius: 999, border: "none", cursor: "pointer", background: i === idx ? "var(--accent)" : "var(--rule)", transition: "all .2s" }} />)}
            </div>
          </div>
        ) : (
          <div style={{ display: "grid", gap: 16 }}>
            <div ref={scrollRef} className="no-sb" style={{ maxHeight: 380, overflowY: "auto", display: "grid", gap: 5, paddingRight: 4 }}>
              {d.turns.map((turn, i) => <div key={i} data-line={i}><TurnLine turn={turn} speaker={d.speakers[turn.s]} showRomaja={showRomaja} active={i === idx} transMode={transMode} glossed={glossed} glossFor={glossFor} onReport={report} onClick={() => go(i)} /></div>)}
            </div>
            <div style={{ height: 1, background: "var(--rule)" }} />
            {audioBar}
          </div>
        )}
      </div>

      <p className="faint" style={{ fontSize: 13, textAlign: "center", margin: "-4px 0 0" }}>
        {transMode === "hover" ? "Survolez une ligne floutée pour la traduction · touchez un mot pour son sens." : "Touchez une ligne floutée pour la traduction · touchez un mot pour son sens."}
        {mode === "entrainement" ? " Entraînement libre : aucune note SRS." : ""}
      </p>

      <div style={{ display: "grid", gap: 12 }}>
        {mode === "lecture" ? (
          <>
            <Collapsible eyebrow="Aide" title="Mini-grammaire" defaultOpen><GrammarPanel d={d} /></Collapsible>
            <Collapsible eyebrow="Lexique" title="Vocabulaire nouveau" hint={`${d.vocabNew.length} mots`}><VocabPanel items={d.vocabNew} /></Collapsible>
            <Collapsible eyebrow="Lexique" title="Vocabulaire réutilisé" hint={`${d.vocabSeen.length} mots`}><VocabPanel items={d.vocabSeen} reused /></Collapsible>
          </>
        ) : (
          <>
            {d.questions.length > 0 && <Collapsible eyebrow="Comprendre" title="Questions de compréhension" hint={`${d.questions.length} questions`} defaultOpen><QuestionsPanel d={d} onGoLine={go} /></Collapsible>}
            {d.reconstruct && <Collapsible eyebrow="Construire" title="Reconstruction à tuiles" defaultOpen><ReconstructionPanel d={d} /></Collapsible>}
            {d.shadowing.length > 0 && <Collapsible eyebrow="Oral" title="Shadowing" hint={`${d.shadowing.length} blocs`}><ShadowingPanel d={d} /></Collapsible>}
            <Collapsible eyebrow="Produire" title="Production guidée"><ProductionGuidedPanel d={d} /></Collapsible>
            <Collapsible eyebrow="Aide" title="Mini-grammaire"><GrammarPanel d={d} /></Collapsible>
          </>
        )}
      </div>

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

function DialoguesPage({ t, openId, setOpenId, mode }) {
  const [loading, setLoading] = useState(false);
  const [opening, setOpening] = useState(false);
  const [dialogues, setDialogues] = useState([]);
  const [imageBank, setImageBank] = useState(null);
  const [stats, setStats] = useState(null);
  const [error, setError] = useState("");

  const loadDialogues = useCallback(() => {
    if (!window.LangData || !window.LangData.loadDialogues) return undefined;
    let alive = true;
    setLoading(true);
    setError("");
    Promise.all([
      window.LangData.loadDialogues(),
      window.LangData.loadImageBank ? window.LangData.loadImageBank().catch(() => null) : Promise.resolve(null),
    ])
      .then(([payload, nextImageBank]) => {
        if (!alive) return;
        setDialogues(payload.dialogues || []);
        setImageBank(nextImageBank);
        setStats({
          dialogues: payload.meta ? payload.meta.total : (payload.dialogues || []).length,
          dialogueLines: payload.meta ? payload.meta.lines : (payload.dialogues || []).reduce((n, d) => n + (d.line_count || 0), 0),
        });
      })
      .catch(() => {
        if (!alive) return;
        setError("Impossible de charger les dialogues canoniques.");
      })
      .finally(() => { if (alive) setLoading(false); });
    return () => { alive = false; };
  }, []);

  useEffect(loadDialogues, [loadDialogues]);

  const open = (id, m) => {
    setOpening(true);
    setTimeout(() => { setOpening(false); setOpenId(id); }, 250);
    window.__dlgMode = m;
  };
  const selected = dialogues.find(dialogue => dialogue.id === openId);
  if (openId && !selected) {
    if (loading || !dialogues.length) {
      return (
        <div style={{ maxWidth: 760, margin: "40px auto 0" }} className="fade-up">
          <LoadingCard lines={4} />
        </div>
      );
    }
    return (
      <div style={{ maxWidth: 680, margin: "40px auto 0" }} className="fade-up">
        <ErrorState
          title="Dialogue introuvable"
          body="Ce raccourci ne correspond pas à un dialogue canonique chargé."
          onRetry={() => { setOpenId(null); loadDialogues(); }}
        />
      </div>
    );
  }
  if (openId) return <DialogueReader variant={t.dialoguePlayer} transMode={t.transReveal} initialMode={window.__dlgMode || mode} onBack={() => setOpenId(null)} dialogue={selected} imageBank={imageBank} />;
  return <DialoguesHub onOpen={open} loading={loading || opening} error={!dialogues.length ? error : ""} onRetry={loadDialogues} items={dialogues} stats={stats} imageBank={imageBank} />;
}

Object.assign(window, { DialoguesPage });
