/* global React, ReactDOM, t, setLang */
const { useState, useEffect, useRef, useMemo } = React;

// Re-render this component whenever the global language flips. window.LANG
// drives every t() call; the langchange CustomEvent fires from setLang() in
// i18n.js (host self-selection) and from the snapshot handler below
// (server-dictated quiz language).
function useLang() {
  const [lang, setLangState] = useState(window.LANG);
  useEffect(() => {
    const handler = () => setLangState(window.LANG);
    window.addEventListener("langchange", handler);
    return () => window.removeEventListener("langchange", handler);
  }, []);
  return lang;
}

const SHAPES = [
  { name: "triangle", color: "var(--ans1)", path: "M50 8 L92 88 L8 88 Z" },
  { name: "diamond",  color: "var(--ans2)", path: "M50 6 L94 50 L50 94 L6 50 Z" },
  { name: "circle",   color: "var(--ans3)", path: "M50 8 a42 42 0 1 0 0.01 0" },
  { name: "square",   color: "var(--ans4)", path: "M10 10 H90 V90 H10 Z" },
  // 5th shape — star — used only when a question has 5 options.
  { name: "star",     color: "var(--ans5)", path: "M50 6 L61 38 L94 38 L67 58 L77 90 L50 70 L23 90 L33 58 L6 38 L39 38 Z" },
];

// "A", "B" or "A".."D" or "A".."E" depending on option count.
const KEY_LETTERS = ["A", "B", "C", "D", "E"];

const clsx = (...xs) => xs.filter(Boolean).join(" ");

// Floating top-right language switcher. Visible to public (pre-join) players
// and persists the choice in localStorage so refresh keeps the language.
// Once the player joins a session the server snapshot's `language` field
// takes over and may flip the UI to match the host's quiz language.
function LangToggle({ floating }) {
  const lang = useLang();
  function pick(next) {
    setLang(next, { persist: true });
  }
  return (
    <div
      className={clsx("lang-toggle", floating && "lang-toggle--floating")}
      role="group"
      aria-label="Language"
    >
      <button
        type="button"
        className={clsx("lang-toggle-btn", lang === "en" && "lang-toggle-btn--active")}
        onClick={() => pick("en")}
        aria-pressed={lang === "en"}
      >EN</button>
      <button
        type="button"
        className={clsx("lang-toggle-btn", lang === "tr" && "lang-toggle-btn--active")}
        onClick={() => pick("tr")}
        aria-pressed={lang === "tr"}
      >TR</button>
    </div>
  );
}

function BrandRow({ small }) {
  return (
    <div className={clsx("brand-row", small && "brand-row--sm")}>
      <img src="/assets/cloudflare.png" alt="Cloudflare" />
    </div>
  );
}

// Persistent "don't close this tab" reminder. Event runs ~4h with long breaks
// between questions; accidental closes lose the player's session/score.
function KeepTabNotice() {
  useLang();
  return (
    <div className="keep-tab-notice" role="note">
      <span className="keep-tab-icon" aria-hidden>{t("notice.keep_tab_warn")}</span>
      <span>
        {t("notice.keep_tab_body_prefix")} <strong>{t("notice.keep_tab_body_strong")}</strong> {t("notice.keep_tab_body_suffix")}
      </span>
    </div>
  );
}

function FloatingShapes() {
  const items = useMemo(() => {
    const arr = [];
    for (let i = 0; i < 14; i++) {
      arr.push({
        s: SHAPES[i % 4],
        left: Math.random() * 100,
        top: Math.random() * 100,
        size: 24 + Math.random() * 72,
        dur: 14 + Math.random() * 22,
        delay: -Math.random() * 20,
        rot: Math.random() * 360,
      });
    }
    return arr;
  }, []);
  return (
    <div className="float-bg" aria-hidden>
      {items.map((it, i) => (
        <svg key={i} viewBox="0 0 100 100" className="float-shape"
          style={{
            left: it.left + "%", top: it.top + "%",
            width: it.size, height: it.size,
            animationDuration: it.dur + "s",
            animationDelay: it.delay + "s",
            transform: `rotate(${it.rot}deg)`,
            color: it.s.color,
          }}>
          <path d={it.s.path} fill="currentColor" />
        </svg>
      ))}
    </div>
  );
}

// =====================================================
// JOIN
// =====================================================
function JoinScreen({ initialPin, onJoined }) {
  useLang();
  const [step, setStep] = useState(initialPin ? 1 : 0);
  const [pin, setPin] = useState(initialPin ?? "");
  const [name, setName] = useState("");
  const [surname, setSurname] = useState("");
  const [email, setEmail] = useState("");
  const [kvkk, setKvkk] = useState(false);
  const [shake, setShake] = useState(false);
  const [busy, setBusy] = useState(false);
  const [errors, setErrors] = useState({});
  const [globalErr, setGlobalErr] = useState(null);

  async function submitPin(e) {
    e.preventDefault();
    setGlobalErr(null);
    if (pin.length !== 6) {
      setShake(true);
      setTimeout(() => setShake(false), 500);
      return;
    }
    setBusy(true);
    try {
      const res = await fetch(`/api/rooms/${pin}`);
      const data = await res.json();
      if (!res.ok || !data.exists) {
        setGlobalErr(t("player.err_generic"));
        setShake(true); setTimeout(() => setShake(false), 500);
      } else if (!data.canJoin) {
        setGlobalErr(t("player.err_generic"));
      } else {
        setStep(1);
      }
    } catch {
      setGlobalErr(t("common.disconnected"));
    } finally {
      setBusy(false);
    }
  }

  async function submitDetails(e) {
    e.preventDefault();
    const errs = {};
    if (!name.trim()) errs.name = t("player.err_name");
    if (!surname.trim()) errs.surname = t("player.err_surname");
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errs.email = t("player.err_email");
    if (!kvkk) errs.kvkk = t("player.err_kvkk");
    setErrors(errs);
    if (Object.keys(errs).length) return;

    setBusy(true);
    setGlobalErr(null);
    try {
      const res = await fetch(`/api/rooms/${pin}/join`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          name: name.trim(),
          surname: surname.trim(),
          email: email.trim(),
          kvkkConsent: kvkk,
        }),
      });
      const data = await res.json();
      if (!res.ok) {
        setGlobalErr(data.error || t("player.err_generic"));
      } else {
        onJoined({
          pin,
          playerId: data.playerId,
          playerToken: data.playerToken,
          name: data.name,
          surname: data.surname,
        });
      }
    } catch {
      setGlobalErr(t("common.disconnected"));
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="screen join-screen">
      <FloatingShapes />
      <div className="join-card">
        <BrandRow />
        <div className="event-tag">{t("common.kahoot_tag")}</div>

        {step === 0 && (
          <form onSubmit={submitPin} className={clsx("join-form", shake && "shake")}>
            <h1>{t("player.join_title")}</h1>
            <p className="muted">{t("player.join_pin_help")}</p>
            {globalErr && <div className="err-banner">{globalErr}</div>}
            <input
              autoFocus
              className="pin-input"
              inputMode="numeric"
              maxLength={6}
              placeholder={t("player.join_pin_placeholder")}
              value={pin}
              onChange={(e) => setPin(e.target.value.replace(/[^0-9]/g, ""))}
            />
            <button className="btn-primary" type="submit" disabled={pin.length < 6 || busy}>
              {busy ? t("player.join_check") : t("player.join_continue")}
            </button>
          </form>
        )}

        {step === 1 && (
          <form onSubmit={submitDetails} className="join-form">
            <h1>{t("player.details_title")}</h1>
            <p className="muted">{t("player.details_help")}</p>
            {globalErr && <div className="err-banner">{globalErr}</div>}
            <div className="field">
              <label>{t("player.label_name")}</label>
              <input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("player.placeholder_name")} autoFocus />
              {errors.name && <span className="err">{errors.name}</span>}
            </div>
            <div className="field">
              <label>{t("player.label_surname")}</label>
              <input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("player.placeholder_surname")} />
              {errors.surname && <span className="err">{errors.surname}</span>}
            </div>
            <div className="field">
              <label>{t("player.label_email")}</label>
              <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t("player.placeholder_email")} />
              {errors.email && <span className="err">{errors.email}</span>}
            </div>
            <label className="consent">
              <input type="checkbox" checked={kvkk} onChange={(e) => setKvkk(e.target.checked)} />
              <span>{t("player.consent_text")}</span>
            </label>
            {errors.kvkk && <span className="err">{errors.kvkk}</span>}
            <button className="btn-primary" type="submit" disabled={busy} style={{ marginTop: 12 }}>
              {busy ? t("player.join_submitting") : t("player.join_lobby")}
            </button>
            <button type="button" className="btn-ghost" onClick={() => setStep(0)}>{t("player.back_new_pin")}</button>
          </form>
        )}
      </div>
    </div>
  );
}

// =====================================================
// LOBBY
// =====================================================
function LobbyScreen({ player, pin, players, countdown, sseStatus, onReset }) {
  useLang();
  return (
    <div className="screen lobby">
      <FloatingShapes />
      <div className="lobby-top">
        <BrandRow small />
        <div className="pin-pill">PIN <strong>{pin}</strong></div>
      </div>
      {sseStatus !== "open" && (
        <div className="ws-status-banner">
          <span>
            {sseStatus === "connecting" ? t("common.connecting") : t("common.disconnected")}
          </span>
        </div>
      )}

      <div className="lobby-center">
        {countdown == null ? (
          <>
            <div className="you-card">
              <div className="avatar" aria-hidden>
                {(player.name[0] || "").toUpperCase()}
                {(player.surname[0] || "").toUpperCase()}
              </div>
              <div>
                <div className="you-label">{t("player.welcome")}</div>
                <div className="you-name">{player.name} {player.surname}</div>
              </div>
              <div className="status-dot"><span /> {t("player.in_lobby")}</div>
            </div>
            <div className="waiting">{t("player.waiting_host")}</div>
            <KeepTabNotice />
          </>
        ) : (
          <div className="countdown">
            <div className="countdown-label">{t("player.starting")}</div>
            <div className="countdown-num" key={countdown}>{countdown <= 0 ? "GO" : countdown}</div>
          </div>
        )}
      </div>

      <div className="lobby-players">
        <div className="players-head">
          <span className="dot" /> {players.length} {t("player.lobby_count")}
        </div>
        <div className="players-grid">
          {players.map((p, i) => (
            <div className="chip" key={p.id} style={{ animationDelay: i * 0.04 + "s" }}>
              <div className="chip-av">
                {(p.name[0] || "").toUpperCase()}
                {(p.surname[0] || "").toUpperCase()}
              </div>
              <span>{p.name} {p.surname[0] || ""}.</span>
            </div>
          ))}
        </div>
        {onReset && (
          <button
            type="button"
            className="btn-ghost lobby-reset"
            onClick={onReset}
          >
            {t("player.lobby_reset_help")}
          </button>
        )}
      </div>
    </div>
  );
}

// =====================================================
// QUESTION (player view — just shapes, question text on display)
// =====================================================
function QuestionScreen({ idx, total, durationSec, startedAt, onAnswer, picked, optionCount }) {
  useLang();
  const [now, setNow] = useState(Date.now());
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 100);
    return () => clearInterval(id);
  }, []);
  const elapsed = Math.max(0, (now - startedAt) / 1000);
  const timeLeft = Math.max(0, durationSec - elapsed);
  const pct = Math.max(0, timeLeft / durationSec);
  // Clamp to supported counts so a malformed payload doesn't blow up the grid.
  const count = optionCount === 2 || optionCount === 5 ? optionCount : 4;
  const shapes = SHAPES.slice(0, count);
  const gridClass = count === 2 ? "shape-grid shape-grid--2"
    : count === 5 ? "shape-grid shape-grid--5"
    : "shape-grid";

  return (
    <div className="screen question">
      <div className="q-top">
        <div className="q-meta">
          <span className="q-idx">{t("player.q_idx")} {idx + 1} / {total}</span>
        </div>
        <BrandRow small />
        <div className="q-meta right">
          <div className="q-timer-ring">
            <svg viewBox="0 0 100 100">
              <circle cx="50" cy="50" r="44" className="ring-bg" />
              <circle cx="50" cy="50" r="44" className="ring-fg"
                strokeDasharray={`${2 * Math.PI * 44}`}
                strokeDashoffset={`${2 * Math.PI * 44 * (1 - pct)}`} />
            </svg>
            <div className="ring-num">{Math.ceil(timeLeft)}</div>
          </div>
        </div>
      </div>

      <div className="q-bar">
        <div className="q-bar-fill" style={{ width: pct * 100 + "%" }} />
      </div>

      {picked == null && (
        <div className="muted" style={{ textAlign: "center", marginTop: 8 }}>
          Sahnedeki ekrandan soruyu oku ve cevabını seç
        </div>
      )}

      <div className={gridClass}>
        {shapes.map((shape, i) => {
          const state = picked == null ? "" : (i === picked ? "picked" : "dim");
          return (
            <button
              key={i}
              className={clsx("ans", `ans--${i}`, state)}
              onClick={() => picked == null && onAnswer(i)}
              disabled={picked != null}
            >
              <svg viewBox="0 0 100 100" className="ans-icon" aria-hidden>
                <path d={shape.path} fill="currentColor" />
              </svg>
              <span className="ans-text">{KEY_LETTERS[i]}</span>
              <span className="ans-key">{KEY_LETTERS[i]}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// =====================================================
// RESULT (per question — derived from question_end)
// =====================================================
function ResultScreen({ isCorrect, isTimeout, gained, total, streak, last, rank, totalPlayers, explanation }) {
  useLang();
  return (
    <div className={clsx("screen result", isCorrect ? "result--good" : "result--bad")}>
      <div className="result-inner">
        <div className="result-emoji">
          {isCorrect ? (
            <svg viewBox="0 0 100 100"><path d="M20 52 L42 74 L82 28" fill="none" stroke="white" strokeWidth="10" strokeLinecap="round" strokeLinejoin="round"/></svg>
          ) : (
            <svg viewBox="0 0 100 100"><path d="M28 28 L72 72 M72 28 L28 72" fill="none" stroke="white" strokeWidth="10" strokeLinecap="round"/></svg>
          )}
        </div>
        <h2>{isCorrect ? t("player.result_correct") : isTimeout ? t("player.result_timeout") : t("player.result_wrong")}</h2>
        {streak > 1 && isCorrect && <div className="streak">🔥 {streak} {t("player.result_streak")}</div>}
        <div className="result-stats">
          <div><div className="stat-num">+{gained}</div><div className="stat-lbl">{t("player.result_from_q")}</div></div>
          <div><div className="stat-num">{total}</div><div className="stat-lbl">{t("player.result_total")}</div></div>
          <div><div className="stat-num">#{rank ?? "—"}</div><div className="stat-lbl">{t("player.result_rank")}</div></div>
        </div>
        {totalPlayers > 0 && (
          <div className="muted" style={{ fontSize: 12 }}>{totalPlayers} {t("player.result_of_players")}</div>
        )}
        {explanation && (
          <div className="result-explanation">
            <div className="result-explanation-label">{t("player.result_why")}</div>
            <div className="result-explanation-text">{explanation}</div>
          </div>
        )}
        <div className="muted" style={{ marginTop: 14 }}>
          {last ? t("player.result_loading_final") : t("player.result_next_q")}
        </div>
        {!last && <KeepTabNotice />}
      </div>
    </div>
  );
}

// =====================================================
// LEADERBOARD (final)
// =====================================================
function LeaderboardScreen({ player, leaderboard }) {
  useLang();
  const top10 = leaderboard.slice(0, 10);
  const yourRank = leaderboard.findIndex((p) => p.id === player.playerId) + 1;
  const yourScore = leaderboard.find((p) => p.id === player.playerId)?.score ?? 0;

  return (
    <div className="screen leaderboard">
      <FloatingShapes />
      <div className="lb-head">
        <BrandRow small />
        <h1>{t("player.lb_title")}</h1>
        {yourRank > 0 ? (
          <div className="your-rank">{t("player.lb_your_rank")} <strong>#{yourRank}</strong> · {yourScore} {t("common.points")}</div>
        ) : (
          <div className="your-rank">{t("player.lb_not_listed")}</div>
        )}
      </div>

      <div className="podium">
        {[1, 0, 2].map((idx) => {
          const p = top10[idx];
          if (!p) return <div key={idx} />;
          const you = p.id === player.playerId;
          return (
            <div key={idx} className={`podium-col podium--${idx}`}>
              <div className="podium-name">{p.name} {p.surname[0] || ""}.{you && " " + t("common.you_paren")}</div>
              <div className="podium-score">{p.score}</div>
              <div className="podium-bar"><span className="podium-rank">{idx + 1}</span></div>
            </div>
          );
        })}
      </div>

      <div className="lb-list">
        {top10.slice(3).map((p, i) => {
          const you = p.id === player.playerId;
          return (
            <div key={p.id} className={clsx("lb-row", you && "lb-row--you")}>
              <span className="lb-rank">#{i + 4}</span>
              <span className="lb-name">{p.name} {p.surname[0] || ""}.{you && " · " + t("common.you")}</span>
              <span className="lb-score">{p.score}</span>
            </div>
          );
        })}
      </div>

      <div className="lb-footer">
        <div className="lb-thanks">{t("common.brand_thanks")}</div>
      </div>
    </div>
  );
}

// =====================================================
// APP
// =====================================================
const LS_KEY = "kahoot_player_v1";
const LS_TTL_MS = 2 * 60 * 60 * 1000; // 2 saat — etkinlik süresinin biraz üstü

function loadStoredPlayer() {
  try {
    const raw = localStorage.getItem(LS_KEY);
    if (!raw) return null;
    const obj = JSON.parse(raw);
    if (!obj || typeof obj !== "object") return null;
    if (!obj.pin || !obj.playerId || !obj.playerToken || !obj.name) return null;
    if (typeof obj.savedAt !== "number" || Date.now() - obj.savedAt > LS_TTL_MS) {
      localStorage.removeItem(LS_KEY);
      return null;
    }
    return obj;
  } catch {
    return null;
  }
}

function clearStoredPlayer() {
  try { localStorage.removeItem(LS_KEY); } catch {}
}

function saveStoredPlayer(p) {
  try {
    localStorage.setItem(LS_KEY, JSON.stringify({
      pin: p.pin,
      playerId: p.playerId,
      playerToken: p.playerToken,
      name: p.name,
      surname: p.surname,
      savedAt: Date.now(),
    }));
  } catch {}
}

function App() {
  useLang();
  const [stage, setStage] = useState("loading"); // loading | join | lobby | question | result | leaderboard
  const [player, setPlayer] = useState(null); // {pin, playerId, playerToken, name, surname}
  const [players, setPlayers] = useState([]);
  const [countdown, setCountdown] = useState(null);
  const [question, setQuestion] = useState(null); // {idx, total, durationSec, startedAt}
  const [picked, setPicked] = useState(null);
  const [score, setScore] = useState(0);
  const [streak, setStreak] = useState(0);
  const [lastResult, setLastResult] = useState(null);
  const [finalLb, setFinalLb] = useState([]);
  const [sseStatus, setSseStatus] = useState("connecting");
  const sseRef = useRef(null);
  const startedAtRef = useRef(0);
  // Refs mirror state for values the SSE handler reads — guards against React
  // render lag (a question_end could arrive before setPicked has flushed).
  const pickedRef = useRef(null);
  const scoreRef = useRef(0);
  const questionRef = useRef(null);
  const countdownIntervalRef = useRef(null);

  // Detect ?pin=... in URL
  const initialPin = useMemo(() => {
    const p = new URLSearchParams(location.search).get("pin");
    return p && /^\d{6}$/.test(p) ? p : null;
  }, []);

  // Mount: try to resume from localStorage. If the saved room no longer exists,
  // or the user clearly came here to join a *different* PIN (QR scan with a new
  // pin in the URL), clear the stale localStorage and show the join screen.
  useEffect(() => {
    const stored = loadStoredPlayer();
    const urlPin = new URLSearchParams(location.search).get("pin");

    // The QR / shared link case: URL says a specific PIN, stored is for a
    // different one. User explicitly chose this new game — discard stale data.
    if (urlPin && stored && stored.pin !== urlPin) {
      clearStoredPlayer();
      setStage("join");
      return;
    }

    if (!stored) {
      setStage("join");
      return;
    }
    let cancelled = false;
    fetch(`/api/rooms/${stored.pin}`)
      .then((r) => r.json().then((d) => ({ ok: r.ok, d })))
      .then(({ ok, d }) => {
        if (cancelled) return;
        if (!ok || !d.exists) {
          clearStoredPlayer();
          setStage("join");
        } else if (d.status === "ended") {
          clearStoredPlayer();
          setStage("join");
        } else {
          setPlayer(stored);
          setStage("lobby");
        }
      })
      .catch(() => {
        if (cancelled) return;
        // Network blip on resume — assume the room is still there; SSE will retry.
        setPlayer(stored);
        setStage("lobby");
      });
    return () => { cancelled = true; };
  }, []);

  // Cleanup countdown interval on unmount.
  useEffect(() => () => {
    if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
  }, []);

  useEffect(() => {
    if (!player) return;
    let cancelled = false;
    setSseStatus("connecting");

    // Preflight: validate pin+pid+token via HTTP before opening the SSE stream.
    // If anything's wrong (token expired, stale localStorage, DO doesn't know
    // this player), we get a precise reason instead of an opaque stream close.
    (async () => {
      try {
        const url = `/api/rooms/${player.pin}/players/${player.playerId}?token=${encodeURIComponent(player.playerToken)}`;
        const res = await fetch(url);
        if (cancelled) return;
        if (!res.ok) {
          const data = await res.json().catch(() => ({}));
          console.warn("Preflight failed:", data.reason || res.status);
          clearStoredPlayer();
          setPlayer(null);
          setStage("join");
          return;
        }
      } catch (err) {
        console.warn("Preflight network error, proceeding to SSE:", err);
        if (cancelled) return;
      }

      if (cancelled) return;
      const sse = window.createSse({
        pin: player.pin,
        role: "player",
        playerId: player.playerId,
        playerToken: player.playerToken,
        onMessage: handleMessage,
        onStatus: setSseStatus,
        onFailedFast: (reason) => {
          // Server explicitly refused (401/404) or stream couldn't establish
          // after many retries — kayıt artık geçerli değil. Reset to join.
          console.warn("SSE unavailable:", reason);
          clearStoredPlayer();
          setPlayer(null);
          setStage("join");
        },
      });
      sseRef.current = sse;
    })();

    return () => {
      cancelled = true;
      if (sseRef.current) {
        sseRef.current.close();
        sseRef.current = null;
      }
    };
  }, [player]);

  function handleMessage(msg) {
    switch (msg.t) {
      case "snapshot": {
        // Server-dictated UI language for this session — overrides any local
        // preference so every device in the same quiz speaks the same chrome.
        if (msg.language) setLang(msg.language);
        // Orphan check: if I'm not in the player list, my row is gone (DO was
        // re-init'd, host ended + restarted, etc.). Clear LS and bounce to join.
        const meInRoom = msg.players.some((p) => p.id === player.playerId);
        if (!meInRoom) {
          clearStoredPlayer();
          if (sseRef.current) sseRef.current.close();
          setPlayer(null);
          setStage("join");
          return;
        }
        // Resume score from snapshot so refresh mid-game shows the right total.
        const me = msg.players.find((p) => p.id === player.playerId);
        if (me) {
          scoreRef.current = me.score;
          setScore(me.score);
        }
        setPlayers(msg.players);
        if (msg.status === "ended") setStage("leaderboard");
        else if (stage === "loading") setStage("lobby");
        break;
      }
      case "lobby_update":
        setPlayers(msg.players);
        break;
      case "countdown":
        setCountdown(msg.n);
        setStage("lobby");
        // Local tick to GO — clear any prior interval so reconnect-driven
        // countdown replays don't stack and double-decrement.
        if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
        if (msg.n > 0) {
          let n = msg.n;
          countdownIntervalRef.current = setInterval(() => {
            n--;
            setCountdown(Math.max(0, n));
            if (n <= 0) {
              clearInterval(countdownIntervalRef.current);
              countdownIntervalRef.current = null;
            }
          }, 1000);
        }
        break;
      case "question": {
        // Store option count so the shape grid renders the right layout (2/4/5).
        const q = {
          idx: msg.idx,
          total: msg.total,
          durationSec: msg.durationSec,
          startedAt: msg.startedAt,
          optionCount: Array.isArray(msg.answers) ? msg.answers.length : 4,
        };
        setQuestion(q);
        questionRef.current = q;
        startedAtRef.current = msg.startedAt;
        // Snapshot replay path: server tells us whether we already answered
        // (and what we picked). Restore the lock so reconnect mid-question
        // doesn't let the user click again, and so the later question_end
        // doesn't mis-compute "gained" off a wrong baseline. Live broadcasts
        // omit both fields → treat as a fresh question.
        if (msg.myAlreadyAnswered) {
          const myPick = msg.myPick ?? null;
          pickedRef.current = myPick;
          setPicked(myPick);
        } else {
          pickedRef.current = null;
          setPicked(null);
        }
        setCountdown(null);
        setStage("question");
        break;
      }
      case "ack_answer":
        // server confirmed our answer; nothing to do (UI already optimistic)
        break;
      case "question_end": {
        const meIdx = msg.leaderboard.findIndex((p) => p.id === player.playerId);
        const me = meIdx >= 0 ? msg.leaderboard[meIdx] : null;
        const prevScore = scoreRef.current;
        const newTotal = me?.score ?? prevScore;
        const localQuestion = questionRef.current;
        // Snapshot replay path: when reconnecting mid-reveal, the server
        // replays `question` (which resets pickedRef) then this `question_end`
        // with the player's actual pick + points-on-this-question attached.
        // Live broadcast omits both fields → fall back to local refs.
        const usingServerSide = msg.myPointsThisQ !== undefined;
        const localPicked = usingServerSide
          ? (msg.myPick ?? null)
          : pickedRef.current;
        const gained = usingServerSide
          ? msg.myPointsThisQ
          : (newTotal - prevScore);
        const isCorrect = localPicked != null && localPicked === msg.correct;
        const isTimeout = localPicked == null;
        scoreRef.current = newTotal;
        setScore(newTotal);
        // Streak is a cosmetic 🔥 badge. On reconnect we don't know prior
        // streak length, so leave it untouched in the snapshot path.
        if (!usingServerSide) {
          if (isCorrect) setStreak((s) => s + 1); else setStreak(0);
        }
        setLastResult({
          isCorrect,
          isTimeout,
          gained,
          last: localQuestion && localQuestion.idx + 1 >= localQuestion.total,
          rank: meIdx >= 0 ? meIdx + 1 : null,
          totalPlayers: msg.leaderboard.length,
          // Only surface the explanation to players who answered wrong or timed out.
          explanation: !isCorrect ? (msg.explanation || null) : null,
        });
        setStage("result");
        break;
      }
      case "game_end":
        setFinalLb(msg.leaderboard);
        setStage("leaderboard");
        break;
      case "error":
        // For an event device, surfacing as an alert is simplest.
        console.warn("server error:", msg.message);
        break;
    }
  }

  function handleAnswer(i) {
    if (pickedRef.current != null) return;
    const elapsedMs = Date.now() - startedAtRef.current;
    pickedRef.current = i;
    setPicked(i);
    // Always POST — SSE is one-way (server→client). Failure isn't fatal: the
    // server's question timer will count it as a timeout if we never reach.
    fetch(`/api/rooms/${player.pin}/players/${player.playerId}/answer`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ pick: i, elapsedMs, token: player.playerToken }),
    }).catch(() => {});
  }

  function handleRestart() {
    if (sseRef.current) {
      sseRef.current.close();
      sseRef.current = null;
    }
    if (countdownIntervalRef.current) {
      clearInterval(countdownIntervalRef.current);
      countdownIntervalRef.current = null;
    }
    clearStoredPlayer();
    pickedRef.current = null;
    scoreRef.current = 0;
    questionRef.current = null;
    setPlayer(null);
    setPlayers([]);
    setQuestion(null);
    setScore(0);
    setStreak(0);
    setLastResult(null);
    setFinalLb([]);
    setStage("join");
  }

  // Render the active screen, then wrap with the always-visible floating
  // language switcher so it appears on join / lobby / question / result /
  // leaderboard alike.
  let screen;
  if (stage === "loading") {
    screen = <div className="screen"><div className="muted">{t("common.loading")}</div></div>;
  } else if (stage === "join" || !player) {
    screen = (
      <JoinScreen
        initialPin={initialPin}
        onJoined={(p) => {
          saveStoredPlayer(p);
          setPlayer(p);
          setStage("lobby");
        }}
      />
    );
  } else if (stage === "lobby") {
    screen = (
      <LobbyScreen
        player={player}
        pin={player.pin}
        players={players}
        countdown={countdown}
        sseStatus={sseStatus}
        onReset={handleRestart}
      />
    );
  } else if (stage === "question" && question) {
    screen = (
      <QuestionScreen
        idx={question.idx}
        total={question.total}
        durationSec={question.durationSec}
        startedAt={question.startedAt}
        onAnswer={handleAnswer}
        picked={picked}
        optionCount={question.optionCount}
      />
    );
  } else if (stage === "result" && lastResult) {
    screen = (
      <ResultScreen
        isCorrect={lastResult.isCorrect}
        isTimeout={lastResult.isTimeout}
        gained={lastResult.gained}
        total={score}
        streak={streak}
        last={lastResult.last}
        rank={lastResult.rank}
        totalPlayers={lastResult.totalPlayers}
        explanation={lastResult.explanation}
      />
    );
  } else if (stage === "leaderboard") {
    screen = <LeaderboardScreen player={player} leaderboard={finalLb} />;
  } else {
    screen = <div className="screen"><div className="muted">{t("common.loading")}</div></div>;
  }

  return (
    <>
      <LangToggle floating />
      {screen}
    </>
  );
}

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