/* =========================================================================
   CISCO ARENA v2 — App root
   Two operation modes:
     • Connected  — Session.getGameId() returns a valid ID → real API + polling
     • Demo       — No game ID set → local mock data, local timer, no API calls
   ========================================================================= */

const { useState: aS, useEffect: aE, useMemo: aM, useRef: aR, useCallback: aC } = React;

// ---------------------------------------------------------------------------
// Demo-mode player roster (replaces window.PLAYERS which was removed in v2)
// ---------------------------------------------------------------------------
const DEMO_PLAYERS = [
  {tid:"t1", id:"p1",  name:"Mira Osei",      role:"Captain",    isMe:false},
  {tid:"t1", id:"p2",  name:"Theo Park",       role:"Strategist", isMe:true},
  {tid:"t1", id:"p3",  name:"Ines Calder",     role:"Analyst",    isMe:false},
  {tid:"t1", id:"p4",  name:"Dev Ramos",       role:"Specialist", isMe:false},
  {tid:"t1", id:"p5",  name:"Sana Li",         role:"Specialist", isMe:false},
  {tid:"t1", id:"p6",  name:"Omar Fields",     role:"Rookie",     isMe:false},
  {tid:"t2", id:"p7",  name:"Kai Brenner",     role:"Captain",    isMe:false},
  {tid:"t2", id:"p8",  name:"Alba Rinaldi",    role:"Strategist", isMe:false},
  {tid:"t2", id:"p9",  name:"Noor Jameel",     role:"Analyst",    isMe:false},
  {tid:"t2", id:"p10", name:"Leo Vance",       role:"Specialist", isMe:false},
  {tid:"t3", id:"p11", name:"Yuki Okafor",     role:"Captain",    isMe:false},
  {tid:"t3", id:"p12", name:"Mathias Roan",    role:"Strategist", isMe:false},
  {tid:"t3", id:"p13", name:"Zara Quinn",      role:"Analyst",    isMe:false},
  {tid:"t3", id:"p14", name:"Bast Okonkwo",    role:"Specialist", isMe:false},
  {tid:"t4", id:"p15", name:"Priya Shah",      role:"Captain",    isMe:false},
  {tid:"t4", id:"p16", name:"Rafa Ibáñez",     role:"Strategist", isMe:false},
  {tid:"t4", id:"p17", name:"Juno Keller",     role:"Analyst",    isMe:false},
  {tid:"t4", id:"p18", name:"Hiro Tanaka",     role:"Specialist", isMe:false},
];

// ---------------------------------------------------------------------------
// Tweak defaults pulled from ARENA_CONFIG set in data.js
// ---------------------------------------------------------------------------
// Ticket normalizer — TriageScreen expects a stable shape, but the three
// ticket sources in this repo each use different fields:
//   - v2 demo TICKET → already has `.log` (array) + desc/customer/priority
//   - modules/ise.js → `cliOutput` (string) + `symptoms` (array) +
//                      scenario / audiences / phase / audienceFraming /
//                      symptoms / licensingAngle / solution
//   - legacy data-tickets.js → `symptoms` (string)
// Normalize so any source schema renders correctly, and surface the rich
// ISE fields under the legacy keys TriageScreen already reads.
// ---------------------------------------------------------------------------
function normalizeTicket(t) {
  if (!t) return t;

  // 1. Build the syslog `.log` array
  let log;
  if (Array.isArray(t.log)) {
    log = t.log;
  } else if (typeof t.cliOutput === 'string' && t.cliOutput.trim()) {
    log = t.cliOutput.split('\n');
  } else if (Array.isArray(t.symptoms)) {
    log = t.symptoms;
  } else if (typeof t.symptoms === 'string' && t.symptoms.trim()) {
    log = t.symptoms.split('\n');
  } else {
    log = [];
  }

  // 2. Pick the first available audienceFraming question (sales / presales /
  //    deployment / ops / etc.) — this is the actual prompt for the player.
  let framedQuestion = null;
  if (t.audienceFraming && typeof t.audienceFraming === 'object') {
    const order = ['sales', 'presales', 'deployment', 'ops', 'operations', 'security'];
    for (const k of order) {
      const v = t.audienceFraming[k];
      if (v && (v.question || typeof v === 'string')) {
        framedQuestion = v.question || v;
        break;
      }
    }
    if (!framedQuestion) {
      const firstKey = Object.keys(t.audienceFraming)[0];
      const v = firstKey ? t.audienceFraming[firstKey] : null;
      if (v) framedQuestion = v.question || (typeof v === 'string' ? v : null);
    }
  }

  // 3. Symptoms list (preserved separately from `.log` for richer UI).
  const symptomsList = Array.isArray(t.symptoms) ? t.symptoms : null;

  return {
    ...t,
    log,
    desc:     t.desc     || t.scenario || '',
    customer: t.customer || (Array.isArray(t.audiences) ? t.audiences.join(' · ') : (t.audiences || '')),
    priority: t.priority || (t.phase ? String(t.phase).toUpperCase() : ''),
    question: t.question || framedQuestion || '',
    symptomsList,
    licensingAngle: t.licensingAngle || '',
  };
}

const cfg = window.ARENA_CONFIG;
const TWEAK_DEFAULTS = {
  theme:   cfg.defaultTheme,
  mode:    cfg.defaultMode,
  density: cfg.defaultDensity,
  layout:  cfg.defaultLayout,
  celeb:   cfg.defaultCeleb,
};

const PHASE_MAP = {
  registration: "join",
  draft:        "lobby",
  // "playing" means game is underway — don't override V2's internal screen navigation
};

// ---------------------------------------------------------------------------
// Toast helpers
// ---------------------------------------------------------------------------
let _toastSeq = 0;
function makeToast(head, msg, kind = "") {
  return { id: ++_toastSeq, head, msg, kind };
}

// ---------------------------------------------------------------------------
// TickerBar — top strip shown when layout === "ticker"
// ---------------------------------------------------------------------------
function TickerBar({ theme, leaderboard, timerSec, urgent }) {
  const activePlayers = leaderboard.filter(p => !p.is_proctor && p.role !== 'proctor');
  const teamTotals = theme.teams.map(t => ({
    ...t,
    total: activePlayers.filter(p => p.tid === t.id).reduce((s, p) => s + (p.score || 0), 0),
  })).sort((a, b) => b.total - a.total);
  const mm = String(Math.floor(timerSec/60)).padStart(2,"0");
  const ss = String(timerSec%60).padStart(2,"0");
  return (
    <aside className="side" style={{display:"flex", flexDirection:"row", alignItems:"center", padding:"0 20px", gap: 20, borderLeft:"0", borderBottom:"1px solid var(--line)", borderTop:"1px solid var(--line)", background: "var(--bg-elev)"}}>
      <div style={{display:"flex", alignItems:"center", gap: 10}}>
        <span className="live-dot">LIVE</span>
        <span className="mono" style={{fontSize: 20, fontWeight: 700, color: urgent?"var(--danger)":"var(--ink)"}}>{mm}:{ss}</span>
      </div>
      <div style={{width: 1, height: 22, background: "var(--line)"}}/>
      <div style={{display:"flex", gap: 14, overflow:"hidden"}}>
        {teamTotals.map((t, i) => (
          <div key={t.id} style={{display:"flex", alignItems:"center", gap: 6, whiteSpace:"nowrap"}}>
            <span className="mono" style={{fontSize:11, color:"var(--ink-muted)"}}>{i+1}</span>
            <span style={{width: 8, height: 8, borderRadius:2, background: t.color}}/>
            <span style={{fontSize: 12.5, fontWeight:600}}>{t.emoji} {t.short}</span>
            <span className="mono" style={{fontSize: 12, fontWeight:700, color:"var(--ink-dim)"}}>{t.total.toLocaleString()}</span>
          </div>
        ))}
      </div>
    </aside>
  );
}

// ---------------------------------------------------------------------------
// URL param → session bridge: ?game=XXXX sets game, ?reset clears everything
// ---------------------------------------------------------------------------
(function _initFromURL() {
  const p = new URLSearchParams(window.location.search);

  // ?reset — wipe all player state and go back to join screen
  if (p.has("reset")) {
    Session.clear();
    localStorage.removeItem("arena.screen");
    localStorage.removeItem("arena.tweaks");
    sessionStorage.removeItem("arena.pendingScreen");
    window.history.replaceState({}, "", window.location.pathname);
    return; // clean slate — app will render join screen
  }

  const g = p.get("game");
  if (g) {
    const prior = Session.getGameId();
    if (prior && prior !== g) {
      // Different game in QR than previously stored in this tab — this is a
      // fresh join (e.g. user scanned a new event QR). Wipe stale identity so
      // the join form appears instead of silently dropping into the old game.
      Session.clear();
      localStorage.removeItem("arena.screen");
      sessionStorage.removeItem("arena.pendingScreen");
    }
    if (!Session.getGameId()) {
      Session.setGameId(g);
    }
    // Observer mode: capture ?email, ?name, ?observer params before URL is cleaned.
    // Force-clear any stale session identity (prior visits to same game) so the
    // screen always initialises to "join" and the auto-join effect can fire.
    const _obsFlag = p.get("observer");
    const _obsEmail = p.get("email");
    const _obsName = p.get("name");
    if (_obsFlag === "1" && _obsEmail) {
      window._arenaObserverEmail = _obsEmail;
      window._arenaObserverName = _obsName || "Observer";
      // Wipe stale identity — ensures screen === "join" on mount regardless of
      // what a prior session stored in localStorage / sessionStorage.
      Session.clear();
      localStorage.removeItem("arena.screen");
      sessionStorage.removeItem("arena.pendingScreen");
      Session.setGameId(g); // re-establish game link after clear
    }
    window.history.replaceState({}, "", window.location.pathname); // clean URL
  }
})();

// ---------------------------------------------------------------------------
// Language init: read event language from game config and set i18n
// ---------------------------------------------------------------------------
(function _initLanguage() {
  const gameId = Session.getGameId();
  if (!gameId) return;
  fetch("/api/games/" + encodeURIComponent(gameId))
    .then(function(res) { return res.ok ? res.json() : null; })
    .then(function(game) {
      if (!game) return;
      const lang = game.language || "en";
      const mods = game.modules || [];
      // Load module-specific translations for modules with English source strings
      var loadPromises = [];
      if (lang !== "en") {
        mods.forEach(function(modId) {
          if (typeof loadModuleTranslations === "function") {
            loadPromises.push(loadModuleTranslations(modId, lang));
          }
        });
      }
      Promise.all(loadPromises).then(function() {
        if (typeof setLanguage === "function" && lang !== getLanguage()) {
          return setLanguage(lang);
        }
      });
    })
    .catch(function(err) { console.warn("[lang-init] Could not load game language:", err); });
})();

// ---------------------------------------------------------------------------
// App root
// ---------------------------------------------------------------------------
function BreachIncidentScreenHoisted({ gameId, game, me, isCaptain }) {
  const scenario = window.BREACH_SCENARIO;
  const gid = gameId || 'demo';
  const initialBeat = (() => {
    try {
      const manual = parseInt(window.localStorage.getItem('breach_beat_' + gid), 10);
      if (!isNaN(manual) && scenario?.beats?.length) return Math.max(0, Math.min(scenario.beats.length - 1, manual));
    } catch (_) {}
    return 0;
  })();
  const [beatIndex, setBeatIndex] = aS(initialBeat);
  const [now, setNow] = aS(Date.now());
  const [submitted, setSubmitted] = aS(false);
  const beatId = scenario?.beats?.[beatIndex]?.id || 'b';
  const notesKeyInit = 'breach_notes_' + gid + '_' + (me?.email || 'anon') + '_' + beatId;
  const [notes, setNotes] = aS(() => { try { return window.localStorage.getItem(notesKeyInit) || ''; } catch (_) { return ''; } });

  aE(() => {
    if (!scenario || !scenario.beats || !scenario.beats.length) return;
    const startKey = 'breach_start_' + gid;
    const beatKey = 'breach_beat_' + gid;
    if (!window.localStorage.getItem(startKey)) window.localStorage.setItem(startKey, String(Date.now()));

    const sync = () => {
      const manual = parseInt(window.localStorage.getItem(beatKey), 10);
      if (!isNaN(manual)) {
        setBeatIndex(prev => {
          const next = Math.max(0, Math.min(scenario.beats.length - 1, manual));
          return next === prev ? prev : next;
        });
        return;
      }
      const start = parseInt(window.localStorage.getItem(startKey), 10) || Date.now();
      const elapsed = Math.max(0, Math.floor((Date.now() - start) / 1000));
      let acc = 0;
      let idx = 0;
      for (let i = 0; i < scenario.beats.length; i += 1) {
        acc += Number(scenario.beats[i].durationSec || 900);
        if (elapsed < acc) { idx = i; break; }
        idx = i;
      }
      setBeatIndex(prev => idx === prev ? prev : idx);
    };

    sync();
    const iv = setInterval(() => { setNow(Date.now()); sync(); }, 1000);
    return () => clearInterval(iv);
  }, [gid, scenario]);

  // Beat changed → load that beat's saved notes
  aE(() => {
    setSubmitted(false);
    const notesKey = 'breach_notes_' + gid + '_' + (me?.email || 'anon') + '_' + (scenario?.beats?.[beatIndex]?.id || 'b');
    try { setNotes(window.localStorage.getItem(notesKey) || ''); } catch (_) { setNotes(''); }
  }, [beatIndex]);

  // On mount: backfill any decisions/notes from localStorage that haven't been synced yet
  aE(() => {
    try {
      var keys = Object.keys(window.localStorage);
      var decisionPrefix = 'breach_decision_' + gid + '_';
      var notesPrefix = 'breach_notes_' + gid + '_';
      keys.forEach(function(k) {
        if (k.endsWith('__synced')) return;
        var syncFlag = k + '__synced';
        if (window.localStorage.getItem(syncFlag)) return;
        if (k.startsWith(decisionPrefix)) {
          try {
            var d = JSON.parse(window.localStorage.getItem(k) || 'null');
            if (d && d.decision) {
              fetch('/api/g/' + gid + '/breach-decision', {
                method: 'POST',
                headers: { 'content-type': 'application/json' },
                body: JSON.stringify({
                  team: d.team || '',
                  beat: d.beat || '',
                  captain_email: me?.email || '',
                  captain_name: d.captain || '',
                  decision: d.decision
                })
              }).then(function() {
                try { window.localStorage.setItem(syncFlag, '1'); } catch (_) {}
              }).catch(function() {});
            }
          } catch (_) {}
        } else if (k.startsWith(notesPrefix)) {
          try {
            var text = window.localStorage.getItem(k) || '';
            // Key pattern: breach_notes_<gid>_<email>_<beatId>
            var rest = k.slice(notesPrefix.length);
            var lastUnderscore = rest.lastIndexOf('_');
            var emailPart = lastUnderscore >= 0 ? rest.slice(0, lastUnderscore) : rest;
            var beatPart = lastUnderscore >= 0 ? rest.slice(lastUnderscore + 1) : '';
            if (text && beatPart) {
              fetch('/api/g/' + gid + '/breach-notes', {
                method: 'POST',
                headers: { 'content-type': 'application/json' },
                body: JSON.stringify({ email: emailPart || 'anon', beat: beatPart, notes: text })
              }).then(function() {
                try { window.localStorage.setItem(syncFlag, '1'); } catch (_) {}
              }).catch(function() {});
            }
          } catch (_) {}
        }
      });
    } catch (_) {}
  }, []);

  if (!scenario || !scenario.beats || !scenario.beats.length) {
    return <section className="breach-phone"><div className="breach-phone-card">Cargando escenario…</div></section>;
  }

  const beat = scenario.beats[Math.max(0, Math.min(scenario.beats.length - 1, beatIndex))];
  const start = parseInt(window.localStorage.getItem('breach_start_' + gid), 10) || now;
  const elapsedTotal = Math.max(0, Math.floor((now - start) / 1000));
  let before = 0;
  for (let i = 0; i < beatIndex; i += 1) before += Number(scenario.beats[i].durationSec || 900);
  const beatElapsed = Math.max(0, elapsedTotal - before);
  const remaining = Math.max(0, Number(beat.durationSec || 900) - beatElapsed);
  const mm = String(Math.floor(remaining / 60)).padStart(2, '0');
  const ss = String(remaining % 60).padStart(2, '0');
  const decisionKey = 'breach_decision_' + gid + '_' + (me?.tid || 'team') + '_' + beat.id;
  const notesKey = 'breach_notes_' + gid + '_' + (me?.email || 'anon') + '_' + beat.id;

  const submitDecision = (e) => {
    e.preventDefault();
    if (!isCaptain || !notes.trim()) return;
    window.localStorage.setItem(decisionKey, JSON.stringify({
      beat: beat.id, team: me?.tid || '', captain: me?.name || '',
      decision: notes.trim(), at: new Date().toISOString()
    }));
    setSubmitted(true);
    // Sync to server (fire-and-forget)
    try {
      fetch('/api/g/' + gid + '/breach-decision', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          team: me?.tid || '',
          beat: beat.id,
          captain_email: me?.email || '',
          captain_name: me?.name || '',
          decision: notes.trim()
        })
      }).catch(function() {});
    } catch (_) {}
  };

  const _notesDebounceRef = React.useRef(null);
  const saveNotes = (val) => {
    setNotes(val);
    try { window.localStorage.setItem(notesKey, val); } catch (_) {}
    // Debounce-sync notes to server (1500ms)
    if (_notesDebounceRef.current) clearTimeout(_notesDebounceRef.current);
    _notesDebounceRef.current = setTimeout(function() {
      try {
        fetch('/api/g/' + gid + '/breach-notes', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ email: me?.email || 'anon', beat: beat.id, notes: val })
        }).catch(function() {});
      } catch (_) {}
    }, 1500);
  };

  const advanceBeat = (delta) => {
    const next = Math.max(0, Math.min(scenario.beats.length - 1, beatIndex + delta));
    window.localStorage.setItem('breach_beat_' + gid, String(next));
    setBeatIndex(next);
  };

  return (
    <section className={'breach-phone severity-' + beat.severity}>
      <div className="breach-phone-card">
        <div className="breach-phone-top">
          <span>{scenario.victim.logo} {scenario.victim.name}</span>
          <b>{mm}:{ss}</b>
        </div>
        <div className="breach-phone-dots">
          {scenario.beats.map((item, idx) => <span key={item.id} className={idx === beatIndex ? 'active' : (idx < beatIndex ? 'done' : '')}></span>)}
        </div>
        <div className="breach-phone-kicker">{beat.kicker} · Beat {beatIndex + 1}/{scenario.beats.length}</div>
        <h1>{beat.headline}</h1>
        <p className="breach-phone-narrative">{beat.narrative}</p>
        <div className="breach-team-prompt">
          <strong>Discutan en equipo:</strong>
          <span>{beat.teamPrompt || 'Analicen el escenario y decidan qué harían como partner.'}</span>
        </div>

        <div className="breach-vote-title">{tc('Opciones sobre la mesa · inspiración para el debate')}</div>
        <ul className="breach-choices-list">
          {(beat.decisions || beat.choices || []).map((raw, i) => {
            // Back-compat: support either string choices or {label, hint} decision objects
            const label = (raw && typeof raw === 'object') ? (raw.label || raw.text || '') : String(raw || '');
            const hint  = (raw && typeof raw === 'object') ? (raw.hint || '') : '';
            const onPick = () => { if (!submitted) saveNotes((notes ? notes + '\n' : '') + '• ' + label + (hint ? ' — ' + hint : '')); };
            return (
              <li key={i} onClick={onPick} title={hint || ''}>
                <span className="breach-choice-num">{String.fromCharCode(65 + i)}</span>
                <span>
                  <span style={{ display: 'block', fontWeight: 600 }}>{tc(label)}</span>
                  {hint ? <span style={{ display: 'block', fontSize: '0.78em', opacity: 0.7, marginTop: 4, fontStyle: 'italic' }}>{tc(hint)}</span> : null}
                </span>
              </li>
            );
          })}
        </ul>

        <form className="breach-vote" onSubmit={submitDecision}>
          <label className="breach-notes-label">{isCaptain ? 'Decisión del equipo · escribe aquí lo que envías' : 'Notas del taller · ayuda a tu capitán a decidir'}</label>
          <textarea
            className="breach-notes-input"
            rows="5"
            placeholder="¿Qué haría su partner? ¿Por qué? Combinen opciones, agreguen matices, justifiquen…"
            value={notes}
            onChange={(e) => saveNotes(e.target.value)}
            disabled={submitted}
          />
          {isCaptain ? (
            <button type="submit" disabled={!notes.trim() || submitted}>{submitted ? '✓ Decisión enviada' : 'Enviar decisión del equipo'}</button>
          ) : null}
        </form>

        {isCaptain ? null : <div className="breach-confirm">El capitán envía la decisión final del equipo.</div>}
        <div className="breach-captain-controls">
          <button type="button" className="breach-nav-btn" onClick={() => advanceBeat(-1)} disabled={beatIndex === 0}>◀ Beat anterior</button>
          <button type="button" className="breach-nav-btn primary" onClick={() => advanceBeat(1)} disabled={beatIndex >= scenario.beats.length - 1}>Siguiente beat ▶</button>
        </div>
      </div>
    </section>
  );
}

function App() {
  // ── Mode detection ────────────────────────────────────────────────────────
  const connected = !!Session.getGameId();

  // ── Persistent UI state ───────────────────────────────────────────────────
  const [screen, setScreen] = aS(() => {
    // Check for a pending screen redirect (set before reload during game-code join)
    const pending = sessionStorage.getItem("arena.pendingScreen");
    if (pending) {
      sessionStorage.removeItem("arena.pendingScreen");
      localStorage.setItem("arena.screen", pending);
      return pending;
    }
    // Only restore last screen if connected (has a game ID in this tab).
    // Without this guard, a new tab inherits the stale localStorage screen
    // and lands in demo mode with timers already running.
    if (connected) {
      // If we have a game but no registered email yet (e.g. a QR scan with
      // no prior check-in in this tab), force the join screen so the player
      // can enter their name + email instead of silently landing in lobby
      // with `arena.screen` carried over from a different prior visit.
      if (!Session.getEmail()) {
        return "join";
      }
      return localStorage.getItem("arena.screen") || "join";
    }
    return "join";
  });
  const [tweaks, setTweaks] = aS(() => {
    try {
      const saved = JSON.parse(localStorage.getItem("arena.tweaks") || "null");
      return { ...TWEAK_DEFAULTS, ...(saved || {}) };
    } catch { return TWEAK_DEFAULTS; }
  });
  const [tweakOpen, setTweakOpen] = aS(false);
  const [editMode, setEditMode] = aS(false);

  // ── Observer mode ─────────────────────────────────────────────────────────
  const [isObserver, setIsObserver] = aS(false);

  // ── Leaderboard ───────────────────────────────────────────────────────────
  const [leaderboard, setLeaderboard] = aS(() =>
    connected ? [] : DEMO_PLAYERS.map(p => ({ ...p, score: Math.floor(380 + Math.random() * 640) }))
  );

  // ── Player / score ────────────────────────────────────────────────────────
  const [connectedPlayer, setConnectedPlayer] = aS(null);
  const [prevScore, setPrevScore] = aS(connected ? 0 : 900);
  const [myScore, setMyScore]     = aS(connected ? 0 : 900);
  const [streak, setStreak]       = aS(connected ? 0 : 2);

  // ── Quiz state ────────────────────────────────────────────────────────────
  const [questions, setQuestions] = aS(() => window.QUESTIONS);

  // ── Tech-Day Q&A state ────────────────────────────────────────────────────
  // Polled separately from game phase. When questions_open + active_agenda_id,
  // the player UI is overridden with TechDayQuizScreen regardless of game phase.
  const [tdState, setTdState] = aS(null);
  const [qIdx, setQIdx]           = aS(0);
  const serverQIdxRef             = aR(0); // server-derived quiz progress length — authoritative across sessions
  const [selected, setSelected]   = aS(null);
  const [locked, setLocked]       = aS(false);
  const [gradedOutcome, setGradedOutcome] = aS(null); // 'correct' | 'wrong' | null — authoritative answer outcome

  // ── Power-ups ─────────────────────────────────────────────────────────────
  const [powerupsSpent, setPowerupsSpent] = aS([]);

  // ── Celebration / toasts ──────────────────────────────────────────────────
  const [celebration, setCelebration] = aS(null);
  const [toasts, setToasts]           = aS([]);

  // ── Language reactivity ────────────────────────────────────────────────────
  // Track i18n language in React state so tc() re-evaluates on language change
  const [_lang, _setLang] = aS(() => typeof getLanguage === "function" ? getLanguage() : "es");
  aE(() => {
    if (typeof onLanguageChange === "function") {
      return onLanguageChange(function(newLang) { _setLang(newLang); });
    }
  }, []);

  // ── API health ────────────────────────────────────────────────────────────
  const [apiOk, setApiOk] = aS(true);

  // ── Triage (multi-ticket) ────────────────────────────────────────────────
  const [tickets, setTickets]       = aS([]);       // assigned tickets array
  const [ticketIdx, setTicketIdx]   = aS(0);        // current ticket index
  const [graded, setGraded]         = aS(false);
  const [gradeMsg, setGradeMsg]     = aS(null);
  const [grading, setGrading]       = aS(false);    // API call in progress
  const ticket = tickets[ticketIdx] || window.TICKET; // current ticket (fallback)

  // ── Phase transition target ──────────────────────────────────────────────
  const [transitionTarget, setTransitionTarget] = aS("triage");

  // ── Active game module (ISE, SDWAN, etc.) ────────────────────────────────
  // gameModuleIds is populated from the server's game config (game.modules) by
  // the game-init effect below. The memo resolves it against window.GAME_MODULES
  // so it stays reactive: as soon as either side updates we re-evaluate. This
  // avoids the bug where gameModule stayed null on stale sessions and the
  // GamePlan screen rendered an empty "no briefs" state ("black screen").
  const [gameModuleIds, setGameModuleIds] = aS([]);
  const gameModule = aM(() => {
    if (!connected) return null;
    const REG = window.GAME_MODULES;
    if (!REG) return null;
    // Prefer modules declared by the game config
    for (const id of gameModuleIds) {
      if (REG[id]) return REG[id];
    }
    // Fallback: if registry has exactly one module, use it
    const allIds = Object.keys(REG);
    if (allIds.length === 1) return REG[allIds[0]];
    // Last resort: 'ise' if present, else first available
    return REG['ise'] || (allIds.length > 0 ? REG[allIds[0]] : null);
  }, [connected, gameModuleIds]);

  // ── Draft state (connected mode) ──────────────────────────────────────────
  const [draftStatus, setDraftStatus] = aS(null);
  const [draftTeams, setDraftTeams] = aS({});
  const [draftUndrafted, setDraftUndrafted] = aS([]);
  const [serverPhase, setServerPhase] = aS(null);
  const [gameConfig, setGameConfig] = aS(null);

  // ── Timer ─────────────────────────────────────────────────────────────────
  const totalSec = cfg.timerQuizSec; // 180
  const [timerSec, setTimerSec] = aS(totalSec);
  const timerRef = aR(null);

  // ── Load triage tickets when entering triage screen ────────────────────
  aE(() => {
    if (screen !== "triage" && screen !== "phaseTransition") return;
    if (tickets.length > 0) return; // already loaded
    if (!connected) {
      // Demo mode: use fallback ticket
      setTickets([window.TICKET]);
      return;
    }
    const email = Session.getEmail();
    if (!email) return;

    // Get module from game config
    const gameId = Session.getGameId();
    fetch("/api/games/" + encodeURIComponent(gameId))
      .then(r => r.ok ? r.json() : null)
      .then(game => {
        if (!game) return;
        const modId = (game.modules || ["ise"])[0];
        const mod = window.GAME_MODULES && window.GAME_MODULES[modId];
        if (!mod || !mod.data || !mod.data.tickets) {
          setTickets([window.TICKET]);
          return;
        }
        const allTickets = mod.data.tickets;
        const ticketCount = game.ticket_count || 1;

        // Fetch deterministic assignment from server
        Api.getTicketsAssignment(email, modId, allTickets.length, ticketCount)
          .then(result => {
            const indices = result.indices || [];
            // Normalize each ticket so every render path sees a consistent
            // shape (see normalizeTicket() at top of file).
            const assigned = indices.map(i => allTickets[i]).filter(Boolean).map(normalizeTicket);
            if (assigned.length > 0) {
              setTickets(assigned);
              setTicketIdx(0);
            } else {
              setTickets([window.TICKET]);
            }
          })
          .catch(() => {
            setTickets([window.TICKET]);
          });
      })
      .catch(() => {
        setTickets([window.TICKET]);
      });
  }, [screen, connected, tickets.length]);

  // ── Derived ───────────────────────────────────────────────────────────────
  const theme = aM(() => window.THEMES.find(t => t.id === tweaks.theme) || window.THEMES[0], [tweaks.theme]);
  const me    = aM(() => {
    if (connected && connectedPlayer) {
      return {
        id: connectedPlayer.email,
        tid: connectedPlayer.team,
        name: connectedPlayer.name,
        role: connectedPlayer.isCaptain ? "Captain" : "",
        isMe: true,
      };
    }
    return DEMO_PLAYERS.find(p => p.isMe) || DEMO_PLAYERS[0];
  }, [connected, connectedPlayer]);
  const team  = aM(() => theme.teams.find(t => t.id === me.tid) || theme.teams[0], [theme, me]);

  // ── Draft computed ────────────────────────────────────────────────────────
  const isCaptain = aM(() => {
    if (!connected || !connectedPlayer) return false;
    return !!connectedPlayer.isCaptain;
  }, [connected, connectedPlayer]);

  const isMyDraftTurn = aM(() => {
    if (!isCaptain || !draftStatus || !draftStatus.active) return false;
    const email = Session.getEmail();
    const order = draftStatus.captain_order || [];
    const idx = draftStatus.current_pick_index != null ? draftStatus.current_pick_index : 0;
    const round = draftStatus.round || 1;
    const maxManual = draftStatus.max_manual_rounds || 2;
    if (round > maxManual) return false;
    const n = order.length;
    if (n === 0) return false;
    const captainIdx = round % 2 === 1 ? (idx % n) : ((n - 1) - (idx % n));
    return order[captainIdx] === email;
  }, [isCaptain, draftStatus]);

  const handleDraftPick = aC((playerEmail) => {
    const captainEmail = Session.getEmail();
    Api.draftPick(captainEmail, playerEmail)
      .then(() => { addToast(tc("Drafted!"), tc("Player added to your team"), "success"); })
      .catch(err => { addToast(tc("Pick failed"), err.message || tc("Try again"), "error"); });
  }, []);

  // ── Sync leaderboard with current me.score ────────────────────────────────
  aE(() => {
    setLeaderboard(lb =>
      lb.map(p => p.id === me.id ? { ...p, score: myScore } : p)
    );
  }, [myScore]);

  // ── Hydrate connected player state on refresh ─────────────────────────────
  aE(() => {
    if (!connected) return;
    const email = Session.getEmail();
    if (!email) return;
    Api.getLeaderboard().then(res => {
      const players = res?.data?.leaderboard || res?.data || [];
      const me = players.find(p => p.email === email);
      if (me) {
        setMyScore(me.score || 0);
        setStreak(me.streak || 0);
        setConnectedPlayer({ name: me.name, email: me.email, team: me.team, isCaptain: !!me.isCaptain });
        // Seed serverQIdxRef so ScreenNav and game-init can both use it
        const sqIdx = (me.quizProgress || []).length;
        if (sqIdx > serverQIdxRef.current) serverQIdxRef.current = sqIdx;
      }
    }).catch(() => {});
  }, []);

  // ── Apply game config (theme, modules, language) on connected mount ───────
  aE(() => {
    if (!connected) return;
    const gameId = Session.getGameId();
    if (!gameId) return;
    fetch("/api/games/" + encodeURIComponent(gameId))
      .then(r => r.ok ? r.json() : null)
      .then(game => {
        if (!game) return;
        setGameConfig(game);
        console.log("[game-init] Game config:", game.theme_id, game.modules, game.language);

        // Stash phase content limits globally so GamePlanScreen / ZonaMixtaScreen
        // can slice briefs[] / curveballs[] without prop plumbing. 0 = no cap.
        window.__GAME_CONFIG__ = window.__GAME_CONFIG__ || {};
        window.__GAME_CONFIG__.gameplan_count = game.gameplan_count || 0;
        window.__GAME_CONFIG__.zonamixta_count = game.zonamixta_count || 0;

        // Theme: map server theme_id to client theme id
        const themeMap = { 'f1': 'gp', 'world-cup': 'cup', 'nfl': 'nfl', 'realm': 'realm' };
        const clientTheme = themeMap[game.theme_id] || game.theme_id || 'cisco';
        setTweak('theme', clientTheme);

        const mods = game.modules || [];
        const lang = game.language || "en";

        // Publish module IDs so the gameModule memo can resolve reactively.
        // Without this, GamePlanScreen renders an empty "no briefs" state when
        // organizer clicks "Advance to Game Plan" on a fresh session.
        setGameModuleIds(mods);

        // ── Step 1: Load translations + set language FIRST ──────────────
        let langReady;
        if (lang !== "en" && mods.length > 0) {
          const loadPromises = mods.map(modId => {
            if (typeof loadModuleTranslations === "function") {
              return loadModuleTranslations(modId, lang);
            }
            return Promise.resolve();
          });
          langReady = Promise.all(loadPromises).then(() => {
            if (typeof setLanguage === "function" && lang !== getLanguage()) {
              return setLanguage(lang).then(() => {
                console.log("[game-init] Language set to", lang, "currentLang:", getLanguage());
              });
            }
          });
        } else {
          langReady = Promise.resolve();
        }

        // ── Step 2: AFTER language is ready, load questions ─────────────
        // (module getters call tc() which now uses the correct language)
        langReady.then(async () => {
          if (mods.length > 0 && window.GAME_MODULES) {
            let allQuestions = [];
            mods.forEach(modId => {
              const mod = window.GAME_MODULES[modId];
              if (mod && mod.data && mod.data.quiz) {
                allQuestions = allQuestions.concat(mod.data.quiz);
              }
            });
            if (allQuestions.length > 0) {
              // Filter to question types that have renderers or are classic format
              const RENDERER_TYPES = window.RENDERERS ? new Set(Object.keys(window.RENDERERS)) : new Set();
              const playable = allQuestions.filter(q => {
                // Only include typed questions with a matching renderer.
                // Bare-legacy questions (firewall/sdwan/secure-access old schema with
                // q.q + options:string[]) render blank in screens.jsx → exclude.
                if (!q.type) return false;
                return RENDERER_TYPES.has(q.type);
              });
              console.log("[game-init] Loaded", playable.length, "playable questions from modules:", mods, "(raw:", allQuestions.length, ")");

              if (playable.length > 0) {
                // Seeded PRNG (mulberry32) — same game_id = same questions for all players
                const seedHash = (function(s) {
                  let h = 0;
                  for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; }
                  return h;
                })(gameId);
                function seededShuffle(arr, seed) {
                  let t = seed;
                  function rand() {
                    t = (t + 0x6D2B79F5) | 0;
                    t = Math.imul(t ^ (t >>> 15), t | 1);
                    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
                    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
                  }
                  const sh = arr.slice();
                  for (let i = sh.length - 1; i > 0; i--) {
                    const j = Math.floor(rand() * (i + 1));
                    const tmp = sh[i]; sh[i] = sh[j]; sh[j] = tmp;
                  }
                  return sh;
                }

                // Separate tech vs fun questions, filter fun by game theme
                const techPool = playable.filter(q => q.source !== 'fun');
                const funThemeMap = { gp: 'gp', cup: 'wc', nfl: 'nfl', realm: 'realm', cisco: 'cisco' };
                const funPrefix = funThemeMap[clientTheme] || 'cisco';
                const funPool  = playable.filter(q => q.source === 'fun' &&
                  q.id && q.id.indexOf('-fun-' + funPrefix + '-') !== -1);

                // Config: quiz_count (tech) + fun_count from server, with defaults
                const quizCount = game.quiz_count || 10;
                const funCount  = game.fun_count  || 3;

                // Seeded-shuffle each pool, take requested count
                const techPick = seededShuffle(techPool, seedHash).slice(0, quizCount);
                const funPick  = seededShuffle(funPool,  seedHash + 1).slice(0, funCount);

                // Interleave: insert fun questions every ~4th position
                const final = [];
                let fi = 0;
                for (let i = 0; i < techPick.length; i++) {
                  final.push(techPick[i]);
                  // After every 3rd tech question, insert a fun question
                  if ((i + 1) % 3 === 0 && fi < funPick.length) {
                    final.push(funPick[fi++]);
                  }
                }
                // Append any remaining fun questions at the end
                while (fi < funPick.length) { final.push(funPick[fi++]); }

                console.log("[game-init] Quiz sampled:", final.length, "questions (", techPick.length, "tech +", funPick.length, "fun ) from pool of", playable.length);
                setQuestions(final);

                // Restore progress: server is authoritative; sessionStorage acts as tie-breaker
                // for ultra-fresh local state that hasn't reached the server yet.
                const savedIdx = parseInt(sessionStorage.getItem('arena_qIdx_' + gameId), 10);
                const sessionIdx = (!isNaN(savedIdx) && savedIdx >= 0 && savedIdx < final.length) ? savedIdx : 0;
                let restoredIdx = sessionIdx;
                const playerEmail = Session.getEmail();
                if (playerEmail) {
                  try {
                    const playerData = await Api.getPlayer(playerEmail);
                    if (playerData) {
                      const serverQIdx = (playerData.quizProgress || []).length;
                      if (serverQIdx > serverQIdxRef.current) serverQIdxRef.current = serverQIdx;
                      if (serverQIdx > 0) {
                        restoredIdx = Math.min(Math.max(serverQIdx, sessionIdx), final.length - 1);
                        // Reseed the duplicate-guard answered-index set from server progress so
                        // the anti-cheat dedupe works correctly after an AFK / cross-browser rejoin
                        const answeredKey = 'arena_ans_' + gameId;
                        const qIdToIdx = {};
                        final.forEach((q, i) => { qIdToIdx[q.id || q.tag || `q${i}`] = i; });
                        const serverAnsweredIdxs = (playerData.quizProgress || [])
                          .map(e => qIdToIdx[e.qId])
                          .filter(i => i !== undefined && i >= 0);
                        const existingAns = JSON.parse(sessionStorage.getItem(answeredKey) || '[]');
                        const merged = Array.from(new Set([...existingAns, ...serverAnsweredIdxs]));
                        sessionStorage.setItem(answeredKey, JSON.stringify(merged));
                        // Sync score/streak from server on reconnect
                        setMyScore(playerData.score || 0);
                        setStreak(playerData.streak || 0);
                      }
                    }
                  } catch (err) {
                    console.warn("[game-init] Could not fetch player progress:", err.message);
                  }
                }
                setQIdx(restoredIdx);
                if (restoredIdx > 0) {
                  console.log("[game-init] Restored quiz progress to Q" + (restoredIdx + 1) + " (server=" + serverQIdxRef.current + " entries, session=" + sessionIdx + ")");
                }
              }
            }
          }
        });
      }).catch(err => console.warn("[game-init] Failed:", err));
  }, []);

  // Clinic breach fallback: some endpoints may omit event_type from /api/games.
  // Pull /api/g/:id once, cache in gameConfig, and keep triage optimistic while loading.
  aE(() => {
    if (!connected || !gameConfig || gameConfig.event_type) return;
    const gameId = Session.getGameId();
    if (!gameId) return;
    fetch('/api/g/' + encodeURIComponent(gameId))
      .then(r => r.ok ? r.json() : null)
      .then(game => {
        if (game && game.event_type) setGameConfig(prev => ({ ...(prev || {}), ...game }));
      })
      .catch(() => {});
  }, [connected, gameConfig]);

  // ── Persist screen & tweaks ───────────────────────────────────────────────
  aE(() => { localStorage.setItem("arena.screen", screen); }, [screen]);

  // ── Move focus to stage on screen transition (a11y) ───────────────────────
  aE(() => {
    const stage = document.getElementById('main-stage');
    if (stage) {
      stage.focus({ preventScroll: false });
    }
  }, [screen]);
  aE(() => {
    const { triggerCeleb: _tc, triggerToast: _tt, ...toSave } = tweaks;
    localStorage.setItem("arena.tweaks", JSON.stringify(toSave));
    document.documentElement.setAttribute("data-theme",   tweaks.theme);
    document.documentElement.setAttribute("data-mode",    tweaks.mode);
    document.documentElement.setAttribute("data-density", tweaks.density);
    document.documentElement.setAttribute("data-layout",  tweaks.layout);
  }, [tweaks]);

  // ── Load theme display fonts ──────────────────────────────────────────────
  aE(() => {
    const fonts = {
      cisco: "Inter+Tight:wght@600;700;800",
      cup:   "Oswald:wght@500;700",
      gp:    "Rajdhani:wght@500;600;700",
      nfl:   "Anton",
      realm: "Cinzel:wght@600;700",
    };
    const id = "theme-font";
    let link = document.getElementById(id);
    if (!link) {
      link = document.createElement("link");
      link.id = id; link.rel = "stylesheet";
      document.head.appendChild(link);
    }
    link.href = `https://fonts.googleapis.com/css2?family=${fonts[tweaks.theme]}&family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap`;
  }, [tweaks.theme]);

  // ── Tweak demo triggers ───────────────────────────────────────────────────
  aE(() => {
    if (tweaks.triggerCeleb) setCelebration(Date.now());
  }, [tweaks.triggerCeleb]);

  aE(() => {
    if (tweaks.triggerToast) {
      addToast(tc("Score update"), "+120 pts for Q03", "success");
    }
  }, [tweaks.triggerToast]);

  // ── Timer logic ───────────────────────────────────────────────────────────
  const startTimer = (initialSec) => {
    stopTimer();
    setTimerSec(initialSec ?? totalSec);
    timerRef.current = setInterval(() => {
      setTimerSec(s => {
        if (s <= 1) { stopTimer(); return 0; }
        return s - 1;
      });
    }, 1000);
  };

  const stopTimer = () => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
  };

  aE(() => () => stopTimer(), []);

  // ── Screen ref for stale-closure-safe comparisons ────────────────────────
  const screenRef = aR(screen);
  aE(() => { screenRef.current = screen; }, [screen]);

  // ── Connected mode: leaderboard polling ───────────────────────────────────
  aE(() => {
    if (!connected) return;
    GameStore.startPolling("leaderboard", () => Api.getLeaderboard().then(r => { setApiOk(true); return r; }).catch(e => { setApiOk(false); throw e; }), cfg.pollIntervalMs);
    const unsub = GameStore.subscribe("leaderboard", (data) => {
      if (data && Array.isArray(data)) {
        const myEmail = Session.getEmail();
        const normalized = data.map((p, i) => ({
          id: p.email,
          tid: p.team,
          name: p.name,
          role: p.isCaptain ? "Captain" : "",
          score: p.score,
          climbing: false,
          isMe: p.email === myEmail,
        }));
        setLeaderboard(normalized);
        const meLive = data.find(p => p.email === myEmail);
        if (meLive) {
          setConnectedPlayer(prev => ({
            ...(prev || {}),
            name: meLive.name,
            email: meLive.email,
            team: meLive.team,
            isCaptain: !!meLive.isCaptain,
          }));
        }
      }
    });
    return () => {
      GameStore.stopPolling("leaderboard");
      unsub && unsub();
    };
  }, [connected]);

  // ── Connected mode: phase polling ─────────────────────────────────────────
  // Only react to server phase CHANGES — don't continuously override local nav.
  const lastServerPhase = aR(null);
  aE(() => {
    if (!connected) return;
    GameStore.startPolling("phase", () => Api.getGamePhase().then(r => { setApiOk(true); return r; }).catch(e => { setApiOk(false); throw e; }), cfg.pollIntervalMs);
    const unsub = GameStore.subscribe("phase", (data) => {
      if (data && data.phase) {
        setServerPhase(data.phase);
        // Skip if server phase hasn't changed since last poll
        if (data.phase === lastServerPhase.current) return;
        const isFirstPoll = lastServerPhase.current === null;
        lastServerPhase.current = data.phase;
        const mapped = PHASE_MAP[data.phase];
        // "playing" = game underway → advance to quiz from any pre-game screen.
        // Do NOT override screens that are already inside the playing phase
        // (gameplan, zonamixta, phaseTransition, wrap, triage, quiz) — those are
        // managed by the sub-phase poller and handleJoin routing.
        if (mapped === undefined && data.phase === "playing") {
          const cur = screenRef.current;
          const IN_GAME = new Set(["quiz","triage","gameplan","zonamixta","phaseTransition","wrap"]);
          if (!IN_GAME.has(cur)) setScreen("quiz");
          return;
        }
        const target = mapped || data.phase;
        // First poll: only transition FORWARD (e.g. draft→lobby), never backward to "join"
        if (isFirstPoll && target === "join") return;
        if (target !== screenRef.current) setScreen(target);
      }
    });
    return () => {
      GameStore.stopPolling("phase");
      unsub && unsub();
    };
  }, [connected]);

  // ── Sub-phase polling — proctor can force-navigate all players ────────────
  // Admin is authoritative: when sub_phase changes, all players navigate to
  // that screen regardless of where they currently are (forward OR backward).
  // This lets the proctor pull players back from gameplan/zonamixta to quiz.
  aE(() => {
    if (!connected) return;
    const gameId = Session.getGameId();
    if (!gameId) return;

    const SUB_PHASE_SCREEN = { quiz: "quiz", triage: "triage", gameplan: "gameplan", zonamixta: "zonamixta", wrap: "wrap" };
    const lastSubPhase = { current: null };

    const pollId = setInterval(() => {
      fetch(`/api/g/${gameId}/sub-phase`)
        .then(r => r.ok ? r.json() : null)
        .then(data => {
          if (!data || !data.sub_phase) return;
          const target = SUB_PHASE_SCREEN[data.sub_phase];
          if (!target) return;
          // Only act when sub_phase CHANGES — avoid yanking the player every 3s
          if (data.sub_phase === lastSubPhase.current) return;
          lastSubPhase.current = data.sub_phase;
          if (target === screenRef.current) return;
          if (target === "triage") {
            setGraded(false);
            setGradeMsg(null);
            setGrading(false);
          }
          setScreen(target);
        })
        .catch(() => {});
    }, 3000);

    return () => clearInterval(pollId);
  }, [connected]);

  // ── Tech-Day Q&A state polling ────────────────────────────────────────────
  // Polls /tech-day/state every 3s. When questions_open && active_agenda_id,
  // the player UI swaps to TechDayQuizScreen.
  aE(() => {
    if (!connected) return;
    let cancelled = false;
    const tick = () => {
      Api.getTechDayState()
        .then(s => { if (!cancelled) setTdState(s); })
        .catch(() => {});
    };
    tick();
    const iv = setInterval(tick, 3000);
    return () => { cancelled = true; clearInterval(iv); };
  }, [connected]);

  // ── Connected mode: draft polling ─────────────────────────────────────────
  aE(() => {
    if (!connected) return;
    const phase = serverPhase || lastServerPhase.current;
    if (phase !== "draft") {
      setDraftStatus(null);
      return;
    }
    const fetchDraft = () => {
      Promise.all([
        Api.getDraftStatus().catch(() => null),
        Api.getTeams().catch(() => ({})),
        Api.getUndrafted().catch(() => []),
      ]).then(([status, teams, undrafted]) => {
        setDraftStatus(status);
        setDraftTeams(teams || {});
        setDraftUndrafted(Array.isArray(undrafted) ? undrafted : []);
      });
    };
    fetchDraft();
    const iv = setInterval(fetchDraft, 1500);
    return () => clearInterval(iv);
  }, [connected, serverPhase, lastServerPhase.current]);

  // ── Connected mode: timer polling ─────────────────────────────────────────
  aE(() => {
    if (!connected) return;
    GameStore.startPolling("timer", () => Api.getTimerStatus().then(r => { setApiOk(true); return r; }).catch(e => { setApiOk(false); throw e; }), cfg.pollIntervalMs);
    const unsub = GameStore.subscribe("timer", (data) => {
      if (data && typeof data === "object") {
        // Prefer "quiz" timer, fall back to first available
        const phase = data.quiz || data[Object.keys(data)[0]];
        if (phase && typeof phase.remaining === "number") {
          setTimerSec(phase.remaining);
        }
      }
    });
    return () => {
      GameStore.stopPolling("timer");
      unsub && unsub();
    };
  }, [connected]);

  // ── Demo mode: leaderboard wobble ─────────────────────────────────────────
  aE(() => {
    if (connected) return;
    if (screen !== "quiz" && screen !== "triage") return;
    const t = setInterval(() => {
      setLeaderboard(lb =>
        lb.map(p => p.id === me.id ? p : {
          ...p,
          score: Math.max(0, p.score + Math.floor((Math.random() - 0.4) * 60)),
          climbing: Math.random() > 0.6,
        })
      );
    }, 4000);
    return () => clearInterval(t);
  }, [connected, screen]);

  // ── Demo mode: timer tick (starts when quiz or triage begins) ─────────────
  aE(() => {
    if (connected) return; // server drives timer in connected mode
    if (screen === "quiz" || screen === "triage") {
      startTimer(screen === "triage" ? cfg.timerTriageSec : totalSec);
    } else {
      stopTimer();
    }
  }, [connected, screen]);

  // ── Toast helper ──────────────────────────────────────────────────────────
  const addToast = (head, msg, kind = "") => {
    const t = makeToast(head, msg, kind);
    setToasts(prev => [...prev.slice(-3), t]);
    setTimeout(() => setToasts(prev => prev.filter(x => x.id !== t.id)), 3500);
  };

  // ── Edit-mode postMessage wiring (Claude Design integration) ──────────────
  aE(() => {
    const handler = (e) => {
      if (!e.data || typeof e.data !== "object") return;
      if (e.data.type === "ARENA_EDIT_MODE") setEditMode(!!e.data.value);
      if (e.data.type === "ARENA_SET_SCREEN" && e.data.screen) setScreen(e.data.screen);
      if (e.data.type === "ARENA_SET_TWEAK" && e.data.key) {
        setTweaks(t => ({ ...t, [e.data.key]: e.data.value }));
      }
    };
    window.addEventListener("message", handler);
    return () => window.removeEventListener("message", handler);
  }, []);

  // ── Tweak setter ──────────────────────────────────────────────────────────
  const setTweak = (key, val) => setTweaks(t => ({ ...t, [key]: val }));

  // ── Join handler ──────────────────────────────────────────────────────────
  const handleJoin = async (name, email, teamId, code) => {
    if (!name.trim()) { addToast(tc("Name required"), tc("Enter your display name"), "danger"); return; }
    if (!email.trim()) { addToast(tc("Email required"), tc("Enter your email"), "danger"); return; }

    // ── Path A: Already in connected mode — register directly ──
    if (connected) {
      try {
        await Api.register(name, email, teamId, false, false);
        Session.setEmail(email);
        try {
          const player = await Api.getPlayer(email);
          setConnectedPlayer(player);
        } catch (_) { /* Player fetch optional */ }
        addToast(tc("Checked in"), `Welcome, ${name}!`, "success");
      } catch (err) {
        addToast(tc("Check-in failed"), err.message || "API error", "danger");
        return;
      }
      // Clear stale quiz progress for this new player session
      const gameId = Session.getGameId();
      if (gameId) {
        sessionStorage.removeItem('arena_qIdx_' + gameId);
        sessionStorage.removeItem('arena_ans_' + gameId);
      }
      setQIdx(0);
      setMyScore(0);
      setStreak(0);
      // Route to correct screen based on current game phase + sub-phase.
      // sub_phase wins over the macro phase: if the proctor has advanced the
      // game to gameplan/triage/zonamixta/wrap, the new player must land
      // there directly instead of starting at quiz and waiting 3s for the
      // sub-phase poll to push them forward.
      try {
        const gameId2 = Session.getGameId();
        const [phaseData, subData] = await Promise.all([
          Api.getGamePhase().catch(() => null),
          gameId2 ? fetch(`/api/g/${gameId2}/sub-phase`).then(r => r.ok ? r.json() : null).catch(() => null) : Promise.resolve(null),
        ]);
        const SUB_TO_SCREEN = { quiz: "quiz", triage: "triage", gameplan: "gameplan", zonamixta: "zonamixta", wrap: "wrap" };
        const subTarget = subData && subData.sub_phase && SUB_TO_SCREEN[subData.sub_phase];
        if (subTarget) {
          setScreen(subTarget);
        } else {
          const p = phaseData && phaseData.phase;
          if (p === "playing") { setScreen("quiz"); }
          else { setScreen("lobby"); }
        }
      } catch (_) { setScreen("lobby"); }
      return;
    }

    // ── Path B: Code entered — resolve join code, register, reload ──
    if (code && code.trim()) {
        const gameCode = code.trim().toUpperCase();
        try {
            let gameId = null;

            // Try resolving as join_code first
            try {
                const joinRes = await fetch("/api/events/join", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ code: gameCode }),
                });
                if (joinRes.ok) {
                    const joinData = await joinRes.json();
                    gameId = joinData.game_id;
                    console.log("[join] Resolved join code", gameCode, "→ game_id", gameId);
                }
            } catch (_) { /* join endpoint failed, try direct */ }

            // Fall back to treating code as game_id directly
            if (!gameId) {
                const res = await fetch("/api/games/" + encodeURIComponent(gameCode));
                if (!res.ok) throw new Error("not found");
                gameId = gameCode;
                console.log("[join] Using code as direct game_id:", gameId);
            }

            // Valid game — register + switch to connected mode
            Session.setGameId(gameId);
            Session.setEmail(email);
            await fetch("/api/g/" + encodeURIComponent(gameId) + "/register", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ name, email, team: teamId }),
            });
            sessionStorage.setItem("arena.pendingScreen", "lobby");
            window.location.reload();
            return;
        } catch (err) {
            addToast(tc("Invalid code"), "Session not found — check your code or leave blank for demo.", "danger");
            return;
        }
    }

    // ── Path C: No code — demo mode ──
    setScreen("lobby");
  };

  // ── Observer auto-join ────────────────────────────────────────────────────
  // If URL had ?observer=1&email=...&game=..., _initFromURL stored params on
  // window._arenaObserverEmail. Fire once when screen==="join" and connected.
  // Registers the observer, then reads the LIVE sub_phase from the server and
  // routes directly to that screen — never to lobby/draft.
  aE(function() {
    const obsEmail = window._arenaObserverEmail;
    if (!obsEmail || screen !== "join" || !connected) return;
    const obsName = window._arenaObserverName || "Observer";
    delete window._arenaObserverEmail;
    delete window._arenaObserverName;
    setIsObserver(true);
    const obsGameId = Session.getGameId();
    // Observer-specific join: explicit register + sub-phase fetch,
    // fallback to "phaseTransition" (waiting screen) — NEVER lobby/draft.
    (async function _observerJoin() {
      try {
        await Api.register(obsName, obsEmail, "", false, "internal");
        Session.setEmail(obsEmail);
        addToast(tc("Checked in"), "Observer mode — no scoring", "success");
      } catch (err) {
        addToast(tc("Check-in failed"), err.message || "API error", "danger");
        return;
      }
      // Route to the screen matching the CURRENT live sub_phase.
      // "phaseTransition" is the safe waiting screen — it shows a neutral
      // scoreboard view and the sub_phase poller will advance us automatically.
      const OBS_SUB_SCREEN = {
        quiz: "quiz", triage: "triage", breach: "triage",
        gameplan: "gameplan", zonamixta: "zonamixta", wrap: "wrap",
      };
      let target = "phaseTransition"; // safe fallback — never lobby
      try {
        if (obsGameId) {
          const subData = await fetch(`/api/g/${encodeURIComponent(obsGameId)}/sub-phase`)
            .then(function(r) { return r.ok ? r.json() : null; })
            .catch(function() { return null; });
          if (subData && subData.sub_phase) {
            target = OBS_SUB_SCREEN[subData.sub_phase] || "phaseTransition";
          }
        }
      } catch (_) { /* keep phaseTransition */ }
      console.log("[observer] sub_phase →", target);
      setScreen(target);
    })();
  }, [screen, connected]); // eslint-disable-line react-hooks/exhaustive-deps

  // ── Quiz: select & answer ─────────────────────────────────────────────────
  const handleSelect = (key) => {
    if (!locked) setSelected(key);
  };

  const handleAnswer = async (key) => {
    if (locked) return;
    setLocked(true);

    const q = questions[qIdx];
    const gameId = Session.getGameId();

    // Anti-cheat: skip scoring if this question was already answered (refresh exploit)
    const answeredKey = gameId ? 'arena_ans_' + gameId : null;
    const answered = answeredKey ? JSON.parse(sessionStorage.getItem(answeredKey) || '[]') : [];
    if (answered.includes(qIdx)) {
      console.log("[Arena] Q" + (qIdx + 1) + " already scored — skipping duplicate");
      addToast(tc("Already answered"), tc("Score unchanged"), "");
      return;
    }

    let correct, points;
    let partialMatch = false; // true for drag_match with 0 < pct < 1 (no streak multiplier)

    if (q.type && q.spec && window.RENDERERS && window.RENDERERS[q.type]) {
      if (q.type === 'drag_match' && q.spec.pairs) {
        // Partial-credit grading for drag-match.
        // Scoring: all correct → full q.points × streak mult;
        //          partial (0 < pct < 1) → Math.round(q.points * pct * 0.5), no streak mult;
        //          zero correct → 0 pts, streak broken.
        // "correct" for streak/leaderboard: pct >= 0.5 (50% threshold).
        const pairs = q.spec.pairs;
        const total = pairs.length;
        const matched = pairs.filter(p => key?.[p.leftId] === p.rightId).length;
        const pct = total > 0 ? matched / total : 0;
        correct = pct >= 0.5; // 50% threshold counts as correct for streak
        if (pct === 1) {
          points = q.points || 200; // full credit; streak multiplier applied below
        } else if (pct > 0) {
          points = Math.round((q.points || 200) * pct * 0.5); // partial, no streak mult
          partialMatch = true;
        } else {
          points = 0;
        }
      } else {
        // New format — use checkCorrect helper
        correct = window.checkCorrect(q, key);
        if (correct === null) {
          // Ungraded type (free_text, hotspot, etc.) — award participation points, keep streak
          points = q.points || 50;
          correct = true; // treat as "ok" for streak purposes
        } else {
          points = correct ? (q.points || 120) : 0;
        }
      }
    } else {
      // Legacy format
      correct = key === q.correct;
      points = correct ? 120 : 0;
    }
    setGradedOutcome(partialMatch ? 'partial' : correct ? 'correct' : 'wrong');
    const newStreak = correct ? streak + 1 : 0;

    // Streak multiplier: 3+ correct = 1.5×, 5+ = 2× (not applied for partial drag_match)
    let streakMultiplier = 1;
    if (!partialMatch && correct && newStreak >= 5) streakMultiplier = 2;
    else if (!partialMatch && correct && newStreak >= 3) streakMultiplier = 1.5;
    const finalPoints = Math.round(points * streakMultiplier);
    const newScore  = myScore + finalPoints;

    setPrevScore(myScore);
    setMyScore(newScore);
    setStreak(newStreak);

    // Mark as answered + save progress position
    answered.push(qIdx);
    if (answeredKey) sessionStorage.setItem(answeredKey, JSON.stringify(answered));

    if (correct && newStreak >= 3) {
      setCelebration(Date.now());
    }

    if (connected) {
      const email = Session.getEmail();
      if (email) {
        try {
          const serverResult = await Api.updateScore(email, newScore, newStreak);
          // Sync with server truth — catches silent rejections (timer expired, etc.)
          if (serverResult && typeof serverResult.score === 'number' && serverResult.score !== newScore) {
            console.warn("[Arena] Server score differs:", serverResult.score, "vs local:", newScore);
            setMyScore(serverResult.score);
          }
          const qId = q.id || q.tag || `q${qIdx}`;
          const progressEntry = { qId, answered: key, correct, pts: finalPoints, streak: newStreak, multiplier: streakMultiplier };
          const saveResult = await Api.saveQuizProgress(email, [progressEntry], newScore, newStreak);
          // Reconcile local score with server truth — server recomputes from progress entries
          // and may differ (e.g. timer expired, duplicate rejected, non-quiz score offset)
          if (saveResult && typeof saveResult.score === 'number') {
            if (saveResult.score !== newScore) {
              console.warn("[Arena] saveQuizProgress score differs — server:", saveResult.score, "local:", newScore);
            }
            setMyScore(saveResult.score);
            if (saveResult.streak != null) setStreak(saveResult.streak);
          }
        } catch (err) {
          console.warn("[Arena] score sync failed:", err.message);
        }
      }
    }

    addToast(
      correct ? tc("Correct!") : tc("Not quite"),
      correct
        ? `+${finalPoints} pts${streakMultiplier > 1 ? ` (${streakMultiplier}× streak!)` : ''} · streak × ${newStreak}`
        : tc("No points this round"),
      correct ? "success" : "danger"
    );
  };

  // ── Quiz: skip question ───────────────────────────────────────────────────
  // Awards 0 pts, resets streak, marks question as answered (no replay).
  // Auto-advances after 800 ms. API call is fire-and-forget to avoid blocking.
  const handleSkip = () => {
    const q = questions[qIdx];
    const gameId = Session.getGameId();

    // Anti-cheat: already answered (e.g. race from double-tap timing)
    const answeredKey = gameId ? 'arena_ans_' + gameId : null;
    const answered = answeredKey ? JSON.parse(sessionStorage.getItem(answeredKey) || '[]') : [];
    if (answered.includes(qIdx)) {
      handleNext();
      return;
    }

    // 0 pts, streak broken
    setStreak(0);
    setGradedOutcome('skipped');

    // Mark qIdx as answered so it can't be replayed
    answered.push(qIdx);
    if (answeredKey) sessionStorage.setItem(answeredKey, JSON.stringify(answered));

    addToast(tc("Skipped"), `+0 pts · ${tc("streak broken")}`, "");

    // Auto-advance after short pause regardless of API outcome
    setTimeout(handleNext, 800);

    // Fire-and-forget progress entry
    if (connected) {
      const email = Session.getEmail();
      if (email) {
        const qId = q.id || q.tag || `q${qIdx}`;
        const progressEntry = { qId, answered: true, correct: false, skipped: true, pts: 0, streak: 0 };
        Api.saveQuizProgress(email, [progressEntry], myScore, 0)
          .then(result => {
            if (result && typeof result.score === 'number') setMyScore(result.score);
            if (result && result.streak != null) setStreak(result.streak);
          })
          .catch(err => console.warn("[Arena] skip sync failed:", err.message));
      }
    }
  };

  const handleNext = () => {
    const gameId = Session.getGameId();
    if (qIdx < questions.length - 1) {
      const next = qIdx + 1;
      setQIdx(next);
      if (gameId) sessionStorage.setItem('arena_qIdx_' + gameId, String(next));
      setSelected(null);
      setLocked(false);
      setGradedOutcome(null);
    } else {
      if (gameId) {
        sessionStorage.removeItem('arena_qIdx_' + gameId);
        sessionStorage.removeItem('arena_ans_' + gameId);
      }
      setTransitionTarget("triage");
      setScreen("phaseTransition");
      setSelected(null);
      setLocked(false);
      setGradedOutcome(null);
    }
  };

  // ── Power-up handler ─────────────────────────────────────────────────────
  const handlePowerup = async (puId) => {
    const newSpent = [...powerupsSpent, puId];
    setPowerupsSpent(newSpent);
    const newScore = Math.max(0, myScore - 50);
    setPrevScore(myScore);
    setMyScore(newScore);

    if (connected) {
      const email = Session.getEmail();
      if (email) {
        try {
          // Convert spent array to dict format expected by server
          // Server expects { key: boolean } where false = spent
          const puDict = {};
          (theme.powerups || []).forEach(p => {
            puDict[p.id] = !newSpent.includes(p.id);
          });
          await Api.savePowerUps(email, puDict);
        } catch (err) {
          console.warn("[Arena] power-up sync failed:", err.message);
        }
      }
    }

    addToast(tc("Power-up used"), "-50 pts", "");
  };

  // ── Triage submit ─────────────────────────────────────────────────────────
  const handleTriageSubmit = async (ans, powerUps = {}) => {
    // Handle skip
    if (powerUps.skipUsed) {
      const penalty = Math.round((powerUps.penaltyPct || 50) / 100 * 100); // 50% of max 100 pts
      setPrevScore(myScore);
      setMyScore(Math.max(0, myScore - penalty));
      setGraded(true);
      setGradeMsg({ score: 0, note: tc("Ticket skipped. Penalty applied: -") + penalty + tc(" pts") });
      addToast(tc("Ticket skipped"), "-" + penalty + " pts", "danger");
      setGrading(false);
      return;
    }

    if (connected) {
      const email = Session.getEmail();
      if (email) {
        setGrading(true);
        try {
          const gradeResult = await Api.gradeTicket({
            email,
            ticket_id: ticket.id,
            answer: ans,
            ticket_question: ticket.question || ticket.title,
            ticket_scenario: ticket.desc || ticket.scenario || "",
            ticket_hint: powerUps.hintUsed ? (typeof ticket.hint === 'object' && ticket.hint ? (ticket.hint.text || "") : (ticket.hint || "")) : "",
            // Real answer key — `ticket.solution` is the multi-step playbook
            // for ISE tickets. Fall back to the syslog dump only when no
            // solution field exists (legacy v2 demo tickets).
            ticket_solution: ticket.solution || (ticket.log ? ticket.log.join("\n") : ""),
            ticket_licensing_angle: ticket.licensingAngle || "",
            ticket_symptoms: Array.isArray(ticket.symptomsList) ? ticket.symptomsList.join("\n") : "",
            max_points: 100,
          });

          // Apply power-up modifiers to score
          let finalScore = gradeResult.score ?? 75;
          if (powerUps.hintUsed) finalScore = Math.round(finalScore * (1 - (powerUps.hintPenalty || 20) / 100));
          if (powerUps.shuffleUsed) finalScore = Math.round(finalScore * (1 - (powerUps.shufflePenalty || 10) / 100));
          if (powerUps.double) finalScore = finalScore >= 60 ? finalScore * 2 : 0;

          // Save the answer with ticket context so server can grade authoritatively (Fix 3)
          await Api.saveTicketAnswer({
            email,
            ticket_id: ticket.id,
            module: "",
            answer: ans,
            ai_score: finalScore,        // server ignores this; kept for compatibility
            ai_available: gradeResult.ai_available ?? false,
            ai_feedback: gradeResult.feedback ?? "",
            ticket_question: ticket.question || ticket.title,
            ticket_scenario: ticket.desc || ticket.scenario || "",
            ticket_hint: powerUps.hintUsed ? (typeof ticket.hint === 'object' && ticket.hint ? (ticket.hint.text || "") : (ticket.hint || "")) : "",
            ticket_solution: ticket.solution || (ticket.log ? ticket.log.join("\n") : ""),
          });

          setGraded(true);
          setGrading(false);
          let note = gradeResult.feedback ?? "Graded by server.";
          if (powerUps.double && finalScore > 0) note += " ⚡ Double bonus!";
          if (powerUps.double && finalScore === 0) note += " ⚡ Double risk — zero points.";
          setGradeMsg({ score: finalScore, note });
          setPrevScore(myScore);
          setMyScore(myScore + finalScore);
          addToast(tc("Triage graded"), `Score: ${finalScore}/100`, finalScore > 0 ? "success" : "danger");
        } catch (err) {
          console.warn("[Arena] triage grade failed:", err.message);
          setGrading(false);
          mockGradeTriage(ans, powerUps);
        }
      }
    } else {
      setGrading(false);
      mockGradeTriage(ans, powerUps);
    }
  };

  const mockGradeTriage = (ans, powerUps = {}) => {
    const keywords = ["loop guard","loopguard","udld","gi1/0/24","bpdu","stp","spanning-tree"];
    const hits = keywords.filter(k => (ans || "").toLowerCase().includes(k)).length;
    let score = Math.min(100, 40 + hits * 10 + ((ans || "").length > 200 ? 10 : 0));
    if (powerUps.hintUsed) score = Math.round(score * 0.8);
    if (powerUps.shuffleUsed) score = Math.round(score * 0.9);
    if (powerUps.double) score = score >= 60 ? score * 2 : 0;
    const note = hits >= 3
      ? "Strong diagnosis — loop-guard misconfig identified, rollback mentioned."
      : hits >= 1
      ? "Partial credit — mention the STP loop-guard trigger and remediation."
      : "Review the syslog carefully — look for the repeating STP block event.";
    const bonus = Math.round(score * 1.2);
    setPrevScore(myScore);
    setMyScore(myScore + bonus);
    setGraded(true);
    setGradeMsg({ score, note });
    addToast(tc("Triage graded"), `Score: ${score}/100 · +${bonus} pts`, "success");
  };

  // ── Next ticket (multi-ticket) ───────────────────────────────────────────
  const handleNextTicket = () => {
    const nextIdx = ticketIdx + 1;
    if (nextIdx < tickets.length) {
      setTicketIdx(nextIdx);
      setGraded(false);
      setGradeMsg(null);
      setGrading(false);
    }
  };

  // ── Shuffle power-up — swap current ticket for a random unused one ──────
  const handleShuffle = () => {
    if (!connected) return null;
    const email = Session.getEmail();
    const gameId = Session.getGameId();
    if (!email || !gameId) return null;

    // Get all tickets from module
    fetch("/api/games/" + encodeURIComponent(gameId))
      .then(r => r.ok ? r.json() : null)
      .then(game => {
        if (!game) return;
        const modId = (game.modules || ["ise"])[0];
        const mod = window.GAME_MODULES && window.GAME_MODULES[modId];
        if (!mod || !mod.data || !mod.data.tickets) return;

        const allTickets = mod.data.tickets;
        const usedIds = new Set(tickets.map(t => t.id));
        const available = allTickets.filter(t => !usedIds.has(t.id));
        if (available.length === 0) return; // no tickets left to shuffle

        const randomIdx = Math.floor(Math.random() * available.length);
        // Normalize the new ticket so TriageScreen sees `.log` and the rich
        // ISE fields (scenario, audienceFraming, symptoms, etc.). Without
        // this, shuffle would drop the player back to "Loading ticket…".
        const newTicket = normalizeTicket(available[randomIdx]);

        // Replace current ticket in the array
        setTickets(prev => {
          const updated = [...prev];
          updated[ticketIdx] = newTicket;
          return updated;
        });
      });
  };

  // BreachIncidentScreen alias removed — was being redeclared inside App() body
  // every render, creating a new component identity and unmounting/remounting
  // BreachIncidentScreenHoisted on each re-render. That destroyed the textarea
  // local state and wiped what players typed. Use BreachIncidentScreenHoisted
  // directly in JSX below.

  // ── Computed ──────────────────────────────────────────────────────────────
  const urgent    = timerSec <= 30;
  const phaseKey  = screen === "phaseTransition" ? (transitionTarget || "triage") : screen;
  const phaseLabel = theme.phaseNames[phaseKey] || phaseKey;

  const layoutCompact = tweaks.layout === "compact";
  // Tech-Day override: when an active talk has questions open, swap the
  // entire stage for the TechDayQuizScreen regardless of game phase.
  const tdActive = connected && tdState && tdState.questions_open && !!tdState.active_agenda_id && !!Session.getEmail() && screen !== "join";

  // ── Render ────────────────────────────────────────────────────────────────

  // Join screen renders full-viewport WITHOUT the game shell (no header/sidebar/footer)
  if (screen === "join") {
    return (
      <>
        <JoinScreen
          theme={theme}
          onJoin={handleJoin}
          me={me}
          leaderboard={leaderboard}
          connected={connected}
        />
        <Toasts toasts={toasts} />
      </>
    );
  }

  return (
    <>
      <div className="shell" data-screen={screen} aria-label="Cisco Arena">
        <Header
          theme={theme}
          phaseLabel={phaseLabel}
          phaseKey={phaseKey}
          me={me}
          team={team}
          sessionCode={Session.getGameId() || "CA-7842-KR"}
          proctorName="J. Medina"
        />

        <main className="stage" id="main-stage" role="main" aria-live="polite" aria-label="Game stage" tabIndex={-1} style={{outline: "none"}}>

          {/* Tech-Day Q&A override: when an active talk has questions open,
              the player must answer here regardless of macro game phase. */}
          {tdActive && (
            <TechDayQuizScreen
              theme={theme}
              tdState={tdState}
              email={Session.getEmail()}
              name={me?.name || ""}
            />
          )}

          {!tdActive && screen === "lobby" && (
            <LobbyScreen
              theme={theme}
              leaderboard={leaderboard}
              me={me}
              onStart={() => setScreen("quiz")}
              draftStatus={draftStatus}
              draftTeams={draftTeams}
              draftUndrafted={draftUndrafted}
              isCaptain={isCaptain}
              isMyTurn={isMyDraftTurn}
              onDraftPick={handleDraftPick}
              connected={connected}
            />
          )}

          {!tdActive && screen === "quiz" && (() => {
            if (!questions.length || !questions[qIdx]) return null;
            return (
            <QuizScreen
              key={qIdx}
              theme={theme}
              qIdx={qIdx}
              question={questions[qIdx]}
              totalQuestions={questions.length}
              onAnswer={handleAnswer}
              onNext={handleNext}
              onSkip={handleSkip}
              selected={selected}
              locked={locked}
              correctKey={questions[qIdx].correct || questions[qIdx].spec?.correct}
              onSelect={handleSelect}
              gradedOutcome={gradedOutcome}
            />
            );
          })()}

          {!tdActive && screen === "phaseTransition" && (
            <PhaseTransitionScreen
              theme={theme}
              leaderboard={leaderboard}
              me={me}
              gameId={Session.getGameId()}
              targetSubPhase={transitionTarget}
              fromPhase={transitionTarget === "triage" ? "quiz" : transitionTarget === "gameplan" ? "triage" : "gameplan"}
              onContinue={() => {
                if (transitionTarget === "triage") {
                  setGraded(false);
                  setGradeMsg(null);
                  setGrading(false);
                  setScreen("triage");
                } else if (transitionTarget === "gameplan") {
                  setScreen("gameplan");
                } else if (transitionTarget === "zonamixta") {
                  setScreen("zonamixta");
                } else {
                  setScreen("wrap");
                }
              }}
            />
          )}

          {!tdActive && screen === "triage" && (
            (!gameConfig && connected) || gameConfig?.event_type === 'clinic' ? (
              <BreachIncidentScreenHoisted
                gameId={connected ? Session.getGameId() : null}
                game={gameConfig}
                me={me}
                isCaptain={isCaptain}
              />
            ) : (
              <TriageScreen
                theme={theme}
                ticket={ticket}
                ticketIdx={ticketIdx}
                totalTickets={tickets.length}
                gameId={connected ? Session.getGameId() : null}
                onSubmit={handleTriageSubmit}
                grading={grading}
                graded={graded}
                gradeMsg={gradeMsg}
                onFinish={() => {
                  setTransitionTarget("gameplan");
                  setScreen("phaseTransition");
                }}
                onNextTicket={handleNextTicket}
                onShuffle={handleShuffle}
              />
            )
          )}

          {!tdActive && screen === "gameplan" && (
            <GamePlanScreen
              theme={theme}
              gameModule={gameModule}
              me={me}
              gameId={Session.getGameId()}
              onFinish={() => {
                setTransitionTarget("zonamixta");
                setScreen("phaseTransition");
              }}
            />
          )}

          {!tdActive && screen === "zonamixta" && (
            <ZonaMixtaScreen
              theme={theme}
              gameModule={gameModule}
              me={me}
              onFinish={() => setScreen("wrap")}
            />
          )}

          {!tdActive && screen === "wrap" && (
            <WrapScreen
              theme={theme}
              leaderboard={leaderboard}
              me={me}
            />
          )}

          {celebration && (
            <Celebration
              key={celebration}
              theme={theme}
              streak={streak}
              onDone={() => setCelebration(null)}
            />
          )}

          <Toasts toasts={toasts} />
        </main>

        {tweaks.layout === "ticker" ? (
          <TickerBar theme={theme} leaderboard={leaderboard} timerSec={timerSec} urgent={urgent} />
        ) : (connected && (screen === "lobby" || screen === "phaseTransition")) ? null : (
          <Sidebar
            theme={theme}
            leaderboard={leaderboard}
            me={me}
            streak={streak}
            score={myScore}
            prevScore={prevScore}
            timerSec={timerSec}
            totalSec={totalSec}
            urgent={urgent}
            powerupsSpent={powerupsSpent}
            onPowerup={handlePowerup}
            layoutCompact={layoutCompact}
            screen={screen}
            draftStatus={draftStatus}
            draftTeams={draftTeams}
            draftUndrafted={draftUndrafted}
            isCaptain={isCaptain}
            isMyTurn={isMyDraftTurn}
            connected={connected}
            tdActive={tdActive}
          />
        )}

        <Footer
          theme={theme}
          sessionCode={Session.getGameId() || "CA-7842-KR"}
          proctorName="J. Medina"
          region="LATAM-S"
        />
      </div>

      {!connected && <ScreenNav screen={screen} onPick={(s) => {
        setScreen(s);
        if (s === "quiz") { setQIdx(serverQIdxRef.current); setSelected(null); setLocked(false); }
        if (s === "triage") { setGraded(false); setGradeMsg(null); }
      }} />}

      {!connected && (
        <TweaksPanel
          open={tweakOpen}
          onOpen={() => setTweakOpen(true)}
          onClose={() => setTweakOpen(false)}
          state={tweaks}
          set={setTweak}
        />
      )}

      {/* Connected-mode badge */}
      {connected && (
        <div className="mono" style={{
          position: "fixed", bottom: 8, left: 8,
          fontSize: 10, color: apiOk ? "var(--success)" : "var(--danger)", opacity: 0.7,
          pointerEvents: "none", zIndex: 9999,
        }}>
          {apiOk ? "●" : "○"} {apiOk ? tc("LIVE") : tc("OFFLINE")} · {Session.getGameId()}
        </div>
      )}

      {/* Observer mode badge */}
      {connected && isObserver && (
        <div style={{
          position: "fixed", top: 8, right: 8,
          background: "rgba(0,0,0,0.75)", color: "#fff",
          fontSize: 11, fontWeight: 700, padding: "4px 10px", borderRadius: 6,
          letterSpacing: "0.05em", zIndex: 9998, pointerEvents: "none",
        }}>
          👁 OBSERVER — no scoring
        </div>
      )}
    </>
  );
}

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