/* Roleplay UI — Practice with AI button + session modal + evaluation modal.
 *
 * Exposes:
 *   window.RoleplayLauncher — small button to drop into a script card
 *   window.RoleplaySession  — full modal (handles chat + evaluation)
 *
 * Both expect tokens/accent matching the Chairside design system.
 * Backend calls go through window.RoleplayAPI (see roleplay-api.js).
 */

(function () {
  const { useState, useEffect, useRef, useMemo, useCallback, useReducer } = React;
  const API = window.RoleplayAPI;

  const ARCHETYPE_LABELS = {
    newAssociate:    "New Associate",
    profitDriven:    "Profit-Driven",
    qualityObsessed: "Quality-Obsessed",
    egoInvested:     "Ego-Invested",
    anxious:         "Anxious",
    skeptic:         "Skeptic",
    costFocused:     "Cost-Focused",
    avoider:         "Avoider",
    difficult:       "Difficult",
    veteran:         "Veteran",
    newerHire:       "Newer Hire",
    underperformer:  "Underperformer",
    frictionCreator: "Friction-Creator",
  };

  // ─── "Practice with AI" launcher button ──────────────────────────────
  function RoleplayLauncher({ script, tokens, accent }) {
    const [open, setOpen] = useState(false);
    const ready = API.isRoleplayReady(script);

    return (
      <>
        <button
          className="btn"
          disabled={!ready}
          onClick={() => ready && setOpen(true)}
          title={ready ? "Practice this script against an AI patient" : "Coming soon"}
          style={{
            padding: "10px 18px",
            fontSize: 13,
            display: "inline-flex",
            alignItems: "center",
            gap: 10,
            borderColor: ready ? accent.c : tokens.line,
            color: ready ? accent.c : tokens.mute,
            background: ready ? `color-mix(in srgb, ${accent.c} 6%, transparent)` : "transparent",
            cursor: ready ? "pointer" : "not-allowed",
            opacity: ready ? 1 : 0.55,
          }}
        >
          <PracticeGlyph color={ready ? accent.c : tokens.mute} />
          Practice with AI
          {!ready && (
            <span className="mono" style={{ marginLeft: 4, fontSize: 9, color: tokens.mute, letterSpacing: "0.12em" }}>
              SOON
            </span>
          )}
        </button>
        {open && (
          <RoleplaySession
            script={script}
            tokens={tokens}
            accent={accent}
            onClose={() => setOpen(false)}
          />
        )}
      </>
    );
  }

  function PracticeGlyph({ color }) {
    return (
      <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
        <path d="M2.5 3.5h7a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H6l-3 2.5V5.5a2 2 0 0 1 2-2h-2.5z" stroke={color} strokeWidth="1.2" strokeLinejoin="round" fill="none" />
        <circle cx="13" cy="11" r="1.5" stroke={color} strokeWidth="1.2" fill="none" />
      </svg>
    );
  }

  // Rubric score — passed = 1, partial = 0.5, missed = 0. Returns null if rubric is empty.
  function computeRubricScore(rubricScores) {
    if (!Array.isArray(rubricScores) || rubricScores.length === 0) return null;
    let points = 0;
    let passed = 0;
    let partial = 0;
    let missed = 0;
    for (const row of rubricScores) {
      if (row && row.status === "passed")  { points += 1;   passed  += 1; }
      else if (row && row.status === "partial") { points += 0.5; partial += 1; }
      else { missed += 1; }
    }
    const total = rubricScores.length;
    const percent = Math.round((points / total) * 100);
    return { percent, points, total, passed, partial, missed };
  }

  // ─── VoiceDebugOverlay — opt-in on-screen log surface ───────────────
  // Captures voice-mode lifecycle events on devices where the DevTools
  // console isn't reachable (real phones without remote inspect). When
  // enabled, exposes window._voiceLog(label, data?) and renders a
  // fixed-position panel showing the last 30 events.
  //
  // Enable on phone by pasting into Safari's URL bar:
  //   javascript:localStorage.setItem("roleplay.voiceDebug","true");location.reload();
  // Disable with:
  //   javascript:localStorage.removeItem("roleplay.voiceDebug");location.reload();
  //
  // Off by default — production users never see this. When disabled,
  // window._voiceLog is undefined, and every `window._voiceLog?.(...)`
  // call site is a no-op with zero overhead.
  function VoiceDebugOverlay() {
    const [enabled, setEnabled] = useState(false);
    const [logs, setLogs] = useState([]);

    useEffect(() => {
      try {
        const flag = localStorage.getItem("roleplay.voiceDebug");
        setEnabled(flag === "true");
      } catch { /* localStorage unavailable */ }
    }, []);

    useEffect(() => {
      if (!enabled) return undefined;
      window._voiceLog = (label, data) => {
        setLogs((prev) => {
          const entry = { ts: Date.now(), label, data: data === undefined ? "" : JSON.stringify(data) };
          const next = prev.concat(entry);
          return next.length > 30 ? next.slice(-30) : next;
        });
      };
      return () => { try { delete window._voiceLog; } catch { /* noop */ } };
    }, [enabled]);

    if (!enabled) return null;

    return (
      <div style={{
        position: "fixed",
        top: 0,
        right: 0,
        width: 280,
        maxHeight: "60vh",
        overflowY: "auto",
        background: "rgba(14, 13, 11, 0.92)",
        color: "#C9A65A",
        fontSize: 10,
        fontFamily: "monospace",
        lineHeight: 1.35,
        padding: 8,
        zIndex: 9999,
        pointerEvents: "none",
        borderLeft: "1px solid #C9A65A",
        borderBottom: "1px solid #C9A65A",
      }}>
        {logs.length === 0 && <div>[voice debug — no events yet]</div>}
        {logs.map((l, i) => {
          const ms = (l.ts % 1000).toString().padStart(3, "0");
          const time = new Date(l.ts).toLocaleTimeString().slice(0, 8);
          return (
            <div key={i} style={{ marginBottom: 2, wordBreak: "break-word" }}>
              <span style={{ opacity: 0.55 }}>{time}.{ms}</span>
              <span style={{ marginLeft: 4 }}>{l.label}</span>
              {l.data && l.data !== '""' && (
                <span style={{ marginLeft: 4, opacity: 0.7 }}>{l.data}</span>
              )}
            </div>
          );
        })}
      </div>
    );
  }

  // ─── useSpeechRecognition — Web Speech API hook ─────────────────────
  // Encapsulates the supported-check, recognition lifecycle, and error state.
  // Caller wires `onFinal` (commit transcribed segment to draft) and reads
  // `interim` for the live preview. No auto-stop — caller decides when to
  // stop(). Used by ChatBody's mic button; no auto-submit per UX constraint.
  function useSpeechRecognition({ onFinal, lang = "en-US" } = {}) {
    const SpeechRecognition =
      typeof window !== "undefined" &&
      (window.SpeechRecognition || window.webkitSpeechRecognition);
    const supported = Boolean(SpeechRecognition);

    const [listening, setListening] = useState(false);
    const [interim, setInterim] = useState("");
    const [error, setError] = useState(null); // "denied" | "no-speech" | "network" | "generic" | null

    const recognitionRef = useRef(null);
    // onFinalRef lets the parent update the callback without re-creating
    // the recognition instance on every textarea keystroke.
    const onFinalRef = useRef(onFinal);
    useEffect(() => { onFinalRef.current = onFinal; }, [onFinal]);

    // Fully release the previous SpeechRecognition instance and detach its
    // event handlers before discarding. On iOS Safari, a lingering handle
    // from the previous turn can block a fresh start() from acquiring the
    // mic; abort() releases synchronously where stop() flushes asynchronously.
    const releaseRecognition = () => {
      const rec = recognitionRef.current;
      if (!rec) return;
      try {
        rec.onresult = null;
        rec.onerror = null;
        rec.onend = null;
        rec.abort();
      } catch (e) { /* ignore — instance already gone */ }
      recognitionRef.current = null;
    };

    const stop = useCallback(() => {
      const rec = recognitionRef.current;
      if (rec) {
        try { rec.stop(); } catch (e) { /* ignore — already stopped */ }
      }
      setListening(false);
      setInterim("");
    }, []);

    const start = useCallback(() => {
      if (!supported) return;
      // Always release any prior instance before constructing a new one.
      // The previous behavior was an early-return when `listening` was true,
      // which could wedge the hook if state lagged behind reality (iOS
      // sometimes leaves the listening flag stuck after a missed onend).
      releaseRecognition();
      setError(null);
      setInterim("");
      const rec = new SpeechRecognition();
      rec.continuous = true;
      rec.interimResults = true;
      rec.lang = lang;

      rec.onresult = (event) => {
        let interimText = "";
        for (let i = event.resultIndex; i < event.results.length; i++) {
          const result = event.results[i];
          const transcript = result[0] && result[0].transcript ? result[0].transcript : "";
          if (result.isFinal) {
            const cleaned = transcript.trim();
            if (cleaned && onFinalRef.current) onFinalRef.current(cleaned);
          } else {
            interimText += transcript;
          }
        }
        setInterim(interimText.trim());
      };

      rec.onerror = (event) => {
        const kind = event && event.error ? String(event.error) : "generic";
        if (kind === "not-allowed" || kind === "service-not-allowed") setError("denied");
        else if (kind === "no-speech") setError("no-speech");
        else if (kind === "network") setError("network");
        else setError("generic");
        setListening(false);
        setInterim("");
        if (typeof window !== "undefined" && window._voiceLog) window._voiceLog("speech.onerror", { kind });
      };

      rec.onend = () => {
        setListening(false);
        setInterim("");
        if (typeof window !== "undefined" && window._voiceLog) window._voiceLog("speech.onend", {});
      };

      try {
        rec.start();
        recognitionRef.current = rec;
        setListening(true);
        if (typeof window !== "undefined" && window._voiceLog) window._voiceLog("speech.start ok", {});
      } catch (e) {
        // start() can throw if already started; treat as no-op
        setError("generic");
        setListening(false);
        if (typeof window !== "undefined" && window._voiceLog) window._voiceLog("speech.start throw", { msg: String(e && e.message ? e.message : e) });
      }
    }, [supported, lang]);

    // Cleanup on unmount — modal can close mid-recording; we don't want a
    // stale SpeechRecognition holding the mic.
    useEffect(() => {
      return () => { releaseRecognition(); };
    }, []);

    return { supported, listening, interim, error, start, stop };
  }

  // ─── useVAD — RMS-threshold Voice Activity Detector (Phase B.2) ─────
  // Replaces Web Speech's unpredictable `onend` for turn-end detection.
  // Uses Web Audio AnalyserNode to sample mic RMS at frame rate. When RMS
  // stays below `rmsThreshold` for `silenceThresholdMs` consecutive
  // milliseconds, fires `onSilence`. Caller is responsible for disabling
  // (echo suppression) while the AI's audio is playing — otherwise the
  // speaker output trips false positives.
  function useVAD({ enabled, onSilence, silenceThresholdMs = 6000, rmsThreshold = 0.01, ctxRef } = {}) {
    const onSilenceRef = useRef(onSilence);
    useEffect(() => { onSilenceRef.current = onSilence; }, [onSilence]);

    useEffect(() => {
      if (!enabled) return undefined;
      if (typeof navigator === "undefined" || !navigator.mediaDevices) return undefined;

      let cancelled = false;
      let stream = null;
      let source = null;
      let analyser = null;
      let rafId = null;
      let silenceStartedAt = null;

      (async () => {
        try {
          if (window._voiceLog) window._voiceLog("VAD getUserMedia begin", {});
          stream = await navigator.mediaDevices.getUserMedia({ audio: true });
          if (window._voiceLog) window._voiceLog("VAD getUserMedia ok", { tracks: stream.getTracks().length });
          if (cancelled) {
            stream.getTracks().forEach((t) => t.stop());
            return;
          }
          // The AudioContext is created and resumed by the tap handler
          // (iOS requires both inside the synchronous gesture window). We
          // only attach an analyser graph to the context the parent owns.
          const ctx = ctxRef && ctxRef.current;
          if (!ctx) {
            if (window._voiceLog) window._voiceLog("VAD no ctx (handler should have created it)", {});
            // Release the mic grant so we don't leak it.
            stream.getTracks().forEach((t) => t.stop());
            return;
          }
          source = ctx.createMediaStreamSource(stream);
          analyser = ctx.createAnalyser();
          analyser.fftSize = 2048;
          source.connect(analyser);

          const buffer = new Float32Array(analyser.fftSize);

          const tick = () => {
            if (cancelled) return;
            analyser.getFloatTimeDomainData(buffer);
            let sumSquares = 0;
            for (let i = 0; i < buffer.length; i++) sumSquares += buffer[i] * buffer[i];
            const rms = Math.sqrt(sumSquares / buffer.length);

            const now = performance.now();
            if (rms < rmsThreshold) {
              if (silenceStartedAt === null) silenceStartedAt = now;
              else if (now - silenceStartedAt >= silenceThresholdMs) {
                silenceStartedAt = null;
                if (window._voiceLog) window._voiceLog("VAD silence fire", {});
                const fn = onSilenceRef.current;
                if (typeof fn === "function") fn();
              }
            } else {
              silenceStartedAt = null;
            }

            rafId = requestAnimationFrame(tick);
          };
          rafId = requestAnimationFrame(tick);
        } catch (err) {
          // Permission denied or unsupported — caller surfaces via
          // useSpeechRecognition's parallel permission check.
          if (window._voiceLog) window._voiceLog("VAD error", { name: err && err.name ? err.name : String(err) });
        }
      })();

      return () => {
        if (window._voiceLog) window._voiceLog("VAD cleanup", { hadStream: Boolean(stream) });
        cancelled = true;
        if (rafId !== null) cancelAnimationFrame(rafId);
        if (source) { try { source.disconnect(); } catch { /* ignore */ } }
        if (analyser) { try { analyser.disconnect(); } catch { /* ignore */ } }
        if (stream) stream.getTracks().forEach((t) => t.stop());
        // NOTE: do not close ctx or null ctxRef — the session keeps one context.
      };
    }, [enabled, silenceThresholdMs, rmsThreshold, ctxRef]);
  }

  // ─── Sentence-boundary detection (Phase B.2 — client mirror) ────────
  // Pulls completed sentences out of an accumulating buffer. Server-side
  // canonical copy lives in api/tts-shared.ts; update both in lockstep.
  // See that file for the abbreviation false-split caveat.
  function extractSentences(buffer) {
    const sentences = [];
    const pattern = /([^.!?]+[.!?]+)\s*/g;
    let match;
    let lastEnd = 0;
    while ((match = pattern.exec(buffer)) !== null) {
      const sentence = match[1].trim();
      if (sentence) sentences.push(sentence);
      lastEnd = pattern.lastIndex;
    }
    return { sentences, remainder: buffer.slice(lastEnd) };
  }

  // ─── Voice conversation — feature flag + character map ──────────────
  // Phase B: voice mode is available for all users. The hook stays as the
  // single source of truth so future per-user opt-out (Phase C) can hook
  // in here without rewiring every caller. The legacy
  // `roleplay.voiceMode` localStorage key is now ignored.
  function useVoiceModeFlag() {
    return true;
  }

  // Hardcoded character names for the 36 voice-enrolled scripts. Mirrors
  // the `character` field on each script entry in voice-mapping.json. The
  // presence of a key here gates the Voice toggle visibility (the script
  // is enrolled for voice mode). First-name only — used in state labels
  // like "Karen is thinking". Phase C will pull this from a proper schema
  // field on each script and retire this map.
  const VOICE_PILOT_CHARACTERS = {
    "tc-script-1":  "Renee",
    "tc-script-2":  "Joanna",
    "tc-script-3":  "Leon",
    "tc-script-4":  "Brenda",
    "tc-script-5":  "Karen",
    "tc-script-6":  "Greg",
    "tc-script-7":  "David",
    "tc-script-8":  "Monica",
    "fd-script-1":  "Maria",
    "fd-script-2":  "Tyler",
    "fd-script-3":  "Lauren",
    "fd-script-4":  "Nora",
    "fd-script-5":  "Phil",
    "fd-script-6":  "Pat",
    "fd-script-7":  "Diana",
    "hyg-script-1": "Beth",
    "hyg-script-2": "Jeff",
    "hyg-script-3": "Ashley",
    "hyg-script-4": "Tom",
    "hyg-script-5": "Marcus",
    "hyg-script-6": "Celia",
    "hyg-script-7": "Dr. Kim",
    "doc-script-1": "Ray",
    "doc-script-2": "Mara",
    "doc-script-3": "Sandra",
    "doc-script-4": "Mike",
    "doc-script-5": "Carlos",
    "doc-script-6": "Donna",
    "doc-script-7": "Frank",
    "own-script-1": "Evelyn",
    "own-script-2": "Harold",
    "own-script-3": "Patricia",
    "own-script-4": "Megan",
    "own-script-5": "Jade",
    "own-script-6": "Daniel",
    "own-script-7": "Isabel",
    "po-ltd-1":  "The dentist",
    "po-ltd-8":  "The dentist",
    "po-ltd-11": "The dentist",
  };

  function getVoiceCharacterName(scriptId) {
    return VOICE_PILOT_CHARACTERS[scriptId] || "The patient";
  }

  function isScriptVoiceEnrolled(scriptId) {
    return Object.prototype.hasOwnProperty.call(VOICE_PILOT_CHARACTERS, scriptId);
  }

  // ─── Modal shell ────────────────────────────────────────────────────
  function ModalShell({ tokens, accent, children, onClose, width = 720, label }) {
    useEffect(() => {
      const onKey = (e) => { if (e.key === "Escape") onClose(); };
      const root = document.documentElement;
      const applyVisualViewport = () => {
        const vv = window.visualViewport;
        root.style.setProperty("--roleplay-vv-top", `${vv ? vv.offsetTop : 0}px`);
        root.style.setProperty("--roleplay-vv-left", `${vv ? vv.offsetLeft : 0}px`);
        root.style.setProperty("--roleplay-vv-width", `${vv ? vv.width : window.innerWidth}px`);
        root.style.setProperty("--roleplay-vv-height", `${vv ? vv.height : window.innerHeight}px`);
      };
      document.addEventListener("keydown", onKey);
      applyVisualViewport();
      window.visualViewport?.addEventListener("resize", applyVisualViewport);
      window.visualViewport?.addEventListener("scroll", applyVisualViewport);
      const prevOverflow = document.body.style.overflow;
      document.body.style.overflow = "hidden";
      return () => {
        document.removeEventListener("keydown", onKey);
        window.visualViewport?.removeEventListener("resize", applyVisualViewport);
        window.visualViewport?.removeEventListener("scroll", applyVisualViewport);
        root.style.removeProperty("--roleplay-vv-top");
        root.style.removeProperty("--roleplay-vv-left");
        root.style.removeProperty("--roleplay-vv-width");
        root.style.removeProperty("--roleplay-vv-height");
        document.body.style.overflow = prevOverflow;
      };
    }, [onClose]);

    return (
      <div
        role="dialog"
        aria-modal="true"
        aria-label={label}
        onClick={onClose}
        className="modal-shell-backdrop"
        style={{
          position: "fixed", inset: 0, zIndex: 1000,
          background: "color-mix(in srgb, #000 48%, transparent)",
          backdropFilter: "blur(6px)",
          display: "flex", alignItems: "center", justifyContent: "center",
          padding: 24,
        }}
      >
        <div
          onClick={(e) => e.stopPropagation()}
          className="modal-shell-dialog"
          style={{
            width: "100%", maxWidth: width, maxHeight: "calc(100vh - 48px)",
            background: tokens.surface,
            border: `1px solid ${tokens.line}`,
            borderRadius: 16,
            boxShadow: "0 30px 80px -10px rgba(0,0,0,.45), 0 0 0 1px color-mix(in srgb, " + accent.c + " 20%, transparent)",
            display: "flex", flexDirection: "column",
            overflow: "hidden",
          }}
        >
          {children}
        </div>
      </div>
    );
  }

  // ─── Roleplay session modal (chat + transitions to evaluation) ──────
  function RoleplaySession({ script, tokens, accent, onClose }) {
    // Phase: "chat" → "evaluating" → "review"
    const [phase, setPhase] = useState("chat");

    // Resume existing session for this script if one exists; else start fresh.
    const initial = useMemo(() => {
      const existing = API.findActiveSessionForScript(script.id);
      if (existing) return existing;
      const sid = API.newSessionId();
      const data = {
        scriptId: script.id,
        startedAt: Date.now(),
        turns: [],
        ended: false,
        evaluation: null,
      };
      API.saveSession(sid, data);
      return { sessionId: sid, data };
    }, [script.id]);

    const [sessionId, setSessionId] = useState(initial.sessionId);
    const [turns, setTurns] = useState(initial.data.turns);
    const [pending, setPending] = useState(false);
    const [evaluation, setEvaluation] = useState(initial.data.evaluation || null);
    const [draft, setDraft] = useState("");
    const [error, setError] = useState(null);
    const [ended, setEnded] = useState(false);
    const [difficulty, setDifficulty] = useState("intermediate");
    const [archetype, setArchetype] = useState(
      script.archetypes && script.archetypes.length > 0 ? script.archetypes[0] : null
    );
    const [expertUnlockedScripts, setExpertUnlockedScripts] = useState([]);

    // Session prelude — a generated "what happened before this conversation"
    // recap, grounded in the setup + the session's opening line. Fetched once
    // per mount, shown in the scenario briefing (expandable), and echoed back
    // on every turn so the persona treats it as canon. Fail soft: no prelude
    // = the rep behaves exactly as before.
    const [prelude, setPrelude] = useState(null);
    const [preludeStatus, setPreludeStatus] = useState("idle"); // idle | loading | ready | failed
    const preludeRequested = useRef(false);
    const fetchPrelude = useCallback(async () => {
      if (typeof API.fetchScenarioPrelude !== "function") return;
      setPreludeStatus("loading");
      try {
        const payload = { scriptId: script.id, difficulty };
        if (archetype) payload.archetype = archetype;
        const text = await API.fetchScenarioPrelude(payload);
        if (text) { setPrelude(text); setPreludeStatus("ready"); }
        else { setPreludeStatus("failed"); }
      } catch { setPreludeStatus("failed"); }
    }, [script.id, difficulty, archetype]);
    useEffect(() => {
      if (preludeRequested.current) return;
      preludeRequested.current = true;
      fetchPrelude();
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    // Voice conversation Phase A — mode persists across sessions via the
    // existing useChairsideStored helper. Feature flag + enrolment + browser
    // support gate whether the toggle is rendered at all.
    const [mode, setMode] = useChairsideStored("roleplay.lastMode", "text");
    const voiceModeFlag = useVoiceModeFlag();
    const voiceModeBrowserSupported = (typeof window !== "undefined" &&
      (window.SpeechRecognition || window.webkitSpeechRecognition));
    const voiceModeEligible = voiceModeFlag && isScriptVoiceEnrolled(script.id) && Boolean(voiceModeBrowserSupported);
    // If the user has voice persisted but they opened a script that isn't
    // enrolled (or the flag was disabled in another tab), silently fall
    // back to text — keeps the modal coherent without a mid-flow popover.
    const effectiveMode = voiceModeEligible ? mode : "text";

    // Phase 8.5 — fetch which scripts have Expert unlocked for this user
    useEffect(() => {
      fetch("/api/progress")
        .then((r) => (r.ok ? r.json() : { expertUnlocked: [] }))
        .then((data) => setExpertUnlockedScripts(data.expertUnlocked || []))
        .catch(() => {});
    }, [script.id]);

    const availableLevels = useMemo(() => {
      if (script.personaSet && script.scenarioContext) {
        return ["beginner", "intermediate", "expert"].filter((k) => {
          if (k === "expert") return expertUnlockedScripts.includes(script.id);
          return true;
        });
      }
      const p = script.roleplayPersona;
      if (!p || typeof p !== "object") return ["intermediate"];
      return ["beginner", "intermediate", "expert"].filter((k) => {
        if (typeof p[k] !== "string" || p[k].trim().length === 0) return false;
        if (k === "expert") return expertUnlockedScripts.includes(script.id);
        return true;
      });
    }, [script.roleplayPersona, script.personaSet, script.scenarioContext, expertUnlockedScripts, script.id]);

    const scrollRef = useRef(null);

    // Phase 9 Layer 2 — refs let the unload listener read the live values
    // without re-binding on every render. `endedRef` mirrors the same `ended`
    // state the composer reads (single source of truth, two consumers).
    const sessionStartedAtRef = useRef(initial.data.startedAt);
    const sessionIdRef = useRef(initial.sessionId);
    const phaseRef = useRef("chat");
    const endedRef = useRef(false);
    const turnsRef = useRef(initial.data.turns);
    const layer2Ref = useRef({ startedFired: false, terminalFired: false });
    // Phase A — dedupe flag for the transcript-save sendBeacon. Distinct from
    // the Layer 2 telemetry terminalFired flag; the two have different gates
    // and shouldn't share state.
    const transcriptFlushedRef = useRef(false);

    useEffect(() => { sessionIdRef.current = sessionId; }, [sessionId]);
    useEffect(() => { phaseRef.current = phase; }, [phase]);
    useEffect(() => { endedRef.current = ended; }, [ended]);
    useEffect(() => { turnsRef.current = turns; }, [turns]);

    // Persist on every change
    useEffect(() => {
      const data = {
        scriptId: script.id,
        startedAt: initial.data.startedAt,
        turns,
        ended: phase !== "chat",
        evaluation,
      };
      API.saveSession(sessionId, data);
    }, [turns, phase, evaluation, sessionId, script.id, initial.data.startedAt]);

    // Auto-scroll chat
    useEffect(() => {
      const el = scrollRef.current;
      if (el) el.scrollTop = el.scrollHeight;
    }, [turns, pending]);

    // ── Phase 9 Layer 2 — silent session lifecycle telemetry ──────────
    // Mount-time `started`. If a different in-flight session existed for
    // this script (started but no terminal event), fire `restarted` for it
    // first so Layer 3 sees a clean lifecycle.
    useEffect(() => {
      const prior = findInFlightSessionToRestart(script.id, sessionId);
      if (prior) {
        API.submitSessionEvent({
          event: "restarted",
          sessionId: prior.sessionId,
          scriptId: script.id,
          timestamp: new Date().toISOString(),
          durationMs: Math.max(0, Date.now() - prior.startedAt),
          turnsCompleted: prior.turnsCompleted || 0,
        });
        markSessionTerminal(prior.sessionId);
      }

      if (sessionStorage.getItem(LAYER2_STARTED_PREFIX + sessionId) !== "1") {
        API.submitSessionEvent({
          event: "started",
          sessionId,
          scriptId: script.id,
          timestamp: new Date().toISOString(),
        });
        sessionStorage.setItem(LAYER2_STARTED_PREFIX + sessionId, "1");
      }
      layer2Ref.current.startedFired = true;
      layer2Ref.current.terminalFired = sessionStorage.getItem(LAYER2_TERMINAL_PREFIX + sessionId) === "1";
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    // Completed event — fires once when the rubric renders (phase=review +
    // evaluation populated). Same trigger that QuickFlagFeedback hooks into.
    useEffect(() => {
      if (phase !== "review" || !evaluation) return;
      if (!layer2Ref.current.startedFired || layer2Ref.current.terminalFired) return;
      const evaluationSummary = computeEvaluationSummary(evaluation);
      API.submitSessionEvent({
        event: "completed",
        sessionId,
        scriptId: script.id,
        timestamp: new Date().toISOString(),
        durationMs: Math.max(0, Date.now() - sessionStartedAtRef.current),
        turnsCompleted: turnsRef.current.filter((t) => t.role === "user").length,
        evaluationSummary,
      });
      layer2Ref.current.terminalFired = true;
      markSessionTerminal(sessionId);
    }, [phase, evaluation, sessionId, script.id]);

    // Abandonment — only fires on real tab close / hide while still in chat
    // phase. Modal close (Exit) is not abandonment per spec; only window-level
    // unload counts. Uses sendBeacon under the hood.
    useEffect(() => {
      const fireAbandoned = () => {
        if (!layer2Ref.current.startedFired || layer2Ref.current.terminalFired) return;
        if (phaseRef.current !== "chat") return;
        const sid = sessionIdRef.current;
        API.submitSessionEvent({
          event: "abandoned",
          sessionId: sid,
          scriptId: script.id,
          timestamp: new Date().toISOString(),
          durationMs: Math.max(0, Date.now() - sessionStartedAtRef.current),
          turnsCompleted: turnsRef.current.filter((t) => t.role === "user").length,
          reachedEnd: !!endedRef.current,
        });
        layer2Ref.current.terminalFired = true;
        markSessionTerminal(sid);
      };

      const onBeforeUnload = () => { fireAbandoned(); flushAbandonedTranscript(); };
      const onVisChange = () => {
        if (typeof document !== "undefined" && document.visibilityState === "hidden") {
          fireAbandoned();
          flushAbandonedTranscript();
        }
      };

      window.addEventListener("beforeunload", onBeforeUnload);
      document.addEventListener("visibilitychange", onVisChange);
      return () => {
        window.removeEventListener("beforeunload", onBeforeUnload);
        document.removeEventListener("visibilitychange", onVisChange);
      };
    }, [script.id, flushAbandonedTranscript]);

    // Auto-fetch the AI's opening line on first mount if no turns yet
    const openingFetched = useRef(false);
    useEffect(() => {
      if (openingFetched.current) return;
      if (turns.length > 0) { openingFetched.current = true; return; }
      openingFetched.current = true;
      (async () => {
        setPending(true);
        try {
          const turnPayload = { scriptId: script.id, conversationHistory: [], userMessage: "", difficulty };
          if (archetype) turnPayload.archetype = archetype;
          const res = await API.roleplayTurn(turnPayload);
          const opening = typeof res.aiResponse === "string" ? res.aiResponse.trim() : "";
          if (!opening) {
            // Don't seed turns[0] with empty content — would propagate to
            // voice-mode's opening TTS as a null-text request. Surface as
            // a chat error so the user can retry instead of seeing a
            // silent broken scenario.
            setError("Couldn't load the opening line — please try again.");
            // Allow the retry path: reset openingFetched ref so a script
            // change (or modal re-open) re-attempts the fetch.
            openingFetched.current = false;
            return;
          }
          setTurns([{ role: "ai", content: opening }]);
        } catch (e) {
          setError(humanizeError(e));
        } finally {
          setPending(false);
        }
      })();
    }, [script.id, turns.length, difficulty, archetype]);

    const userTurnCount = turns.filter(t => t.role === "user").length;
    const turnsLeft = Math.max(0, API.MAX_TURNS - userTurnCount);

    // Core send path — used by both text mode (consumes draft) and voice
    // mode (consumes a transcript). Returns the AI reply string on success,
    // or null on failure (so the voice path can short-circuit without
    // attempting TTS on a missing reply).
    const sendUserTurn = useCallback(async (text) => {
      if (!text || pending) return null;
      setError(null);
      const newTurns = [...turns, { role: "user", content: text }];
      setTurns(newTurns);
      setPending(true);
      try {
        const payload = {
          scriptId: script.id,
          conversationHistory: newTurns,
          userMessage: text,
          difficulty,
        };
        if (archetype) payload.archetype = archetype;
        if (prelude) payload.prelude = prelude;
        const res = await API.roleplayTurn(payload);
        const afterAi = [...newTurns, { role: "ai", content: res.aiResponse }];
        setTurns(afterAi);
        if (res.endScenario) setEnded(true);
        return res.aiResponse;
      } catch (e) {
        setError(humanizeError(e));
        setTurns(turns); // roll back the user turn so they can retry
        return null;
      } finally {
        setPending(false);
      }
    }, [pending, turns, script.id, difficulty, archetype, prelude]);

    const sendMessage = useCallback(async () => {
      const text = draft.trim();
      if (!text || pending) return;
      setDraft("");
      const reply = await sendUserTurn(text);
      if (reply === null) {
        // Restore the draft on failure so the user can edit and retry.
        setDraft(text);
      }
    }, [draft, pending, sendUserTurn]);

    // Voice mode entry point — streaming counterpart to sendUserTurn.
    // Forwards SSE events to the caller's onChunk/onDone/onError while
    // owning conversationHistory + ended state updates. Returns once the
    // stream closes (whether by `done` event or terminal error).
    const streamUserTurn = useCallback(async (text, callbacks = {}) => {
      if (!text || pending) return;
      setError(null);
      const newTurns = [...turns, { role: "user", content: text }];
      setTurns(newTurns);
      setPending(true);
      try {
        const streamPayload = {
          scriptId: script.id,
          conversationHistory: newTurns,
          userMessage: text,
          difficulty,
        };
        if (archetype) streamPayload.archetype = archetype;
        if (prelude) streamPayload.prelude = prelude;
        await API.streamRoleplayTurn(
          streamPayload,
          {
            onChunk: (chunk) => {
              if (callbacks.onChunk) callbacks.onChunk(chunk);
            },
            onDone: (summary) => {
              const afterAi = [...newTurns, { role: "ai", content: summary.fullText }];
              setTurns(afterAi);
              if (summary.endScenario) setEnded(true);
              if (callbacks.onDone) callbacks.onDone(summary);
            },
            onError: (err) => {
              setError(humanizeError(err));
              setTurns(turns); // roll back to the pre-user-turn snapshot
              if (callbacks.onError) callbacks.onError(err);
            },
          },
        );
      } finally {
        setPending(false);
      }
    }, [pending, turns, script.id, difficulty, archetype, prelude]);

    const endSession = useCallback(async (finalTurns) => {
      const history = finalTurns || turns;
      setPhase("evaluating");
      setError(null);
      try {
        const evalPayload = {
          scriptId: script.id,
          conversationHistory: history,
          difficulty,
          sessionId: sessionIdRef.current,
          startedAt: sessionStartedAtRef.current,
          inputModality: effectiveMode === "voice" ? "voice" : "text",
        };
        if (archetype) evalPayload.archetype = archetype;
        const ev = await API.roleplayEvaluate(evalPayload);
        if (ev && ev.expertUnlocked) {
          setExpertUnlockedScripts((prev) =>
            prev.includes(script.id) ? prev : [...prev, script.id]
          );
        }
        setEvaluation(ev);
        setPhase("review");
      } catch (e) {
        setError(humanizeError(e));
        setPhase("chat"); // back to chat so they can retry
      }
    }, [turns, script.id, difficulty, effectiveMode, archetype]);

    const restart = useCallback(() => {
      // Phase 9 Layer 2 — if the previous session never reached a terminal
      // event (e.g., user clicks Practice again from an error mid-chat),
      // fire `restarted` for it before the new session starts.
      if (layer2Ref.current.startedFired && !layer2Ref.current.terminalFired) {
        const prevSessionId = sessionIdRef.current;
        API.submitSessionEvent({
          event: "restarted",
          sessionId: prevSessionId,
          scriptId: script.id,
          timestamp: new Date().toISOString(),
          durationMs: Math.max(0, Date.now() - sessionStartedAtRef.current),
          turnsCompleted: turnsRef.current.filter((t) => t.role === "user").length,
        });
        markSessionTerminal(prevSessionId);
      }

      // Mark current session ended (already done) and start fresh.
      const sid = API.newSessionId();
      const startedAt = Date.now();
      const data = { scriptId: script.id, startedAt, turns: [], ended: false, evaluation: null };
      API.saveSession(sid, data);
      setSessionId(sid);
      setTurns([]);
      setEvaluation(null);
      setError(null);
      setEnded(false);
      setPhase("chat");
      openingFetched.current = false;

      // Reset Layer 2 tracking and fire `started` for the new session.
      sessionStartedAtRef.current = startedAt;
      sessionIdRef.current = sid;
      turnsRef.current = [];
      endedRef.current = false;
      phaseRef.current = "chat";
      layer2Ref.current = { startedFired: true, terminalFired: false };
      sessionStorage.setItem(LAYER2_STARTED_PREFIX + sid, "1");
      API.submitSessionEvent({
        event: "started",
        sessionId: sid,
        scriptId: script.id,
        timestamp: new Date().toISOString(),
      });
    }, [script.id]);

    // Phase A — flush the in-progress transcript to /api/sessions/abandoned
    // via sendBeacon. Gated on chat phase + non-empty turns + not already
    // flushed. The eval path saves completed sessions server-side, so we
    // explicitly skip when phase has moved past "chat".
    const flushAbandonedTranscript = useCallback(() => {
      if (transcriptFlushedRef.current) return;
      if (phaseRef.current !== "chat") return;
      const turns = turnsRef.current;
      if (!turns || turns.length === 0) return;
      const startedAt = sessionStartedAtRef.current;
      const endedAt = Date.now();
      if (typeof API.flushAbandonedSession !== "function") return;
      API.flushAbandonedSession({
        sessionId: sessionIdRef.current,
        scriptId: script.id,
        difficulty,
        startedAt,
        endedAt,
        turns: turns.map((t) => ({
          role: t.role === "ai" ? "assistant" : "user",
          content: t.content,
        })),
        inputModality: effectiveMode === "voice" ? "voice" : "text",
      });
      transcriptFlushedRef.current = true;
    }, [script.id, difficulty, effectiveMode]);

    const handleClose = () => {
      // Persistence to sessionStorage already happened on the last state
      // change. Phase A — also flush the transcript to the server if the
      // user is closing the modal mid-chat.
      flushAbandonedTranscript();
      onClose();
    };

    const role = useMemo(() => guessRoleForScript(script.id), [script.id]);

    return (
      <ModalShell tokens={tokens} accent={accent} onClose={handleClose} label="Practice with AI" width={760}>
        <VoiceDebugOverlay />
        {/* Header — single row on tablet+, stacked into two rows on phone */}
        <div
          className="modal-header-stack"
          style={{
            padding: "14px var(--page-pad-x)", borderBottom: `1px solid ${tokens.line}`,
          }}
        >
          <div className="modal-header-row-primary">
            <div style={{ minWidth: 0 }}>
              <div className="eyebrow" style={{ color: accent.c, fontSize: 10, letterSpacing: "0.2em" }}>
                Practice · {role || "Roleplay"}
              </div>
              <h3 style={{ fontSize: 18, fontWeight: 500, margin: "4px 0 0", letterSpacing: "-0.01em" }}>
                {script.title}
              </h3>
            </div>
            <button onClick={handleClose} className="btn ghost touch-min"
              style={{ padding: "6px 12px", fontSize: 12, color: tokens.soft }}>Exit</button>
          </div>
          {phase === "chat" && (
            <div className="modal-header-row-controls">
              <DifficultyToggle
                tokens={tokens}
                accent={accent}
                difficulty={difficulty}
                setDifficulty={setDifficulty}
                availableLevels={availableLevels}
              />
              {script.personaSet && script.archetypes && script.archetypes.length > 1 && (
                <ArchetypeToggle
                  tokens={tokens}
                  accent={accent}
                  archetype={archetype}
                  setArchetype={(a) => {
                    setArchetype(a);
                    if (turns.length <= 1) {
                      setTurns([]);
                      openingFetched.current = false;
                    }
                  }}
                  archetypes={script.archetypes}
                />
              )}
              {voiceModeFlag && isScriptVoiceEnrolled(script.id) && (
                <VoiceModeToggle
                  tokens={tokens}
                  accent={accent}
                  mode={effectiveMode}
                  setMode={setMode}
                  supported={Boolean(voiceModeBrowserSupported)}
                />
              )}
              <span className="mono roleplay-turn-counter" style={{ color: tokens.mute, fontSize: 10, letterSpacing: "0.1em" }}>
                Turn {userTurnCount} of {API.MAX_TURNS} max
              </span>
            </div>
          )}
        </div>

        {phase === "chat" && effectiveMode === "text" && (
          <ChatBody
            tokens={tokens} accent={accent}
            script={script}
            turns={turns}
            pending={pending}
            error={error}
            draft={draft}
            setDraft={setDraft}
            sendMessage={sendMessage}
            prelude={prelude}
            preludeStatus={preludeStatus}
            onRetryPrelude={fetchPrelude}
            endSession={() => endSession()}
            restartSession={restart}
            scrollRef={scrollRef}
            turnsLeft={turnsLeft}
            ended={ended}
          />
        )}

        {phase === "chat" && effectiveMode === "voice" && (
          <VoiceConversation
            tokens={tokens} accent={accent}
            script={script}
            difficulty={difficulty}
            turns={turns}
            streamUserTurn={streamUserTurn}
            endSession={() => endSession()}
            ended={ended}
            sessionId={sessionId}
          />
        )}

        {phase === "evaluating" && (
          <EvaluatingBody tokens={tokens} accent={accent} />
        )}

        {phase === "review" && evaluation && (
          <ReviewBody
            tokens={tokens} accent={accent}
            script={script}
            evaluation={evaluation}
            sessionId={sessionId}
            startedAt={initial.data.startedAt}
            difficulty={difficulty}
            turns={turns}
            onPracticeAgain={restart}
            onDone={handleClose}
          />
        )}
      </ModalShell>
    );
  }

  function DifficultyToggle({ tokens, accent, difficulty, setDifficulty, availableLevels }) {
    const labels = {
      beginner: "Beginner",
      intermediate: "Intermediate",
      expert: "Expert",
    };
    const onlyOneAvailable = availableLevels.length <= 1;

    return (
      <div style={{
        display: "flex",
        gap: 2,
        background: tokens.line,
        borderRadius: 6,
        padding: 2,
      }}>
        {["beginner", "intermediate", "expert"].map((level) => {
          const available = availableLevels.includes(level);
          const selected = difficulty === level;
          const clickable = available && !onlyOneAvailable;
          return (
            <button
              key={level}
              type="button"
              onClick={clickable ? () => setDifficulty(level) : undefined}
              disabled={!clickable}
              aria-pressed={selected}
              title={available ? labels[level] : `${labels[level]} coming soon`}
              className="touch-min"
              style={{
                border: 0,
                padding: "8px 12px",
                fontSize: 11,
                borderRadius: 4,
                background: selected && available ? tokens.surface : "transparent",
                color: selected && available ? accent.c : available ? tokens.soft : tokens.mute,
                fontWeight: selected && available ? 500 : 400,
                opacity: available ? 1 : 0.5,
                cursor: clickable ? "pointer" : "default",
                justifyContent: "center",
              }}
            >
              {labels[level]}{available ? "" : " soon"}
            </button>
          );
        })}
      </div>
    );
  }

  // ─── Archetype toggle (persona engine scripts only) ─────────────────
  function ArchetypeToggle({ tokens, accent, archetype, setArchetype, archetypes }) {
    return (
      <div style={{
        display: "flex",
        gap: 2,
        background: tokens.line,
        borderRadius: 6,
        padding: 2,
        flexWrap: "wrap",
      }}>
        {archetypes.map((id) => {
          const selected = archetype === id;
          return (
            <button
              key={id}
              type="button"
              onClick={() => setArchetype(id)}
              aria-pressed={selected}
              title={ARCHETYPE_LABELS[id] || id}
              className="touch-min"
              style={{
                border: 0,
                padding: "8px 10px",
                fontSize: 11,
                borderRadius: 4,
                background: selected ? tokens.surface : "transparent",
                color: selected ? accent.c : tokens.soft,
                fontWeight: selected ? 500 : 400,
                cursor: "pointer",
                whiteSpace: "nowrap",
              }}
            >
              {ARCHETYPE_LABELS[id] || id}
            </button>
          );
        })}
      </div>
    );
  }

  // ─── Mic button (voice input) ───────────────────────────────────────
  function MicGlyph({ color, filled }) {
    // Simple line-art mic. Matches PracticeGlyph stroke weight (1.2).
    return (
      <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
        <rect x="6" y="2.5" width="4" height="7" rx="2"
              stroke={color} strokeWidth="1.2"
              fill={filled ? color : "none"} />
        <path d="M3.5 8.5a4.5 4.5 0 0 0 9 0M8 13v1.5M5.5 14.5h5"
              stroke={color} strokeWidth="1.2" strokeLinecap="round" fill="none" />
      </svg>
    );
  }

  function MicButton({ tokens, accent, listening, error, disabled, onToggle }) {
    const denied = error === "denied";
    const title = denied
      ? "Microphone access denied — enable in browser settings"
      : listening
        ? "Stop listening"
        : "Speak your response";
    const color = listening ? accent.c : (denied ? tokens.mute : tokens.soft);

    return (
      <button
        type="button"
        onClick={onToggle}
        disabled={disabled || denied}
        aria-label={title}
        aria-pressed={listening}
        title={title}
        className="btn ghost"
        style={{
          padding: "10px 12px",
          fontSize: 13,
          display: "inline-flex",
          alignItems: "center",
          gap: 6,
          borderColor: listening ? accent.c : tokens.line,
          background: listening ? `color-mix(in srgb, ${accent.c} 10%, transparent)` : "transparent",
          cursor: disabled || denied ? "not-allowed" : "pointer",
          opacity: disabled || denied ? 0.5 : 1,
          whiteSpace: "nowrap",
        }}
      >
        <MicGlyph color={color} filled={listening} />
        {listening && (
          <span className="mono" style={{ fontSize: 10, color: accent.c, letterSpacing: "0.12em" }}>
            REC
          </span>
        )}
      </button>
    );
  }

  // ─── Voice conversation Phase A — mode toggle, state machine, view ──

  function VoiceModeToggle({ tokens, accent, mode, setMode, supported }) {
    const options = [
      { value: "text",  label: "Text",  disabled: false },
      { value: "voice", label: "Voice", disabled: !supported },
    ];
    return (
      <div style={{
        display: "inline-flex",
        gap: 2,
        background: tokens.line,
        borderRadius: 6,
        padding: 2,
      }}>
        {options.map((opt) => {
          const active = mode === opt.value;
          const clickable = !opt.disabled;
          return (
            <button
              key={opt.value}
              type="button"
              disabled={opt.disabled}
              onClick={clickable ? () => setMode(opt.value) : undefined}
              aria-pressed={active}
              title={opt.disabled ? "Voice mode coming soon for this script" : opt.label}
              className="mono"
              style={{
                border: 0,
                padding: "3px 8px",
                fontSize: 11,
                letterSpacing: "0.08em",
                borderRadius: 4,
                background: active && !opt.disabled ? tokens.surface : "transparent",
                color: active && !opt.disabled ? accent.c : opt.disabled ? tokens.mute : tokens.soft,
                fontWeight: active && !opt.disabled ? 500 : 400,
                opacity: opt.disabled ? 0.5 : 1,
                cursor: clickable ? "pointer" : "not-allowed",
              }}
            >
              {opt.label}
            </button>
          );
        })}
      </div>
    );
  }

  // Phase-aware SVG glyph rendered inside the voice state circle.
  function PhaseGlyph({ phase, accent, tokens }) {
    const color =
      phase === "listening" || phase === "speaking_ai"
        ? accent.c
        : tokens.soft;
    if (phase === "speaking_ai") {
      // Audio bars
      return (
        <svg width="44" height="44" viewBox="0 0 44 44" fill="none" aria-hidden="true">
          {[10, 16, 22, 28, 34].map((x, i) => (
            <rect
              key={i}
              x={x - 2}
              y={i % 2 === 0 ? 14 : 18}
              width={4}
              height={i % 2 === 0 ? 16 : 8}
              rx={1.5}
              fill={color}
            />
          ))}
        </svg>
      );
    }
    if (phase === "waiting_ai") {
      // Three dots
      return (
        <svg width="44" height="44" viewBox="0 0 44 44" fill="none" aria-hidden="true">
          <circle cx="14" cy="22" r="3" fill={color} />
          <circle cx="22" cy="22" r="3" fill={color} />
          <circle cx="30" cy="22" r="3" fill={color} />
        </svg>
      );
    }
    // idle / listening / ended → mic glyph
    return (
      <svg width="44" height="44" viewBox="0 0 44 44" fill="none" aria-hidden="true">
        <rect x="17" y="9" width="10" height="18" rx="5" stroke={color} strokeWidth="1.5" fill={phase === "listening" ? color : "none"} />
        <path d="M11 22a11 11 0 0 0 22 0M22 33v4M16 37h12" stroke={color} strokeWidth="1.5" strokeLinecap="round" fill="none" />
      </svg>
    );
  }

  // Phase B.2 — GENERATING_AUDIO removed; WAITING_AI covers both "Anthropic
  // is streaming" and "first sentence's TTS is in flight". SPEAKING_AI
  // covers "audio queue has items OR is playing." Transition back to
  // LISTENING happens only when the SSE stream has closed AND the audio
  // queue is fully drained — otherwise we'd cut off a queued sentence.
  const VOICE_STATES = {
    IDLE: "idle",
    // READY_TO_LISTEN — Karen has finished speaking; the mic is NOT active.
    // The user must tap to start listening. This intermediate state replaces
    // the previous auto-engage pattern that auto-started SpeechRecognition
    // when Karen's audio drained: on iOS Safari, that auto-engage ran
    // outside a fresh user gesture, so SpeechRecognition silently captured
    // nothing and AudioContext stayed suspended on turn 2+. Every entry
    // into LISTENING is now the direct result of a tap.
    READY_TO_LISTEN: "ready_to_listen",
    LISTENING: "listening",
    WAITING_AI: "waiting_ai",
    SPEAKING_AI: "speaking_ai",
    ENDED: "ended",
  };

  function voiceReducer(state, action) {
    if (typeof window !== "undefined" && window._voiceLog) {
      window._voiceLog(action.type, { from: state.phase, error: action.error });
    }
    switch (action.type) {
      // USER_STARTED is dispatched from inside the tap handler on every
      // "Tap to speak" press (not just the first one). Always lands in
      // LISTENING, guaranteeing the downstream mic-activation effect runs
      // inside a fresh iOS user-activation window.
      case "USER_STARTED":         return { ...state, phase: VOICE_STATES.LISTENING, interim: "", error: null };
      case "INTERIM_TRANSCRIPT":   return { ...state, interim: action.text };
      // TURN_COMPLETE — VAD-detected silence path (safety net auto-end).
      case "TURN_COMPLETE":        return { ...state, phase: VOICE_STATES.WAITING_AI, interim: "" };
      // USER_ENDED_MANUALLY — user tapped "Tap when done" while LISTENING
      // and a transcript was captured. Same downstream as TURN_COMPLETE.
      case "USER_ENDED_MANUALLY":  return { ...state, phase: VOICE_STATES.WAITING_AI, interim: "" };
      // USER_CANCELLED — user tapped "Tap when done" but no transcript was
      // captured (tapped before speaking). Snap back to READY_TO_LISTEN
      // instead of leaving them in LISTENING with the mic off.
      case "USER_CANCELLED":       return { ...state, phase: VOICE_STATES.READY_TO_LISTEN, interim: "" };
      // PLAY_OPENING fires on the user's first "Tap to begin" in voice mode.
      // Transitions IDLE → SPEAKING_AI immediately so the UI shows
      // "Karen is speaking" while the opening TTS is fetched + plays.
      case "PLAY_OPENING":         return { ...state, phase: VOICE_STATES.SPEAKING_AI, error: null };
      case "FIRST_AUDIO_READY":    return { ...state, phase: VOICE_STATES.SPEAKING_AI };
      // AUDIO_QUEUE_DRAINED now lands in READY_TO_LISTEN, not LISTENING.
      // The user explicitly taps to resume listening — fresh gesture context
      // required by iOS for SpeechRecognition + AudioContext.resume().
      case "AUDIO_QUEUE_DRAINED":  return { ...state, phase: VOICE_STATES.READY_TO_LISTEN };
      case "ERROR":                return { ...state, phase: VOICE_STATES.IDLE, error: action.error };
      case "SESSION_ENDED":        return { ...state, phase: VOICE_STATES.ENDED };
      default: return state;
    }
  }

  // Central focal element — 200×200 circle with state-aware label + glyph.
  function VoiceStateIndicator({ phase, interim, characterName, tokens, accent }) {
    const labelByPhase = {
      [VOICE_STATES.IDLE]: `${characterName} is waiting for you`,
      [VOICE_STATES.READY_TO_LISTEN]: "Your turn",
      [VOICE_STATES.LISTENING]: "Listening",
      [VOICE_STATES.WAITING_AI]: `${characterName} is thinking`,
      [VOICE_STATES.SPEAKING_AI]: `${characterName} is speaking`,
      [VOICE_STATES.ENDED]: "Scenario complete",
    };
    const isActive = phase === VOICE_STATES.LISTENING || phase === VOICE_STATES.SPEAKING_AI;
    return (
      <div style={{
        width: 200,
        height: 200,
        borderRadius: "50%",
        border: `2px solid ${isActive ? accent.c : tokens.line}`,
        background: phase === VOICE_STATES.SPEAKING_AI
          ? `color-mix(in srgb, ${accent.c} 10%, ${tokens.surface})`
          : tokens.surface,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        gap: 10,
        transition: "border-color .2s, background .2s",
      }}>
        <PhaseGlyph phase={phase} accent={accent} tokens={tokens} />
        <div className="mono" style={{
          fontSize: 11,
          letterSpacing: "0.14em",
          color: isActive ? accent.c : tokens.soft,
          textTransform: "uppercase",
          textAlign: "center",
          padding: "0 10px",
          maxWidth: 180,
        }}>
          {labelByPhase[phase] || ""}
        </div>
      </div>
    );
  }

  // Tiny silent WAV (0.5s, mono, 8-bit, 44.1kHz). Played inside the user's
  // "Tap to speak" gesture to unlock the audio element on iOS Safari.
  // Once iOS accepts a .play() with a real source inside a gesture, future
  // programmatic .play() calls on the same element are treated as gesture-
  // initiated for the rest of the session.
  const SILENT_WAV =
    "data:audio/wav;base64,UklGRkAAAABXQVZFZm10IBAAAAABAAEARKwAAESsAAABAAgAZGF0YRwAAACA" +
    "gICAgICAgICAgICAgICAgICAgICAgICAgICA";

  // Earlier (PR #44) this used `audioEl.play()` on an empty src element +
  // setTimeout pause cleanup, which on some iOS versions emitted an audible
  // click ("beep") and left the element in an inconsistent state by the
  // time the real TTS blob arrived. The silent-WAV approach activates the
  // element with a real (silent) source — no click, no cleanup needed. The
  // next assignment of audioEl.src (the TTS blob) interrupts the silent
  // buffer cleanly.
  function unlockAudioElement(audioEl) {
    if (!audioEl) return;
    try {
      audioEl.src = SILENT_WAV;
      audioEl.load();
      const p = audioEl.play();
      if (p && typeof p.catch === "function") {
        p.catch((err) => {
          if (typeof window !== "undefined" && window._voiceLog) {
            window._voiceLog("audio unlock play reject", { name: err && err.name ? err.name : String(err) });
          }
        });
      }
      if (typeof window !== "undefined" && window._voiceLog) window._voiceLog("audio unlock attempt", {});
    } catch (e) {
      if (typeof window !== "undefined" && window._voiceLog) window._voiceLog("audio unlock throw", { msg: String(e && e.message ? e.message : e) });
    }
  }

  function VoiceConversation({
    tokens, accent, script, difficulty, turns,
    streamUserTurn, endSession, ended, sessionId,
  }) {
    const [state, dispatch] = useReducer(voiceReducer, {
      phase: VOICE_STATES.IDLE,
      error: null,
    });

    const audioRef = useRef(null);
    // Audio output context (the <audio> element's playback) and the input
    // capture context (VAD's analyser) are separate concerns. audioCtxRef
    // holds the VAD-side context so the tap handler can call ctx.resume()
    // from inside a fresh user gesture before the LISTENING-entry effect
    // re-runs. iOS Safari requires AudioContext.resume() to be invoked
    // synchronously from a gesture; calling it from the tap handler is the
    // most reliable way to wake a suspended context between turns.
    const audioCtxRef = useRef(null);
    // openingPlayed gates the first-tap-plays-Karen's-opening flow. State
    // (not ref) so the button label can re-render between "Tap to begin"
    // and "Tap to speak" without an extra dispatch.
    const [openingPlayed, setOpeningPlayed] = useState(false);
    const characterName = getVoiceCharacterName(script.id);

    // Audio queue — Blob URLs awaiting playback. The queue is FIFO; a
    // separate `consumerCursorRef` orders TTS Promise drains so audio
    // plays in the same order sentences were detected, even though the
    // TTS requests fire in parallel.
    const audioQueueRef = useRef([]);
    const audioPlayingRef = useRef(false);
    const streamDoneRef = useRef(true); // true means no in-flight turn
    const transcriptRef = useRef("");
    const firstAudioDispatchedRef = useRef(false);

    // Speech recognition — used to capture transcript text. Turn-end
    // detection comes from useVAD below, not from onFinal, because the
    // Web Speech API's silence heuristic is unreliable across browsers.
    const speech = useSpeechRecognition({
      onFinal: (segment) => {
        const trimmed = segment.trim();
        if (!trimmed) return;
        transcriptRef.current = transcriptRef.current
          ? `${transcriptRef.current} ${trimmed}`
          : trimmed;
      },
    });

    // Echo suppression — VAD is enabled ONLY while the user can plausibly
    // be speaking. Disabling during SPEAKING_AI prevents the AI's voice
    // through the speaker from registering as user speech and firing a
    // spurious turn-end. Critical for the conversation loop to close.
    //
    // silenceThresholdMs bumped to 6000 (was 1500): with explicit
    // "Tap when done" as the primary end-of-turn signal, VAD silence
    // detection is a safety net for users who forget to tap. 6s tolerates
    // natural thinking pauses without cutting users off mid-thought.
    useVAD({
      enabled: state.phase === VOICE_STATES.LISTENING,
      silenceThresholdMs: 6000,
      ctxRef: audioCtxRef,
      onSilence: () => {
        const transcript = transcriptRef.current.trim();
        if (!transcript) return; // false-positive; user never spoke
        transcriptRef.current = "";
        if (speech.listening) speech.stop();
        dispatch({ type: "TURN_COMPLETE" });
        handleUserTurn(transcript);
      },
    });

    // Reset audio queue + cursors between turns so an old turn's leftover
    // state can't bleed into a new one.
    const resetTurnState = useCallback(() => {
      // Revoke any unplayed URLs to free memory.
      for (const url of audioQueueRef.current) {
        try { URL.revokeObjectURL(url); } catch { /* noop */ }
      }
      audioQueueRef.current = [];
      audioPlayingRef.current = false;
      firstAudioDispatchedRef.current = false;
    }, []);

    // Plays the next blob in the queue if the audio element is idle.
    // No-op when the queue is empty — the consumer loop calls back here
    // after each TTS arrives.
    const playNext = useCallback(() => {
      const el = audioRef.current;
      if (!el) return;
      if (audioPlayingRef.current) return;
      const next = audioQueueRef.current.shift();
      if (!next) {
        if (window._voiceLog) window._voiceLog("playNext empty", { streamDone: streamDoneRef.current });
        // Queue drained. If the stream is also done, transition back to
        // LISTENING; otherwise wait for more TTS to land.
        if (streamDoneRef.current) {
          dispatch({ type: "AUDIO_QUEUE_DRAINED" });
        }
        return;
      }
      audioPlayingRef.current = true;
      el.src = next;
      // Force iOS Safari to re-read the source. Without .load() after a src
      // swap, iOS sometimes plays the previous buffer (or the silent unlock
      // buffer that's still queued internally) instead of the new blob.
      el.load();
      if (window._voiceLog) window._voiceLog("audio.play attempt", { qLen: audioQueueRef.current.length });
      el.play().catch((err) => {
        // iOS Safari may reject auto-play outside a user gesture.
        if (window._voiceLog) window._voiceLog("audio.play reject", { name: err && err.name ? err.name : String(err) });
        audioPlayingRef.current = false;
        dispatch({ type: "ERROR", error: "Tap the reply to play it." });
      });
    }, []);

    // Adds a Blob URL to the queue. Fires FIRST_AUDIO_READY (transition
    // WAITING_AI → SPEAKING_AI) the first time we have something to play
    // this turn. Kicks off playback if idle.
    const enqueueAudio = useCallback((blobUrl) => {
      if (window._voiceLog) window._voiceLog("enqueueAudio", { first: !firstAudioDispatchedRef.current });
      audioQueueRef.current.push(blobUrl);
      if (!firstAudioDispatchedRef.current) {
        firstAudioDispatchedRef.current = true;
        dispatch({ type: "FIRST_AUDIO_READY" });
      }
      playNext();
    }, [playNext]);

    // Core flow for a user turn: stream Anthropic → detect sentences →
    // fire TTS per sentence (parallel) → drain TTS results in order →
    // enqueue audio. Producer + consumer run concurrently so the first
    // sentence's audio can play while later chunks are still arriving.
    const handleUserTurn = useCallback(async (transcript) => {
      if (!streamUserTurn) {
        dispatch({ type: "ERROR", error: "Voice mode is not configured." });
        return;
      }
      resetTurnState();
      streamDoneRef.current = false;

      let buffer = "";
      const ttsPromises = [];
      const wasOpeningTurn = false; // user-initiated turn; never the opening

      const fireTts = (text, isOpening) => {
        // Guard against null/undefined/empty AND whitespace-only sentences.
        // extractSentences already trims, but a sentence that's purely
        // punctuation (e.g., the regex matched `"  ."` → trimmed → `"."`)
        // would slip through. Server would either accept (silent audio) or
        // reject; either way it's a wasted round-trip.
        const trimmed = typeof text === "string" ? text.trim() : "";
        if (!trimmed) return;
        ttsPromises.push(
          API.ttsForReply({
            scriptId: script.id,
            difficulty,
            text: trimmed,
            isOpening,
            sessionId,
          }).catch((err) => {
            // Swallow per-sentence TTS failures — skip and continue with
            // the remaining sentences. The terminal error path is reserved
            // for cases where nothing playable arrives.
            console.warn("voice_tts_sentence_failed", {
              error: err && err.message ? err.message : String(err),
            });
            return null;
          }),
        );
      };

      // Consumer — awaits each TTS in declaration order and enqueues the
      // resulting Blob URL. Runs concurrently with the producer.
      let consumerCursor = 0;
      let consumerCancelled = false;
      const consumerLoop = (async () => {
        while (!consumerCancelled) {
          if (consumerCursor >= ttsPromises.length) {
            if (streamDoneRef.current) break;
            await new Promise((r) => setTimeout(r, 30));
            continue;
          }
          const blobUrl = await ttsPromises[consumerCursor];
          consumerCursor += 1;
          if (blobUrl) enqueueAudio(blobUrl);
        }
        // All TTS consumed. If audio is also idle, transition LISTENING.
        if (!audioPlayingRef.current && audioQueueRef.current.length === 0) {
          dispatch({ type: "AUDIO_QUEUE_DRAINED" });
        }
      })();

      try {
        // streamUserTurn is the parent's wrapper around API.streamRoleplayTurn.
        // It handles the conversationHistory + ended state updates and
        // forwards onChunk / onDone / onError to our callbacks here.
        await streamUserTurn(transcript, {
          onChunk: (chunk) => {
            buffer += chunk;
            const { sentences, remainder } = extractSentences(buffer);
            buffer = remainder;
            for (const sentence of sentences) fireTts(sentence, false);
          },
          onDone: () => {
            const trailing = buffer.trim();
            if (trailing) fireTts(trailing, false);
            buffer = "";
          },
          onError: (err) => {
            consumerCancelled = true;
            dispatch({ type: "ERROR", error: err && err.message ? err.message : "Stream error." });
          },
        });
      } catch (err) {
        consumerCancelled = true;
        dispatch({ type: "ERROR", error: err && err.message ? err.message : "Stream error." });
      } finally {
        streamDoneRef.current = true;
      }

      await consumerLoop;
    }, [streamUserTurn, resetTurnState, enqueueAudio, script.id, difficulty, sessionId]);

    // Opening line — previously played automatically on first turns[0]
    // arrival, but iOS Safari rejected the resulting audio.play() because
    // it fired outside any user gesture. Now wired into the "Tap to begin"
    // click handler (see handleTapToBegin below) so the TTS fetch happens
    // inside the same gesture that unlocks the audio element.

    // Plays Karen's opening line on the user's first tap (when the script
    // has a pre-seeded turns[0] from RoleplaySession's auto-fetch). The
    // unlock + TTS fetch + enqueue all happen on the same gesture so iOS
    // accepts the eventual audio.play() as gesture-initiated.
    const fetchAndEnqueueOpening = useCallback(async () => {
      if (!turns.length || turns[0]?.role !== "ai") return;
      // Skip TTS when opening content is missing / whitespace-only. The
      // source-side guard in RoleplaySession.openingFetched should prevent
      // this from being reachable, but keeping the check makes the contract
      // explicit and resilient. Without it the request would either be
      // server-rejected ("Turn content must be a non-empty string.") or
      // ttsForReply would return null and we'd pass null to enqueueAudio.
      const openingText = typeof turns[0]?.content === "string" ? turns[0].content.trim() : "";
      if (!openingText) {
        if (window._voiceLog) window._voiceLog("opening skip (empty content)", {});
        // Reset openingPlayed so the user can retry once content arrives.
        setOpeningPlayed(false);
        return;
      }
      if (window._voiceLog) window._voiceLog("opening play start", { textLen: openingText.length });
      streamDoneRef.current = false;
      try {
        const blobUrl = await API.ttsForReply({
          scriptId: script.id,
          difficulty,
          text: openingText,
          isOpening: true,
          sessionId,
        });
        // ttsForReply returns null on empty-text guard hit; null-guard
        // before passing to enqueueAudio so playNext never sees `null` src.
        if (!blobUrl) {
          if (window._voiceLog) window._voiceLog("opening tts returned null", {});
          setOpeningPlayed(false);
          return;
        }
        enqueueAudio(blobUrl);
      } catch (err) {
        if (window._voiceLog) window._voiceLog("opening TTS failed", { msg: err && err.message ? err.message : String(err) });
        // Reset openingPlayed so the user can tap again to retry.
        setOpeningPlayed(false);
        dispatch({ type: "ERROR", error: "Couldn't load the opening — tap again to retry." });
      } finally {
        streamDoneRef.current = true;
      }
    }, [turns, script.id, difficulty, sessionId, enqueueAudio]);

    // Single tap handler used by the primary button across all phases.
    // Every tap runs synchronously so the iOS gesture-critical calls
    // (AudioContext.resume, SpeechRecognition.start) happen inside the
    // activation window. Do NOT add an `await` above speech.start().
    const handleButtonTap = useCallback(() => {
      // Output audio unlock (existing) — synchronous call, fine.
      unlockAudioElement(audioRef.current);

      // Input AudioContext: create ONCE inside the first gesture and keep
      // it for the whole session. iOS only resumes synchronously from a
      // gesture, so we create-and-resume here, never in the post-render
      // VAD effect.
      if (!audioCtxRef.current) {
        const Ctor = window.AudioContext || window.webkitAudioContext;
        if (Ctor) {
          audioCtxRef.current = new Ctor();
          if (window._voiceLog) window._voiceLog("ctx created in tap", { state: audioCtxRef.current.state });
        }
      }
      const ctx = audioCtxRef.current;
      if (ctx && typeof ctx.resume === "function") {
        // Call resume() synchronously (no await before this). The promise
        // can settle later; what matters to iOS is that the CALL is in
        // the gesture.
        ctx.resume()
          .then(() => { if (window._voiceLog) window._voiceLog("ctx.resume from tap", { state: ctx.state }); })
          .catch((err) => { if (window._voiceLog) window._voiceLog("ctx.resume from tap failed", { name: err && err.name ? err.name : String(err) }); });
      }

      // Case 1 — first tap of the session: play Karen's opening.
      // Subsequent taps fall through to the phase-based cases below.
      if (!openingPlayed && turns.length > 0 && turns[0]?.role === "ai") {
        setOpeningPlayed(true);
        dispatch({ type: "PLAY_OPENING" });
        // fetchAndEnqueueOpening handles its own _voiceLog + error reset.
        fetchAndEnqueueOpening();
        return;
      }

      // Case 2 — READY_TO_LISTEN: start the next turn.
      // speech.start() MUST be called here, synchronously in the gesture
      // — not in a post-render effect. This is the core of the fix.
      if (state.phase === VOICE_STATES.READY_TO_LISTEN) {
        if (window._voiceLog) window._voiceLog("user start listening", { from: "ready_to_listen" });
        speech.start();
        dispatch({ type: "USER_STARTED" });
        return;
      }

      // Case 3 — LISTENING: user is ending their turn manually.
      // Submit the captured transcript if any, else cancel back to ready.
      if (state.phase === VOICE_STATES.LISTENING) {
        if (speech.listening) speech.stop();
        const transcript = transcriptRef.current.trim();
        transcriptRef.current = "";
        if (transcript) {
          if (window._voiceLog) window._voiceLog("user end manually", { textLen: transcript.length });
          dispatch({ type: "USER_ENDED_MANUALLY" });
          handleUserTurn(transcript);
        } else {
          if (window._voiceLog) window._voiceLog("user cancelled (no transcript)", {});
          dispatch({ type: "USER_CANCELLED" });
        }
        return;
      }

      // Case 4 — SPEAKING_AI or WAITING_AI: button is disabled in render.
      // Defensive: if a tap somehow lands here, ignore.
      if (window._voiceLog) window._voiceLog("button tap ignored", { phase: state.phase });
    }, [openingPlayed, state.phase, turns, fetchAndEnqueueOpening, speech, handleUserTurn]);

    // Visual label cycles with phase so the user always knows what the
    // next tap will do. Keeping this as a plain function (not memoized)
    // since it's a pure phase-to-string lookup invoked once per render.
    const buttonLabel = () => {
      if (!openingPlayed && state.phase === VOICE_STATES.IDLE) return "Tap to begin";
      switch (state.phase) {
        case VOICE_STATES.READY_TO_LISTEN: return "Tap to speak";
        case VOICE_STATES.LISTENING:        return "Tap when done";
        case VOICE_STATES.SPEAKING_AI:      return `${characterName} is speaking…`;
        case VOICE_STATES.WAITING_AI:       return "Thinking…";
        case VOICE_STATES.IDLE:             return "Tap to speak";
        case VOICE_STATES.ENDED:            return "Session ended";
        default:                            return "Tap to speak";
      }
    };
    const buttonDisabled =
      state.phase === VOICE_STATES.SPEAKING_AI ||
      state.phase === VOICE_STATES.WAITING_AI ||
      state.phase === VOICE_STATES.ENDED ||
      !speech.supported ||
      ended;

    // Stop the mic and revoke any queued URLs when the session ends.
    useEffect(() => {
      if (!ended) return;
      if (speech.listening) speech.stop();
      for (const url of audioQueueRef.current) {
        try { URL.revokeObjectURL(url); } catch { /* noop */ }
      }
      audioQueueRef.current = [];
      audioPlayingRef.current = false;
      dispatch({ type: "SESSION_ENDED" });
    }, [ended, speech.listening, speech.stop]);

    // Cleanup any remaining queue URLs on unmount.
    useEffect(() => {
      return () => {
        for (const url of audioQueueRef.current) {
          try { URL.revokeObjectURL(url); } catch { /* noop */ }
        }
        audioQueueRef.current = [];
      };
    }, []);

    // The input AudioContext lives for the whole voice session and is
    // closed only when the modal unmounts. (useVAD no longer owns its
    // lifecycle.)
    useEffect(() => {
      return () => {
        const ctx = audioCtxRef.current;
        if (ctx) {
          try { ctx.close(); } catch { /* ignore */ }
          audioCtxRef.current = null;
        }
      };
    }, []);

    // Handler for the audio element's `ended` event — revoke the just-
    // played URL, advance the queue, transition LISTENING if drained.
    const handleAudioEnded = useCallback(() => {
      const el = audioRef.current;
      if (el && el.src && el.src.startsWith("blob:")) {
        try { URL.revokeObjectURL(el.src); } catch { /* noop */ }
      }
      audioPlayingRef.current = false;
      playNext();
    }, [playNext]);

    const permissionDenied = speech.error === "denied";

    return (
      <div className="safe-bottom" style={{
        padding: "32px var(--page-pad-x)",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 20,
        minHeight: 360,
      }}>
        <VoiceStateIndicator
          phase={state.phase}
          interim={speech.interim}
          characterName={characterName}
          tokens={tokens}
          accent={accent}
        />

        {speech.interim && state.phase === VOICE_STATES.LISTENING && (
          <div style={{
            fontSize: 13,
            color: tokens.soft,
            fontStyle: "italic",
            textAlign: "center",
            maxWidth: 420,
            lineHeight: 1.5,
            minHeight: 18,
          }}>
            "{speech.interim}"
          </div>
        )}

        {/* Always-mounted audio element so playNext can assign src and
            call play() at any time without remount latency. */}
        <audio
          ref={audioRef}
          onEnded={handleAudioEnded}
          style={{ display: "none" }}
        />

        <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
          {!permissionDenied && (
            <button
              type="button"
              onClick={handleButtonTap}
              className="btn primary touch-min"
              style={{
                padding: "10px 22px",
                fontSize: 13,
                justifyContent: "center",
                opacity: buttonDisabled ? 0.6 : 1,
                cursor: buttonDisabled ? "default" : "pointer",
              }}
              disabled={buttonDisabled}
            >
              {buttonLabel()}
            </button>
          )}
          <button
            type="button"
            onClick={endSession}
            className="btn ghost touch-min"
            style={{ padding: "10px 18px", fontSize: 13, color: tokens.soft, justifyContent: "center" }}
          >
            End session →
          </button>
        </div>

        {permissionDenied && (
          <div style={{ fontSize: 12, color: tokens.soft, textAlign: "center", maxWidth: 420, lineHeight: 1.5 }}>
            Microphone access denied — enable in browser settings, or switch to Text mode.
          </div>
        )}

        {state.error && !permissionDenied && (
          <div style={{ fontSize: 12, color: "#a8443c", textAlign: "center", maxWidth: 420, lineHeight: 1.5 }}>
            {state.error}
          </div>
        )}
      </div>
    );
  }

  // ─── Chat body ──────────────────────────────────────────────────────
  // Expandable "what was said before you walked in" — the generated session
  // prelude, inside the scenario briefing. Collapsed by default so the setup
  // stays scannable; opens to the specific prior conversation this session's
  // patient will reference.
  function PreludeDisclosure({ tokens, accent, prelude, status, onRetry }) {
    const [open, setOpen] = useState(false);
    const linkBtn = {
      background: "none", border: "none", padding: 0, cursor: "pointer",
      color: accent.c, fontSize: 12, fontWeight: 500, fontFamily: "inherit",
    };
    return (
      <div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${tokens.line}` }}>
        <button onClick={() => setOpen((o) => !o)} style={{ ...linkBtn, display: "inline-flex", alignItems: "center", gap: 6 }} aria-expanded={open}>
          <span style={{ display: "inline-block", transform: open ? "rotate(90deg)" : "none", transition: "transform .15s ease" }}>▸</span>
          What was said before you walked in
        </button>
        {open && (
          <div style={{ marginTop: 8, fontSize: 12.5, color: tokens.soft, lineHeight: 1.6, whiteSpace: "pre-line" }}>
            {status === "ready" && prelude}
            {(status === "loading" || status === "idle") && "Pulling up what happened before…"}
            {status === "failed" && (
              <span>
                Couldn't load it — the rep still works without it.{" "}
                <button onClick={onRetry} style={{ ...linkBtn, textDecoration: "underline" }}>Try again</button>
              </span>
            )}
          </div>
        )}
      </div>
    );
  }

  function ChatBody({ tokens, accent, script, turns, pending, error, draft, setDraft, sendMessage, endSession, restartSession, scrollRef, turnsLeft, ended, prelude, preludeStatus, onRetryPrelude }) {
    // Voice input — final segments append to `draft`, user must press Send.
    const speech = useSpeechRecognition({
      onFinal: (segment) => {
        setDraft((prev) => {
          const base = (prev || "").trimEnd();
          return base ? `${base} ${segment}` : segment;
        });
      },
    });

    const handleMicToggle = () => {
      if (!speech.supported) return;
      if (speech.listening) speech.stop();
      else speech.start();
    };

    // Stop the mic if the scenario ends while it's still listening.
    useEffect(() => {
      if (ended && speech.listening) speech.stop();
    }, [ended, speech.listening, speech.stop]);

    return (
      <>
        {/* Messages */}
        <div ref={scrollRef} className="roleplay-chat-scroll" style={{
          flex: 1, overflowY: "auto", padding: "20px 24px",
          display: "flex", flexDirection: "column", gap: 12,
          minHeight: 280, maxHeight: "calc(100vh - 380px)",
        }}>
          {/* Scenario briefing — the full setup, untruncated, as the first
              thing in the conversation (replaces the old 280-char strip).
              A rep starts mid-moment by design: the doctor has already
              presented, the earlier call already happened. Without this
              recap, the patient's "like we discussed" references read as
              missing context instead of backstory. The persona blocks stay
              hidden — the patient's real agenda is the exercise, not the
              briefing. */}
          {script.setup && (
            <div style={{
              border: `1px solid ${tokens.line}`,
              background: `color-mix(in srgb, ${accent.c} 4%, ${tokens.surface})`,
              borderRadius: 14, padding: "16px 18px",
            }}>
              <div className="mono" style={{ color: accent.c, fontSize: 9, letterSpacing: "0.18em", marginBottom: 8 }}>
                THE SCENARIO — WHERE YOU'RE WALKING IN
              </div>
              <div style={{ fontSize: 13.5, color: tokens.text, lineHeight: 1.6, whiteSpace: "pre-line" }}>
                {script.setup}
              </div>
              <PreludeDisclosure
                tokens={tokens} accent={accent}
                prelude={prelude} status={preludeStatus} onRetry={onRetryPrelude}
              />
            </div>
          )}
          {turns.map((t, i) => (
            <Bubble key={i} turn={t} tokens={tokens} accent={accent} />
          ))}
          {pending && <TypingIndicator tokens={tokens} accent={accent} />}
        </div>

        {error && (
          <div style={{ padding: "8px 24px", color: "#a8443c", background: "#fcebea", borderTop: `1px solid ${tokens.line}`, fontSize: 12, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
            <span>{error}</span>
            <button onClick={restartSession} className="btn ghost" style={{ padding: "5px 10px", fontSize: 11, color: "#a8443c", borderColor: "#efc7c2" }}>
              Restart session
            </button>
          </div>
        )}

        {/* Composer */}
        <div className="safe-bottom" style={{ padding: "14px var(--page-pad-x)", borderTop: `1px solid ${tokens.line}`, background: tokens.surface }}>
          <div className="roleplay-composer-row" style={{ display: "flex", gap: 10, alignItems: "flex-end", flexWrap: "wrap" }}>
            <textarea
              value={draft}
              onChange={(e) => setDraft(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter" && !e.shiftKey) {
                  e.preventDefault();
                  if (!ended) sendMessage();
                }
              }}
              placeholder={ended ? "Scenario complete — request your report below." : "Type your response… (Shift+Enter for newline)"}
              rows={2}
              disabled={ended}
              style={{
                flex: 1, minWidth: "min(100%, 240px)", resize: "none",
                padding: "10px 12px", fontSize: 14, lineHeight: 1.45,
                fontFamily: "inherit", color: tokens.text,
                background: tokens.bg,
                border: `1px solid ${tokens.line}`, borderRadius: 10,
                outline: "none",
                opacity: ended ? 0.5 : 1,
                cursor: ended ? "not-allowed" : "text",
              }}
            />
            {speech.supported && (
              <MicButton
                tokens={tokens}
                accent={accent}
                listening={speech.listening}
                error={speech.error}
                disabled={ended}
                onToggle={handleMicToggle}
              />
            )}
            <button
              onClick={sendMessage}
              disabled={ended || pending || !draft.trim()}
              className="btn primary touch-min"
              style={{
                padding: "10px 18px", fontSize: 13,
                opacity: ended || pending || !draft.trim() ? 0.5 : 1,
                cursor: ended || pending || !draft.trim() ? "not-allowed" : "pointer",
                whiteSpace: "nowrap",
                justifyContent: "center",
              }}
            >
              Send →
            </button>
          </div>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10 }}>
            <span className="mono" style={{ fontSize: 10, color: speech.listening ? accent.c : ended ? accent.c : tokens.mute, letterSpacing: "0.12em" }}>
              {speech.listening
                ? "LISTENING…"
                : ended
                  ? "SCENARIO COMPLETE"
                  : (
                    <>
                      {turnsLeft} turn{turnsLeft === 1 ? "" : "s"} left
                      <span className="composer-hint-desktop-only"> · ⌘⏎ to send</span>
                    </>
                  )}
            </span>
            {ended ? (
              <button onClick={endSession} className="btn primary touch-min"
                style={{ padding: "8px 16px", fontSize: 13, whiteSpace: "nowrap", justifyContent: "center" }}>
                Get my report →
              </button>
            ) : (
              <button onClick={endSession} className="btn ghost touch-min"
                style={{ padding: "6px 12px", fontSize: 12, color: tokens.soft }}>
                End session →
              </button>
            )}
          </div>
          {speech.listening && speech.interim && (
            <div style={{
              marginTop: 6,
              fontSize: 12,
              color: tokens.soft,
              fontStyle: "italic",
              textAlign: "right",
              lineHeight: 1.4,
              minHeight: 16,
            }}>
              {speech.interim}
            </div>
          )}
        </div>
      </>
    );
  }

  function Bubble({ turn, tokens, accent }) {
    const isUser = turn.role === "user";
    return (
      <div style={{ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" }}>
        <div style={{
          maxWidth: "78%",
          padding: "10px 14px",
          background: isUser ? accent.c : `color-mix(in srgb, ${tokens.text} 5%, ${tokens.surface})`,
          color: isUser ? "#fff" : tokens.text,
          border: isUser ? "none" : `1px solid ${tokens.line}`,
          borderRadius: isUser ? "14px 14px 4px 14px" : "14px 14px 14px 4px",
          fontSize: 14, lineHeight: 1.55,
          whiteSpace: "pre-wrap",
        }}>
          {turn.content}
        </div>
      </div>
    );
  }

  function TypingIndicator({ tokens, accent }) {
    return (
      <div style={{ display: "flex", justifyContent: "flex-start" }}>
        <div style={{
          padding: "12px 16px",
          background: `color-mix(in srgb, ${tokens.text} 5%, ${tokens.surface})`,
          border: `1px solid ${tokens.line}`,
          borderRadius: "14px 14px 14px 4px",
          display: "inline-flex", gap: 4,
        }}>
          {[0, 1, 2].map(i => (
            <span key={i} style={{
              width: 6, height: 6, borderRadius: "50%",
              background: tokens.soft,
              animation: `roleplayBlink 1.2s ${i * 0.18}s infinite ease-in-out`,
            }} />
          ))}
        </div>
        <style>{`
          @keyframes roleplayBlink {
            0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
            30% { opacity: 1; transform: translateY(-2px); }
          }
        `}</style>
      </div>
    );
  }

  // ─── Evaluating placeholder ─────────────────────────────────────────
  function EvaluatingBody({ tokens, accent }) {
    return (
      <div style={{ padding: "60px 24px", textAlign: "center", color: tokens.soft }}>
        <div className="eyebrow" style={{ color: accent.c, marginBottom: 16 }}>Reviewing the session</div>
        <div style={{ display: "inline-flex", gap: 6, marginBottom: 14 }}>
          {[0, 1, 2].map(i => (
            <span key={i} style={{
              width: 8, height: 8, borderRadius: "50%",
              background: accent.c,
              animation: `roleplayBlink 1.4s ${i * 0.2}s infinite ease-in-out`,
            }} />
          ))}
        </div>
        <p style={{ fontSize: 14, maxWidth: 380, margin: "0 auto" }}>
          Walking through the conversation against the rubric. This takes a few seconds.
        </p>
      </div>
    );
  }

  // ─── Score headline ─────────────────────────────────────────────────
  function ScoreHeadline({ tokens, accent, score }) {
    if (!score) return null;
    // Color the numeral by band so a glance tells the story without changing the rubric.
    // Bands intentionally generous on the high end to reward strong partial-credit performance.
    const band =
      score.percent >= 85 ? "#1f6f4a" :  // green — strong pass
      score.percent >= 60 ? accent.c   :  // accent — solid practice
                           "#a8443c";    // muted red — needs another pass
    return (
      <div style={{
        display: "flex",
        alignItems: "baseline",
        gap: 14,
        marginBottom: 14,
        flexWrap: "wrap",
      }}>
        <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
          <span className="mono" style={{
            color: tokens.mute,
            fontSize: 10,
            letterSpacing: "0.14em",
          }}>
            SCORE
          </span>
          <span style={{
            fontFamily: "Fraunces, serif",
            fontSize: 34,
            lineHeight: 1,
            color: band,
            fontWeight: 500,
            letterSpacing: "-0.01em",
          }}>
            {score.percent}%
          </span>
        </div>
        <span style={{
          fontSize: 12,
          color: tokens.soft,
          lineHeight: 1.4,
        }}>
          {score.passed} passed
          {score.partial > 0 && ` · ${score.partial} partial`}
          {score.missed > 0 && ` · ${score.missed} missed`}
          {" · "}{score.total} total
        </span>
      </div>
    );
  }

  // ─── Review body ────────────────────────────────────────────────────
  function ReviewBody({ tokens, accent, script, evaluation, sessionId, startedAt, difficulty, turns, onPracticeAgain, onDone }) {
    const score = computeRubricScore(evaluation && evaluation.rubricScores);

    return (
      <>
        <div style={{ padding: "24px 24px 0" }}>
          <ScoreHeadline tokens={tokens} accent={accent} score={score} />
          <div className="eyebrow" style={{ color: accent.c, marginBottom: 8 }}>Session review</div>
          <p style={{ fontSize: 16, lineHeight: 1.55, color: tokens.text, margin: 0 }}>
            {evaluation.summary}
          </p>
          {evaluation.expertUnlocked && (
            <div style={{ marginTop: 12, fontSize: 12, color: accent.c, fontWeight: 500, letterSpacing: "0.04em" }}>
              Expert unlocked for this script.
            </div>
          )}
        </div>

        <div style={{ flex: 1, overflowY: "auto", padding: "24px" }}>
          <div className="eyebrow" style={{ color: tokens.mute, marginBottom: 12 }}>Rubric</div>
          <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 10 }}>
            {evaluation.rubricScores.map((row, i) => (
              <RubricRow key={i} row={row} tokens={tokens} accent={accent} />
            ))}
          </ul>

          {evaluation.coachingPoints && evaluation.coachingPoints.length > 0 && (
            <>
              <div className="eyebrow" style={{ color: tokens.mute, marginTop: 28, marginBottom: 12 }}>Coaching points</div>
              <ol style={{ paddingLeft: 18, margin: 0, display: "grid", gap: 8, color: tokens.text }}>
                {evaluation.coachingPoints.map((p, i) => (
                  <li key={i} style={{ fontSize: 14, lineHeight: 1.55 }}>{p}</li>
                ))}
              </ol>
            </>
          )}

          {Array.isArray(evaluation.perTurnNotes) && evaluation.perTurnNotes.length > 0 && Array.isArray(turns) && (
            <ReplayCoaching
              tokens={tokens}
              accent={accent}
              sessionId={sessionId}
              turns={turns}
              perTurnNotes={evaluation.perTurnNotes}
            />
          )}

          <QuickFlagFeedback
            tokens={tokens}
            accent={accent}
            scriptId={script.id}
            sessionId={sessionId}
            startedAt={startedAt}
            difficulty={difficulty}
            evaluation={evaluation}
          />
        </div>

        <div className="safe-bottom roleplay-review-actions" style={{
          padding: "14px var(--page-pad-x)", borderTop: `1px solid ${tokens.line}`,
          display: "flex", justifyContent: "space-between", gap: 12, background: tokens.surface,
        }}>
          <button onClick={onDone} className="btn ghost touch-min" style={{ padding: "10px 18px", fontSize: 13, color: tokens.soft, justifyContent: "center" }}>
            Try a different script
          </button>
          <button onClick={onPracticeAgain} className="btn primary touch-min" style={{ padding: "10px 18px", fontSize: 13, justifyContent: "center" }}>
            Practice again →
          </button>
        </div>
      </>
    );
  }

  function RubricRow({ row, tokens, accent }) {
    const colors = {
      passed:  { fg: "#1f6f4a", bg: "#e6f3ec", glyph: "✓" },
      partial: { fg: "#8a6a18", bg: "#f8efd4", glyph: "◐" },
      missed:  { fg: "#a8443c", bg: "#fcebea", glyph: "✕" },
    };
    const c = colors[row.status] || colors.partial;

    return (
      <li style={{
        display: "grid", gridTemplateColumns: "32px 1fr", gap: 12,
        padding: "12px 14px",
        background: tokens.bg,
        border: `1px solid ${tokens.line}`,
        borderRadius: 10,
      }}>
        <div style={{
          width: 28, height: 28, borderRadius: "50%",
          background: c.bg, color: c.fg,
          display: "inline-flex", alignItems: "center", justifyContent: "center",
          fontSize: 14, fontWeight: 600,
        }}>{c.glyph}</div>
        <div>
          <div style={{ fontSize: 13, lineHeight: 1.45, color: tokens.text }}>
            {row.criterion}
            {row.patternId && (
              <a
                href={`Chairside-Language-Patterns.html#${encodeURIComponent(row.patternId)}`}
                className="touch-min"
                style={{
                  marginLeft: 8,
                  padding: "8px 10px",
                  borderRadius: 6,
                  fontSize: 11,
                  color: tokens.soft,
                  textDecoration: "none",
                  borderBottom: `1px dotted ${tokens.soft}`,
                  whiteSpace: "nowrap",
                  opacity: 0.8,
                }}
              >
                see pattern →
              </a>
            )}
          </div>
          <div style={{ fontSize: 12, color: tokens.soft, marginTop: 4, lineHeight: 1.5, fontStyle: "italic" }}>{row.note}</div>
        </div>
      </li>
    );
  }

  // ─── Replay with coaching (Phase 13.2) ─────────────────────────────

  function ReplayCoaching({ tokens, accent, sessionId, turns, perTurnNotes }) {
    const [expanded, setExpanded] = useState(false);
    const [phase, setPhase] = useState("idle"); // idle | loading | ready | error
    const [coached, setCoached] = useState(null);
    const [errorMsg, setErrorMsg] = useState(null);

    const learnerTurns = useMemo(
      () => turns
        .map((t, i) => ({ ...t, transcriptIndex: i }))
        .filter((t) => t.role === "user"),
      [turns],
    );

    const handleExpand = useCallback(async () => {
      setExpanded(true);
      if (phase !== "idle") return;
      setPhase("loading");
      try {
        const result = await API.fetchReplayCoaching({ sessionId, transcript: turns, perTurnNotes });
        setCoached(result.coachedTurns);
        setPhase("ready");
      } catch (e) {
        setErrorMsg(humanizeError(e));
        setPhase("error");
      }
    }, [phase, sessionId, turns, perTurnNotes]);

    return (
      <div style={{ marginTop: 24, borderTop: `1px solid ${tokens.line}` }}>
        <button
          type="button"
          onClick={expanded ? () => setExpanded(false) : handleExpand}
          className="touch-min-block"
          style={{
            width: "100%",
            gap: 8,
            padding: "12px var(--space-3)",
            background: "none",
            border: "none",
            cursor: "pointer",
            textAlign: "left",
            color: tokens.text,
          }}
        >
          <span style={{
            fontSize: 11,
            color: tokens.mute,
            transition: "transform .15s",
            transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
            display: "inline-block",
          }}>›</span>
          <span className="eyebrow" style={{ color: tokens.mute }}>Replay with coaching</span>
        </button>

        {expanded && phase === "loading" && (
          <div style={{ padding: "8px 0 16px", color: tokens.soft, fontSize: 13 }}>Generating coaching…</div>
        )}
        {expanded && phase === "error" && (
          <div style={{ padding: "8px 0 16px", color: "#a8443c", fontSize: 13 }}>{errorMsg}</div>
        )}
        {expanded && phase === "ready" && (
          <div style={{ paddingBottom: 8 }}>
            {learnerTurns.map((turn) => {
              const note = Array.isArray(perTurnNotes)
                ? perTurnNotes.find((n) => n.turnIndex === turn.transcriptIndex)
                : undefined;
              const coachedTurn = Array.isArray(coached)
                ? coached.find((c) => c.turnIndex === turn.transcriptIndex)
                : undefined;
              return (
                <ReplayCoachingRow
                  key={turn.transcriptIndex}
                  turn={turn}
                  note={note}
                  coachedTurn={coachedTurn}
                  tokens={tokens}
                  accent={accent}
                />
              );
            })}
          </div>
        )}
      </div>
    );
  }

  function ReplayCoachingRow({ turn, note, coachedTurn, tokens, accent }) {
    return (
      <div style={{
        display: "flex",
        flexWrap: "wrap",
        gap: 16,
        padding: "14px 0",
        borderBottom: `1px solid ${tokens.line}`,
      }}>
        <div style={{ flex: "1 1 240px", minWidth: 0 }}>
          <div className="eyebrow" style={{ color: tokens.mute, marginBottom: 6 }}>Your turn</div>
          <div style={{ fontSize: 14, lineHeight: 1.55, color: tokens.text }}>"{turn.content}"</div>
        </div>
        <div style={{ flex: "1 1 240px", minWidth: 0 }}>
          <div className="eyebrow" style={{ color: tokens.mute, marginBottom: 6 }}>Coaching</div>
          {note?.whatWorked && (
            <div style={{ fontSize: 13, lineHeight: 1.5, color: tokens.text, marginBottom: 6 }}>
              <span style={{ color: "#1f6f4a", marginRight: 4 }}>✓</span>{note.whatWorked}
            </div>
          )}
          {note?.refinementHint && (
            <div style={{ fontSize: 13, lineHeight: 1.5, color: tokens.soft, marginBottom: 6 }}>
              {note.refinementHint}
            </div>
          )}
          {coachedTurn?.alternative && (
            <div style={{ fontSize: 13, lineHeight: 1.55, color: tokens.text, fontStyle: "italic", marginBottom: 6 }}>
              "{coachedTurn.alternative}"
            </div>
          )}
          {note?.suggestedPattern && (
            <a
              href={`Chairside-Language-Patterns.html#${encodeURIComponent(note.suggestedPattern)}`}
              style={{
                fontSize: 11,
                color: tokens.soft,
                textDecoration: "none",
                borderBottom: `1px dotted ${tokens.soft}`,
              }}
            >
              Pattern: {note.suggestedPattern} →
            </a>
          )}
        </div>
      </div>
    );
  }

  // ─── Post-session quick flag (Phase 9 Layer 1) ──────────────────────
  const QUICK_FLAG_OPTIONS = [
    { value: "realistic",       label: "Realistic" },
    { value: "slightly_off",    label: "Slightly off" },
    { value: "broke_character", label: "Broke character" },
  ];
  const QUICK_FLAG_COMMENT_MAX = 280;

  function QuickFlagFeedback({ tokens, accent, scriptId, sessionId, startedAt, difficulty, evaluation }) {
    const [phase, setPhase] = useState("idle"); // idle | submitting | submitted | skipped | error
    const [comment, setComment] = useState("");
    const [errorKind, setErrorKind] = useState(null); // "rate_limited" | "unavailable"

    const evaluationSummary = useMemo(() => {
      const rubric = (evaluation && evaluation.rubricScores) || [];
      const criteriaCount = rubric.length;
      const criteriaMet = rubric.filter(r => r.status === "passed").length;
      return {
        criteriaCount,
        criteriaMet,
        passed: criteriaCount > 0 && criteriaMet === criteriaCount,
      };
    }, [evaluation]);

    const submit = useCallback(async (flag) => {
      if (phase !== "idle") return;
      setPhase("submitting");
      setErrorKind(null);
      try {
        await API.submitQuickFlag({
          scriptId,
          difficulty,
          flag,
          comment: comment.slice(0, QUICK_FLAG_COMMENT_MAX),
          sessionId,
          sessionDurationMs: startedAt ? Math.max(0, Date.now() - startedAt) : 0,
          evaluationSummary,
        });
        setPhase("submitted");
      } catch (e) {
        const status = e && typeof e.status === "number" ? e.status : 0;
        if (status === 429) setErrorKind("rate_limited");
        else setErrorKind("unavailable");
        setPhase("error");
      }
    }, [phase, comment, scriptId, sessionId, startedAt, difficulty, evaluationSummary]);

    const skip = useCallback(() => {
      if (phase !== "idle") return;
      setPhase("skipped");
    }, [phase]);

    if (phase === "skipped") return null;

    const containerStyle = {
      marginTop: 28,
      padding: "16px 16px 14px",
      background: `color-mix(in srgb, ${accent.c} 4%, ${tokens.surface})`,
      border: `1px solid ${tokens.line}`,
      borderRadius: 10,
    };

    if (phase === "submitted") {
      return (
        <div style={containerStyle}>
          <p style={{ margin: 0, fontSize: 13, color: tokens.soft, lineHeight: 1.5 }}>
            Thanks — that helps us tune.
          </p>
        </div>
      );
    }

    if (phase === "error") {
      const message = errorKind === "rate_limited"
        ? "You've sent feedback recently — try again in a bit."
        : "Feedback unavailable right now.";
      return (
        <div style={containerStyle}>
          <p style={{ margin: 0, fontSize: 13, color: tokens.soft, lineHeight: 1.5 }}>{message}</p>
        </div>
      );
    }

    const submitting = phase === "submitting";
    const remaining = QUICK_FLAG_COMMENT_MAX - comment.length;

    return (
      <div style={containerStyle}>
        <div className="eyebrow" style={{ color: tokens.mute, marginBottom: 10 }}>
          How did the AI patient feel?
        </div>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
          {QUICK_FLAG_OPTIONS.map((opt) => (
            <button
              key={opt.value}
              type="button"
              className="btn"
              onClick={() => submit(opt.value)}
              disabled={submitting}
              style={{
                padding: "8px 14px",
                fontSize: 12,
                borderColor: tokens.line,
                color: tokens.text,
                background: tokens.bg,
                cursor: submitting ? "not-allowed" : "pointer",
                opacity: submitting ? 0.55 : 1,
              }}
            >
              {opt.label}
            </button>
          ))}
        </div>
        <textarea
          value={comment}
          onChange={(e) => {
            const next = e.target.value;
            setComment(next.length > QUICK_FLAG_COMMENT_MAX ? next.slice(0, QUICK_FLAG_COMMENT_MAX) : next);
          }}
          maxLength={QUICK_FLAG_COMMENT_MAX}
          placeholder="What stood out? (optional)"
          rows={2}
          disabled={submitting}
          style={{
            marginTop: 10,
            width: "100%",
            resize: "none",
            padding: "8px 10px",
            fontSize: 13,
            lineHeight: 1.45,
            fontFamily: "inherit",
            color: tokens.text,
            background: tokens.bg,
            border: `1px solid ${tokens.line}`,
            borderRadius: 8,
            outline: "none",
            boxSizing: "border-box",
          }}
        />
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 8 }}>
          <button
            type="button"
            onClick={skip}
            disabled={submitting}
            style={{
              background: "none",
              border: "none",
              padding: 0,
              fontSize: 12,
              color: tokens.soft,
              textDecoration: "underline",
              cursor: submitting ? "not-allowed" : "pointer",
            }}
          >
            Skip
          </button>
          {comment.length > 0 && (
            <span className="mono" style={{ fontSize: 10, color: tokens.mute, letterSpacing: "0.12em" }}>
              {remaining}
            </span>
          )}
        </div>
      </div>
    );
  }

  // ─── Phase 9 Layer 2 helpers ─────────────────────────────────────────
  const SESSION_STORAGE_PREFIX = "chairside.roleplay.";
  const LAYER2_STARTED_PREFIX = "chairside.layer2.started:";
  const LAYER2_TERMINAL_PREFIX = "chairside.layer2.terminal:";

  // Find a still-in-flight session for this script — one whose `started`
  // event was sent but no terminal (`completed`/`abandoned`/`restarted`)
  // event has fired yet, and which is not the current session. Returns null
  // when there is nothing to restart for.
  function findInFlightSessionToRestart(scriptId, currentSessionId) {
    try {
      let best = null;
      for (let i = 0; i < sessionStorage.length; i++) {
        const key = sessionStorage.key(i);
        if (!key || !key.startsWith(SESSION_STORAGE_PREFIX)) continue;
        const sid = key.slice(SESSION_STORAGE_PREFIX.length);
        if (sid === currentSessionId) continue;
        if (sessionStorage.getItem(LAYER2_STARTED_PREFIX + sid) !== "1") continue;
        if (sessionStorage.getItem(LAYER2_TERMINAL_PREFIX + sid) === "1") continue;
        const raw = sessionStorage.getItem(key);
        if (!raw) continue;
        let data = null;
        try { data = JSON.parse(raw); } catch { continue; }
        if (!data || data.scriptId !== scriptId) continue;
        const turnsCompleted = Array.isArray(data.turns)
          ? data.turns.filter((t) => t && t.role === "user").length
          : 0;
        const candidate = { sessionId: sid, startedAt: data.startedAt || Date.now(), turnsCompleted };
        if (!best || candidate.startedAt > best.startedAt) best = candidate;
      }
      return best;
    } catch {
      return null;
    }
  }

  function markSessionTerminal(sessionId) {
    try {
      sessionStorage.setItem(LAYER2_TERMINAL_PREFIX + sessionId, "1");
    } catch {}
  }

  function computeEvaluationSummary(evaluation) {
    const rubric = (evaluation && evaluation.rubricScores) || [];
    const criteriaCount = rubric.length;
    const criteriaMet = rubric.filter((r) => r && r.status === "passed").length;
    return {
      criteriaCount,
      criteriaMet,
      passed: criteriaCount > 0 && criteriaMet === criteriaCount,
    };
  }

  // ─── Helpers ─────────────────────────────────────────────────────────
  function truncate(s, n) {
    if (!s) return "";
    return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "…";
  }

  function humanizeError(e) {
    if (!e) return "Something went wrong. Please try again.";
    const msg = String(e.message || e);
    if (e.code === "API_SERVER_UNAVAILABLE" || ([404, 405, 501].includes(e.status) && /File not found|Unsupported method|Cannot (GET|POST)|Not Found|Method Not Allowed/i.test(msg))) {
      return "AI practice needs the Vercel API server. Start npx vercel dev --listen 4173 from the app folder, then refresh.";
    }
    if (e.code === "ANTHROPIC_API_KEY_MISSING" || /ANTHROPIC_API_KEY/i.test(msg)) {
      return "AI practice is connected, but ANTHROPIC_API_KEY is missing. Add it to .env.local, restart Vercel dev, and try again.";
    }
    if (e.code === "RATE_LIMITING_NOT_CONFIGURED" || /rate limiting is not configured/i.test(msg)) {
      return "AI practice is connected, but rate limiting is not configured. Add Upstash Redis env vars or set RATE_LIMITS_DISABLED=true for local testing, then restart Vercel dev.";
    }
    if (e.code === "BUDGET_TRACKING_NOT_CONFIGURED" || /budget tracking is not configured/i.test(msg)) {
      return "AI practice is connected, but budget tracking is not configured. Add UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN to .env.local, then restart Vercel dev.";
    }
    if (e.status === 429) return "You've hit the practice limit for this hour. Try again later.";
    if (e.status === 503) return "The practice feature is temporarily unavailable. Try again shortly.";
    if (e.status === 500 || e.status === 0) return "Something went wrong. Try again, or restart the session.";
    if (/429/.test(msg)) return "You've hit the practice limit for this hour. Try again later.";
    if (/503/.test(msg)) return "The practice feature is temporarily unavailable. Try again shortly.";
    return "Something went wrong. " + msg;
  }

  function guessRoleForScript(scriptId) {
    if (!scriptId) return null;
    const prefix = scriptId.split("-")[0];
    return ({
      tc: "Treatment Coordinator",
      fd: "Front Desk",
      hyg: "Hygienist",
      doc: "Doctor",
      own: "Owner",
    })[prefix] || null;
  }

  // ─── Exports ─────────────────────────────────────────────────────────
  Object.assign(window, { RoleplayLauncher, RoleplaySession, ReplayCoaching });
})();
