// Kairós — raíz de la app: salas (estado compartido en vivo), navegación,
// cronómetro y tweaks.
const { useState, useEffect, useRef } = React;

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "timerMinutes": 15,
  "showLinks": true
} /*EDITMODE-END*/;

const STORE_KEY = 'kairos-proto-v1';
const MAX_SPRINT = 5; // los mazos cubren los sprints 1..5

function loadStore() {
  try {return JSON.parse(localStorage.getItem(STORE_KEY)) || {};} catch (e) {return {};}
}

// --- mazos servidos por la BBDD (api/cards.php) ---
function deckForNode(nodeId) {
  if (nodeId === 'DEC') return 'decide';
  if (nodeId === 'VOR') return 'vortice';
  if (nodeId === 'EXP') return 'explora';
  return null; // RESPIRO y NÚCLEO aún no tienen mazo
}
function deckKey(deck, monumentId, sprint) {
  return deck === 'decide' ? 'decide:' + monumentId + ':' + sprint : deck + ':' + sprint;
}
function deckUrl(deck, monumentId, sprint) {
  return deck === 'decide'
    ? 'api/cards.php?deck=decide&monument=' + encodeURIComponent(monumentId) + '&sprint=' + sprint
    : 'api/cards.php?deck=' + deck + '&sprint=' + sprint;
}
// carta al azar del mazo EXCLUYENDO las ya usadas por este equipo en este sprint.
// Una vez usada, una carta no vuelve a salir; si se agotan todas, devuelve null
// (mazo exhausto) — el que actúa NO repite ninguna.
function pickCard(cards, usedIds) {
  if (!cards || !cards.length) return null;
  const used = Array.isArray(usedIds) ? usedIds : [];
  const avail = cards.filter((c) => used.indexOf(c.id) === -1);
  if (!avail.length) return null; // todas usadas en este sprint
  return avail[Math.floor(Math.random() * avail.length)];
}

// Interpreta el efecto mecánico de una carta VÓRTICE (texto libre como
// "+3 Materia", "-2 Sistema", "+2 Energía · +1 Sistema", "-dado TC") y lo
// convierte en deltas {MAT,ENE,SIS,TC}. Solo la 1ª línea (antes de la acción 🚶).
// "dado" lanza un dado 1-6. Si no hay número, devuelve deltas vacíos.
function parseEffectDeltas(text) {
  const out = { deltas: {}, note: '' };
  if (!text) return out;
  const firstLine = String(text).split('\n')[0];
  const map = { materia: 'MAT', energia: 'ENE', 'energía': 'ENE', sistema: 'SIS', tc: 'TC' };
  const re = /([+\-−])\s*(\d+|dado)\s*(Materia|Energía|Energia|Sistema|TC)/gi;
  let m, found = false;
  while ((m = re.exec(firstLine)) !== null) {
    found = true;
    const sign = (m[1] === '-' || m[1] === '−') ? -1 : 1;
    let val;
    if (/dado/i.test(m[2])) { val = 1 + Math.floor(Math.random() * 6); out.note = 'dado = ' + val; }
    else val = parseInt(m[2], 10);
    const key = map[m[3].toLowerCase()];
    if (key) out.deltas[key] = (out.deltas[key] || 0) + sign * val;
  }
  if (!found) out.note = 'sin efecto numérico directo';
  return out;
}

// ===================== SALAS (estado compartido en vivo) =====================
// Modelo «espejo total»: todas las sesiones de una sala comparten exactamente el
// mismo estado del juego. El cronómetro NO se sincroniza (es local por pantalla).
// Transporte por GET a propósito: el WAF del servidor bloquea los POST a /api.
const ROOM_API = 'api/room.php';
const POLL_MS = 1200; // sondeo de cambios remotos (ajustable)

// Trackers POR MONUMENTO: cada monumento es su propia partida y NO comparte
// puntuación con los demás (siguen siendo compartidos en vivo dentro de la sala).
// Valores de inicio comunes.
const STATS_DEFAULT = { MAT: 6, ENE: 7, SIS: 5, TC: 12 };
function defaultStatsByMonument() {
  const out = {};
  MONUMENTS.forEach((m) => { out[m.id] = Object.assign({}, STATS_DEFAULT); });
  return out;
}
// canonicaliza los trackers de UN monumento (claves/orden fijos, valores numéricos;
// los que falten toman el valor de inicio). Para que firma == estado.
function buildStats(s) {
  const x = s || {};
  return {
    MAT: typeof x.MAT === 'number' ? x.MAT : STATS_DEFAULT.MAT,
    ENE: typeof x.ENE === 'number' ? x.ENE : STATS_DEFAULT.ENE,
    SIS: typeof x.SIS === 'number' ? x.SIS : STATS_DEFAULT.SIS,
    TC: typeof x.TC === 'number' ? x.TC : STATS_DEFAULT.TC,
  };
}
// canonicaliza el mapa monumento -> trackers (orden de monumentos fijo)
function buildStatsByMonument(sbm) {
  const src = sbm || {};
  const out = {};
  MONUMENTS.forEach((m) => { out[m.id] = buildStats(src[m.id]); });
  return out;
}

// canonicaliza `resolved` a una forma FIJA (claves y orden estables). Imprescindible
// para el anti-eco: la firma siempre pasa por aquí, así da igual el orden de claves
// con el que el servidor (que sanea el estado) devuelva resolved — la firma coincide.
function buildResolved(r) {
  if (!r || typeof r !== 'object') return null;
  const a = (r.applied && typeof r.applied === 'object') ? r.applied : {};
  const num = (v) => (typeof v === 'number' ? v : 0);
  return {
    choice: (typeof r.choice === 'string') ? r.choice : null,
    applied: { MAT: num(a.MAT), ENE: num(a.ENE), SIS: num(a.SIS), TC: num(a.TC) },
    note: (typeof r.note === 'string') ? r.note : null,
  };
}

// canonicaliza el mapa de cartas usadas: { 'decide:coliseo:1':['id',...], 'vortice:1':[...] }
// con ORDEN ESTABLE (claves y valores ordenados, sin duplicados) para que la firma
// anti-eco sea determinista pase lo que pase con el orden que devuelva el servidor.
// Acepta [] (PHP puede serializar el mapa vacío como array) y lo normaliza a {}.
function buildUsedCards(u) {
  const src = (u && typeof u === 'object') ? u : {};
  const out = {};
  Object.keys(src).sort().forEach((k) => {
    const arr = Array.isArray(src[k]) ? src[k] : [];
    const seen = {};
    const clean = [];
    arr.forEach((x) => { if (typeof x === 'string' && !seen[x]) { seen[x] = 1; clean.push(x); } });
    if (clean.length) out[k] = clean.sort();
  });
  return out;
}

// estado de sala normalizado, con ORDEN DE CLAVES ESTABLE (para comparar firmas)
function buildRoomState(view, monumentId, nodeId, sprint, statsByMonument, timer, cardId, resolved, usedCards) {
  return {
    view: view || 'monumentos',
    monumentId: monumentId || null,
    nodeId: nodeId || null,
    sprint: Math.min(MAX_SPRINT, Math.max(1, Number(sprint) || 1)), // acotado como en hydrate (firma == estado)
    statsByMonument: buildStatsByMonument(statsByMonument),
    timer: buildTimer(timer),
    cardId: cardId || null,
    resolved: buildResolved(resolved),
    usedCards: buildUsedCards(usedCards),
  };
}
function roomStateSig(stateObj) { return JSON.stringify(stateObj); }

// base64 seguro con UTF-8 (acentos en resolved.note); el WAF no filtra base64 por GET
function roomEncodeState(stateObj) {
  const json = JSON.stringify(stateObj);
  return btoa(encodeURIComponent(json).replace(/%([0-9A-F]{2})/g, function (_, h) {
    return String.fromCharCode(parseInt(h, 16));
  }));
}

function roomCreate() {
  return fetch(ROOM_API + '?action=create').then((r) => r.json());
}
function roomGet(code, since) {
  return fetch(ROOM_API + '?action=get&code=' + encodeURIComponent(code) + '&since=' + (since | 0)).then((r) => r.json());
}
function roomSave(code, stateObj) {
  const s = roomEncodeState(stateObj);
  return fetch(ROOM_API + '?action=save&code=' + encodeURIComponent(code) + '&s=' + encodeURIComponent(s)).then((r) => r.json());
}

// --- CRONÓMETRO COMPARTIDO por sala (anclado al reloj del servidor) ---
// running 0|1 · duration (s) · remaining (s, cuando NO corre) · deadline (epoch s, cuando corre).
// El admin (y cualquier jugador) lo controla; todas las pantallas ven la misma cuenta.
const TIMER_DEFAULT = { running: 0, duration: 900, remaining: 900, deadline: 0 };
function buildTimer(t) {
  const x = (t && typeof t === 'object') ? t : {};
  const dur = Math.max(1, Math.min(36000, Math.floor(Number(x.duration)) || 900));
  let rem = Number(x.remaining); rem = Number.isFinite(rem) ? rem : dur;
  return {
    running: (x.running === 1 || x.running === true) ? 1 : 0,
    duration: dur,
    remaining: Math.max(0, Math.min(dur, Math.floor(rem))),
    deadline: Math.max(0, Math.floor(Number(x.deadline)) || 0),
  };
}
// segundos a mostrar (offsetSec = serverNow - clientNow, para anular desfase de reloj)
function timerSeconds(t, offsetSec) {
  if (!t) return 0;
  if (t.running) return Math.max(0, Math.round(t.deadline - (Date.now() / 1000 + (offsetSec || 0))));
  return t.remaining;
}
// aplica un comando localmente (nowAdj = reloj del servidor estimado, en s)
function applyTimerCmd(t, cmd, value, nowAdj) {
  t = buildTimer(t);
  if (cmd === 'set') { const d = Math.max(1, Math.min(600, value | 0)) * 60; return { running: 0, duration: d, remaining: d, deadline: 0 }; }
  if (cmd === 'start') { if (t.running || t.remaining <= 0) return t; return { running: 1, duration: t.duration, remaining: t.remaining, deadline: Math.round(nowAdj + t.remaining) }; }
  if (cmd === 'pause') { if (!t.running) return t; return { running: 0, duration: t.duration, remaining: Math.max(0, Math.round(t.deadline - nowAdj)), deadline: 0 }; }
  if (cmd === 'reset') { return { running: 0, duration: t.duration, remaining: t.duration, deadline: 0 }; }
  return t;
}

// URL del hub WebSocket (mismo host, /ws). Se evalúa en runtime (no en carga) para
// no referenciar `location` en entornos de test sin DOM (parser_test).
function wsUrl() { return (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'; }

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const saved = useRef(loadStore()).current;

  // ---- estado de SALA ----
  const [roomCode, setRoomCode] = useState(saved.roomCode || null);
  const [online, setOnline] = useState(true);
  const [codeInput, setCodeInput] = useState('');
  const [lobbyBusy, setLobbyBusy] = useState(false);
  const [lobbyError, setLobbyError] = useState('');
  const localRev = useRef(0);          // última rev aplicada del servidor
  const lastSyncedSig = useRef(null);  // firma del estado que el servidor ya conoce (anti-eco)

  // ---- estado del JUEGO (sincronizado por la sala) ----
  const [view, setView] = useState('monumentos');
  const [monument, setMonument] = useState(null);
  const [node, setNode] = useState(null);
  const [sprint, setSprint] = useState(1);
  // trackers por monumento: { coliseo:{MAT,ENE,SIS,TC}, stonehenge:{...}, ... }
  const [statsByMonument, setStatsByMonument] = useState(defaultStatsByMonument);
  // de la carta visible se comparten SOLO su id + la resolución; el mazo se baja local
  const [cardId, setCardId] = useState(null);
  const [resolved, setResolved] = useState(null);
  // cartas ya USADAS por este equipo, por contexto (deck:monumento:sprint). Compartido
  // por sala: una carta usada no vuelve a salir en ese sprint (LWW como el resto).
  const [usedCards, setUsedCards] = useState({});
  const [deckState, setDeckState] = useState({ loading: false, error: null, cards: [] });

  const deckCache = useRef({});    // key -> array de cartas (evita repetir fetch)
  const deckInflight = useRef({}); // key -> promesa en vuelo (deduplica peticiones)
  const reqToken = useRef(0);      // descarta deckState (mazo a mostrar) obsoleto
  const drawToken = useRef(0);     // descarta robos (cardId) obsoletos
  const navTimer = useRef(null);   // (defensivo) cancela saltos diferidos al tablero si los hubiera
  const didInteract = useRef(false); // ¿hubo ya alguna acción local? (no pisar al rehidratar)
  const usedCardsRef = useRef(usedCards); // espejo de usedCards para leerlo en el draw asíncrono

  // ---- cronómetro COMPARTIDO por sala (lo controla el admin y/o los jugadores) ----
  const [timer, setTimer] = useState(TIMER_DEFAULT);
  const [timerOpen, setTimerOpen] = useState(false);
  const [, setTick] = useState(0);          // re-render por segundo mientras corre
  const clockOffset = useRef(0);            // serverNow - clientNow (s), para la cuenta atrás

  // -------- helpers (no hooks) --------
  const clearNav = () => { if (navTimer.current) { clearTimeout(navTimer.current); navTimer.current = null; } };
  const currentCard = () => (cardId && deckState.cards) ? (deckState.cards.find((c) => c.id === cardId) || null) : null;

  // carga el mazo: caché + deduplicación de peticiones en vuelo (clave deck:monumento:sprint),
  // así el efecto de "mazo a mostrar" y un "robar" simultáneos comparten UN solo fetch.
  // No toca deckState (de eso se encarga el efecto); solo devuelve las cartas.
  const fetchDeck = (deck, monId, spr) => {
    const key = deckKey(deck, monId, spr);
    if (deckCache.current[key]) return Promise.resolve(deckCache.current[key]);
    if (deckInflight.current[key]) return deckInflight.current[key];
    const p = fetch(deckUrl(deck, monId, spr))
      .then((r) => r.json())
      .then((d) => {
        delete deckInflight.current[key];
        if (d && d.error) throw new Error(d.error);
        const cards = (d && d.cards) || [];
        deckCache.current[key] = cards;
        return cards;
      })
      .catch((e) => { delete deckInflight.current[key]; throw e; });
    deckInflight.current[key] = p;
    return p;
  };

  // clave de contexto de un mazo (deck:monumento:sprint) — así se agrupan las cartas
  // usadas: DECIDE por monumento+sprint; VÓRTICE/EXPLORA por sprint.
  const ctxKeyFor = (nodeObj, mon, spr) => {
    const deck = nodeObj ? deckForNode(nodeObj.id) : null;
    return deck ? deckKey(deck, mon && mon.id, spr) : null;
  };

  // elegir carta (ACCIÓN del usuario): saca la SIGUIENTE carta no usada al azar y
  // comparte su id. Sin repeticiones dentro del sprint; si se agotan, cardId=null
  // (el mazo queda exhausto). Solo el que actúa elige; los demás la ven por sync.
  const drawCard = (nodeObj, mon, spr) => {
    clearNav();
    const deck = nodeObj ? deckForNode(nodeObj.id) : null;
    if (!deck) { setCardId(null); setResolved(null); return; }
    const ctxKey = deckKey(deck, mon && mon.id, spr);
    const my = ++drawToken.current;
    fetchDeck(deck, mon && mon.id, spr).then((cards) => {
      if (my !== drawToken.current) return;
      const used = (usedCardsRef.current && usedCardsRef.current[ctxKey]) || [];
      const picked = pickCard(cards, used);
      setCardId(picked ? picked.id : null);
      setResolved(null);
    }).catch(() => { if (my === drawToken.current) { setCardId(null); setResolved(null); } });
  };

  // marca una carta como USADA en su contexto (deck:monumento:sprint). Idempotente.
  const markUsed = (ctxKey, id) => {
    if (!ctxKey || !id) return;
    setUsedCards((prev) => {
      const cur = (prev && prev[ctxKey]) || [];
      if (cur.indexOf(id) !== -1) return prev;
      return Object.assign({}, prev, { [ctxKey]: cur.concat([id]) });
    });
  };

  // aplica deltas {MAT,ENE,SIS,TC} a los trackers DEL MONUMENTO ACTIVO, acotados a [0, max]
  const applyDeltas = (deltas) => {
    if (!deltas || !monument) return;
    const mid = monument.id;
    setStatsByMonument((all) => {
      const next = Object.assign({}, all[mid] || STATS_DEFAULT);
      STAT_DEFS.forEach((d) => {
        const dv = deltas[d.id] || 0;
        if (dv) next[d.id] = Math.max(0, Math.min(d.max, (next[d.id] || 0) + dv));
      });
      return Object.assign({}, all, { [mid]: next });
    });
  };

  // DECIDE: al elegir A/B se aplica su efecto a los trackers y se marca resuelta.
  // NO se salta al tablero automáticamente: la carta queda visible con los puntos
  // que suma/resta YA revelados, hasta que se pulsa «Continuar» (continueToBoard).
  // Como la vista es estado compartido, ese salto lleva a TODA la sala al tablero
  // (espejo). Todo va al estado compartido.
  const chooseOption = (letter) => {
    const card = currentCard();
    if (!card || resolved) return;
    const opt = card[letter.toLowerCase()] || {};
    const deltas = { MAT: opt.mat, ENE: opt.ene, SIS: opt.sis, TC: opt.tc };
    applyDeltas(deltas);
    setResolved({ choice: letter, applied: deltas });
    markUsed(ctxKeyFor(node, monument, sprint), card.id); // no volverá a salir este sprint
  };

  // VÓRTICE/EXPLORA: aceptar aplica su efecto a los trackers automáticamente
  const acceptCard = () => {
    const card = currentCard();
    if (!card || resolved || !node) return;
    let res;
    if (node.id === 'VOR') {
      const eff = parseEffectDeltas(card.efectoCompleto);
      applyDeltas(eff.deltas);
      res = { applied: eff.deltas, note: eff.note };
    } else { // EXPLORA: profecía, sin efecto mecánico sobre los trackers
      res = { applied: {}, note: 'Carta en juego' };
    }
    setResolved(res);
    markUsed(ctxKeyFor(node, monument, sprint), card.id); // no volverá a salir este sprint
  };

  // ajuste MANUAL de un tracker del monumento activo (p.ej. recibir materia de otro
  // equipo). ±delta, acotado; se sincroniza como el resto del estado.
  const adjustStat = (statId, delta) => { applyDeltas({ [statId]: delta }); };

  // -------- sincronización de sala --------
  const hydrate = (st) => {
    setView(st.view || 'monumentos');
    setMonument(st.monumentId ? (MONUMENTS.find((m) => m.id === st.monumentId) || null) : null);
    setNode(st.nodeId ? (NODE_TYPES.find((n) => n.id === st.nodeId) || null) : null);
    setSprint(Math.min(MAX_SPRINT, Math.max(1, st.sprint || 1)));
    setStatsByMonument(buildStatsByMonument(st.statsByMonument));
    setTimer(buildTimer(st.timer));
    setCardId(st.cardId || null);
    setResolved(st.resolved || null);
    setUsedCards(buildUsedCards(st.usedCards));
  };

  const applyRemoteState = (st, rev) => {
    localRev.current = rev || 0;
    lastSyncedSig.current = roomStateSig(buildRoomState(st.view, st.monumentId, st.nodeId, st.sprint, st.statsByMonument, st.timer, st.cardId, st.resolved, st.usedCards));
    clearNav();
    ++drawToken.current;  // descarta cualquier robo local en vuelo (no debe pisar el estado remoto)
    ++reqToken.current;   // descarta cargas de mazo en vuelo (el efecto recargará el mazo correcto)
    hydrate(st);
  };

  // adopta estado remoto SOLO si es genuinamente nuevo (rev mayor a la ya aplicada)
  // y no es nuestro propio estado recién guardado (anti-eco). Así un eco del PULL no
  // pisa cambios locales con una lectura vieja ni cancela un salto en curso.
  const maybeAdopt = (d) => {
    if (!d || !d.state || typeof d.rev !== 'number') return;
    if (d.rev <= localRev.current) return; // ya aplicado o lectura obsoleta
    const incomingSig = roomStateSig(buildRoomState(d.state.view, d.state.monumentId, d.state.nodeId, d.state.sprint, d.state.statsByMonument, d.state.timer, d.state.cardId, d.state.resolved, d.state.usedCards));
    if (incomingSig === lastSyncedSig.current) { localRev.current = d.rev; return; } // es nuestro estado
    applyRemoteState(d.state, d.rev);
  };

  const enterRoom = (code, st, rev) => {
    applyRemoteState(st, rev || 1);
    setRoomCode(code);
    setCodeInput(''); setLobbyError(''); setLobbyBusy(false); setOnline(true);
  };

  const leaveRoom = (gone) => {
    clearNav();
    localRev.current = 0; lastSyncedSig.current = null;
    setRoomCode(null);
    setView('monumentos'); setMonument(null); setNode(null); setSprint(1);
    setStatsByMonument(defaultStatsByMonument()); setCardId(null); setResolved(null);
    setUsedCards({});
    setDeckState({ loading: false, error: null, cards: [] });
    setLobbyError(gone ? 'La sala ya no existe. Crea o entra en otra.' : '');
  };

  const createRoom = () => {
    setLobbyBusy(true); setLobbyError('');
    roomCreate().then((d) => {
      noteServerNow(d);
      if (d && d.code && d.state) enterRoom(d.code, d.state, d.rev);
      else { setLobbyBusy(false); setLobbyError((d && d.error) || 'No se pudo crear la sala'); }
    }).catch(() => { setLobbyBusy(false); setLobbyError('Error de red'); });
  };

  const joinRoom = () => {
    const code = (codeInput || '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
    if (code.length < 4) { setLobbyError('Introduce el código completo'); return; }
    setLobbyBusy(true); setLobbyError('');
    roomGet(code, -1).then((d) => {
      noteServerNow(d);
      if (d && d.notFound) { setLobbyBusy(false); setLobbyError('No existe ninguna sala con ese código'); return; }
      if (d && d.state) enterRoom(code, d.state, d.rev);
      else { setLobbyBusy(false); setLobbyError((d && d.error) || 'No se pudo entrar en la sala'); }
    }).catch(() => { setLobbyBusy(false); setLobbyError('Error de red'); });
  };

  // ancla el reloj con el `now` del servidor (anula desfase para la cuenta atrás)
  const noteServerNow = (d) => { if (d && typeof d.now === 'number') clockOffset.current = d.now - Date.now() / 1000; };

  // refresca el estado desde el servidor por la vía probada (rev-guard + anti-eco).
  // Lo usan tanto el polling como el "nudge" del WebSocket. Devuelve la promesa.
  const pullNow = () => {
    if (!roomCode) return Promise.resolve();
    return roomGet(roomCode, localRev.current).then((d) => {
      setOnline(true); noteServerNow(d);
      if (d && d.notFound) { leaveRoom(true); return; }
      if (d && d.changed) maybeAdopt(d);
      else if (d && typeof d.rev === 'number' && d.rev > localRev.current) localRev.current = d.rev;
    }).catch(() => setOnline(false));
  };

  // comando de cronómetro: muta el timer COMPARTIDO (se sincroniza a toda la sala)
  const timerCmd = (cmd, value) => {
    setTimer((tm) => applyTimerCmd(tm, cmd, value, Date.now() / 1000 + clockOffset.current));
  };

  // -------- efectos --------
  // al montar con una sala guardada: rehidrata su estado desde el servidor
  useEffect(() => {
    if (!roomCode) return;
    roomGet(roomCode, -1).then((d) => {
      noteServerNow(d);
      if (d && d.notFound) { leaveRoom(true); return; }
      if (!didInteract.current) maybeAdopt(d); // no pisar si el usuario ya empezó a jugar
      setOnline(true);
    }).catch(() => setOnline(false));
  }, []);

  // PUSH: cuando el estado del juego cambia por una acción local, lo guarda (LWW)
  useEffect(() => {
    if (!roomCode) return;
    const st = buildRoomState(view, monument && monument.id, node && node.id, sprint, statsByMonument, timer, cardId, resolved, usedCards);
    const sig = roomStateSig(st);
    if (sig === lastSyncedSig.current) return; // venía del servidor o sin cambio real
    didInteract.current = true;                // hubo un cambio LOCAL genuino
    lastSyncedSig.current = sig;               // optimista: corta el bucle de eco
    const id = setTimeout(() => {
      roomSave(roomCode, st).then((d) => {
        if (d && d.notFound) { leaveRoom(true); return; }
        if (d && typeof d.rev === 'number') localRev.current = Math.max(localRev.current, d.rev);
        setOnline(true);
      }).catch(() => setOnline(false));
    }, 120);
    return () => clearTimeout(id);
  }, [roomCode, view, monument, node, sprint, statsByMonument, timer, cardId, resolved, usedCards]);

  // espejo de usedCards en un ref, para leer las cartas usadas dentro del draw asíncrono
  useEffect(() => { usedCardsRef.current = usedCards; }, [usedCards]);

  // PULL: sondea el servidor como BASE robusta (el WebSocket solo acelera). Si hay
  // rev nueva, adopta el estado remoto (espejo). Auto-encadenado para no solapar.
  useEffect(() => {
    if (!roomCode) return;
    let alive = true; let to = null;
    const tick = () => { pullNow().finally(() => { if (alive) to = setTimeout(tick, POLL_MS); }); };
    to = setTimeout(tick, POLL_MS);
    return () => { alive = false; if (to) clearTimeout(to); };
  }, [roomCode]);

  // NUDGE por WebSocket (acelerador sobre el polling): al recibir aviso de cambio
  // de NUESTRA sala, refresca al instante por la vía probada. Si no hay WS (o cae),
  // el polling de arriba sigue funcionando -> el juego nunca se rompe.
  useEffect(() => {
    if (!roomCode || typeof WebSocket === 'undefined') return;
    let ws = null, alive = true, reconn = null, fails = 0;
    const open = () => {
      try { ws = new WebSocket(wsUrl()); } catch (e) { schedule(); return; }
      ws.onopen = () => { fails = 0; try { ws.send(JSON.stringify({ type: 'sub', code: roomCode })); } catch (e) {} };
      ws.onmessage = (ev) => {
        let m = null; try { m = JSON.parse(ev.data); } catch (e) { return; }
        if (m && m.type === 'changed' && m.code === roomCode) {
          if (typeof m.rev === 'number' && m.rev >= 0 && m.rev <= localRev.current) return; // ya tenemos esa rev
          pullNow();
        }
      };
      ws.onclose = () => { if (alive) schedule(); };
      ws.onerror = () => { try { ws.close(); } catch (e) {} };
    };
    const schedule = () => { fails++; clearTimeout(reconn); reconn = setTimeout(open, Math.min(8000, 800 * fails)); };
    open();
    return () => { alive = false; clearTimeout(reconn); if (ws) { try { ws.onclose = null; ws.close(); } catch (e) {} } };
  }, [roomCode]);

  // baja el mazo a mostrar cuando se entra en vista de cartas (también para
  // quien solo mira). Único responsable de deckState; reqToken descarta el
  // resultado si el contexto (casilla/monumento/sprint) cambió entretanto.
  useEffect(() => {
    if (!roomCode || view !== 'cartas' || !node) return;
    const deck = deckForNode(node.id);
    if (!deck) { setDeckState({ loading: false, error: null, cards: [] }); return; }
    const key = deckKey(deck, monument && monument.id, sprint);
    const token = ++reqToken.current;
    if (deckCache.current[key]) { setDeckState({ loading: false, error: null, cards: deckCache.current[key] }); return; }
    setDeckState({ loading: true, error: null, cards: [] });
    fetchDeck(deck, monument && monument.id, sprint)
      .then((cards) => { if (token === reqToken.current) setDeckState({ loading: false, error: null, cards: cards }); })
      .catch(() => { if (token === reqToken.current) setDeckState({ loading: false, error: 'Error de red', cards: [] }); });
  }, [roomCode, view, node, monument, sprint]);

  // «Continuar» tras resolver una carta DECIDE: la carta se queda visible (con los
  // puntos revelados) y solo al pulsar se vuelve al tablero. Al ser `view` estado
  // compartido, el salto se propaga a toda la sala (espejo). clearNav cancela
  // cualquier salto diferido heredado por robustez.
  const continueToBoard = () => { clearNav(); setView('tablero'); };

  // tick local: mientras el cronómetro corre, re-renderiza cada segundo para que
  // la cuenta atrás avance suave (el estado del timer NO cambia cada segundo).
  useEffect(() => {
    if (!timer || !timer.running) return;
    const id = setInterval(() => {
      // al expirar: normaliza a PARADO (corrige el estado y propaga); si no, re-renderiza
      if (timerSeconds(timer, clockOffset.current) <= 0) {
        setTimer((tm) => (tm && tm.running) ? { running: 0, duration: tm.duration, remaining: 0, deadline: 0 } : tm);
      } else {
        setTick((x) => (x + 1) % 1000000);
      }
    }, 1000);
    return () => clearInterval(id);
  }, [timer]);

  // persistencia LOCAL: solo el código de sala (el juego y el cronómetro viven en el servidor)
  useEffect(() => {
    try { localStorage.setItem(STORE_KEY, JSON.stringify({ roomCode: roomCode })); } catch (e) {}
  }, [roomCode]);

  // ---- lobby: crear o entrar a una sala (no se renderiza el juego sin sala) ----
  if (!roomCode) {
    return (
      <div className="app-root" style={{ borderRadius: "0px" }}>
        <div className="print-frame" aria-hidden="true"></div>
        <LobbyView
          onCreate={createRoom}
          codeInput={codeInput}
          onCodeInput={(v) => { setCodeInput(v); if (lobbyError) setLobbyError(''); }}
          onJoin={joinRoom}
          busy={lobbyBusy}
          error={lobbyError} />
      </div>);
  }

  const card = currentCard();
  // trackers del monumento activo (cada monumento tiene los suyos)
  const stats = (monument && statsByMonument[monument.id]) || STATS_DEFAULT;
  // cronómetro compartido: segundos a mostrar (anclados al reloj del servidor)
  const timeLeft = timerSeconds(timer, clockOffset.current);
  const timerRunning = !!(timer && timer.running);
  // ¿mazo exhausto? (todas las cartas del contexto ya usadas por este equipo)
  const activeCtxKey = ctxKeyFor(node, monument, sprint);
  const usedForCtx = (activeCtxKey && usedCards[activeCtxKey]) || [];
  const deckCount = deckState.cards ? deckState.cards.length : 0;
  const exhausted = !!activeCtxKey && !deckState.loading && !deckState.error && deckCount > 0 && !card && usedForCtx.length >= deckCount;
  return (
    <div className="app-root" style={{ borderRadius: "0px" }}>
      <div className="print-frame" aria-hidden="true"></div>
      <RoomBar code={roomCode} online={online} onLeave={() => leaveRoom(false)} />
      {view === 'monumentos' &&
      <MonumentsView onPick={(m) => {setMonument(m);setView('tablero');}} />
      }
      {view === 'tablero' &&
      <BoardView
        monument={monument}
        stats={stats}
        sprint={sprint}
        onSprint={(d) => setSprint((s) => Math.min(MAX_SPRINT, Math.max(1, s + d)))}
        timeLeft={timeLeft}
        timerRunning={timerRunning}
        onOpenTimer={() => setTimerOpen(true)}
        onPickNode={(n) => {setNode(n);setView('cartas');setResolved(null);setCardId(null);drawCard(n, monument, sprint);}}
        onAdjustStat={adjustStat}
        onBack={() => setView('monumentos')}
        showLinks={!!t.showLinks} />

      }
      {/* timeLeft/timerRunning derivan del cronómetro COMPARTIDO de la sala */}
      {view === 'cartas' &&
      <CardsView
        node={node}
        monument={monument}
        sprint={sprint}
        card={card}
        loading={deckState.loading || (!!cardId && !card && !deckState.error)}
        error={deckState.error}
        deckSize={deckState.cards ? deckState.cards.length : 0}
        exhausted={exhausted}
        resolved={resolved}
        onChoose={chooseOption}
        onAccept={acceptCard}
        onContinue={continueToBoard}
        onBack={() => { clearNav(); setView('tablero'); }} />
      }

      <TimerOverlay
        open={timerOpen}
        seconds={timeLeft}
        duration={timer.duration}
        running={timerRunning}
        onToggle={() => timerCmd(timerRunning ? 'pause' : 'start')}
        onReset={() => timerCmd('reset')}
        onSetMinutes={(min) => timerCmd('set', min)}
        onClose={() => setTimerOpen(false)} />


      <TweaksPanel>
        <TweakSection label="Tablero" />
        <TweakToggle label="Conexiones entre casillas" value={t.showLinks} onChange={(v) => setTweak('showLinks', v)} />
      </TweaksPanel>
    </div>);

}

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