// Luminara — Foundations (chapter grid) + Scene reader
const { useState: useState_c, useEffect: useEffect_c, useRef: useRef_c } = React;

// Renders scene body as a longread from Markdown (what Directus' Markdown interface
// produces). Supported:
//   • blank line              → new paragraph
//   • # / ## / ###            → headings
//   • > quote                 → highlighted callout
//   • - or * bullets          → bullet list
//   • 1. 2. 3. ordered        → numbered list
//   • inline **bold**, *italic*, `code`, [text](url)
// Plain text still works unchanged.
function renderInline(text, keyPrefix) {
  // tokenizer for **bold**, *italic*, `code`, [text](url)
  const out = [];
  let rest = String(text);
  let k = 0;
  const re = /(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(`([^`]+)`)|(\[([^\]]+)\]\(([^)]+)\))/;
  let m;
  while ((m = re.exec(rest)) !== null) {
    if (m.index > 0) out.push(rest.slice(0, m.index));
    if (m[1]) out.push(<strong key={keyPrefix + "-b" + k}>{m[2]}</strong>);
    else if (m[3]) out.push(<em key={keyPrefix + "-i" + k}>{m[4]}</em>);
    else if (m[5]) out.push(<code key={keyPrefix + "-c" + k} className="sp-code">{m[6]}</code>);
    else if (m[7]) out.push(<a key={keyPrefix + "-a" + k} href={m[9]} target="_blank" rel="noopener noreferrer">{m[8]}</a>);
    rest = rest.slice(m.index + m[0].length);
    k++;
  }
  if (rest) out.push(rest);
  return out;
}

function SceneProse({ text }) {
  if (!text) return null;
  const blocks = String(text).split(/\n\s*\n/).map(b => b.trim()).filter(Boolean);
  return (
    <div className="scene-prose">
      {blocks.map((blk, i) => {
        // headings
        const h = blk.match(/^(#{1,3})\s+(.*)$/);
        if (h && blk.indexOf("\n") === -1) {
          const lvl = h[1].length; const txt = h[2].trim();
          if (lvl === 1) return <h3 key={i} className="sp-h sp-h1">{renderInline(txt, "h" + i)}</h3>;
          if (lvl === 2) return <h4 key={i} className="sp-h">{renderInline(txt, "h" + i)}</h4>;
          return <h5 key={i} className="sp-h sp-h3">{renderInline(txt, "h" + i)}</h5>;
        }
        // quote (may be multi-line)
        if (blk.split("\n").every(l => l.trim().startsWith(">"))) {
          const q = blk.split("\n").map(l => l.replace(/^>\s?/, "")).join(" ").trim();
          return <blockquote key={i} className="sp-quote">{renderInline(q, "q" + i)}</blockquote>;
        }
        const lines = blk.split("\n");
        // unordered list (- or *)
        if (lines.every(l => /^[-*]\s+/.test(l.trim()))) {
          return (
            <ul key={i} className="sp-list">
              {lines.map((l, j) => <li key={j}>{renderInline(l.trim().replace(/^[-*]\s+/, ""), "u" + i + j)}</li>)}
            </ul>
          );
        }
        // ordered list (1. 2. 3.)
        if (lines.every(l => /^\d+\.\s+/.test(l.trim()))) {
          return (
            <ol key={i} className="sp-list sp-ol">
              {lines.map((l, j) => <li key={j}>{renderInline(l.trim().replace(/^\d+\.\s+/, ""), "o" + i + j)}</li>)}
            </ol>
          );
        }
        // paragraph; preserve single line breaks as <br/>
        return (
          <p key={i}>
            {lines.map((l, j) => (
              <React.Fragment key={j}>{renderInline(l, "p" + i + j)}{j < lines.length - 1 ? <br/> : null}</React.Fragment>
            ))}
          </p>
        );
      })}
    </div>
  );
}

// Крипто-Азбука — A–Z letter-card grid (chapter.layout === "alphabet"). Each card shows
// the letter + term (scene title); clicking opens that scene (TON-style) via onOpenLetter(i).
// Letter derives from index/sort (0→A … 25→Z); terms/bodies come from Directus (extScenes).
function AlphabetGrid({ t, locale, chapter, onOpenLetter, onBack, backLabel }) {
  const pick = (o) => (o && (o[locale] || o.en || o.ru)) || "";
  const scenes = Array.isArray(chapter.extScenes) ? chapter.extScenes : [];
  const N = 26;
  return (
    <div className="section-pad fade-in" style={{ maxWidth: 1180 }}>
      <button className="scene-back" onClick={onBack}>← {backLabel || t.foundationsTitle}</button>
      <div className="section-head">
        <div className="kicker">{pick(chapter.kicker)}</div>
        <h2>{pick(chapter.title)}</h2>
      </div>
      <div className="abc-grid">
        {Array.from({ length: N }).map((_, i) => {
          const letter = String.fromCharCode(65 + i);
          const sc = scenes[i];
          const terms = (sc && Array.isArray(sc.terms)) ? sc.terms : [];
          const has = terms.length > 0;
          return (
            <button className={"abc-card" + (has ? "" : " abc-empty")} key={letter} data-c={chapter.key}
                    onClick={() => { if (has) onOpenLetter(i); }} disabled={!has}>
              <span className="abc-letter">{letter}</span>
              {has ? (
                <span className="abc-terms-list">
                  {terms.map((tm, k) => {
                    const nm = (tm && typeof tm.term === "string") ? tm.term : "";
                    return nm ? <span className="abc-term" key={k}>{nm}</span> : null;
                  })}
                </span>
              ) : null}
            </button>
          );
        })}
      </div>
    </div>
  );
}

// Крипта-Ясли — lesson list (chapter.layout === "lessons"). Shows the chapter's 20 lessons
// (number + title + scene count); clicking a lesson opens its scenes (TON-style SceneReader).
// Scene counts come from Directus (EXTERNAL[lesson.key]) when authored, else the spec count.
function LessonList({ t, locale, chapter, onOpenLesson, onBack, backLabel }) {
  const pick = (o) => (o && (o[locale] || o.en || o.ru)) || "";
  const lessons = Array.isArray(chapter.lessons) ? chapter.lessons : [];
  const bonusCourse = chapter.bonusCourse || null;
  const EXT = ((typeof window !== "undefined" && window.LUMINARA_DATA) || {}).EXTERNAL || {};
  const bonusScenes = bonusCourse && EXT[bonusCourse.key] && Array.isArray(EXT[bonusCourse.key].scenes)
    ? EXT[bonusCourse.key].scenes.length
    : (bonusCourse && bonusCourse.scenes) || 0;
  const bonusCopy = locale === "ru"
    ? { section: "Дополнительный курс", badge: "White Paper", lede: "Разбор первоисточника Ethereum: от замысла к устройству мирового компьютера.", open: "Открыть курс" }
    : { section: "Additional course", badge: "White Paper", lede: "A guided reading of Ethereum’s source document, from its original idea to the world computer.", open: "Open course" };
  return (
    <div className="section-pad fade-in" style={{ maxWidth: 1180 }}>
      <button className="scene-back" onClick={onBack}>← {backLabel || t.foundationsTitle}</button>
      <div className="section-head">
        <div className="kicker">{pick(chapter.kicker)}</div>
        <h2>{pick(chapter.title)}</h2>
      </div>
      <div className="lesson-list">
        {lessons.map((les, i) => {
          const ext = EXT[les.key];
          const n = (ext && Array.isArray(ext.scenes)) ? ext.scenes.length : (les.scenes || 0);
          const has = n > 0;
          return (
            <button className={"lesson-card" + (has ? "" : " lesson-empty")} key={les.key} data-c={chapter.key}
                    onClick={() => { if (has) onOpenLesson(les); }} disabled={!has}>
              <span className="lesson-n mono">{String(i + 1).padStart(2, "0")}</span>
              {/* C1: shared resolver — requested locale → en → localized "Lesson NN"; never a
                  bare key, never Russian in a non-RU UI. */}
              <span className="lesson-title">{(window.resolveLocalizedTitle
                ? window.resolveLocalizedTitle(les.title, locale, { index: i })
                : (pick(les.title) || les.key))}</span>
              <span className="lesson-count mono">{n}</span>
            </button>
          );
        })}
      </div>
      {bonusCourse && bonusScenes > 0 && (
        <div className="lesson-bonus">
          <div className="rs-section-h">{bonusCopy.section}</div>
          <button type="button" className="rs-wp lesson-bonus-card" data-c={bonusCourse.key}
                  style={{ "--rc": "var(--c-eth)" }} onClick={() => onOpenLesson(bonusCourse)}>
            <span className="rs-wp-badge">{bonusCopy.badge}</span>
            <h3>{pick(bonusCourse.title) || "Ethereum White Paper"}</h3>
            <p className="rs-wp-blurb">{bonusCopy.lede}</p>
            <span className="rs-wp-foot">
              <span className="rs-count">{bonusScenes} {locale === "ru" ? "глав" : "chapters"}</span>
              <span className="rs-wp-go">{bonusCopy.open} →</span>
            </span>
          </button>
        </div>
      )}
    </div>
  );
}

function Foundations({ t, locale, onOpen, progressData }) {
  const { CHAPTERS } = window.LUMINARA_DATA;
  const byTopic = (progressData && progressData.byTopic) || {};
  return (
    <div className="section-pad fade-in">
      <div className="section-head">
        <div className="kicker">{t.nav.foundations} · 01 / 05</div>
        <h2>{t.foundationsTitle}</h2>
        <p>{t.foundationsLede}</p>
      </div>

      {(() => {
        // FOUNDATIONS-BONUS-CARDS: numbered chapters (1-8) fill the 4-col rail;
        // the three explicitly flagged bonuses sit together on their own row.
        // The book review is a separate paid learning product; the numbered
        // Chapter 8 "Netocracy" remains in the Foundations history rail.
        // Keep the product order explicit: Crypto Nursery → Crypto ABC → book review.
        const isBonus = (c) => (typeof window.luminaraIsFoundationsBonus === "function")
          ? window.luminaraIsFoundationsBonus(c) : !!(c && c.bonus === true);
        // ETH-atlas is reached from the nav tree under TON, not from the Foundations grid — exclude it here.
        const inFoundations = (c) => (typeof window.luminaraIsFoundationsChapter === "function")
          ? window.luminaraIsFoundationsChapter(c) : !!(c && c.key !== "eth-atlas");
        const numbered = CHAPTERS.filter((c) => !isBonus(c) && inFoundations(c));
        const bonusOrder = Array.isArray(window.LUMINARA_FOUNDATIONS_BONUS_ORDER)
          ? window.LUMINARA_FOUNDATIONS_BONUS_ORDER : ["kripto-yasli", "kripto-azbuka", "netocracy-book"];
        const bonusChs = CHAPTERS
          .filter((c) => isBonus(c) && inFoundations(c))
          .sort((a, b) => {
            const ai = bonusOrder.indexOf(a.key), bi = bonusOrder.indexOf(b.key);
            return (ai < 0 ? Number.MAX_SAFE_INTEGER : ai) - (bi < 0 ? Number.MAX_SAFE_INTEGER : bi);
          });
        const renderCard = (c, i, bonus) => {
          const tp = byTopic[c.key] || {};
          const externalScenes = window.LUMINARA_DATA.EXTERNAL && window.LUMINARA_DATA.EXTERNAL[c.key]
            ? window.LUMINARA_DATA.EXTERNAL[c.key].scenes : [];
          const state = window.LuminaraProgress.topicState(c.key, externalScenes, c.scenes, tp);
          const isCompleted = state.completed;
          const pts = (c.points && (c.points[locale] || c.points.en)) || [];
          return (
            <div className={"chapter" + (bonus ? " chapter--bonus" : "")} key={c.key} onClick={() => onOpen(c)}>
              <div className="ch-top">
                {!bonus && <span className="n">{String(c.n).padStart(2, "0")}</span>}
                <div className="ch-id">
                  {!bonus && <div className="kicker">{t.chapter} {c.n}</div>}
                  <div className="title">{c.title[locale] || c.title.en}</div>
                </div>
              </div>
              <div className="sub">{c.kicker[locale] || c.kicker.en}</div>
              {pts.length > 0 && (
                <ul className="ch-points">
                  {pts.map((p, j) => (<li key={j}>{p}</li>))}
                </ul>
              )}
              <div className="meta">
                <span>{t.readingTime.replace("{n}", c.readMin)}</span>
                <span style={{ flex: 1 }} />
                <span className={"ring" + (isCompleted ? " ring--done" : state.completedCount > 0 ? " ring--partial" : "")} aria-label="scenes">
                  {state.mask.map((done, j) => (
                    <i key={j} className={done ? "f" : ""} />
                  ))}
                </span>
              </div>
            </div>
          );
        };
        return (
          <>
            <div className="chapter-rail">
              {numbered.map((c, i) => renderCard(c, i, false))}
            </div>
            {bonusChs.length > 0 && (
              <div className="chapter-rail chapter-rail--bonus">
                {bonusChs.map((c, i) => renderCard(c, numbered.length + i, true))}
              </div>
            )}
          </>
        );
      })()}
    </div>
  );
}

// POINTS-VERIFY (Q-FE) — per-topic quiz shown at the end of a lesson. Questions are
// fetched from the server (GET /quiz?topic=) WITHOUT the answer key; each answer is
// verified server-side (POST /quiz/answer), which owns correctness and the award.
// A correct answer fires "lum:points-changed" so the header refreshes.
// QUIZ-COMPLETION (ticket 64) — the completion contract is the SERVER's (GET /quiz returns `policy`).
// This local default only keeps the component working before that payload arrives / in older cached
// responses. The browser never invents its own scoring or threshold.
const QUIZ_POLICY_FALLBACK = { scoring: "best", retake: true, pointsOncePerQuestion: true, completion: "all_answered" };

function ThemeQuiz({ topic, locale, onNextLesson, onBackToChapter, nextLessonLabel }) {
  const api = (typeof window !== "undefined") ? window.LUMINARA_API : null;
  const DEMO = (typeof window !== "undefined" && window.LUMINARA_DEMO !== false);
  const L = (o) => (o && (o[locale] || o.en || o.ru)) || "";
  const [qs, setQs] = useState_c(null);   // null = loading, [] = none, [...] = loaded
  const [ans, setAns] = useState_c({});    // qid -> { optId, correct, why }
  const [policy, setPolicy] = useState_c(QUIZ_POLICY_FALLBACK);
  const [pending, setPending] = useState_c({});  // qid -> true while an answer is in flight
  const [failed, setFailed] = useState_c({});    // qid -> true when the last submit errored (retryable)
  const [attempt, setAttempt] = useState_c(0);   // bumped by "take again" → resets local answers
  // In-flight answer ids. A REF (not state) because several clicks dispatched inside one React batch
  // all read the same pre-render state value, so a state-based guard would let duplicates through.
  // Declared with the other hooks, before any early return, so hook order is stable (Rules of Hooks).
  const inFlight = useRef_c({});
  // QUIZ-SHUFFLE: randomize option order once per question (stable for the session), so the
  // correct answer isn't always first. Server verifies by option id, so order is display-only.
  const shufRef = useRef_c({});
  const shuffledOpts = (q) => {
    const base = Array.isArray(q.options) ? q.options : [];
    if (!shufRef.current[q.id]) {
      const arr = base.slice();
      for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = arr[i]; arr[i] = arr[j]; arr[j] = t; }
      shufRef.current[q.id] = arr;
    }
    return shufRef.current[q.id];
  };

  useEffect_c(() => {
    if (!topic || DEMO || !api) { setQs([]); return; }
    let alive = true; setQs(null); setAns({}); setPending({}); setFailed({});
    api.quiz.get(topic)
      .then((d) => {
        if (!alive) return;
        setQs(Array.isArray(d && d.questions) ? d.questions : []);
        if (d && d.policy) setPolicy({ ...QUIZ_POLICY_FALLBACK, ...d.policy });
        // Restore this reader's own prior attempts so the result survives a reload. The server never
        // ships the answer key, so a restored entry knows only whether it was correct (no optId).
        if (d && d.attempts && typeof d.attempts === "object") {
          const seeded = {};
          Object.keys(d.attempts).forEach((qid) => {
            const a = d.attempts[qid];
            if (a) seeded[qid] = { optId: null, correct: !!a.correct, why: null, restored: true };
          });
          if (Object.keys(seeded).length) setAns(seeded);
        }
      })
      .catch(() => { if (alive) setQs([]); });
    return () => { alive = false; };
  }, [topic, attempt]);

  if (!topic || DEMO || !api) return null;
  if (qs === null || !qs.length) return null;   // quiet while loading / no quiz for this topic

  const ui = {
    title:    { en: "Check yourself", ru: "Проверь себя", uk: "Перевір себе", kk: "Өзіңді тексер", uz: "Oʻzingni tekshir", es: "Ponte a prueba", fr: "Teste-toi", hy: "Ստուգիր ինքդ քեզ" },
    correct:  { en: "Correct", ru: "Верно", uk: "Правильно", kk: "Дұрыс", uz: "Toʻgʻri", es: "Correcto", fr: "Correct", hy: "Ճիշտ" },
    tryAgain: { en: "Not quite — try again", ru: "Не совсем — попробуй ещё", uk: "Не зовсім — спробуй ще", kk: "Дәл емес — қайта көр", uz: "Unchalik emas — qayta urin", es: "No del todo: inténtalo de nuevo", fr: "Pas tout à fait — réessaie", hy: "Ոչ այնքան՝ փորձիր նորից" },
    submitError: { en: "Couldn't save the answer. Tap the option again.", ru: "Не удалось сохранить ответ. Нажми вариант ещё раз.", uk: "Не вдалося зберегти відповідь. Натисни варіант ще раз.", kk: "Жауап сақталмады. Нұсқаны қайта басыңыз.", uz: "Javob saqlanmadi. Variantni yana bosing.", es: "No se pudo guardar la respuesta. Toca la opción de nuevo.", fr: "Impossible d'enregistrer la réponse. Touchez à nouveau l'option.", hy: "Պատասխանը չպահվեց։ Կրկին սեղմեք տարբերակը։" },
    restored: { en: "Answered earlier", ru: "Отвечено ранее", uk: "Відповідь раніше", kk: "Бұрын жауап берілген", uz: "Avval javob berilgan", es: "Respondido antes", fr: "Répondu précédemment", hy: "Պատասխանված է ավելի վաղ" },
    done:     { en: "Quiz complete", ru: "Квиз пройден", uk: "Квіз пройдено", kk: "Квиз аяқталды", uz: "Kviz tugadi", es: "Cuestionario completado", fr: "Quiz terminé", hy: "Վիկտորինան ավարտված է" },
    score:    { en: "Correct answers: {c} of {n} · {p}%", ru: "Правильных ответов: {c} из {n} · {p}%", uk: "Правильних відповідей: {c} з {n} · {p}%", kk: "Дұрыс жауаптар: {n} ішінен {c} · {p}%", uz: "Toʻgʻri javoblar: {n} dan {c} · {p}%", es: "Respuestas correctas: {c} de {n} · {p}%", fr: "Bonnes réponses : {c} sur {n} · {p}%", hy: "Ճիշտ պատասխաններ՝ {c} / {n} · {p}%" },
    fbHigh:   { en: "Excellent — the topic is solid.", ru: "Отлично — тема закреплена.", uk: "Відмінно — тема закріплена.", kk: "Тамаша — тақырып бекітілді.", uz: "Ajoyib — mavzu mustahkamlandi.", es: "Excelente: el tema está afianzado.", fr: "Excellent — le sujet est acquis.", hy: "Գերազանց՝ թեման ամրապնդված է։" },
    fbMid:    { en: "Good start — reread the explanations above.", ru: "Хорошее начало — перечитай объяснения выше.", uk: "Добрий початок — перечитай пояснення вище.", kk: "Жақсы бастама — жоғарыдағы түсіндірмелерді қайта оқы.", uz: "Yaxshi boshlanish — yuqoridagi izohlarni qayta oʻqing.", es: "Buen comienzo: relee las explicaciones de arriba.", fr: "Bon début — relisez les explications ci-dessus.", hy: "Լավ սկիզբ՝ վերընթերցեք վերևի բացատրությունները։" },
    fbLow:    { en: "Worth another pass through the lesson.", ru: "Стоит ещё раз пройти урок.", uk: "Варто ще раз пройти урок.", kk: "Сабақты қайта қарап шығу керек.", uz: "Darsni yana bir marta koʻrib chiqish foydali.", es: "Conviene repasar la lección.", fr: "Il vaut mieux refaire la leçon.", hy: "Արժե դասը կրկին անցնել։" },
    nextLesson: { en: "Next lesson", ru: "Следующий урок", uk: "Наступний урок", kk: "Келесі сабақ", uz: "Keyingi dars", es: "Siguiente lección", fr: "Leçon suivante", hy: "Հաջորդ դասը" },
    backChapter: { en: "Back to the chapter", ru: "Вернуться к главе", uk: "Повернутися до розділу", kk: "Тарауға қайту", uz: "Bobga qaytish", es: "Volver al capítulo", fr: "Retour au chapitre", hy: "Վերադառնալ գլուխ" },
    again:    { en: "Take it again", ru: "Пройти ещё раз", uk: "Пройти ще раз", kk: "Қайта өту", uz: "Yana oʻtish", es: "Hacerlo de nuevo", fr: "Refaire le quiz", hy: "Անցնել կրկին" },
    progress: { en: "Answered {c} of {n}", ru: "Отвечено {c} из {n}", uk: "Відповідей {c} з {n}", kk: "{n} ішінен {c} жауап", uz: "{n} dan {c} javob", es: "Respondidas {c} de {n}", fr: "Répondu {c} sur {n}", hy: "Պատասխանված {c} / {n}" },
  };

  // Totals always come from the published question set — never a hardcoded count.
  const total = qs.length;
  const answeredIds = Object.keys(ans);
  const answered = answeredIds.filter((id) => qs.some((q) => String(q.id) === String(id))).length;
  const correctCount = answeredIds.filter((id) => ans[id] && ans[id].correct && qs.some((q) => String(q.id) === String(id))).length;
  const scorePct = answered ? Math.round((correctCount / total) * 100) : 0;
  // Completion is defined by the server policy; 'all_answered' means every published question has an
  // attempt. Correctness is reported as a score, not used as a hidden pass threshold.
  const complete = policy.completion === "all_answered" ? (total > 0 && answered >= total) : (total > 0 && answered >= total);
  const fill = (tpl, map) => String(tpl).replace(/\{(\w+)\}/g, (_, k) => (map[k] != null ? String(map[k]) : ""));
  const feedback = scorePct >= 80 ? ui.fbHigh : (scorePct >= 50 ? ui.fbMid : ui.fbLow);

  // A second click on the same question while its answer is in flight must not fire another POST.
  // The in-flight set lives in a REF, not in state: several clicks dispatched inside one React batch
  // all read the same (pre-render) state value, so a state-based guard lets duplicates through. The
  // `pending` state exists only to drive `disabled`/`aria-busy` in the UI. A failed submit is
  // surfaced as retryable instead of silently doing nothing.
  const choose = (q, opt) => {
    if (inFlight.current[q.id]) return;
    inFlight.current[q.id] = true;
    setPending((p) => ({ ...p, [q.id]: true }));
    setFailed((f) => { if (!f[q.id]) return f; const n = { ...f }; delete n[q.id]; return n; });
    api.quiz.answer(q.id, opt.id).then((r) => {
      setAns((a) => ({ ...a, [q.id]: { optId: opt.id, correct: !!(r && r.correct), why: r && r.why } }));
      try { window.dispatchEvent(new CustomEvent("lum:progress-changed")); } catch (e) {}
      if (r && r.awarded) { try { window.dispatchEvent(new CustomEvent("lum:points-changed")); } catch (e) {} }
    }).catch(() => {
      setFailed((f) => ({ ...f, [q.id]: true }));
    }).then(() => {
      delete inFlight.current[q.id];
      setPending((p) => { const n = { ...p }; delete n[q.id]; return n; });
    });
  };

  return (
    <div className="theme-quiz">
      <div className="tq-head">
        {L(ui.title)} · {total}
        <span className="tq-progress">{fill(L(ui.progress), { c: answered, n: total })}</span>
      </div>
      {qs.map((q, n) => {
        const a = ans[q.id];
        const opts = shuffledOpts(q);
        const busy = !!pending[q.id];
        return (
          <div className="self-check" key={q.id}>
            <div className="sc-q" id={"scq-" + q.id}><span className="sc-n">{String(n + 1).padStart(2, "0")}</span>{L(q.prompt)}</div>
            <div className="sc-opts" role="radiogroup" aria-labelledby={"scq-" + q.id}>
              {opts.map((opt) => {
                const picked = a && a.optId === opt.id;
                const state = picked ? (a.correct ? "ok" : "no") : "";
                return (
                  <button key={opt.id} className={"sc-opt " + state} onClick={() => choose(q, opt)}
                          role="radio" aria-checked={!!picked} disabled={busy} aria-busy={busy}>
                    <span className="sc-dot" />{L(opt.text)}
                  </button>
                );
              })}
            </div>
            {failed[q.id] ? (
              <div className="sc-verdict no" role="alert">{L(ui.submitError)}</div>
            ) : a ? (
              <div className={"sc-verdict " + (a.correct ? "ok" : "no")} role="status" aria-live="polite">
                {a.correct ? L(ui.correct) : L(ui.tryAgain)}
                {a.restored ? <span className="sc-restored"> · {L(ui.restored)}</span> : null}
                {a.why && L(a.why) ? <div className="sc-why">{L(a.why)}</div> : null}
              </div>
            ) : null}
          </div>
        );
      })}

      {/* QUIZ-COMPLETION (ticket 64): after the last answer the reader sees the result and an explicit
          next step. The actions are supplied by the host template, so navigation keeps that template's
          locale, course and entitlement context — this component never builds its own URLs. */}
      {complete ? (
        <div className="tq-done" role="status" aria-live="polite">
          <div className="tq-done-h">{L(ui.done)}</div>
          <div className="tq-done-score">{fill(L(ui.score), { c: correctCount, n: total, p: scorePct })}</div>
          <div className="tq-done-fb">{L(feedback)}</div>
          <div className="tq-done-actions">
            {onNextLesson ? (
              <button className="btn tq-act-primary" onClick={onNextLesson}>{nextLessonLabel || L(ui.nextLesson)}</button>
            ) : null}
            {onBackToChapter ? (
              <button className="btn" onClick={onBackToChapter}>{L(ui.backChapter)}</button>
            ) : null}
            {policy.retake ? (
              <button className="btn tq-act-ghost" onClick={() => { shufRef.current = {}; setAttempt((n) => n + 1); }}>{L(ui.again)}</button>
            ) : null}
          </div>
        </div>
      ) : null}
    </div>
  );
}

// DIFFICULTY-LEVELS: a Foundations scene `body` may be a levels object
// { simple|extended|deep|academic : {ml} } instead of a flat {ml}. When leveled, a switcher lets
// the reader pick depth; only `body` varies by level (title/insight are shared). Flat bodies
// (other sections, legacy data) render exactly as before and the switcher is hidden.
const DIFF_LEVELS = ["simple", "extended", "deep", "academic"];
const DIFF_LEVEL_NAMES = {
  simple:   { ru: "Простыми словами", en: "Simple",   uk: "Простими словами", kk: "Қарапайым",     uz: "Sodda",           es: "Sencillo",  fr: "Simple",     hy: "Պարզ" },
  extended: { ru: "Расширенный",      en: "Extended", uk: "Розширений",       kk: "Кеңейтілген",   uz: "Kengaytirilgan",  es: "Ampliado",  fr: "Étendu",     hy: "Ընդլայնված" },
  deep:     { ru: "Глубокий",         en: "Deep",     uk: "Глибокий",         kk: "Терең",         uz: "Chuqur",          es: "Profundo",  fr: "Approfondi", hy: "Խորը" },
  academic: { ru: "Академический",    en: "Academic", uk: "Академічний",      kk: "Академиялық",   uz: "Akademik",        es: "Académico", fr: "Académique", hy: "Ակադեմիական" },
};
const DIFF_SOON = { en: "Soon", ru: "Скоро", uk: "Незабаром", kk: "Жақында", uz: "Tez orada", es: "Pronto", fr: "Bientôt", hy: "Շուտով" };
const DIFF_SOON_HINT = { en: "This level is coming later", ru: "Уровень появится позже", uk: "Рівень з’явиться пізніше", kk: "Деңгей кейінірек қосылады", uz: "Daraja keyinroq qoʻshiladi", es: "Este nivel llegará más tarde", fr: "Ce niveau arrivera plus tard", hy: "Այս մակարդակը կավելանա ավելի ուշ" };
function bodyIsLeveled(b) {
  return !!(b && typeof b === "object" && DIFF_LEVELS.some((k) => b[k] != null));
}
// LEVELS-PAID-GATING: the premium media bundle belongs strictly to Deep and Academic. Staff roles
// receive the same permanent access on the server, so the UI must recognise them too instead of
// falsely rendering a lock for an admin/superadmin account.
const PAID_LEVELS = ["deep", "academic"];
const PAID_PLANS = ["deep", "academic"];
const PAID_HINT = { en: "Available with Deep or Academic access", ru: "Доступно с доступом «Глубокий» или «Академический»", uk: "Доступно з доступом «Глибокий» або «Академічний»", kk: "«Терең» немесе «Академиялық» қолжетімділігімен ашылады", uz: "«Chuqur» yoki «Akademik» kirish bilan mavjud", es: "Disponible con acceso Profundo o Académico", fr: "Disponible avec l’accès Approfondi ou Académique", hy: "Հասանելի է «Խորը» կամ «Ակադեմիական» մուտքով" };
const paidAccessLabel = (locale) => (typeof window !== "undefined" && typeof window.luminaraAccessLabel === "function")
  ? window.luminaraAccessLabel(locale, "paid") : "With subscription";
function userIsPaidForDeep(viewer) {
  try {
    if (typeof window !== "undefined" && window.LUMINARA_DEMO) return true;
    const user = viewer || (typeof window !== "undefined" && window.LUMINARA_ME) || {};
    const role = user.role != null ? user.role : user.role_id;
    if (["superadmin", "admin", "moderator", 4, 3, 2].includes(role)) return true;
    return PAID_PLANS.includes(user.plan || "");
  } catch (e) { return false; }
}
function levelStr(o, locale) {
  if (o == null) return "";
  return (typeof o === "string") ? o : (o[locale] || o.en || o.ru || "");
}
function levelHasContent(b, level, locale) {
  return !!(b && levelStr(b[level], locale));
}
function levelText(b, level, locale) {
  // Resolve the chosen level with fallbacks: chosen → extended → any present.
  const order = [level, "extended", "simple", "deep", "academic"];
  for (const lv of order) { const s = levelStr(b ? b[lv] : null, locale); if (s) return s; }
  return "";
}

// EMBED-MAP: build the chapter-map iframe URL. Passes the site's theme + accent so the map (which
// reads ?theme= / ?accent=) matches the app. Only the new full-screen "ru" map exists yet; other
// locales fall back to ru until the localized rebuilds ship (then add them to MAP_AVAILABLE).
const MAP_AVAILABLE = ["ru"];
// Foundations chapters that ship an interactive map (file /maps/<key>.<lang>.html). Chapter 9
// (kripto-yasli, lessons) has no map. The CTA in the media block shows only for these keys.
const MAP_CHAPTERS = ["preinternet", "web1", "web2", "social", "web30", "web3", "economy", "netocracy"];
const FOUNDATION_QUIZ_TOPICS = ["preinternet", "web1", "web2", "social", "web30", "web3", "economy", "netocracy"];
const QUIZ_LAST_SCENE_NOTE = {
  en: "The quiz for this chapter is on the final scene.",
  ru: "Квиз по этой главе будет на последней сцене.",
  uk: "Квіз до цієї глави буде на останній сцені.",
  kk: "Бұл тараудың квизі соңғы сахнада болады.",
  uz: "Bu bob bo‘yicha kviz oxirgi sahnada bo‘ladi.",
  es: "El quiz de este capítulo estará en la última escena.",
  fr: "Le quiz de ce chapitre se trouve dans la dernière scène.",
  hy: "Այս գլխի քվիզը կլինի վերջին տեսարանում։",
};
function buildMapSrc(chapterKey, locale) {
  const lang = MAP_AVAILABLE.includes(locale) ? locale : "ru";
  let theme = "dark", accent = "";
  try {
    const root = document.documentElement;
    theme = root.getAttribute("data-theme") === "light" ? "light" : "dark";
    accent = getComputedStyle(root).getPropertyValue("--accent").trim();
  } catch (e) {}
  const qs = "?theme=" + theme + "&lang=" + encodeURIComponent(locale || "ru") + (accent ? "&accent=" + encodeURIComponent(accent) : "");
  return "/maps/" + chapterKey + "." + lang + ".html" + qs;
}

// PREMIUM-MEDIA STATE MACHINE (#57). Resolve the premium-interactives state for a scene with a
// bounded retry, so a transient pre-bootstrap 401 (SceneReader mounted before the auth cookie/token
// was restored) never collapses into a permanent empty "Soon" result. States are explicit:
//   loading   — still resolving (initial or between bounded retries);
//   entitled  — 200 with ≥1 item the server allowed for THIS user;
//   forbidden — 200 with no items AND the viewer is not paid (authenticated but not entitled);
//   absent    — 200 with no items AND the viewer IS paid (genuinely no premium content this scene);
//   error     — a transient/auth error survived the bounded retries (retryable, NOT "absent").
// The server (requireAuth + hasPaid) stays authoritative; the client only decides presentation.
// Exposed on window so the behavioural test drives the exact production orchestration.
async function loadPremiumMediaState({ fetchItems, isPaid, maxAttempts = 4, delay }) {
  const wait = typeof delay === "function" ? delay : (n) => new Promise((r) => setTimeout(r, Math.min(1500, 150 * n)));
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const data = await fetchItems();
      const items = Array.isArray(data && data.items) ? data.items : [];
      if (items.length > 0) return { status: "entitled", items };
      return { status: isPaid ? "absent" : "forbidden", items: [] };
    } catch (e) {
      // 401 (auth not yet restored) and network/unknown errors are transient: retry within budget.
      // A definite non-auth HTTP error (e.g. 500) is NOT silently turned into empty content.
      const code = e && (e.status != null ? e.status : e.code);
      const transient = code === 401 || code === 0 || code == null || code === "network_error";
      if (transient && attempt < maxAttempts) { await wait(attempt); continue; }
      return { status: "error", items: [], transient: !!transient };
    }
  }
  return { status: "error", items: [] };
}
if (typeof window !== "undefined") window.__lumLoadPremiumMediaState = loadPremiumMediaState;

// FOUNDATION-PAID-SCENE-GATE: bootstrap intentionally contains metadata only for
// paid Foundation lessons. The actual body is fetched only from the protected
// scene endpoint after the session has resolved. This hook contains no plan or
// role logic: the server remains the sole authority for paid/staff/expired access.
function useFoundationSceneGate({ api, ck, gated, demo, authReady, authRevision }) {
  const [gate, setGate] = useState_c(null);
  const [retryNonce, setRetryNonce] = useState_c(0);
  useEffect_c(() => {
    if (!gated) { setGate(null); return; }
    if (!authReady) { setGate({ ck, loading: true }); return; }
    if (demo || !api || !api.content || !ck) { setGate({ ck, locked: true }); return; }
    let alive = true;
    setGate({ ck, loading: true });
    api.content.scene(ck).then((response) => {
      if (!alive) return;
      if (response && response.locked) { setGate({ ck, locked: true }); return; }
      if (response && response.scene && response.scene.ck === ck) {
        setGate({ ck, scene: response.scene });
        return;
      }
      setGate({ ck, error: true });
    }).catch(() => { if (alive) setGate({ ck, error: true }); });
    return () => { alive = false; };
  }, [gated, ck, demo, authReady, authRevision, retryNonce]);
  // Do not paint a response for the previous scene while this scene is resolving.
  const current = gated && gate && gate.ck === ck ? gate : null;
  return { gate: current || (gated ? { loading: true } : null), retry: () => setRetryNonce((n) => n + 1) };
}

// FOUNDATION-PREMIUM-LEVEL-GATE: deep and academic bodies never arrive in the
// public bootstrap. They are fetched one level at a time only after the active
// session has been resolved, and the server makes the entitlement decision.
function useFoundationLevelGate({ api, ck, level, gated, demo, authReady, authRevision }) {
  const [gate, setGate] = useState_c(null);
  const [retryNonce, setRetryNonce] = useState_c(0);
  useEffect_c(() => {
    if (!gated) { setGate(null); return; }
    if (!authReady) { setGate({ ck, level, loading: true }); return; }
    if (demo || !api || !api.content || typeof api.content.level !== "function" || !ck || !level) {
      setGate({ ck, level, locked: true });
      return;
    }
    let alive = true;
    setGate({ ck, level, loading: true });
    api.content.level(ck, level).then((response) => {
      if (!alive) return;
      if (response && response.locked) { setGate({ ck, level, locked: true }); return; }
      if (response && response.ck === ck && response.level === level && response.body) {
        setGate({ ck, level, body: response.body });
        return;
      }
      setGate({ ck, level, error: true });
    }).catch(() => { if (alive) setGate({ ck, level, error: true }); });
    return () => { alive = false; };
  }, [gated, ck, level, demo, authReady, authRevision, retryNonce]);
  const current = gated && gate && gate.ck === ck && gate.level === level ? gate : null;
  return { gate: current || (gated ? { loading: true } : null), retry: () => setRetryNonce((n) => n + 1) };
}

const FOUNDATION_GATE_TX = {
  loading: { ru: "Проверяем доступ к материалу…", en: "Checking access to this material…", uk: "Перевіряємо доступ до матеріалу…", kk: "Материалға қолжетімділікті тексеріп жатырмыз…", uz: "Materialga kirish tekshirilmoqda…", es: "Comprobando el acceso al material…", fr: "Vérification de l’accès au contenu…", hy: "Ստուգում ենք նյութի հասանելիությունը…" },
  lockedTitle: { ru: "Материал по подписке", en: "Subscription material", uk: "Матеріал за підпискою", kk: "Жазылым материалы", uz: "Obuna materiali", es: "Contenido con suscripción", fr: "Contenu sur abonnement", hy: "Բաժանորդագրությամբ նյութ" },
  lockedBody: { ru: "Полный текст открывается после проверки доступа.", en: "The full text opens after access is verified.", uk: "Повний текст відкривається після перевірки доступу.", kk: "Толық мәтін қолжетімділік тексерілгеннен кейін ашылады.", uz: "To‘liq matn kirish tekshirilgandan so‘ng ochiladi.", es: "El texto completo se abre tras comprobar el acceso.", fr: "Le texte complet s’ouvre après vérification de l’accès.", hy: "Ամբողջական տեքստը բացվում է հասանելիությունը ստուգելուց հետո։" },
  errorTitle: { ru: "Не удалось загрузить защищённый материал", en: "We could not load the protected material", uk: "Не вдалося завантажити захищений матеріал", kk: "Қорғалған материалды жүктеу мүмкін болмады", uz: "Himoyalangan materialni yuklab bo‘lmadi", es: "No se pudo cargar el contenido protegido", fr: "Impossible de charger le contenu protégé", hy: "Չհաջողվեց բեռնել պաշտպանված նյութը" },
  errorBody: { ru: "Проверьте подключение и повторите попытку.", en: "Check your connection and try again.", uk: "Перевірте з’єднання та повторіть спробу.", kk: "Байланысты тексеріп, қайталап көріңіз.", uz: "Ulanishni tekshiring va qayta urinib ko‘ring.", es: "Comprueba la conexión e inténtalo de nuevo.", fr: "Vérifiez votre connexion et réessayez.", hy: "Ստուգեք կապը և փորձեք կրկին։" },
  retry: { ru: "Повторить", en: "Try again", uk: "Повторити", kk: "Қайталау", uz: "Qayta urinish", es: "Reintentar", fr: "Réessayer", hy: "Կրկնել" },
};
function FoundationSceneGate({ locale, state, onRetry }) {
  const tx = (key) => FOUNDATION_GATE_TX[key][locale] || FOUNDATION_GATE_TX[key].en;
  if (state && state.loading) return <div className="rs-gate-loading" role="status">{tx("loading")}</div>;
  if (state && state.error) return <div className="rs-gate-error" role="alert">
    <div className="rs-gate-error-title">{tx("errorTitle")}</div>
    <div className="rs-gate-error-body">{tx("errorBody")}</div>
    <button className="btn primary" onClick={onRetry}>{tx("retry")}</button>
  </div>;
  return <div className="rs-paywall">
    <div className="rs-paywall-lock" aria-hidden="true">🔒</div>
    <div className="rs-paywall-title">{tx("lockedTitle")}</div>
    <div className="rs-paywall-body">{tx("lockedBody")}</div>
  </div>;
}

function SceneReader({ t, locale, chapter, topic, initialScene, onPointsChanged, onBack, viewer, authReady, authRevision, onNextLesson, nextLessonLabel, backLabel }) {
  const [scene, setScene] = useState_c(Number.isInteger(initialScene) && initialScene >= 0 ? initialScene : 0);
  // EMBED-MAP: full-screen chapter-map overlay (only the preinternet chapter has a map).
  const [mapOpen, setMapOpen] = useState_c(false);
  const mapTriggerRef = useRef_c(null);
  // Escape/close-on-backdrop/focus-return now live once in the shared window.LuminaraModal
  // (Issue #19) instead of being re-implemented here.
  // DIFFICULTY-LEVELS: remembered reading depth (default "extended" = the base authored level).
  const [diffLevel, setDiffLevel] = useState_c(() => {
    try { return localStorage.getItem("lum-level") || "extended"; } catch (e) { return "extended"; }
  });
  useEffect_c(() => { try { localStorage.setItem("lum-level", diffLevel); } catch (e) {} }, [diffLevel]);
  const total = chapter.scenes;

  // SCENEREADER-CONTENT: authored topic scenes (from EXTERNAL) arrive as
  // chapter.extScenes (array of { title{ru,en…}, body{…}, insight{…}, tags[] }).
  // When present we render real lesson text; otherwise fall back to the static
  // Foundations maps below (preinternet/web1/… keyed by chapter.key).
  const extScenes = Array.isArray(chapter.extScenes) ? chapter.extScenes : [];
  const hasExt = extScenes.length > 0;
  const baseCur = hasExt ? (extScenes[scene] || null) : null;
  const L = (o) => (o && (o[locale] || o.en || o.ru)) || "";
  const quizTopic = topic || chapter.key;
  const hasChapterQuiz = FOUNDATION_QUIZ_TOPICS.includes(quizTopic);

  // MAPS-UK-BROKEN: if the remembered scene index falls outside the current scene set
  // (e.g. switching to a locale/chapter whose set is shorter — uk, or social-graph's 7),
  // clamp back to a valid index so cur (below) is never undefined → white screen.
  useEffect_c(() => {
    if (total > 0 && scene > total - 1) setScene(total - 1);
  }, [total, scene]);

  // FE-1: persist the lesson cursor to the backend (one row per topic) and resume
  // where the user left off. Topic = chapter.key. Demo mode stays local-only.
  const api = (typeof window !== "undefined") ? window.LUMINARA_API : null;
  const DEMO = (typeof window !== "undefined" && window.LUMINARA_DEMO !== false);
  // The public bootstrap deliberately omits bodies for paid scenes. Never use it
  // as a fallback: only the protected endpoint may supply a paid scene's body.
  const sceneRequiresGate = !!(hasExt && baseCur && baseCur.access === "paid");
  const sceneCk = baseCur && baseCur.ck ? baseCur.ck : "";
  const { gate: sceneGate, retry: retrySceneGate } = useFoundationSceneGate({
    api, ck: sceneCk, gated: sceneRequiresGate, demo: DEMO, authReady, authRevision,
  });
  const requestedPremiumLevel = PAID_LEVELS.includes(diffLevel);
  // `available_levels` is presence-only metadata from the public bootstrap.
  // It contains no premium text and prevents a 404 for an unfinished level.
  // Entitlement remains server-owned: Deep and Academic always use the same
  // protected endpoint and exactly the same paid/staff access check.
  const availablePremiumLevels = hasExt && baseCur && Array.isArray(baseCur.available_levels)
    ? baseCur.available_levels.filter((level) => PAID_LEVELS.includes(level))
    : [];
  const requestedPremiumLevelAvailable = requestedPremiumLevel && availablePremiumLevels.includes(diffLevel);
  const { gate: levelGate, retry: retryLevelGate } = useFoundationLevelGate({
    api, ck: sceneCk, level: diffLevel,
    gated: !!(hasExt && sceneCk && requestedPremiumLevelAvailable), demo: DEMO, authReady, authRevision,
  });
  useEffect_c(() => {
    // A remembered level can outlive a partially authored scene.  Fall back to
    // the public extended level instead of issuing a failing protected request.
    if (requestedPremiumLevel && !requestedPremiumLevelAvailable) setDiffLevel("extended");
  }, [diffLevel, requestedPremiumLevel, requestedPremiumLevelAvailable]);
  // PREMIUM-MEDIA-ENTITLEMENT: the server is the authority for whether this particular
  // user may see the four Deep/Academic modules. Fetch once per scene, keep failures closed,
  // and pass the confirmed list to the media column rather than relying on hostname/client flags.
  // status: loading | entitled | forbidden | absent | error  (see loadPremiumMediaState).
  const [premiumMedia, setPremiumMedia] = useState_c({ status: "loading", items: [] });
  const premiumPaidHint = userIsPaidForDeep(viewer);
  useEffect_c(() => {
    let alive = true;
    const key = topic || chapter.key;
    if (!api || !key || !Number.isInteger(scene)) {
      // No client/topic/scene yet — this is genuinely nothing to show, not a transient failure.
      setPremiumMedia({ status: premiumPaidHint ? "absent" : "forbidden", items: [] });
      return () => { alive = false; };
    }
    setPremiumMedia({ status: "loading", items: [] });
    // Re-runs when `viewer` settles (auth bootstrap → entitlement known), so an early 401 recovers.
    loadPremiumMediaState({
      fetchItems: () => api.content.interactives(key, scene),
      isPaid: premiumPaidHint,
    }).then((res) => { if (alive) setPremiumMedia(res); });
    return () => { alive = false; };
  }, [api, topic, chapter.key, scene, premiumPaidHint]);
  const resumed = useRef_c(false);
  const [resumeReady, setResumeReady] = useState_c(false);
  const sceneStartRef = useRef_c(null);
  const sceneEndRef = useRef_c(null);
  const persistedRef = useRef_c(new Set());
  const visitedRef = useRef_c(new Set());
  useEffect_c(() => {
    if (Number.isInteger(initialScene)) { resumed.current = true; setResumeReady(true); return; } // opened at a specific letter/scene — don't override
    if (DEMO || !api || total <= 0) { resumed.current = true; setResumeReady(true); return; }
    let alive = true;
    (async () => {
      try {
        const p = await api.progress.get();                 // { byTopic }
        const cur = p && p.byTopic && p.byTopic[quizTopic];
        const resumeAt = cur && Number.isInteger(cur.last_scene_idx)
          ? cur.last_scene_idx
          : (cur && Number.isInteger(cur.scene_idx) ? cur.scene_idx : null);
        if (alive && Number.isInteger(resumeAt)) {
          setScene(Math.max(0, Math.min(total - 1, resumeAt)));
        }
      } catch (e) { /* ignore — start at scene 0 */ }
      finally { if (alive) { resumed.current = true; setResumeReady(true); } }
    })();
    return () => { alive = false; };
  }, []);
  const persistScene = (idx) => {
    if (DEMO || !api || !resumed.current || total <= 0) return;
    const key = quizTopic + ":" + idx;
    if (persistedRef.current.has(key)) return;
    persistedRef.current.add(key);
    const authoredScene = hasExt ? extScenes[idx] : null;
    const completedSceneKey = window.LuminaraProgress.sceneKey(quizTopic, authoredScene, idx);
    api.progress.set(quizTopic, idx, idx >= total - 1, {
      sceneKey: completedSceneKey,
      totalScenes: total,
    }).then(() => {
      try { window.dispatchEvent(new CustomEvent("lum:progress-changed")); } catch (e) {}
    }).catch(() => { persistedRef.current.delete(key); });
  };
  // Record every actual landing point, including an explicitly shared/deep-linked
  // scene.  This intentionally runs separately from persistScene: progress remains
  // monotonic while resume tracks the learner's latest choice.
  useEffect_c(() => {
    if (DEMO || !api || !resumeReady || total <= 0 || !Number.isInteger(scene)) return;
    const key = quizTopic + ":" + scene;
    if (visitedRef.current.has(key)) return;
    visitedRef.current.add(key);
    api.progress.visit(quizTopic, scene).catch(() => { visitedRef.current.delete(key); });
  }, [scene, DEMO, api, total, quizTopic, resumeReady]);
  const moveScene = (next) => {
    const target = Math.max(0, Math.min(total - 1, next));
    if (target > scene) persistScene(scene);
    setScene(target);
    // C2 (20 July decision): changing the scene must NOT move the outer page/viewport. The old
    // code scrolled the new scene start into view, which yanked the whole document to the top on
    // every Next/Previous. We now update the scene content in place and only move keyboard/SR
    // focus, with preventScroll so focusing the new scene start cannot scroll the document either.
    // If the scene body is its OWN scroll container (overflow auto/scroll — not the case in the
    // current layout, but future-proofed), reset only that pane's scrollTop, never the document.
    requestAnimationFrame(() => requestAnimationFrame(() => {
      const el = sceneStartRef.current;
      if (!el) return;
      try {
        const style = window.getComputedStyle(el);
        if (/(auto|scroll)/.test(style.overflowY)) el.scrollTop = 0;
      } catch (e) {}
      try { el.focus({ preventScroll: true }); } catch (e) {}
    }));
  };
  useEffect_c(() => {
    const end = sceneEndRef.current;
    if (!end || DEMO || !api || !resumed.current || typeof IntersectionObserver === "undefined") return;
    const observer = new IntersectionObserver((entries) => {
      if (entries.some((entry) => entry.isIntersecting)) persistScene(scene);
    }, { threshold: 0.75 });
    observer.observe(end);
    return () => observer.disconnect();
  }, [scene, DEMO, api, total]);

  // Empty-state guard (NAV-DEADEND-LESSON): a topic with no authored scenes must
  // not render a broken "1 / 0" pager. Show an honest "coming soon" panel instead.
  if (total <= 0) {
    const soon = { en: "This lesson is coming soon.", ru: "Урок скоро появится.", uk: "Урок незабаром з’явиться.", kk: "Сабақ жақын арада қосылады.", uz: "Dars tez orada qoʻshiladi.", es: "Esta lección estará disponible pronto.", fr: "Cette leçon arrive bientôt.", hy: "Այս դասը շուտով կլինի։" };
    return (
      <div className="section-pad fade-in" style={{ maxWidth: 1180 }}>
        <button className="scene-back" onClick={onBack}>← {backLabel || t.foundationsTitle}</button>
        <div className="scene-meta">
          <span>{L(chapter.title)}</span>
        </div>
        <div className="scene-view">
          <div className="scene-body">
            <div className="kicker mono" style={{ color: 'var(--accent)' }}>{L(chapter.kicker)}</div>
            <h3>{L(soon)}</h3>
          </div>
        </div>
      </div>
    );
  }

  const sceneLocked = sceneRequiresGate && !!(sceneGate && sceneGate.locked);
  const sceneLoading = sceneRequiresGate && !!(sceneGate && sceneGate.loading);
  const sceneGateError = sceneRequiresGate && !!(sceneGate && sceneGate.error);
  const cur = sceneRequiresGate && sceneGate && sceneGate.scene ? sceneGate.scene : baseCur;
  const levelLocked = requestedPremiumLevelAvailable && !!(levelGate && levelGate.locked);
  const levelLoading = requestedPremiumLevelAvailable && !!(levelGate && levelGate.loading);
  const levelGateError = requestedPremiumLevelAvailable && !!(levelGate && levelGate.error);
  const levelBody = requestedPremiumLevelAvailable && levelGate && levelGate.body ? levelGate.body : null;
  const sceneWord = L({ en: "Scene", ru: "Сцена", uk: "Сцена", kk: "Сахна", uz: "Sahna", es: "Escena", fr: "Scène", hy: "Տեսարան" });
  // Missing CMS data must be visible as a localized fallback, not disguised as
  // authored English content. Real scene titles still come from Directus.
  // C1: scene metadata resolves the chapter/lesson title through the SAME shared resolver as the
  // nav menu, so the header title can never disagree with the menu label or fall back to Russian
  // in a non-RU interface. Re-runs on every render, so a locale change re-resolves immediately
  // without a reload (`locale` is a prop; changing it re-renders this component).
  const RT = (o) => (window.resolveLocalizedTitle ? window.resolveLocalizedTitle(o, locale) : L(o));
  const sceneTitle = hasExt
    ? (L(cur && cur.title) || `${sceneWord} ${scene + 1}`)
    : `${RT(chapter.title)} · ${sceneWord} ${scene + 1}`;
  // DIFFICULTY-LEVELS: if this scene's body is leveled, show the level actually available for it
  // (chosen level if present, else extended). Flat bodies (other sections) render as before.
  const leveled = hasExt && cur && bodyIsLeveled(cur.body);
  const paidOk = userIsPaidForDeep(viewer);
  const premiumReady = premiumMedia.status === "entitled" && premiumMedia.items.length > 0;
  // A still-resolving OR transiently-errored load counts as "loading" for a paid viewer so it shows a
  // retry/loading affordance and never the permanent "Soon" state a transient 401 used to produce.
  const premiumLoading = premiumMedia.status === "loading" || premiumMedia.status === "error";
  const levelUsable = (lv) => {
    // A premium option is present only when its server-owned metadata says it
    // exists.  The client never decides whether this user is entitled to read it.
    if (PAID_LEVELS.includes(lv)) return availablePremiumLevels.includes(lv);
    return cur && levelHasContent(cur.body, lv, locale);
  };
  const levelLoaded = !!(levelBody && levelStr(levelBody, locale));
  const shownLevel = leveled ? ((requestedPremiumLevel && (!requestedPremiumLevelAvailable || !levelLoaded)) ? "extended" : (levelUsable(diffLevel) ? diffLevel : "extended")) : diffLevel;
  const bodyText = leveled ? (levelLoaded ? levelStr(levelBody, locale) : levelText(cur.body, shownLevel, locale))
                 : (hasExt ? L(cur && cur.body) : sceneBody(chapter.key, scene, locale));
  const insightText = hasExt ? L(cur && cur.insight) : sceneInsight(chapter.key, scene, locale);
  const tags = hasExt ? (cur && Array.isArray(cur.tags) ? cur.tags : []) : [];
  // CONTENT-ACCESS-FLAG (B3): per-scene free/paid badge. Static (non-Directus) scenes are free.
  const sceneAccess = hasExt ? (cur && cur.access === "paid" ? "paid" : "free") : "free";
  const accLab = (typeof window !== "undefined" && typeof window.luminaraAccessLabel === "function")
    ? window.luminaraAccessLabel(locale, sceneAccess)
    : (sceneAccess === "paid" ? "With subscription" : "Without subscription");
  // Крипто-Азбука: an alphabet scene carries `terms` [{ term, description{ml} }] instead of a
  // single body. The letter comes from the scene index (= sort): 0→A … 25→Z.
  const isAbc = hasExt && cur && Array.isArray(cur.terms) && cur.terms.length > 0;
  const abcLetter = String.fromCharCode(65 + scene);
  // ABC-SOURCES-IN-MEDIA: for a Крипто-Азбука letter, sources live per-term; collect them all
  // into one deduped list (by url) and show them in the left media block's "Источники" section
  // (like TON/Ясли), instead of a mini-block under each term.
  const abcSources = isAbc ? (function () {
    var seen = {}, out = [];
    cur.terms.forEach(function (tm) {
      (Array.isArray(tm.sources) ? tm.sources : []).forEach(function (s) {
        var key = (s && s.url) ? s.url : JSON.stringify(s);
        if (seen[key]) return; seen[key] = 1; out.push(s);
      });
    });
    return out;
  })() : null;
  // SOURCES-IN-MEDIA (#63): authored scenes are preferred, but a static
  // Foundations chapter may also provide a per-scene/chapter bibliography.
  // MediaColumn is the sole visual home for either form.
  const staticScene = Array.isArray(chapter.scene_data) ? chapter.scene_data[scene] : null;
  const sceneSources = isAbc ? abcSources
    : (hasExt && cur ? cur.sources : ((staticScene && staticScene.sources) || chapter.sources || []));

  // NAV-UNIQUE-URL-AUDIT: reflect the scene index (#/scene?c=<key>&s=<i>) so lesson / spine
  // scenes have a shareable, reload-safe URL. Крипто-Азбука uses ?l= (in app-shell) → skip here.
  useEffect_c(() => {
    if (typeof window === "undefined" || !window.LUM_ROUTE) return;
    if (isAbc) return;
    window.LUM_ROUTE.set("s", scene);
  }, [scene, isAbc]);

  return (
    <div className="section-pad fade-in scene-wide" style={{ maxWidth: "none" }}>
      <button className="scene-back" onClick={onBack}>← {backLabel || t.foundationsTitle}</button>

      <div className="scene-meta">
        <span>{chapter.n != null ? (t.chapter + " " + String(chapter.n).padStart(2, "0") + " · ") : ""}{RT(chapter.title)}</span>
        <span>{t.sceneOf.replace("{n}", scene + 1).replace("{total}", total)}</span>
      </div>

      {total > 1 ? (
        <div className="scene-pager scene-pager-top">
          <button className="btn" disabled={scene === 0} onClick={() => moveScene(scene - 1)}
                  style={{ opacity: scene === 0 ? 0.4 : 1 }}>‹ {t.previous}</button>
          <span className="scene-pager-pos">{t.sceneOf.replace("{n}", scene + 1).replace("{total}", total)}</span>
          <button className="btn primary" disabled={scene >= total - 1}
                  onClick={() => moveScene(scene + 1)}
                  style={{ opacity: scene >= total - 1 ? 0.4 : 1 }}>{t.next} ›</button>
        </div>
      ) : null}

      <div className="scene-view">
        {/* LEFT COLUMN: Media block (Видео/Аудио/Материалы/Источники) instead of the SVG art.
            (30.06 Eugene: заменить картинку слева на медиа-блок с источниками.) */}
        {window.MediaColumn ? (
          <aside className="rs-media scene-media-left">
            <div className="rs-media-h">{({ en:"Media", ru:"Медиа", uk:"Медіа", kk:"Медиа", uz:"Media", es:"Multimedia", fr:"Médias", hy:"Մեդիա" }[locale]) || "Media"}</div>
            <window.MediaColumn media={hasExt && cur ? cur.media : null} locale={locale} topicKey={chapter.key}
              mediaKey={sceneCk ? "scene:" + sceneCk : null}
              scene={scene} api={api} sources={sceneSources}
              premiumItems={premiumMedia.items} showPremium={PAID_LEVELS.includes(shownLevel)}
              freeMediaOnly={chapter.key === "preinternet" && !paidOk}
              mapCta={MAP_CHAPTERS.includes(chapter.key) ? (
                <button className="chapter-map-cta" type="button" ref={mapTriggerRef} onClick={() => setMapOpen(true)}>
                  <span className="cmc-icon">🗺</span>
                  <span className="cmc-text">
                    <b>{L({ ru: "Интерактивная карта главы", en: "Interactive chapter map", uk: "Інтерактивна карта розділу", kk: "Тараудың интерактивті картасы", uz: "Bobning interaktiv xaritasi", es: "Mapa interactivo del capítulo", fr: "Carte interactive du chapitre", hy: "Գլխի ինտերակտիվ քարտեզ" })}</b>
                    <small>{L({ ru: "Сцены, связи, источники", en: "Scenes, links, sources", uk: "Сцени, зв'язки, джерела", kk: "Сахналар, байланыстар, дереккөздер", uz: "Sahnalar, bogʻlanishlar, manbalar", es: "Escenas, conexiones, fuentes", fr: "Scènes, liens, sources", hy: "Տեսարաններ, կապեր, աղբյուրներ" })}</small>
                  </span>
                </button>
              ) : null} />
          </aside>
        ) : <SceneArt chapter={chapter} scene={scene} />}

        <div className="scene-body" ref={sceneStartRef} tabIndex="-1">
          <div className="kicker mono" style={{ color: 'var(--accent)' }}>{L(chapter.kicker)}</div>
          {sceneRequiresGate && (sceneLoading || sceneLocked || sceneGateError) ? (
            <FoundationSceneGate locale={locale} state={sceneGate} onRetry={retrySceneGate} />
          ) : isAbc ? (
            <React.Fragment>
              <h3 className="abc-scene-h"><span className="abc-scene-letter">{abcLetter}</span></h3>
              <div className="abc-terms">
                {cur.terms.map((tm, k) => (
                  <div className="abc-term-item" key={k}>
                    <div className="abc-term-name">{tm.term}</div>
                    <div className="abc-term-desc"><SceneProse text={L(tm.description)} /></div>
                  </div>
                ))}
              </div>
            </React.Fragment>
          ) : (
            <React.Fragment>
              <h3>{sceneTitle} <span className={"access-badge access-" + sceneAccess}>{accLab}</span></h3>
              {leveled ? (
                <div className="diff-levels" role="group" aria-label={L({ en: "Difficulty level", ru: "Уровень сложности", uk: "Рівень складності", kk: "Күрделілік деңгейі", uz: "Murakkablik darajasi", es: "Nivel de dificultad", fr: "Niveau de difficulté", hy: "Բարդության մակարդակ" })}>
                  {DIFF_LEVELS.map((lv) => {
                    const premiumLevel = PAID_LEVELS.includes(lv);
                    const selectedPremium = premiumLevel && lv === diffLevel;
                    const has = premiumLevel ? availablePremiumLevels.includes(lv) : levelHasContent(cur.body, lv, locale);
                    const locked = selectedPremium && levelLocked;
                    const loading = selectedPremium && levelLoading;
                    const usable = levelUsable(lv);
                    const active = lv === diffLevel;
                    return (
                      <button key={lv} type="button"
                              className={"diff-level" + (active ? " active" : "") + (usable ? "" : " diff-empty") + (locked ? " diff-locked" : "")}
                              disabled={!usable} aria-pressed={active}
                              title={locked ? L(PAID_HINT) : (loading ? L({ en:"Loading subscription materials", ru:"Загружаем материалы подписки", uk:"Завантажуємо матеріали підписки", kk:"Жазылым материалдары жүктелуде", uz:"Obuna materiallari yuklanmoqda", es:"Cargando materiales de suscripción", fr:"Chargement des contenus d’abonnement", hy:"Բաժանորդագրության նյութերը բեռնվում են" }) : (has ? undefined : L(DIFF_SOON_HINT)))}
                              onClick={() => { if (usable) setDiffLevel(lv); }}>
                        {L(DIFF_LEVEL_NAMES[lv])}
                        {locked ? <span className="diff-soon diff-paid">🔒 {paidAccessLabel(locale)}</span>
                                : (loading ? <span className="diff-soon">…</span> : (has ? null : <span className="diff-soon">{L(DIFF_SOON)}</span>))}
                      </button>
                    );
                  })}
                </div>
              ) : null}
              {leveled && requestedPremiumLevelAvailable && (levelLoading || levelLocked || levelGateError) ? (
                <FoundationSceneGate locale={locale} state={levelGate} onRetry={retryLevelGate} />
              ) : <SceneProse text={bodyText} />}
              {insightText ? <div className="insight">{insightText}</div> : null}
            </React.Fragment>
          )}

          {!sceneRequiresGate || (!sceneLoading && !sceneLocked && !sceneGateError) ? (
            <React.Fragment>
              <div className="tag-row">
                {tags.map(tg => (
                  <span className="tag" key={tg}>{tg}</span>
                ))}
              </div>

              {window.ShareRow ? <window.ShareRow locale={locale} title={sceneTitle}
                description={String(bodyText || "").replace(/[#>*_`]/g, " ").replace(/\s+/g, " ").trim().slice(0, 240)} /> : null}

              {window.InsightField ? (
                <window.InsightField topic={chapter.key} idx={scene} locale={locale}
                                     api={(typeof window !== "undefined") ? window.LUMINARA_API : null}
                                     demo={(typeof window !== "undefined" && window.LUMINARA_DEMO !== false)} />
              ) : null}
            </React.Fragment>
          ) : null}

          <span ref={sceneEndRef} className="scene-read-sentinel" aria-hidden="true" />

          <div className="scene-pager">
            <div className="dots">
              {Array.from({ length: total }).map((_, i) => (
                <i key={i} className={i <= scene ? "f" : ""} />
              ))}
            </div>
            <button className="btn" disabled={scene === 0} onClick={() => moveScene(scene - 1)}
                    style={{ opacity: scene === 0 ? 0.4 : 1 }}>
              ← {t.previous}
            </button>
            <button className="btn primary" disabled={scene >= total - 1}
                    onClick={() => moveScene(scene + 1)}
                    style={{ opacity: scene >= total - 1 ? 0.4 : 1 }}>
              {t.next} <span className="arr">→</span>
            </button>
          </div>

          <LessonInsights chapterKey={chapter.key} locale={locale} t={t} />
          {hasChapterQuiz && scene < total - 1 && (
            <div className="chapter-quiz-note">{L(QUIZ_LAST_SCENE_NOTE)}</div>
          )}
          {hasChapterQuiz && scene >= total - 1 && <ThemeQuiz topic={quizTopic} locale={locale}
            onBackToChapter={onBack} onNextLesson={onNextLesson} nextLessonLabel={nextLessonLabel} />}

          {/* Issue #19: one shared modal component (window.LuminaraModal, defined in research.jsx)
              for this chapter map, the research live-demo, and the new #24 premium interactives —
              not three separate portal implementations. */}
          {mapOpen && window.LuminaraModal ? (
            <window.LuminaraModal
              title={L({ ru: "Карта главы «До интернета»", en: "Chapter map: Before the Internet", uk: "Карта розділу «До інтернету»", kk: "«Интернетке дейін» тарауының картасы", uz: "\"Internetgacha\" bob xaritasi", es: "Mapa del capítulo: Antes de Internet", fr: "Carte du chapitre : Avant Internet", hy: "Գլխի քարտեզ. Մինչ ինտերնետը" })}
              fullscreen={true}
              src={buildMapSrc(chapter.key, locale)}
              onClose={() => setMapOpen(false)}
              returnFocusRef={mapTriggerRef}
            />
          ) : null}
        </div>
      </div>
    </div>
  );
}

// In-lesson rotating insights from others (TICKET-041)
function LessonInsights({ chapterKey, locale, t }) {
  const L = (o) => o[locale] || o.en;
  const seed = (window.LUMINARA_INSIGHTS_SEED || []).filter(x => x.topic === chapterKey);
  const pool = seed.length ? seed : (window.LUMINARA_INSIGHTS_SEED || []).slice(0, 3);
  if (!pool.length) return null;
  const head = { en: "What others realised here", ru: "Что здесь поняли другие", uk: "Що тут зрозуміли інші", kk: "Мұнда басқалар нені түсінді", uz: "Bu yerda boshqalar nimani angladi", es: "Lo que otros descubrieron aquí", fr: "Ce que d'autres ont compris ici", hy: "Ինչ ուրիշներն այստեղ հասկացան" };
  const [open, setOpen] = useState_c(null);
  return (
    <div className="lesson-insights">
      <div className="li-h">✦ {L(head)}</div>
      <div className="li-list">
        {pool.map((it, i) => {
          const full = L(it.text);
          const isOpen = open === i;
          const short = full.length > 70 && !isOpen ? full.slice(0, 70) + "…" : full;
          return (
            <button key={i} className={"li-item" + (isOpen ? " open" : "")} onClick={() => setOpen(isOpen ? null : i)}>
              <span className="li-anon">#{it.topic}</span>
              <span className="li-text">“{short}”</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// Per-scene art — abstract editorial visuals using only SVG primitives
function SceneArt({ chapter, scene }) {
  return (
    <div className="scene-stage">
      <div className="corner tl">scene {String(scene + 1).padStart(2, "0")}</div>
      <div className="corner tr">{chapter.key}</div>
      <div className="corner bl">luminara · field</div>
      <div className="corner br">{(scene * 137 % 360).toFixed(0)}°</div>
      <ArtFor chapterKey={chapter.key} scene={scene} />
    </div>
  );
}

function ArtFor({ chapterKey, scene }) {
  const seed = (chapterKey + scene).split('').reduce((a, c) => a + c.charCodeAt(0), 0);
  const variant = scene % 4;

  if (variant === 0) {
    // concentric rings — evolutionary epoch
    return (
      <svg viewBox="0 0 400 500" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
        <defs>
          <radialGradient id={"g"+seed} cx="50%" cy="50%" r="50%">
            <stop offset="0%" stopColor="oklch(70% 0.18 295)" stopOpacity="0.5" />
            <stop offset="100%" stopColor="oklch(70% 0.18 295)" stopOpacity="0" />
          </radialGradient>
        </defs>
        <circle cx="200" cy="250" r="180" fill={`url(#g${seed})`} />
        {[40, 70, 110, 160].map(r => (
          <circle key={r} cx="200" cy="250" r={r}
                  fill="none" stroke="oklch(72% 0.18 295)" strokeOpacity={0.6 - r/300}
                  strokeWidth="0.5" />
        ))}
        <circle cx="200" cy="250" r="6" fill="oklch(82% 0.12 205)" />
        {Array.from({ length: 12 }).map((_, i) => {
          const a = (i / 12) * Math.PI * 2;
          return (
            <circle key={i}
                    cx={200 + Math.cos(a) * 120}
                    cy={250 + Math.sin(a) * 120}
                    r={2 + (i % 3)} fill="oklch(75% 0.15 280)" opacity={0.5 + (i % 3) * 0.15} />
          );
        })}
      </svg>
    );
  }
  if (variant === 1) {
    // network nodes
    const nodes = Array.from({ length: 14 }).map((_, i) => ({
      x: 60 + ((seed * (i + 1) * 13) % 280),
      y: 80 + ((seed * (i + 7) * 17) % 360),
    }));
    return (
      <svg viewBox="0 0 400 500" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
        {nodes.map((a, i) => nodes.slice(i + 1, i + 4).map((b, j) => (
          <line key={i + '-' + j} x1={a.x} y1={a.y} x2={b.x} y2={b.y}
                stroke="oklch(50% 0.04 285)" strokeWidth="0.4" />
        )))}
        {nodes.map((n, i) => (
          <g key={i}>
            <circle cx={n.x} cy={n.y} r={3 + (i % 4)} fill={i % 3 === 0 ? "oklch(72% 0.18 295)" : "oklch(82% 0.12 205)"} />
            {i % 5 === 0 && <circle cx={n.x} cy={n.y} r={(3 + i % 4) + 6} fill="none" stroke="oklch(72% 0.18 295)" strokeOpacity="0.4" />}
          </g>
        ))}
      </svg>
    );
  }
  if (variant === 2) {
    // flow / bars
    return (
      <svg viewBox="0 0 400 500" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
        {Array.from({ length: 20 }).map((_, i) => {
          const h = 40 + ((seed * (i + 3) * 11) % 280);
          return (
            <rect key={i}
                  x={30 + i * 17}
                  y={400 - h}
                  width="8"
                  height={h}
                  fill={i % 4 === 0 ? "oklch(72% 0.18 295)" : "oklch(50% 0.08 285)"}
                  opacity={0.5 + (i % 5) * 0.1} />
          );
        })}
        <line x1="20" y1="400" x2="380" y2="400" stroke="oklch(40% 0.04 285)" strokeWidth="0.5" />
      </svg>
    );
  }
  // variant 3 — orbiting arcs
  return (
    <svg viewBox="0 0 400 500" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
      {[80, 130, 180].map((r, i) => (
        <ellipse key={i} cx="200" cy="250" rx={r} ry={r * 0.6}
                 fill="none" stroke="oklch(70% 0.14 295)" strokeOpacity={0.3 + i * 0.1}
                 strokeWidth="0.5"
                 transform={`rotate(${20 + i * 35} 200 250)`} />
      ))}
      <circle cx="200" cy="250" r="14" fill="oklch(72% 0.18 295)" />
      <circle cx="320" cy="220" r="6" fill="oklch(82% 0.12 205)" />
      <circle cx="100" cy="290" r="4" fill="oklch(80% 0.14 60)" />
    </svg>
  );
}

function sceneBody(key, scene, locale) {
  const bodies = {
    en: "Each scene is a single insight, paired with a short visual. You read it, you feel it, you move on — the page is not a textbook, it is a sequence of images and ideas that compose into a mental model.",
    ru: "Каждая сцена — одна мысль и одно изображение. Вы читаете, чувствуете и движетесь дальше. Это не учебник, а последовательность образов и идей, складывающихся в ментальную модель.",
    uk: "Кожна сцена — одна думка й одне зображення. Ви читаєте, відчуваєте та рухаєтеся далі. Це не підручник, а послідовність образів та ідей, що складаються в ментальну модель.",
    kk: "Әр көрініс — бір ой және бір сурет. Сіз оны оқисыз, сезінесіз және ары қарай жүресіз. Бұл оқулық емес, ментальды модельге жинақталатын образдар мен идеялардың тізбегі.", uz: "Har bir sahna — bitta fikr va bitta tasvir. Siz oʻqiysiz, his qilasiz va davom etasiz. Bu darslik emas, mental modelga jamlanadigan obraz va gʻoyalar ketma-ketligi.", es: "Cada escena es una sola idea, acompañada de un breve visual. La lees, la sientes, avanzas: la página no es un libro de texto, es una secuencia de imágenes e ideas que se componen en un modelo mental.", fr: "Chaque scène est une seule idée, accompagnée d'un court visuel. Tu la lis, tu la ressens, tu avances — la page n'est pas un manuel, c'est une suite d'images et d'idées qui composent un modèle mental.", hy: "Յուրաքանչյուր տեսարան մեկ գաղափար է՝ զուգորդված կարճ պատկերով։ Կարդում ես, զգում ես, շարժվում առաջ՝ էջը դասագիրք չէ, այլ պատկերների ու գաղափարների հաջորդականություն, որ կազմում է մտավոր մոդել։" };
  return bodies[locale] || bodies.en;
}

function sceneInsight(key, scene, locale) {
  const map = {
    preinternet: { en: "The internet is not a technology — it is a new stage in the development of civilization.", ru: "Интернет — не технология, а новая стадия развития цивилизации.", uk: "Інтернет — не технологія, а нова стадія розвитку цивілізації.", kk: "Интернет — технология емес, өркениеттің жаңа сатысы.", uz: "Internet — texnologiya emas, sivilizatsiya rivojining yangi bosqichi.", es: "Internet no es una tecnología: es una nueva etapa en el desarrollo de la civilización.", fr: "Internet n'est pas une technologie — c'est une nouvelle étape du développement de la civilisation.", hy: "Ինտերնետը տեխնոլոգիա չէ՝ քաղաքակրթության զարգացման նոր փուլ է։" },
    web1:        { en: "Web1 made information global. Web2 made it human.", ru: "Web1 сделал информацию глобальной. Web2 сделал её человеческой.", uk: "Web1 зробив інформацію глобальною. Web2 зробив її людською.", kk: "Web1 ақпаратты жаһандық етті. Web2 оны адами етті.", uz: "Web1 axborotni global qildi. Web2 uni insoniy qildi.", es: "La Web1 hizo global la información. La Web2 la hizo humana.", fr: "Le Web1 a rendu l'information mondiale. Le Web2 l'a rendue humaine.", hy: "Web1-ը տեղեկատվությունը դարձրեց գլոբալ։ Web2-ը՝ մարդկային։" },
    web2:        { en: "When users became participants, platforms became infrastructure.", ru: "Когда пользователи стали участниками, платформы стали инфраструктурой.", uk: "Коли користувачі стали учасниками, платформи стали інфраструктурою.", kk: "Пайдаланушылар қатысушыға айналғанда, платформалар инфрақұрылымға айналды.", uz: "Foydalanuvchilar ishtirokchiga aylanganda, platformalar infratuzilmaga aylandi.", es: "Cuando los usuarios pasaron a ser participantes, las plataformas se volvieron infraestructura.", fr: "Quand les utilisateurs sont devenus des participants, les plateformes sont devenues des infrastructures.", hy: "Երբ օգտատերերը դարձան մասնակիցներ, հարթակները դարձան ենթակառուցվածք։" },
    social:      { en: "Social networks did not just change media — they rewired society.", ru: "Соцсети не просто изменили медиа — они перепрошили общество.", uk: "Соцмережі не просто змінили медіа — вони перепрошили суспільство.", kk: "Әлеуметтік желілер тек БАҚ-ты ғана өзгерткен жоқ — олар қоғамды қайта бағдарламалады.", uz: "Ijtimoiy tarmoqlar nafaqat OAVni oʻzgartirdi — ular jamiyatni qayta dasturladi.", es: "Las redes sociales no solo cambiaron los medios: recablearon la sociedad.", fr: "Les réseaux sociaux n'ont pas seulement changé les médias — ils ont recâblé la société.", hy: "Սոցիալական ցանցերը ոչ միայն փոխեցին մեդիան՝ վերակառուցեցին հասարակությունը։" },
    web30:       { en: "The smarter the internet becomes, the more it shapes what each person sees.", ru: "Чем умнее интернет, тем сильнее он формирует то, что видит каждый.", uk: "Що розумнішим стає інтернет, то сильніше він формує те, що бачить кожен.", kk: "Интернет неғұрлым ақылды болса, әркімнің не көретінін соғұрлым күшті қалыптастырады.", uz: "Internet qancha aqlli boʻlsa, har kim nimani koʻrishini shuncha kuchli shakllantiradi.", es: "Cuanto más inteligente se vuelve internet, más moldea lo que cada persona ve.", fr: "Plus internet devient intelligent, plus il façonne ce que chacun voit.", hy: "Որքան ինտերնետը խելացի է դառնում, այնքան ձևավորում է, թե ինչ է տեսնում յուրաքանչյուրը։" },
    web3:        { en: "A wallet is the first account on the internet you actually own.", ru: "Кошелёк — первый аккаунт в интернете, которым вы по-настоящему владеете.", uk: "Гаманець — перший акаунт в інтернеті, яким ви по-справжньому володієте.", kk: "Әмиян — интернетте сіз шынайы иеленетін алғашқы аккаунт.", uz: "Hamyon — internetda siz haqiqatan egalik qiladigan birinchi hisob.", es: "Una billetera es la primera cuenta de internet que realmente te pertenece.", fr: "Un portefeuille est le premier compte sur internet qui t'appartient vraiment.", hy: "Դրամապանակն առաջին հաշիվն է ինտերնետում, որ իրականում քոնն է։" },
    economy:     { en: "Value used to be made in factories. Now it accrues in networks.", ru: "Ценность раньше создавалась на фабриках. Теперь она накапливается в сетях.", uk: "Цінність колись створювалася на фабриках. Тепер вона накопичується в мережах.", kk: "Бұрын құндылық фабрикаларда жасалған. Енді ол желілерде жинақталады.", uz: "Ilgari qiymat fabrikalarda yaratilardi. Endi u tarmoqlarda toʻplanadi.", es: "El valor antes se creaba en las fábricas. Ahora se acumula en las redes.", fr: "La valeur se créait autrefois dans les usines. Maintenant elle s'accumule dans les réseaux.", hy: "Արժեքը նախկինում ստեղծվում էր գործարաններում։ Այժմ կուտակվում է ցանցերում։" },
    netocracy:   { en: "In the digital world, value is created by networks, attention and the ability to move information.", ru: "В цифровом мире ценность создают сети, внимание и способность управлять информационными потоками.", uk: "У цифровому світі цінність створюють мережі, увага й здатність керувати інформаційними потоками.", kk: "Цифрлық әлемде құндылықты желілер, назар және ақпарат ағындарын басқару қабілеті жасайды.", uz: "Raqamli dunyoda qiymatni tarmoqlar, eʼtibor va axborot oqimlarini boshqarish qobiliyati yaratadi.", es: "En el mundo digital, el valor lo crean las redes, la atención y la capacidad de mover información.", fr: "Dans le monde numérique, la valeur est créée par les réseaux, l'attention et la capacité à faire circuler l'information.", hy: "Թվային աշխարհում արժեքը ստեղծվում է ցանցերով, ուշադրությամբ և տեղեկատվությունը տեղափոխելու կարողությամբ։" },
  };
  return (map[key] && (map[key][locale] || map[key].en)) || "";
}

function sceneTags(key, scene) {
  const sets = {
    preinternet: ["industrial age", "knowledge economy", "global networks"],
    web1:        ["WWW", "browsers", "search"],
    web2:        ["platforms", "data", "attention"],
    social:      ["social graph", "algorithms", "creators"],
    web30:       ["semantic", "AI", "agents"],
    web3:        ["blockchain", "ownership", "smart contracts"],
    economy:     ["tokens", "DAO", "RWA"],
    netocracy:   ["networks", "influence", "reputation"],
  };
  return sets[key] || [];
}

window.Foundations = Foundations;
window.SceneReader = SceneReader;
window.SceneProse = SceneProse;   // MARKDOWN-RENDER: reused by research.jsx reader
window.ThemeQuiz = ThemeQuiz;     // QUIZ-HUB: reused by account guidance and the standalone quiz view
window.LUMINARA_QUIZ_TOPICS = FOUNDATION_QUIZ_TOPICS.slice();
