import React, { useState, useRef, useEffect, useMemo, useCallback, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import { DOUBTS, LANGS, SPEEDS } from "./content.js";
import { md, Md } from "./md.js";

/* ============================================================
   springs
   ============================================================ */
const SNAP = { type: "spring", stiffness: 400, damping: 32 };
const SMOOTH = { type: "spring", stiffness: 260, damping: 28 };
const SHEET = { type: "spring", stiffness: 320, damping: 34, mass: 0.9 };

/* Breathing room between narrated units, on auto-advance only. A step change
   is a bigger beat than moving between cards inside a step — the page turns
   and the subject shifts — so it gets a longer pause. A manual jump (tap,
   arrow, steps sheet) plays immediately; waiting after a deliberate tap
   would read as lag. The payload's own `delayAfterPrev` can lengthen either
   gap but not shorten it. */
const SECTION_GAP = 1000;  // card → card within the same step
const STEP_GAP = 1500;     // crossing into a new step

/* Step pages slide sideways; direction comes from `custom`.
   AnimatePresence runs in mode="wait" so exactly one page is ever mounted —
   otherwise jumping back to a step whose exit is still running re-enters the
   same key and strands a phantom page under the live one. Sequential
   enter/exit means the per-leg duration has to stay short. */
const PAGE_T = { type: "tween", duration: 0.18, ease: [0.2, 0, 0, 1] };
const PAGE = {
  enter: (d) => ({ x: d > 0 ? 60 : -60, opacity: 0 }),
  center: { x: 0, opacity: 1 },
  exit: (d) => ({ x: d > 0 ? -60 : 60, opacity: 0 }),
};

/* ============================================================
   eased auto-scroll

   `scrollTo({ behavior: "smooth" })` has a fixed, non-configurable curve
   that differs between engines, so it never matched the highlight
   crossfade. This drives scrollTop per frame on the same ease-in-out
   cubic as TINT_T — [0.65, 0, 0.35, 1] is exactly easeInOutCubic, so the
   closed form below is the identical curve without a bezier solver.
   ============================================================ */
/* Vertical offset of `node` inside the scroll container `root`.
   NOT `node.offsetTop`: while a section is revealing, Framer Motion holds a
   transform on its wrapper, and Chrome then reports that wrapper as the
   offsetParent — so offsetTop reads ~0 and the scroll target collapses to the
   top of the page. Walking the offsetParent chain is correct whichever
   ancestors happen to be positioned or transformed mid-animation. */
function offsetWithin(node, root) {
  let y = 0;
  for (let n = node; n && n !== root; n = n.offsetParent) y += n.offsetTop;
  return y;
}

const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);

/**
 * @param to  a number, or a getter re-read every frame. Use the getter when
 *            layout is still settling: the collapsing step header changes the
 *            container's height mid-scroll, which shrinks the maximum
 *            scrollTop. Against a fixed target that clamps late and the view
 *            visibly drifts back up after arriving. Re-resolving each frame
 *            lets the motion converge on the target as it moves.
 */
function animateScrollTo(el, to, { instant = false } = {}) {
  const from = el.scrollTop;
  const resolve = () => {
    const limit = Math.max(0, el.scrollHeight - el.clientHeight);
    return Math.max(0, Math.min(limit, typeof to === "function" ? to() : to));
  };
  if (instant || Math.abs(resolve() - from) < 1) { el.scrollTop = resolve(); return () => {}; }

  // distance-scaled: short nudges stay brisk, long jumps don't sprint
  const dur = Math.min(700, Math.max(260, Math.abs(resolve() - from) * 0.7));
  let raf = 0;
  let startedAt = 0;
  let dead = false;

  const stop = () => {
    if (dead) return;
    dead = true;
    if (raf) cancelAnimationFrame(raf);
    el.removeEventListener("wheel", stop);
    el.removeEventListener("touchstart", stop);
    el.removeEventListener("pointerdown", stop);
  };

  // a real gesture always outranks an automatic scroll — never fight the user
  el.addEventListener("wheel", stop, { passive: true });
  el.addEventListener("touchstart", stop, { passive: true });
  el.addEventListener("pointerdown", stop, { passive: true });

  const step = (now) => {
    if (dead) return;
    if (!startedAt) startedAt = now;
    const t = Math.min(1, (now - startedAt) / dur);
    el.scrollTop = from + (resolve() - from) * easeInOutCubic(t);
    if (t < 1) raf = requestAnimationFrame(step);
    else stop();
  };
  raf = requestAnimationFrame(step);
  return stop;
}

/* ============================================================
   icons — 16px grid unless noted
   ============================================================ */
const S = { fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" };

const IcBack = (p) => (
  <svg width="24" height="24" viewBox="0 0 24 24" {...S} {...p}>
    <path d="M19 12H5M11 18l-6-6 6-6" />
  </svg>
);
const IcChevron = ({ dir = "down", size = 16 }) => (
  <svg width={size} height={size} viewBox="0 0 16 16" {...S}
    style={{ transform: dir === "up" ? "rotate(180deg)" : "none", transition: "transform 200ms ease" }}>
    <path d="M4 6.5 8 10.5l4-4" />
  </svg>
);
const IcSpeaker = ({ off }) => (
  <svg width="18" height="18" viewBox="0 0 20 20" {...S}>
    <path d="M4.5 7.5h2.2L10.5 4.5v11L6.7 12.5H4.5z" />
    {off ? <path d="M3 3l14 14" /> : <path d="M13.6 7.4a3.6 3.6 0 0 1 0 5.2" />}
  </svg>
);
const IcPlay = ({ size = 16 }) => (
  <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor">
    <path d="M5 3.4c0-.5.6-.9 1-.6l6.2 4.1c.4.3.4.9 0 1.2L6 12.2c-.4.3-1 0-1-.6z" />
  </svg>
);
const IcPause = ({ size = 16 }) => (
  <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor">
    <rect x="4" y="3" width="3.1" height="10" rx="1.2" />
    <rect x="8.9" y="3" width="3.1" height="10" rx="1.2" />
  </svg>
);
const IcReplay = ({ size = 17 }) => (
  <svg width={size} height={size} viewBox="0 0 18 18" {...S}>
    <path d="M15 9a6 6 0 1 1-1.9-4.4" />
    <path d="M15.2 2.6V6h-3.4" />
  </svg>
);
const IcPrev = () => (
  <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
    <rect x="3" y="3.2" width="1.9" height="9.6" rx="0.9" />
    <path d="M12.9 4.2c0-.6-.7-1-1.2-.6L6.3 7.4a.8.8 0 0 0 0 1.2l5.4 3.8c.5.4 1.2 0 1.2-.6z" />
  </svg>
);
const IcNext = () => (
  <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
    <rect x="11.1" y="3.2" width="1.9" height="9.6" rx="0.9" />
    <path d="M3.1 4.2c0-.6.7-1 1.2-.6l5.4 3.8a.8.8 0 0 1 0 1.2l-5.4 3.8c-.5.4-1.2 0-1.2-.6z" />
  </svg>
);
const IcSteps = () => (
  <svg width="16" height="16" viewBox="0 0 16 16" {...S}>
    <rect x="1.4" y="3" width="13.2" height="10" rx="2.2" />
    <path d="M4.3 6.2h3.1M4.3 9.4h4.6M11 6.2h.01M11 9.4h.01" />
  </svg>
);
const IcCheck = ({ size = 11 }) => (
  <svg width={size} height={size} viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M2.2 6.3 4.7 8.8 9.8 3.6" />
  </svg>
);
const IcX = ({ size = 10 }) => (
  <svg width={size} height={size} viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
    <path d="M3 3l6 6M9 3l-6 6" />
  </svg>
);
const IcBulb = () => (
  <svg width="15" height="15" viewBox="0 0 18 18" {...S}>
    <path d="M6.6 12.4a4.8 4.8 0 1 1 4.8 0v1.4H6.6z" />
    <path d="M7.2 15.8h3.6" />
  </svg>
);
const IcThumb = ({ down }) => (
  <svg width="18" height="18" viewBox="0 0 20 20" {...S} style={{ transform: down ? "rotate(180deg)" : "none" }}>
    <path d="M6.5 17V8.6l3.4-5.4c.9 0 1.7.8 1.7 1.8v2.9h3.3c1 0 1.8.9 1.6 1.9l-.9 5A1.7 1.7 0 0 1 14 16H6.5z" />
    <path d="M6.5 8.6H4.3c-.6 0-1 .5-1 1V16c0 .6.4 1 1 1h2.2" />
  </svg>
);
const IcMic = () => (
  <svg width="18" height="18" viewBox="0 0 20 20" {...S}>
    <rect x="7.6" y="2.4" width="4.8" height="9" rx="2.4" />
    <path d="M4.6 9.2a5.4 5.4 0 0 0 10.8 0M10 14.6V17.6" />
  </svg>
);
const IcSend = () => (
  <svg width="18" height="18" viewBox="0 0 20 20" {...S}>
    <path d="M3.2 10 16.8 4l-6 13.6-1.6-6z" />
  </svg>
);
const IcCamera = () => (
  <svg width="16" height="16" viewBox="0 0 20 20" {...S}>
    <path d="M2.6 6.9c0-.9.7-1.6 1.6-1.6h1.9l1.1-1.7h5.6l1.1 1.7h1.9c.9 0 1.6.7 1.6 1.6v7c0 .9-.7 1.6-1.6 1.6H4.2c-.9 0-1.6-.7-1.6-1.6z" />
    <circle cx="10" cy="10.2" r="2.7" />
  </svg>
);
/* One CLOSED silhouette, so the fill actually seals the shape. The previous
   icon was a single path of open finger subpaths — `fill` left gaps between
   them, and the active card's blue border read straight through the hand. */
const HAND_BODY =
  "M11.4 8.2a2.9 2.9 0 0 1 5.8 0v9.6l3.3.7c.9.2 1.7.4 2.5.6l3.1.9a3.2 3.2 0 0 1 2.3 3.1v5.4c0 5.5-4.5 10-10 10h-1.9a9.3 9.3 0 0 1-7.6-4l-3.5-5a2.4 2.4 0 0 1 .6-3.4c1.1-.7 2.5-.5 3.3.5l2.1 2.4Z";
const HAND_LINES =
  "M17.2 18.4c1.5.4 2.4 1.1 2.8 2.2M20.8 19.3c1.4.4 2.2 1.1 2.6 2.1M24.4 20.3c1.2.4 1.9 1 2.2 1.9";

const IcHand = () => (
  <svg width="34" height="40" viewBox="0 0 34 40" fill="none">
    <path d={HAND_BODY} fill="#fff" stroke="#1e1e22" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
    <path d={HAND_LINES} fill="none" stroke="#1e1e22" strokeWidth="1.3" strokeLinecap="round" opacity="0.75" />
  </svg>
);
/* Looping tap gesture for the coachmark pointer: press, release, rest.
   `times` front-loads the whole tap into the first ~40% so the rest of the
   cycle is a pause — a continuous bob reads as idle animation, whereas a
   tap followed by stillness reads as an instruction to repeat. The ripple is
   keyed to the same timeline so it fires on the press, not alongside it, and
   sits behind the hand — which is opaque, so it only reads around the
   fingertip rather than through the pointer. */
const TAP_LOOP = { duration: 1.7, repeat: Infinity, ease: "easeInOut", times: [0, 0.12, 0.26, 0.42, 1] };

function TapHint({ reduce }) {
  return (
    <div style={{ position: "relative", width: 34, height: 40 }}>
      {!reduce && (
        <motion.span
          aria-hidden
          style={{
            position: "absolute", left: 3, top: -3, width: 22, height: 22,
            borderRadius: "50%", border: "2px solid var(--primary)",
          }}
          initial={{ scale: 0.3, opacity: 0 }}
          animate={{ scale: [0.3, 0.3, 1.1, 1.6, 1.6], opacity: [0, 0.55, 0.3, 0, 0] }}
          transition={TAP_LOOP}
        />
      )}
      <motion.div
        // scale toward the fingertip, so the press looks like it lands there
        style={{ position: "relative", transformOrigin: "42% 14%" }}
        animate={reduce ? {} : { y: [0, 4, 0, 0, 0], scale: [1, 0.9, 1, 1, 1] }}
        transition={TAP_LOOP}
      >
        <IcHand />
      </motion.div>
    </div>
  );
}

const IcAllie = () => (
  <svg width="24" height="24" viewBox="0 0 24 24">
    <defs>
      <linearGradient id="ag" x1="0" y1="0" x2="1" y2="1">
        <stop offset="0" stopColor="#5ec4ff" /><stop offset="1" stopColor="#0266da" />
      </linearGradient>
    </defs>
    <rect width="24" height="24" rx="8" fill="url(#ag)" />
    <circle cx="9.2" cy="10.6" r="1.5" fill="#fff" />
    <circle cx="14.8" cy="10.6" r="1.5" fill="#fff" />
    <path d="M9 14.6c1.8 1.4 4.2 1.4 6 0" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" fill="none" />
  </svg>
);
const Wave = () => (
  <span className="wave" aria-hidden>
    {[0, 1, 2, 3].map((i) => <i key={i} style={{ animationDelay: `${i * 130}ms` }} />)}
  </span>
);

/* ============================================================
   voice pulse — the top tint breathes while narration plays
   ------------------------------------------------------------
   Three blurred brand-blue blobs on different periods so the
   motion reads organic rather than metronomic. At rest they
   settle into the flat gradient from the Figma frames.
   ============================================================ */
/* `rest`→`peak` is the swing while speaking; the idle state settles to a
   fraction of `rest`. Tuned so the top of the screen reads as gently alive,
   not as a notification demanding attention: the swing is wide enough to
   catch peripheral vision, the periods are long and mutually prime so no
   single throb dominates, and the cores stay clear of the title's baseline. */
const BLOBS = [
  { cls: "b1", period: 2.4, rest: 0.34, peak: 0.96, lift: 0.14, delay: 0 },
  { cls: "b2", period: 3.1, rest: 0.26, peak: 0.78, lift: 0.18, delay: 0.35 },
  { cls: "b3", period: 1.9, rest: 0.20, peak: 0.62, lift: 0.22, delay: 0.7 },
];

function VoicePulse({ speaking, reduce }) {
  const on = speaking && !reduce;
  return (
    <div className="voicetint" aria-hidden>
      {BLOBS.map((b) => (
        <motion.div
          key={b.cls}
          className={`blob ${b.cls}`}
          initial={false}
          animate={
            on
              ? { opacity: [b.rest, b.peak, b.rest], scale: [1, 1 + b.lift, 1] }
              : { opacity: b.rest * 0.7, scale: 1 }
          }
          transition={
            on
              ? { duration: b.period, repeat: Infinity, ease: "easeInOut", delay: b.delay }
              : { duration: 0.7, ease: [0.2, 0, 0, 1] }
          }
        />
      ))}
    </div>
  );
}

/* ============================================================
   status bar
   ============================================================ */
function StatusBar() {
  return (
    <div className="statusbar">
      <span>9:41</span>
      <span className="glyphs">
        <svg width="17" height="11" viewBox="0 0 17 11" fill="currentColor">
          {[0, 1, 2, 3].map((i) => <rect key={i} x={i * 4.4} y={8 - i * 2.5} width="3" height={3 + i * 2.5} rx="1" />)}
        </svg>
        <svg width="15" height="11" viewBox="0 0 15 11" fill="currentColor">
          <path d="M7.5 9.6 9.9 7a3.4 3.4 0 0 0-4.8 0zM7.5 4.3c1.6 0 3.1.6 4.2 1.7l1.5-1.6a8.1 8.1 0 0 0-11.4 0l1.5 1.6a5.9 5.9 0 0 1 4.2-1.7z" />
        </svg>
        <svg width="25" height="12" viewBox="0 0 25 12" fill="none">
          <rect x="0.6" y="0.6" width="21" height="10.8" rx="3" stroke="currentColor" strokeOpacity="0.45" />
          <rect x="2.2" y="2.2" width="17.8" height="7.6" rx="1.8" fill="currentColor" />
          <path d="M23.2 4.2v3.6c.9-.3 1.4-.9 1.4-1.8s-.5-1.5-1.4-1.8z" fill="currentColor" fillOpacity="0.45" />
        </svg>
      </span>
    </div>
  );
}

/* ============================================================
   narration

   Per SECTION, not per block — the pipeline ships one audio
   file per section and that is the unit that gets highlighted.

   Resolution order:
     1. section.audio[lang]  -> <audio>, playbackRate = speed, live mute
     2. speechSynthesis      -> section.narrate[lang], rate = speed
     3. word-count timer     -> silent, keeps auto-advance honest
   ============================================================ */
function estimate(text, speed) {
  const words = Math.max(4, String(text || "").trim().split(/\s+/).length);
  return Math.min(40000, Math.max(2400, (words / 2.7) * 1000)) / speed;
}
/* The Web Speech API exposes no gender field, so gender has to be inferred
   from voice names. These lists cover the common macOS / iOS / Windows /
   Chrome / Android voices; the recorded clips are an Indian-English female,
   so female + en-IN is what we steer towards. */
const FEMALE_VOICE = /\bfemale\b|samantha|victoria|karen|moira|tessa|fiona|serena|susan|allison|ava|nicky|zoe|veena|neerja|heera|swara|kalpana|zira|aria|jenny|michelle|lekha|monica|joana|luciana|amelie|milena|yuna/i;
const MALE_VOICE = /\bmale\b|rishi|alex|daniel|fred|thomas|oliver|aaron|arthur|gordon|ravi|hemant|madhur|prabhat|guy|davis|tony|jason|eric|christopher|brian|george|james|diego|jorge|juan|xander|maged/i;
const RICH_VOICE = /natural|neural|premium|enhanced|siri|google/i;
/* macOS ships a set of novelty voices that must never be chosen */
const NOVELTY_VOICE = /bad news|good news|bahh|bells|boing|bubbles|cellos|jester|organ|superstar|trinoids|whisper|wobble|zarvox|deranged|hysterical|albert|junior|ralph|fred|kathy|agnes|princess|flo|grandma|grandpa|rocko|sandy|shelley|eddy|reed/i;

function pickVoice(bcp) {
  if (!("speechSynthesis" in window)) return null;
  const all = window.speechSynthesis.getVoices() || [];
  if (!all.length) return null;
  // lowercase both sides — BCP-47 tags are case-insensitive and the voice
  // list reports them inconsistently ("en-IN", "en_IN", "en-in")
  const want = (bcp.startsWith("hi")
    ? ["hi-IN", "en-IN", "en"]
    : ["en-IN", "en-GB", "en-AU", "en-US", "en"]
  ).map((s) => s.toLowerCase());
  const tagOf = (v) => (v.lang || "").toLowerCase().replace(/_/g, "-");

  /* Gender outranks locale on purpose. Many machines ship exactly one
     Indian-English voice and it's male (Rishi on macOS) — taking it just
     because the locale matched is how a male voice slipped in. A female
     en-GB/en-US read is the closer match to the recorded narration. */
  const localeRank = (v) => {
    const t = tagOf(v);
    for (let i = 0; i < want.length; i++) if (t.startsWith(want[i])) return want.length - i;
    return 0;
  };

  const score = (v) => {
    let s = 0;
    if (NOVELTY_VOICE.test(v.name)) return -1000;   // macOS joke voices
    if (FEMALE_VOICE.test(v.name)) s += 1000;
    else if (MALE_VOICE.test(v.name)) s -= 1000;
    s += localeRank(v) * 10;
    if (RICH_VOICE.test(v.name)) s += 5;
    if (v.localService === false) s += 2;
    return s;
  };

  const pool = all.filter((v) => localeRank(v) > 0);
  const ranked = (pool.length ? pool : all).slice().sort((a, b) => score(b) - score(a));
  return ranked[0] || null;
}

function useNarrator({ section, active, speed, lang, muted, delay, onDone, onMode }) {
  const doneRef = useRef(onDone); doneRef.current = onDone;
  const modeRef = useRef(onMode); modeRef.current = onMode;
  const audioRef = useRef(null);
  const src = section ? section.audio[lang.code] : null;
  const ttsMuted = muted && !src;

  useEffect(() => {
    if (!section || !active) return;
    let dead = false;
    let timer = null;
    let startTimer = null;

    const finish = () => { if (dead) return; dead = true; doneRef.current(); };

    // a highlight-only unit: nothing to say, just hold the highlight a beat
    if (section.silent) {
      modeRef.current("silent", null);
      timer = setTimeout(finish, 400);
      return () => { dead = true; clearTimeout(timer); };
    }

    const text = (section.narrate || {})[lang.code] || "";
    const dur = estimate(text, speed);

    const speakOrWait = () => {
      if (dead) return;
      if ("speechSynthesis" in window && !muted && window.speechSynthesis.getVoices().length) {
        const synth = window.speechSynthesis;
        synth.cancel();
        const u = new SpeechSynthesisUtterance(text);
        // the recorded clips are an unhurried female read — nudge towards that
        u.rate = Math.min(2, Math.max(0.5, speed * 0.95));
        u.pitch = 1.05;
        const v = pickVoice(lang.bcp);
        if (v) u.voice = v;
        u.lang = lang.bcp;
        u.onend = finish;
        u.onerror = finish;
        modeRef.current("tts", v ? `${v.name} · ${v.lang}` : null);
        synth.speak(u);
        timer = setTimeout(finish, dur + 6000); // onend misfires on some engines
      } else {
        modeRef.current("silent", null);
        timer = setTimeout(finish, dur);
      }
    };

    const begin = () => {
      if (dead) return;
      if (src) {
        // no crossOrigin: the S3 bucket sends no CORS headers, and plain
        // <audio> playback doesn't need them
        const el = new Audio();
        el.preload = "auto";
        el.src = src;
        audioRef.current = el;
        el.playbackRate = speed;
        el.muted = muted;
        el.onended = finish;
        const bail = () => {
          if (dead || audioRef.current !== el) return;
          audioRef.current = null;
          speakOrWait(); // S3 unreachable / codec refused — degrade, don't stall
        };
        el.onerror = bail;
        modeRef.current("audio", null);
        const p = el.play();
        if (p && p.catch) p.catch(bail);
      } else {
        speakOrWait();
      }
    };

    startTimer = setTimeout(begin, delay || 0);

    return () => {
      dead = true;
      if (timer) clearTimeout(timer);
      if (startTimer) clearTimeout(startTimer);
      const el = audioRef.current;
      if (el) { el.pause(); el.onended = null; el.onerror = null; audioRef.current = null; }
      if ("speechSynthesis" in window) window.speechSynthesis.cancel();
    };
    // `speed` is deliberately NOT a dependency: a speed change alone must not
    // restart this section's narration. A fresh section start still reads
    // the current speed (closures re-close over it whenever section/active
    // change), and the live-rate effect below re-paces whatever's already
    // playing without touching its position.
  }, [section && section.id, active, lang.code, ttsMuted, delay]);

  // audio-backed narration mutes and re-rates live, in place, no restart.
  // (Web Speech has no live rate API — an in-flight TTS utterance keeps its
  // original rate until the next section starts; only the audio-clip path,
  // which is what real narration uses, can re-pace mid-sentence.)
  useEffect(() => { if (audioRef.current) audioRef.current.muted = muted; }, [muted]);
  useEffect(() => { if (audioRef.current) audioRef.current.playbackRate = speed; }, [speed]);
}

/* ============================================================
   entry screen — Allie chat
   ============================================================ */
function ChatScreen({ doubt, onOpen, onBack }) {
  return (
    <div style={{ height: "100%", display: "flex", flexDirection: "column", background: "var(--bg)" }}>
      <StatusBar />
      <div style={{ height: 56, display: "flex", alignItems: "center", gap: 14, padding: "0 16px", flex: "none" }}>
        <button onClick={onBack} aria-label="Back to doubts" style={{ color: "var(--icon)", display: "flex" }}><IcBack /></button>
        <span className="t-lg" style={{ flex: 1 }}>Allie</span>
        <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" style={{ color: "var(--icon)" }}>
          {[4.4, 10, 15.6].map((cy) => <circle key={cy} cx="10" cy={cy} r="1.5" />)}
        </svg>
      </div>

      <div className="scroll" style={{ flex: 1, padding: "4px 16px 0" }}>
        <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 22 }}>
          <div style={{ background: "var(--chat-bubble)", borderRadius: "16px 16px 4px 16px", padding: "9px 14px", fontSize: 14, color: "var(--title)", maxWidth: 260 }}>
            {doubt.doubt}
          </div>
        </div>

        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
          <IcAllie />
          <span style={{ color: "var(--icon)" }}><IcSpeaker /></span>
        </div>

        <Md text={doubt.chat} style={{ marginBottom: 20 }} />

        <motion.button
          onClick={onOpen}
          whileTap={{ scale: 0.985 }}
          transition={SNAP}
          style={{
            width: "100%", display: "block", textAlign: "left", background: "var(--bg-alt)",
            border: "1px solid var(--border-soft)", borderRadius: 16, padding: 12, marginBottom: 16,
          }}
        >
          <div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
            {["Problem", "Approach", "Final Solution"].map((t) => (
              <div key={t} style={{ flex: 1, background: "var(--surface)", borderRadius: 8, padding: "8px 9px", minWidth: 0 }}>
                <div style={{ fontSize: 8, fontWeight: 700, color: "var(--title)", marginBottom: 6, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{t}</div>
                {[1, 0.78, 0.55].map((w, j) => (
                  <div key={j} style={{ height: 3, borderRadius: 2, background: "var(--inactive)", width: `${w * 100}%`, marginBottom: 4 }} />
                ))}
              </div>
            ))}
          </div>
          <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 15, fontWeight: 700, color: "var(--title)", lineHeight: "20px" }}>Slides Solution</div>
              <div style={{ fontSize: 12, color: "var(--muted)", lineHeight: "18px" }}>Explained with narration</div>
            </div>
            <div style={{ width: 34, height: 34, borderRadius: "50%", background: "var(--primary)", color: "#fff", display: "grid", placeItems: "center", paddingLeft: 2 }}>
              <IcPlay size={14} />
            </div>
          </div>
        </motion.button>

        <div style={{ display: "flex", gap: 18, color: "var(--icon)", marginBottom: 18 }}>
          <IcThumb /><IcThumb down />
        </div>
      </div>

      <div style={{ flex: "none", padding: "0 16px 24px" }}>
        <div style={{ border: "1px solid var(--border-soft)", borderRadius: 16, background: "var(--surface)", padding: "12px 12px 10px", boxShadow: "var(--opt-shadow)" }}>
          <div style={{ fontSize: 14, color: "var(--muted)", marginBottom: 12 }}>Ask followups here</div>
          <div style={{ display: "flex", alignItems: "center" }}>
            <div style={{ width: 30, height: 30, borderRadius: "50%", background: "var(--play-fill)", color: "var(--primary)", display: "grid", placeItems: "center" }}>
              <IcCamera />
            </div>
            <div style={{ flex: 1 }} />
            <span style={{ color: "var(--icon)", marginRight: 14 }}><IcMic /></span>
            <div style={{ width: 30, height: 30, borderRadius: "50%", background: "var(--inactive)", color: "var(--muted)", display: "grid", placeItems: "center", paddingLeft: 1 }}>
              <IcSend />
            </div>
          </div>
        </div>
      </div>
      <div className="home-ind" />
    </div>
  );
}

/* ============================================================
   player
   ============================================================ */
function Player({ doubt, onExit, onVoiceMode }) {
  const reduce = useReducedMotion();
  const SOLUTION = doubt.solution;
  /* Progressive disclosure: sections appear one at a time as the voice
     reaches them. No tint (nothing to distinguish — only the newest card is
     new), no first-run coachmark, and no tap-to-jump, since there is nothing
     ahead to tap. */
  const progressive = doubt.mode === "progressive";

  /* The narration timeline. A practice question is its own unit that follows
     its owning section, so the final answer is spoken and highlighted first,
     and only when that finishes does the highlight move to the practice card.
     It is silent — see the `silent` flag below. */
  const seq = useMemo(() => {
    const out = [];
    SOLUTION.steps.forEach((st, si) => st.sections.forEach((sec) => {
      // a practice-only section has no narration of its own — skip the shell
      if (!sec.practiceOnly) out.push({ ...sec, stepIndex: si });
      if (sec.practice) {
        out.push({
          id: sec.practice.id,
          kind: "practice",
          stepIndex: si,
          /* Silent by design. The payload ships no clip for the practice
             question, and device speech synthesis can't match the recorded
             narrator, so this unit only moves the highlight — it says
             nothing. Give it a clip in the payload and it will speak. */
          silent: true,
          audio: {},
          narrate: {},
          blocks: [],
        });
      }
    }));
    return out;
  }, [SOLUTION]);
  const idxOf = useCallback((id) => seq.findIndex((s) => s.id === id), [seq]);

  const [started, setStarted] = useState(false);
  const [activeId, setActiveId] = useState(null);
  const [playing, setPlaying] = useState(false);
  const [ended, setEnded] = useState(false);
  const [speed, setSpeed] = useState(1);
  const [lang, setLang] = useState(LANGS[0]);
  const [muted, setMuted] = useState(false);
  const [coach, setCoach] = useState(0);
  const [sheet, setSheet] = useState(false);
  const [pop, setPop] = useState(null); // 'speed' | 'lang'
  const [answers, setAnswers] = useState({}); // quizId -> option id
  const [hints, setHints] = useState({});
  const [collapsed, setCollapsed] = useState(false);

  const scrollRef = useRef(null);
  const phoneRef = useRef(null);
  const secRefs = useRef({});
  const autoRef = useRef(false); // was the last move an auto-advance?
  // gap the pending auto-advance earned — set in handleDone, where both the
  // outgoing and incoming units are known, and read on the next render
  const gapRef = useRef(SECTION_GAP);

  const active = activeId ? seq.find((s) => s.id === activeId) : null;
  const idx = activeId ? idxOf(activeId) : -1;
  const stepIndex = active ? active.stepIndex : ended ? SOLUTION.steps.length - 1 : 0;
  const step = SOLUTION.steps[stepIndex];

  const prevStep = useRef(stepIndex);
  const dir = stepIndex >= prevStep.current ? 1 : -1;
  useEffect(() => { prevStep.current = stepIndex; }, [stepIndex]);

  /* Whether this page can afford to collapse its header, decided ONCE per
     page from the header-expanded layout. Collapsing grows the scroller by
     the header's height, shrinking the maximum scrollTop — so testing live
     overflow is unstable (collapse → less overflow → un-collapse → repeat)
     and on a barely-overflowing page it clamps the scroll back, dragging the
     view up right after it arrived. Requiring real slack means the collapse
     only fires where the clamp can't bite. */
  const [canCollapse, setCanCollapse] = useState(false);
  useEffect(() => {
    setCollapsed(false);
    setCanCollapse(false);
    let r2;
    const r1 = requestAnimationFrame(() => {
      r2 = requestAnimationFrame(() => {
        const sc = scrollRef.current;
        if (sc) setCanCollapse(sc.scrollHeight - sc.clientHeight > 240);
      });
    });
    return () => { cancelAnimationFrame(r1); cancelAnimationFrame(r2); };
  }, [stepIndex]);

  const handleDone = useCallback(() => {
    if (!active) return;
    const n = idxOf(active.id) + 1;
    if (n >= seq.length) {
      setPlaying(false);
      setEnded(true);
      // a practice question stays highlighted — it's the thing left to act on
      if (active.kind !== "practice") setActiveId(null);
      return;
    }
    const next = seq[n];
    autoRef.current = true;
    gapRef.current = next.stepIndex !== active.stepIndex ? STEP_GAP : SECTION_GAP;
    setActiveId(next.id);
  }, [active, idxOf, seq]);

  useNarrator({
    section: active,
    active: playing,
    speed,
    lang,
    muted,
    delay: autoRef.current ? Math.max(gapRef.current, (active && active.delay) || 0) : 0,
    onDone: handleDone,
    onMode: onVoiceMode,
  });

  const speaking = playing && !!active;

  /* warm the next clip so page turns don't stall on the network */
  useEffect(() => {
    if (idx < 0 || idx + 1 >= seq.length) return;
    const next = seq[idx + 1].audio[lang.code];
    if (!next) return;
    const el = new Audio();
    el.preload = "auto";
    el.src = next;
    return () => { el.src = ""; };
  }, [idx, lang.code, seq]);

  /* keep the spoken section on screen — two frames, the page may be mounting */
  useEffect(() => {
    if (!activeId) return;
    let stop = () => {};
    let r2;
    const r1 = requestAnimationFrame(() => {
      r2 = requestAnimationFrame(() => {
        const el = secRefs.current[activeId];
        const sc = scrollRef.current;
        if (!el || !sc) return;
        // getter, not a value — the header collapse resizes `sc` mid-scroll
        stop = animateScrollTo(sc, () => offsetWithin(el, sc) - sc.clientHeight / 2 + el.offsetHeight / 2, { instant: reduce });
      });
    });
    // a newer highlight supersedes this scroll — kill it rather than race it
    return () => { cancelAnimationFrame(r1); cancelAnimationFrame(r2); stop(); };
  }, [activeId, reduce]);

  /* coachmark spotlight geometry */
  const [spot, setSpot] = useState(null);
  useLayoutEffect(() => {
    if (!coach) { setSpot(null); return; }
    let raf;
    let last = "";
    const measure = () => {
      const el = secRefs.current[activeId];
      const ph = phoneRef.current;
      if (el && ph) {
        const a = el.getBoundingClientRect();
        const b = ph.getBoundingClientRect();
        const k = b.width / 360 || 1; // the stage may scale the phone down
        const next = { x: (a.left - b.left) / k, y: (a.top - b.top) / k, w: a.width / k, h: a.height / k };
        // only commit on change — this loop runs every frame while the coach is up
        const sig = `${next.x | 0}:${next.y | 0}:${next.w | 0}:${next.h | 0}`;
        if (sig !== last) { last = sig; setSpot(next); }
      }
      raf = requestAnimationFrame(measure);
    };
    measure();
    return () => cancelAnimationFrame(raf);
  }, [coach, activeId]);

  const jumpTo = (id) => { autoRef.current = false; setEnded(false); setActiveId(id); setPlaying(true); };

  /* The arrows are STEP controls — they sit either side of the "n/5" step
     indicator, so they move a whole page at a time and land on its first
     section. Moving within a step is what tapping a section is for. */
  const lastStep = SOLUTION.steps.length - 1;
  // a step can open on a practice-only section, whose timeline id is the
  // practice's, not the section's
  const stepFirst = (i) => {
    const st = SOLUTION.steps[i];
    const s = st.sections.find((x) => !x.practiceOnly) || st.sections[0];
    return s.practiceOnly ? s.practice.id : s.id;
  };
  const goPrev = () => {
    if (ended) { jumpTo(stepFirst(lastStep)); return; }
    if (stepIndex > 0) jumpTo(stepFirst(stepIndex - 1));
  };
  const goNext = () => {
    if (ended) return;
    if (stepIndex < lastStep) jumpTo(stepFirst(stepIndex + 1));
  };
  const toggle = () => { if (ended) { jumpTo(seq[0].id); return; } setPlaying((p) => !p); };
  // the first-run walkthrough runs before any narration — nothing plays until
  // the student finishes it, then the solution starts from the top
  const begin = () => {
    setStarted(true);
    autoRef.current = false;
    setActiveId(seq[0].id);
    // progressive has no coachmark, so there's nothing to wait for — play at once
    setPlaying(progressive);
    setCoach(progressive ? 0 : 1);
  };

  // free re-selection: picking a different option just re-answers the
  // practice question, it's never locked in
  const answer = (quizId, optId, anchorId) => {
    setAnswers((a) => (a[quizId] === optId ? a : { ...a, [quizId]: optId }));
    requestAnimationFrame(() => {
      const el = secRefs.current[anchorId];
      const sc = scrollRef.current;
      if (!el || !sc) return;
      setTimeout(() => {
        animateScrollTo(sc, () => offsetWithin(el, sc) + el.offsetHeight - sc.clientHeight + 130, { instant: reduce });
      }, 280);
    });
  };
  const closeAll = () => { setPop(null); setSheet(false); };
  const prevOff = ended ? false : stepIndex <= 0;
  const nextOff = ended || stepIndex >= lastStep;

  const coachCopy = coach === 1
    ? "The section being spoken about gets highlighted"
    : "Tap on any section to skip to that section";

  /* How far this step has been disclosed. -1 when the active unit isn't on
     this page (mid page-turn, or ended) — then everything shows. */
  const revealAt = progressive
    ? step.sections.findIndex((s) => s.id === activeId || (s.practice && s.practice.id === activeId))
    : -1;
  const lastVisible = progressive && revealAt >= 0 ? revealAt : step.sections.length - 1;

  return (
    <div ref={phoneRef} style={{ height: "100%", display: "flex", flexDirection: "column", background: "var(--bg)", position: "relative" }}>
      <VoicePulse speaking={speaking && !muted} reduce={reduce} />

      <div style={{ position: "relative", zIndex: 5, flex: "none" }}>
        <StatusBar />
        <div style={{ height: 56, display: "flex", alignItems: "center", padding: "0 16px" }}>
          <button onClick={onExit} aria-label="Back" style={{ color: "var(--icon)" }}><IcBack /></button>
          <div style={{ flex: 1 }} />
          <AnimatePresence>
            {started && (
              <motion.div
                initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }}
                transition={SNAP}
                style={{ display: "flex", alignItems: "center", gap: 12 }}
              >
                <motion.button
                  whileTap={{ scale: 0.94 }} transition={SNAP}
                  className="chip" style={{ border: "1px solid var(--border-soft)" }}
                  onClick={() => setPop((p) => (p === "lang" ? null : "lang"))}
                >
                  {lang.chip}<IcChevron dir={pop === "lang" ? "up" : "down"} />
                </motion.button>
                <motion.button
                  whileTap={{ scale: 0.9 }} transition={SNAP}
                  aria-label={muted ? "Unmute" : "Mute"}
                  style={{
                    width: 32, height: 32, borderRadius: 999, display: "grid", placeItems: "center",
                    background: muted ? "var(--play-fill)" : "var(--surface)",
                    border: "1px solid var(--border-soft)",
                    color: muted ? "var(--primary)" : "var(--icon)",
                  }}
                  onClick={() => setMuted((m) => !m)}
                >
                  <IcSpeaker off={muted} />
                </motion.button>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>

      {/* hugs its content — a fixed height left 40px of dead air under a
          one-line title and 16px under a two-line one */}
      <motion.div
        animate={{ height: collapsed ? 0 : "auto", opacity: collapsed ? 0 : started ? 1 : 0.22 }}
        initial={false}
        transition={SMOOTH}
        style={{ position: "relative", zIndex: 4, flex: "none", overflow: "hidden" }}
      >
        <div className="t-lg" style={{ padding: "16px 16px 12px" }}>{step.title}</div>
      </motion.div>

      {/* content — one page per step */}
      <div style={{
        position: "relative", zIndex: 2, flex: 1, overflow: "hidden",
        opacity: started ? 1 : 0.22,
        filter: started ? "none" : "saturate(0.6)",
        transition: "opacity 260ms ease",
      }}>
        <AnimatePresence initial={false} custom={dir} mode="wait">
          <motion.div
            key={step.id}
            className="scroll page"
            custom={dir}
            variants={PAGE}
            initial="enter" animate="center" exit="exit"
            transition={reduce ? { duration: 0 } : PAGE_T}
            ref={(el) => { if (el) scrollRef.current = el; }}
            onScroll={(e) => setCollapsed(canCollapse && e.currentTarget.scrollTop > 20)}
          >
            {step.sections.map((sec, i) => {
              const prev = step.sections[i - 1];
              const isActive = !progressive && sec.id === activeId;

              /* Progressive: show this step's sections only up to the one the
                 voice has reached. Derived from the active unit rather than a
                 counter, so arriving at a step — first time or on a jump back
                 — always opens on its first section and fills in from there. */
              if (progressive && revealAt >= 0 && i > revealAt) return null;

              const showRule = i > 0 && !isActive && prev && prev.id !== activeId;
              /* Closing rule so the page doesn't trail off into blank space.
                 "Last" means last VISIBLE — while sections are still being
                 disclosed the newest one is the bottom of the page, so it
                 needs the rule immediately, not once the step is complete. */
              const isLast = i === lastVisible;
              const showTrailRule = isLast && !isActive && !sec.practice;
              // the practice card trails its section, so it waits its turn too
              const showPractice = sec.practice && (!progressive || activeId === sec.practice.id);

              return (
                <motion.div
                  key={sec.id}
                  initial={progressive ? { opacity: 0, y: 12 } : false}
                  animate={{ opacity: 1, y: 0 }}
                  transition={reduce ? { duration: 0 } : REVEAL_T}
                >
                  {showRule && <div className="divider" />}
                  {!sec.practiceOnly && (
                    <Section
                      sec={sec}
                      lang={lang}
                      pageTitle={step.title}
                      active={isActive}
                      // nothing ahead to tap when sections arrive one at a time
                      onTap={progressive ? undefined : () => jumpTo(sec.id)}
                      setRef={(el) => { secRefs.current[sec.id] = el; }}
                    />
                  )}
                  {/* its own card AND its own slot on the narration timeline —
                      it highlights only after the section above has finished
                      being spoken, never at the same time */}
                  {showPractice && (
                    <motion.div
                      initial={progressive ? { opacity: 0, y: 12 } : false}
                      animate={{ opacity: 1, y: 0 }}
                      transition={reduce ? { duration: 0 } : REVEAL_T}
                    >
                      <PracticeCard
                        p={sec.practice}
                        lang={lang.code}
                        picked={answers[sec.practice.id]}
                        hintOpen={!!hints[sec.practice.id]}
                        onHint={() => setHints((h) => ({ ...h, [sec.practice.id]: !h[sec.practice.id] }))}
                        onPick={(o) => answer(sec.practice.id, o, sec.practice.id)}
                        active={!progressive && activeId === sec.practice.id}
                        progressive={progressive}
                        setRef={(el) => { secRefs.current[sec.practice.id] = el; }}
                      />
                    </motion.div>
                  )}
                  {showTrailRule && <div className="divider" />}
                </motion.div>
              );
            })}
          </motion.div>
        </AnimatePresence>
      </div>

      <div style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 120, zIndex: 8, pointerEvents: "none", background: "linear-gradient(180deg, var(--fade-0) 0%, var(--fade-60) 38%, var(--fade-90) 62%, var(--fade-100) 100%)" }} />

      <AnimatePresence>
        {started && !sheet && (
          <motion.div
            initial={{ y: 70, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: 70, opacity: 0 }}
            transition={SHEET}
            style={{
              position: "absolute", left: 16, bottom: 28, zIndex: 36,
              // the walkthrough is modal: Next/Done is the only live control
              pointerEvents: coach > 0 ? "none" : "auto",
            }}
          >
            <div className="actionbar">
              <motion.button whileTap={{ scale: 0.94 }} transition={SNAP} className="chip"
                onClick={() => setPop((p) => (p === "speed" ? null : "speed"))}>
                {speed}×<IcChevron dir={pop === "speed" ? "up" : "down"} />
              </motion.button>

              <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
                <motion.button whileTap={prevOff ? {} : { scale: 0.88 }} transition={SNAP}
                  className="icon-btn" onClick={goPrev} disabled={prevOff}
                  aria-label="Previous step" style={{ opacity: prevOff ? 0.3 : 1 }}><IcPrev /></motion.button>

                <motion.button whileTap={{ scale: 0.9 }} transition={SNAP}
                  className="icon-btn" onClick={toggle}
                  aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
                  style={{ background: "var(--play-fill)", color: "var(--icon)" }}>
                  {ended ? <IcReplay /> : playing ? <IcPause /> : <IcPlay />}
                </motion.button>

                <motion.button whileTap={nextOff ? {} : { scale: 0.88 }} transition={SNAP}
                  className="icon-btn" onClick={goNext} disabled={nextOff}
                  aria-label="Next step" style={{ opacity: nextOff ? 0.3 : 1 }}><IcNext /></motion.button>
              </div>

              <motion.button whileTap={{ scale: 0.94 }} transition={SNAP} className="chip"
                onClick={() => setSheet(true)}
                aria-label={`Steps — ${stepIndex + 1} of ${SOLUTION.steps.length}`}>
                <IcSteps />{stepIndex + 1}/{SOLUTION.steps.length}
              </motion.button>
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      <AnimatePresence>
        {pop && <motion.div key="popscrim" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
          onClick={closeAll} style={{ position: "absolute", inset: 0, zIndex: 33 }} />}

        {pop === "speed" && (
          <motion.div key="speed" className="pop"
            initial={{ opacity: 0, scale: 0.94, y: 8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.94, y: 8 }}
            transition={SNAP} style={{ left: 24, bottom: 92, width: 152, "--pop-origin": "bottom left" }}>
            {SPEEDS.map((s) => (
              <button key={s} className="pop-row" onClick={() => { setSpeed(s); setPop(null); }}>
                <span>{s}×{s === 1 ? " (Normal)" : ""}</span>
                <span className="radio" data-on={s === speed} />
              </button>
            ))}
          </motion.div>
        )}

        {pop === "lang" && (
          <motion.div key="lang" className="pop"
            initial={{ opacity: 0, scale: 0.94, y: -8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.94, y: -8 }}
            transition={SNAP} style={{ right: 57, top: 96, width: 150, "--pop-origin": "top right" }}>
            {LANGS.map((l) => (
              <button key={l.code} className="pop-row" onClick={() => { setLang(l); setPop(null); }}>
                <span>{l.label}</span>
                <span className="radio" data-on={l.code === lang.code} />
              </button>
            ))}
          </motion.div>
        )}
      </AnimatePresence>

      <AnimatePresence>
        {sheet && (
          <>
            <motion.div key="ss" className="scrim" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
              transition={{ duration: 0.18 }} onClick={() => setSheet(false)} />
            <motion.div key="sheet" className="sheet"
              initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "100%" }} transition={SHEET}
              drag="y" dragConstraints={{ top: 0, bottom: 0 }} dragElastic={{ top: 0, bottom: 0.5 }}
              onDragEnd={(e, info) => { if (info.offset.y > 90 || info.velocity.y > 550) setSheet(false); }}>
              <div className="grabber" />
              <div style={{ padding: "0 8px" }}>
                {SOLUTION.steps.map((st, i) => {
                  const done = i < stepIndex || (ended && i <= stepIndex);
                  const cur = !ended && i === stepIndex;
                  return (
                    <motion.button key={st.id} className="step-row" data-cur={cur}
                      whileTap={{ scale: 0.985 }} transition={SNAP}
                      onClick={() => { jumpTo(stepFirst(i)); setSheet(false); }}>
                      <span className="n">{i + 1}.</span>
                      <span style={{ flex: 1 }}>{st.title}</span>
                      {done ? (
                        <span style={{ width: 20, height: 20, borderRadius: "50%", background: "var(--ok)", color: "#fff", display: "grid", placeItems: "center" }}><IcCheck /></span>
                      ) : cur ? <Wave /> : (
                        <span style={{ width: 18, height: 18, borderRadius: "50%", border: "1.5px solid var(--border)" }} />
                      )}
                    </motion.button>
                  );
                })}
              </div>
            </motion.div>
          </>
        )}
      </AnimatePresence>

      <AnimatePresence>
        {coach > 0 && spot && (
          <>
            <motion.svg key="spot" width="360" height="800"
              initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}
              style={{ position: "absolute", inset: 0, zIndex: 35 }}>
              <defs>
                <mask id="cutout">
                  <rect width="360" height="800" fill="#fff" />
                  <rect x={spot.x - 2} y={spot.y - 2} width={spot.w + 4} height={spot.h + 4} rx="18" fill="#000" />
                </mask>
              </defs>
              <rect width="360" height="800" fill="rgba(12,17,24,0.55)" mask="url(#cutout)" />
            </motion.svg>

            {coach === 2 && (
              <motion.div key="hand" initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }}
                transition={SMOOTH}
                style={{ position: "absolute", left: spot.x + 6, top: spot.y + spot.h - 22, zIndex: 39, pointerEvents: "none" }}>
                <TapHint reduce={reduce} />
              </motion.div>
            )}

            <motion.div key="coach" className="coach"
              initial={{ opacity: 0, y: 10, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 10, scale: 0.97 }}
              transition={SNAP}
              style={{ left: 16, top: Math.min(640, spot.y + spot.h + 22) }}>
              <div className="beak" style={{ left: 24, top: -6 }} />
              <div className="txt">{coachCopy}</div>
              <div className="foot">
                <span className="pos">{coach}/2</span>
                <motion.button className="next" whileTap={{ scale: 0.94 }} transition={SNAP}
                  onClick={() => {
                    if (coach === 1) {
                      // beat 2 demonstrates tapping a section — move the highlight,
                      // still without speaking anything
                      setActiveId(seq[1].id);
                      setCoach(2);
                    } else {
                      // walkthrough over: rewind to the first section and begin,
                      // so the demo tap doesn't skip past unheard content
                      setCoach(0);
                      jumpTo(seq[0].id);
                    }
                  }}>{coach === 1 ? "Next" : "Done"}</motion.button>
              </div>
            </motion.div>
          </>
        )}
      </AnimatePresence>

      <AnimatePresence>
        {!started && (
          <motion.div key="ready"
            initial={{ opacity: 0, y: 14, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -8, scale: 0.98 }}
            transition={SMOOTH}
            style={{
              position: "absolute", left: 16, right: 16, top: 300, zIndex: 39,
              // opaque: this card sits over the solution, which must not read through it
              background: "var(--active-fill-solid)", borderRadius: 16,
              padding: "22px 20px 24px", textAlign: "center",
            }}>
            <div className="t-lg" style={{ marginBottom: 6 }}>Ready when you are</div>
            <div className="b-base" style={{ marginBottom: 18 }}>Best with sound on. A voice walks you through it.</div>
            <motion.button whileTap={{ scale: 0.96 }} transition={SNAP} onClick={begin}
              style={{ display: "inline-flex", alignItems: "center", gap: 10, height: 44, padding: "0 24px", borderRadius: 999, background: "var(--primary)", color: "#fff", fontSize: 15, fontWeight: 700 }}>
              <IcPlay size={15} />Play Slides Solution
            </motion.button>
          </motion.div>
        )}
      </AnimatePresence>

      <div className="home-ind" />
    </div>
  );
}

/* ============================================================
   one section — the narrated + highlighted unit
   ============================================================ */
const OFF = { background: "rgba(15,153,250,0)", borderColor: "rgba(15,153,250,0)" };
const ON = { background: "var(--active-fill)", borderColor: "var(--active-border)" };
/* An MCQ always reads as a card, even off the narration timeline — it's an
   interactive surface, not just prose. Its resting look differs by mode:
   in HIGHLIGHT the fill is reserved for "this is being spoken about", so the
   card rests as an outline only and filling it means something; in
   PROGRESSIVE nothing is ever tinted, so the fill is free to mark the card
   as interactive. */
const CARD_OFF = { background: "var(--bg-alt)", borderColor: "var(--border-soft)" };
const CARD_OFF_BARE = { background: "rgba(15,153,250,0)", borderColor: "var(--border-soft)" };
/* Highlight crossfade. Symmetric ease-in-out (cubic) rather than the
   decelerate-only curve this used to have — that snapped in hard and only
   softened on the way out, which read as a flick. Easing both ends means the
   outgoing and incoming tints hand over to each other in the middle, so the
   total colour on screen stays roughly constant through the swap. Kept as a
   tween, not a spring: springs overshoot, and an overshooting colour would
   push the tint past its target and back. */
const TINT_T = { duration: 0.4, ease: [0.65, 0, 0.35, 1] };
/* a newly disclosed section rises into place — decelerate-only, because it
   has no outgoing counterpart to hand over to */
const REVEAL_T = { duration: 0.42, ease: [0.2, 0, 0, 1] };

const plain = (s) => String(s || "").replace(/[$\\{}]|\s+/g, "").toLowerCase();

function Section({ sec, lang, pageTitle, active, onTap, setRef }) {
  const L = lang.code;
  let title = sec.title ? sec.title[L] : null;
  // the page header already says it — don't print it twice
  if (title && plain(title) === plain(pageTitle)) title = null;

  return (
    <motion.div
      ref={setRef}
      onClick={onTap}
      animate={active ? ON : OFF}
      transition={TINT_T}
      // no pointer affordance when there's nothing to jump to (progressive)
      style={{ padding: "24px 16px", borderRadius: 16, border: "1px solid transparent", cursor: onTap ? "pointer" : "default" }}
    >
      {title && <div className="sec-title" dangerouslySetInnerHTML={{ __html: md(title).replace(/^<p[^>]*>|<\/p>$/g, "") }} />}

      {sec.blocks.map((b, i) => {
        if (b.type === "md") return <Md key={i} text={b.display[L]} />;

        if (b.type === "boxed") {
          return (
            <div key={i} className="boxed" data-variant={b.variant}>
              <Md text={b.display[L]} />
            </div>
          );
        }

        return null;
      })}
    </motion.div>
  );
}

/* shared option list — used by the inline MCQ and the practice card.
   Selection is never locked: tapping any option re-answers, including
   switching away from a previous pick. */
function Options({ options, picked, onPick }) {
  const answered = picked !== undefined;
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
      {options.map((o) => {
        const isPick = o.id === picked;
        // the Figma colours only the option that was picked — nothing else changes
        const state = !answered ? "idle" : isPick ? (o.correct ? "correct" : "wrong") : "idle";
        return (
          <motion.button key={o.id} className="opt" data-state={state}
            whileTap={{ scale: 0.98, y: 1 }} transition={SNAP}
            onClick={(e) => { e.stopPropagation(); if (o.id !== picked) onPick(o.id); }}>
            {isPick && (
              <motion.span className="opt-badge"
                initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 500, damping: 16 }}
                style={{ background: o.correct ? "var(--ok)" : "var(--warn)" }}>
                {o.correct ? <IcCheck /> : <IcX />}
              </motion.span>
            )}
            <span dangerouslySetInnerHTML={{ __html: md(o.label).replace(/^<p[^>]*>|<\/p>$/g, "") }} />
          </motion.button>
        );
      })}
    </div>
  );
}

/* verdict block, identical for the inline MCQ and the practice card */
function Verdict({ chosen, okLabel, body }) {
  return (
    <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }}
      transition={SMOOTH} style={{ overflow: "hidden" }}>
      <div style={{ paddingTop: 18 }}>
        <div className="b-sm" style={{ color: chosen.correct ? "var(--ok)" : "var(--warn)", fontSize: 13, marginBottom: 4 }}>
          {chosen.correct ? okLabel : "Incorrect:"}
        </div>
        <Md text={body} className="tight" />
      </div>
    </motion.div>
  );
}

/**
 * The one MCQ layout — heading, stem, option cards, verdict. The inline
 * checkpoint and the practice question both render through this so they
 * can't drift apart. `aside` is the only extra slot (the practice
 * question's hint, which the checkpoint doesn't have).
 *
 * `card` controls whether this supplies its own surface. The inline
 * checkpoint passes false: its owning Section is already a card, and
 * nesting a second one inside it reads as two stacked boxes. The practice
 * question passes true because it stands alone on the page.
 */
function Quiz({ heading, question, options, picked, onPick, okLabel = "Correct:", explain, aside, hint, active, progressive, setRef, card = true, children }) {
  const answered = picked !== undefined;
  const chosen = answered ? options.find((o) => o.id === picked) : null;
  const body = (
    <>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
        <div className="t-base">{heading}</div>
        {aside}
      </div>
      <Md text={question} style={{ margin: "10px 0 16px" }} />
      {hint}
      <Options options={options} picked={picked} onPick={onPick} />
      <AnimatePresence>
        {answered && (
          <Verdict chosen={chosen} okLabel={okLabel}
            body={chosen.correct && explain ? explain : chosen.reason} />
        )}
      </AnimatePresence>
      {children}
    </>
  );

  if (!card) return <div ref={setRef} style={{ marginTop: 4 }}>{body}</div>;

  return (
    <motion.div ref={setRef} className="mcq-card"
      animate={active ? ON : progressive ? CARD_OFF : CARD_OFF_BARE} transition={TINT_T}>
      {body}
    </motion.div>
  );
}

function PracticeCard({ p, lang, picked, hintOpen, onHint, onPick, active, progressive, setRef }) {
  const fb = p.feedback[lang] || {};
  return (
    <Quiz
      setRef={setRef}
      active={active}
      progressive={progressive}
      heading={p.title}
      question={p.question}
      options={p.options}
      picked={picked}
      onPick={onPick}
      okLabel={fb.correct || "Correct:"}
      explain={fb.explanation}
      aside={p.hint ? (
        <motion.button className="hint-btn" whileTap={{ scale: 0.94 }} transition={SNAP} onClick={onHint}>
          <IcBulb />{hintOpen ? "Hide hint" : "Hint"}
        </motion.button>
      ) : null}
      hint={
        <AnimatePresence>
          {hintOpen && (
            <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }}
              transition={SMOOTH} style={{ overflow: "hidden" }}>
              <div className="hint"><Md text={p.hint} className="tight" /></div>
            </motion.div>
          )}
        </AnimatePresence>
      }
    />
  );
}

/* ============================================================
   L0 — the doubts list the two flows launch from
   ============================================================ */
function L0Screen({ onPick }) {
  return (
    <div style={{ height: "100%", display: "flex", flexDirection: "column", background: "var(--bg)" }}>
      <StatusBar />
      <div style={{ padding: "8px 16px 4px", flex: "none" }}>
        <div className="t-lg" style={{ fontSize: 22, lineHeight: "28px" }}>Your doubts</div>
        <div className="b-sm" style={{ color: "var(--muted)", marginTop: 2 }}>2 solved · tap to open</div>
      </div>

      <div className="scroll" style={{ flex: 1, padding: "16px" }}>
        {DOUBTS.map((d) => (
          <motion.button
            key={d.key}
            className="doubt-card"
            whileTap={{ scale: 0.985 }}
            transition={SNAP}
            onClick={() => onPick(d.key)}
          >
            <div className="doubt-meta">
              <span className="doubt-chip">{d.subject}</span>
              <span>{d.topic}</span>
            </div>
            <div className="doubt-q">{d.doubt}</div>
            <div className="doubt-foot">
              <span className="doubt-blurb">{d.blurb}</span>
              <span className="doubt-go" aria-hidden><IcPlay size={12} /></span>
            </div>
          </motion.button>
        ))}
      </div>
      <div className="home-ind" />
    </div>
  );
}

/* ============================================================
   root
   ============================================================ */
const MODE_LABEL = { audio: "narration clip (S3)", tts: "device speech synthesis", silent: "silent timer" };

function App() {
  const [theme, setTheme] = useState("light");
  const [screen, setScreen] = useState("l0");
  const [doubtKey, setDoubtKey] = useState(DOUBTS[0].key);
  const doubt = DOUBTS.find((d) => d.key === doubtKey) || DOUBTS[0];
  const [nonce, setNonce] = useState(0);
  const [voiceMode, setVoiceMode] = useState(null);
  const noteVoice = useCallback((mode, detail) => setVoiceMode({ mode, detail }), []);

  useEffect(() => {
    if ("speechSynthesis" in window) {
      window.speechSynthesis.getVoices();
      window.speechSynthesis.onvoiceschanged = () => window.speechSynthesis.getVoices();
    }
    return () => { if ("speechSynthesis" in window) window.speechSynthesis.cancel(); };
  }, []);

  return (
    <div className="stage">
      <div className="stage-bar">
        <span style={{ fontWeight: 700, color: "#fff", marginRight: 4 }}>Slide Solution</span>
        <div className="seg">
          {["light", "dark"].map((t) => (
            <button key={t} data-on={theme === t} onClick={() => setTheme(t)}>{t === "light" ? "Light" : "Dark"}</button>
          ))}
        </div>
        <button className="ghost-btn" onClick={() => { setScreen("l0"); setVoiceMode(null); setNonce((n) => n + 1); }}>Restart</button>
      </div>

      <div className="phone" data-theme={theme}>
        <AnimatePresence mode="wait" initial={false}>
          {screen === "l0" ? (
            <motion.div key={`l0-${nonce}`} style={{ height: "100%" }}
              initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, scale: 0.98 }} transition={{ duration: 0.2 }}>
              <L0Screen onPick={(k) => { setDoubtKey(k); setVoiceMode(null); setScreen("chat"); }} />
            </motion.div>
          ) : screen === "chat" ? (
            <motion.div key={`chat-${doubtKey}-${nonce}`} style={{ height: "100%" }}
              initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, scale: 0.98 }} transition={{ duration: 0.2 }}>
              <ChatScreen doubt={doubt} onOpen={() => setScreen("player")} onBack={() => setScreen("l0")} />
            </motion.div>
          ) : (
            <motion.div key={`player-${doubtKey}-${nonce}`} style={{ height: "100%" }}
              initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 24 }} transition={SMOOTH}>
              <Player doubt={doubt} onExit={() => setScreen("chat")} onVoiceMode={noteVoice} />
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <div className="stage-bar" style={{ fontSize: 11, color: "#8a8d92", textAlign: "center" }}>
        <span>{screen === "l0" ? "Two doubts · two disclosure models" : `${doubt.solution.title} · ${doubt.mode}`}</span>
        <span style={{ color: voiceMode?.mode === "audio" ? "#4ade80" : voiceMode ? "#e8900e" : "#8a8d92" }}>
          voice: {voiceMode ? MODE_LABEL[voiceMode.mode] : "not started"}
          {voiceMode?.detail ? ` — ${voiceMode.detail}` : ""}
        </span>
      </div>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<App />);
