/* Empire Sentry — app principal */
const { useState, useEffect, useLayoutEffect, useMemo, useRef } = React;

/* ───────── i18n (EN / ES) ─────────
 * Sistema simple: STRINGS por idioma + función t(key) global. El idioma actual
 * vive en window.__es_lang (sync con localStorage). Cualquier cambio fuerza un
 * re-render del root vía setLang() del provider. Sin libs, ~50 líneas.
 */
const LANG_KEY = "es_lang";
const SUPPORTED_LANGS = ["en", "es"];
const STRINGS = {
  en: {
    "nav.rankings": "/EVENT RANKINGS",
    "nav.activity": "/CHECK ACTIVITY",
    "nav.records":  "/HALL OF RECORDS",
    "events.inProgress": "/EVENTS IN PROGRESS",
    "events.lastFinished": "/LAST FINISHED EVENTS",
    "events.live": "● LIVE",
    "events.ended": "ENDED",
    "events.remaining": "remaining",
    "events.timerEnded": "timer ended",
    "events.noTimer": "no timer",
    "events.final": "FINAL",
    "empty.noActive": "No active events right now.",
    "empty.noEnded": "No finished events with results.",
    "empty.noRecords": "No records available.",
    "event.mission": "Mission",
    "event.noRanking": "This mission has no public leaderboard — only the event timer is available.",
    "ctrl.server": "SERVER",
    "ctrl.event": "EVENT",
    "ctrl.levels": "LEVELS",
    "ctrl.rankingType": "RANKING TYPE",
    "ctrl.sortBy": "SORT BY",
    "rankType.players": "PLAYERS",
    "rankType.alliances": "ALLIANCES",
    "level.all": "ALL",
    "sort.points": "POINTS",
    "sort.gap": "GAP TO NEXT",
    "sort.maxHit": "MAX HIT",
    "sort.perHour": "SCORE / HOUR",
    "sort.atkHour": "ATTACKS / HOUR",
    "sort.name": "NAME",
    "tbl.search": "Search player or alliance…",
    "tbl.searchAct": "Search player or alliance…",
    "tbl.searchRec": "Search player, alliance or server…",
    "tbl.players": "players",
    "tbl.alliances": "alliances",
    "tbl.records": "records",
    "tbl.noMatch": "No players match",
    "tbl.noMatchRec": "No records match",
    "tbl.name": "NAME",
    "tbl.alliance": "ALLIANCE",
    "tbl.points": "POINTS",
    "tbl.gap": "GAP ▲",
    "tbl.maxHit": "MAX HIT",
    "tbl.perHour": "/HOUR",
    "tbl.atkHour": "ATK/H",
    "tbl.maxAtk": "MAX ATK",
    "tbl.spinHour": "SPINS/H",
    "tbl.maxSpin": "MAX SPINS",
    "tbl.updated": "UPDATED",
    "tbl.leader": "LEADER",
    "tbl.status": "STATUS",
    "tbl.lastSignal": "LAST SIGNAL",
    "tbl.server": "SERVER",
    "tbl.record": "RECORD",
    "tbl.set": "SET",
    "tbl.player": "PLAYER",
    "page.of": "of",
    "panel.ranking": "RANKING",
    "panel.left": "LEFT",
    "panel.finalResults": "FINAL RESULTS",
    "panel.ended": "ENDED",
    "panel.live": "LIVE",
    "activity.title": "ACTIVITY MONITOR",
    "activity.onlineNow": "ONLINE NOW",
    "activity.sortedLatest": "sorted by latest signal",
    "activity.online": "ONLINE",
    "activity.active": "ACTIVE",
    "activity.idle": "IDLE",
    "activity.asleep": "ASLEEP",
    "records.title": "HALL OF RECORDS",
    "records.top": "TOP 50 ALL-TIME",
    "records.world": "WORLD RECORD",
    "records.set": "set",
    "records.today": "today",
    "records.yesterday": "yesterday",
    "detail.player": "PLAYER DETAIL",
    "detail.alliance": "ALLIANCE DETAIL",
    "detail.selectPlayer": "SELECT A PLAYER",
    "detail.selectPlayerHint": "pick anyone in the ranking to inspect their stats and set them as your account",
    "detail.points": "POINTS",
    "detail.attacks": "ATTACKS",
    "detail.spins": "SPINS",
    "detail.perMin": "/min",
    "detail.perHour": "/hour",
    "detail.perDay": "/day",
    "detail.totalPoints": "total points",
    "detail.pointsPerHour": "POINTS / HOUR",
    "detail.groupBy": "GROUP BY",
    "detail.5min": "5 MIN",
    "detail.hour": "1 H",
    "detail.4hours": "4 HOURS",
    "detail.day": "1 DAY",
    "detail.signalsTitle": "SIGNALS / ACTIVITY",
    "detail.signals24h": "SIGNALS · 24H",
    "detail.signalsTotal": "SIGNALS · 30D",
    "detail.peakHour": "PEAK HOUR",
    "detail.today": "TODAY",
    "detail.yesterday": "YESTERDAY",
    "detail.markYou": "SET AS MY ACCOUNT (YOU)",
    "detail.unmarkYou": "THIS IS YOUR ACCOUNT — TAP TO UNSET",
    "alerts.title": "ALERTS",
    "alerts.soundOn": "🔊 SOUND ON",
    "alerts.soundOff": "🔇 SOUND OFF",
    "alerts.tone": "TONE",
    "alerts.silence": "SILENCE",
    "alerts.silenced": "muted — re-arms when alerts clear",
    "alerts.watching": "WATCHING",
    "alerts.notInRanking": "not in this ranking",
    "alerts.youWarn": "Pick a player in the ranking and tap “Set as my account” to enable alerts.",
    "alerts.mobile": "📱 Mobile alerts coming soon — get pushed when a rival closes in",
    "loader.init": "INITIALIZING",
    "loader.loading": "LOADING",
    "loader.loadingRanking": "LOADING RANKING",
    "loader.scanningActivity": "SCANNING ACTIVITY",
    "page.first": "« FIRST",
    "page.prev": "‹ PREV",
    "page.next": "NEXT ›",
    "page.last": "LAST »",
    "time.now": "now",
    "time.mAgo": "m ago",
    "time.hAgo": "h ago",
    "time.dAgo": "d ago",
    "auth.loginTitle": "MEMBERS ONLY",
    "auth.loginSub": "Empire Sentry is private. Sign in with Discord to verify you belong to the community.",
    "auth.loginBtn": "LOGIN WITH DISCORD",
    "auth.deniedTitle": "YOU ARE NOT A MEMBER OF EMPIRE SENTRY",
    "auth.deniedSub": "Your Discord account is not in the community (or lacks the required role). Ask an admin for access.",
    "auth.logout": "LOG OUT",
    "auth.retry": "TRY ANOTHER ACCOUNT",
    "auth.checking": "CHECKING ACCESS",
    "auth.error": "Could not reach the server. Retry.",
    "col.maxHitTip": "MAX HIT — highest points scored in a single attack this event",
    "col.atkHourTip": "ATK/H — attacks launched per hour (raid activity)",
    "col.maxAtkTip": "MAX ATK — total attacks launched over the whole event",
    "col.spinHourTip": "SPINS/H — wheel spins per hour",
    "col.maxSpinTip": "MAX SPINS — total wheel spins over the whole event",
    "badge.inactive": "INACTIVE",   "badge.inactiveTip": "No activity for over 1.5 days",
    "badge.sniper":   "SNIPER",     "badge.sniperTip":   "Event ending & striking far faster than average — last-minute push",
    "badge.rising":   "RISING",     "badge.risingTip":   "Scoring well above the field average",
    "badge.vulnerable":"VULNERABLE","badge.vulnerableTip":"Barely scoring — exposed to being overtaken",
    "alert.proximity.label": "RIVAL PROXIMITY",    "alert.proximity.desc": "A player is closing in below you",
    "alert.overtaken.label": "OVERTAKEN",          "alert.overtaken.desc": "Someone passes you in the ranking",
    "alert.rankOut.label":   "DROP OUT OF TOP",    "alert.rankOut.desc":   "You fall below a rank",
    "alert.rivalSurge.label":"CHASER SURGING",     "alert.rivalSurge.desc":"The player above you sprints past a rate",
    "alert.stalled.label":   "YOU STALLED",        "alert.stalled.desc":   "Your gain rate drops to zero",
    "alert.ending.label":    "EVENT ENDING",       "alert.ending.desc":    "Countdown under a time",
    "alert.ctrl.threshold": "threshold", "alert.ctrl.whenUnder": "when under",
    "alert.ctrl.above": "above",         "alert.ctrl.within": "within",
    "alert.msg.rivalClosing": "RIVAL CLOSING IN",
    "alert.msg.ptsBehind": "pts behind",      "alert.msg.toPass": "to pass",
    "alert.msg.overtaken": "YOU WERE OVERTAKEN", "alert.msg.droppedTo": "Dropped to #",
    "alert.msg.outOfTop": "OUT OF TOP",          "alert.msg.youAreNow": "You are now #",
    "alert.msg.chaserSurging": "CHASER SURGING", "alert.msg.aboveYouAt": "above you at",
    "alert.msg.youStalled": "YOU STALLED",       "alert.msg.stalledMsg": "0 pts/min — rivals are still moving",
    "alert.msg.eventEndingSoon": "EVENT ENDING SOON", "alert.msg.minLeft": "min left",
    "time.moAgo": "mo ago", "time.yAgo": "y ago",
    "alliance.member": "member", "alliance.members": "members", "alliance.noAlliance": "NO ALLIANCE",
    "tag.you": "YOU", "tag.new": "NEW",
    "footer.privacy": "Privacy", "footer.terms": "Terms", "footer.ethics": "Ethics", "footer.contact": "Contact",
    "footer.note": "Empire Sentry — Community tool. Unofficial. Not affiliated with Stillfront Games.",
  },
  es: {
    "nav.rankings": "/RANKINGS",
    "nav.activity": "/ACTIVIDAD",
    "nav.records":  "/RÉCORDS",
    "events.inProgress": "/EVENTOS EN CURSO",
    "events.lastFinished": "/ÚLTIMOS EVENTOS TERMINADOS",
    "events.live": "● EN CURSO",
    "events.ended": "TERMINADOS",
    "events.remaining": "restante",
    "events.timerEnded": "timer terminado",
    "events.noTimer": "sin timer",
    "events.final": "FINAL",
    "empty.noActive": "No hay eventos activos en este momento.",
    "empty.noEnded": "No hay eventos terminados con resultados.",
    "empty.noRecords": "No hay récords disponibles.",
    "event.mission": "Misión",
    "event.noRanking": "Esta misión no publica ranking — solo está disponible el temporizador del evento.",
    "ctrl.server": "SERVIDOR",
    "ctrl.event": "EVENTO",
    "ctrl.levels": "NIVELES",
    "ctrl.rankingType": "TIPO DE RANKING",
    "ctrl.sortBy": "ORDENAR POR",
    "rankType.players": "JUGADORES",
    "rankType.alliances": "ALIANZAS",
    "level.all": "TODOS",
    "sort.points": "PUNTOS",
    "sort.gap": "DIFERENCIA",
    "sort.maxHit": "MAYOR GOLPE",
    "sort.perHour": "PUNTOS / HORA",
    "sort.atkHour": "ATAQUES / HORA",
    "sort.name": "NOMBRE",
    "tbl.search": "Buscar jugador o alianza…",
    "tbl.searchAct": "Buscar jugador o alianza…",
    "tbl.searchRec": "Buscar jugador, alianza o servidor…",
    "tbl.players": "jugadores",
    "tbl.alliances": "alianzas",
    "tbl.records": "récords",
    "tbl.noMatch": "Ningún jugador coincide con",
    "tbl.noMatchRec": "Ningún récord coincide con",
    "tbl.name": "NOMBRE",
    "tbl.alliance": "ALIANZA",
    "tbl.points": "PUNTOS",
    "tbl.gap": "DIFERENCIA ▲",
    "tbl.maxHit": "MAYOR GOLPE",
    "tbl.perHour": "/HORA",
    "tbl.atkHour": "ATQ/H",
    "tbl.maxAtk": "MAX ATQ",
    "tbl.spinHour": "GIROS/H",
    "tbl.maxSpin": "MAX GIROS",
    "tbl.updated": "ACTUALIZADO",
    "tbl.leader": "LÍDER",
    "tbl.status": "ESTADO",
    "tbl.lastSignal": "ÚLTIMA SEÑAL",
    "tbl.server": "SERVIDOR",
    "tbl.record": "RÉCORD",
    "tbl.set": "FECHA",
    "tbl.player": "JUGADOR",
    "page.of": "de",
    "panel.ranking": "RANKING",
    "panel.left": "RESTANTE",
    "panel.finalResults": "RESULTADOS FINALES",
    "panel.ended": "TERMINADO",
    "panel.live": "EN VIVO",
    "activity.title": "MONITOR DE ACTIVIDAD",
    "activity.onlineNow": "EN LÍNEA AHORA",
    "activity.sortedLatest": "ordenado por última señal",
    "activity.online": "EN LÍNEA",
    "activity.active": "ACTIVO",
    "activity.idle": "INACTIVO",
    "activity.asleep": "DORMIDO",
    "records.title": "SALÓN DE RÉCORDS",
    "records.top": "TOP 50 HISTÓRICO",
    "records.world": "RÉCORD MUNDIAL",
    "records.set": "logrado",
    "records.today": "hoy",
    "records.yesterday": "ayer",
    "detail.player": "DETALLE DEL JUGADOR",
    "detail.alliance": "DETALLE DE ALIANZA",
    "detail.selectPlayer": "SELECCIONA UN JUGADOR",
    "detail.selectPlayerHint": "elige a cualquiera del ranking para ver sus estadísticas",
    "detail.points": "PUNTOS",
    "detail.attacks": "ATAQUES",
    "detail.spins": "GIROS",
    "detail.perMin": "/min",
    "detail.perHour": "/hora",
    "detail.perDay": "/día",
    "detail.totalPoints": "puntos totales",
    "detail.pointsPerHour": "PUNTOS / HORA",
    "detail.groupBy": "AGRUPAR POR",
    "detail.5min": "5 MIN",
    "detail.hour": "1 H",
    "detail.4hours": "4 HORAS",
    "detail.day": "1 DÍA",
    "detail.signalsTitle": "SEÑALES / ACTIVIDAD",
    "detail.signals24h": "SEÑALES · 24H",
    "detail.signalsTotal": "SEÑALES · 30D",
    "detail.peakHour": "HORA PICO",
    "detail.today": "HOY",
    "detail.yesterday": "AYER",
    "detail.markYou": "MARCAR COMO MI CUENTA",
    "detail.unmarkYou": "ESTA ES TU CUENTA — TOCA PARA QUITAR",
    "alerts.title": "ALERTAS",
    "alerts.soundOn": "🔊 SONIDO ON",
    "alerts.soundOff": "🔇 SONIDO OFF",
    "alerts.tone": "TONO",
    "alerts.silence": "SILENCIAR",
    "alerts.silenced": "silenciado — se reactivará cuando se limpien",
    "alerts.watching": "VIGILANDO",
    "alerts.notInRanking": "no está en este ranking",
    "alerts.youWarn": "Elige un jugador del ranking y pulsa “Marcar como mi cuenta” para activar las alertas.",
    "alerts.mobile": "📱 Alertas móviles próximamente — notificación cuando un rival se acerque",
    "loader.init": "INICIANDO",
    "loader.loading": "CARGANDO",
    "loader.loadingRanking": "CARGANDO RANKING",
    "loader.scanningActivity": "ESCANEANDO ACTIVIDAD",
    "page.first": "« PRIMERA",
    "page.prev": "‹ ANT",
    "page.next": "SIG ›",
    "page.last": "ÚLTIMA »",
    "time.now": "ahora",
    "time.mAgo": "m",
    "time.hAgo": "h",
    "time.dAgo": "d",
    "auth.loginTitle": "SOLO MIEMBROS",
    "auth.loginSub": "Empire Sentry es privado. Inicia sesión con Discord para verificar que perteneces a la comunidad.",
    "auth.loginBtn": "ENTRAR CON DISCORD",
    "auth.deniedTitle": "NO ERES MIEMBRO DE EMPIRE SENTRY",
    "auth.deniedSub": "Tu cuenta de Discord no está en la comunidad (o no tiene el rol necesario). Pide acceso a un administrador.",
    "auth.logout": "CERRAR SESIÓN",
    "auth.retry": "PROBAR OTRA CUENTA",
    "auth.checking": "COMPROBANDO ACCESO",
    "auth.error": "No se pudo contactar con el servidor. Reintenta.",
    "col.maxHitTip": "MAYOR GOLPE — mayor puntuación en un solo ataque durante el evento",
    "col.atkHourTip": "ATQ/H — ataques lanzados por hora (actividad de saqueo)",
    "col.maxAtkTip": "MAX ATQ — total de ataques lanzados durante el evento",
    "col.spinHourTip": "GIROS/H — giros de ruleta por hora",
    "col.maxSpinTip": "MAX GIROS — total de giros de ruleta durante el evento",
    "badge.inactive": "INACTIVO",      "badge.inactiveTip": "Sin actividad en más de 1,5 días",
    "badge.sniper":   "FRANCOTIRADOR", "badge.sniperTip":   "Evento acabando y atacando mucho más rápido que la media — empujón final",
    "badge.rising":   "ESCALANDO",     "badge.risingTip":   "Puntuando muy por encima de la media",
    "badge.vulnerable":"EXPUESTO",     "badge.vulnerableTip":"Casi sin puntuar — expuesto a ser superado",
    "alert.proximity.label": "RIVAL CERCA",            "alert.proximity.desc": "Un jugador se acerca por debajo",
    "alert.overtaken.label": "SUPERADO",               "alert.overtaken.desc": "Alguien te adelanta en el ranking",
    "alert.rankOut.label":   "FUERA DEL TOP",          "alert.rankOut.desc":   "Caes por debajo de un puesto",
    "alert.rivalSurge.label":"PERSEGUIDOR ACELERANDO", "alert.rivalSurge.desc":"El jugador por encima de ti supera un ritmo",
    "alert.stalled.label":   "SIN RITMO",              "alert.stalled.desc":   "Tu ritmo de puntuación cae a cero",
    "alert.ending.label":    "EVENTO TERMINANDO",      "alert.ending.desc":    "Cuenta atrás inferior a un tiempo",
    "alert.ctrl.threshold": "límite", "alert.ctrl.whenUnder": "cuando baje de",
    "alert.ctrl.above": "por encima de", "alert.ctrl.within": "en un margen de",
    "alert.msg.rivalClosing": "RIVAL ACERCÁNDOSE",
    "alert.msg.ptsBehind": "pts detrás",           "alert.msg.toPass": "para adelantarte",
    "alert.msg.overtaken": "HAS SIDO SUPERADO",    "alert.msg.droppedTo": "Bajaste al #",
    "alert.msg.outOfTop": "FUERA DEL TOP",         "alert.msg.youAreNow": "Ahora estás #",
    "alert.msg.chaserSurging": "PERSEGUIDOR ACELERANDO", "alert.msg.aboveYouAt": "por encima de ti a",
    "alert.msg.youStalled": "SIN RITMO",           "alert.msg.stalledMsg": "0 pts/min — los rivales siguen avanzando",
    "alert.msg.eventEndingSoon": "EVENTO TERMINANDO", "alert.msg.minLeft": "min restantes",
    "time.moAgo": "m",  "time.yAgo": "a",
    "alliance.member": "miembro", "alliance.members": "miembros", "alliance.noAlliance": "SIN ALIANZA",
    "tag.you": "TÚ", "tag.new": "NUEVO",
    "footer.privacy": "Privacidad", "footer.terms": "Términos", "footer.ethics": "Ética", "footer.contact": "Contacto",
    "footer.note": "Empire Sentry — Herramienta comunitaria. No oficial. No afiliada con Stillfront Games.",
  },
};

function detectLang() {
  try {
    const stored = localStorage.getItem(LANG_KEY);
    if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
    if (typeof navigator !== "undefined" && navigator.language?.startsWith("es")) return "es";
  } catch (e) { /* ignore */ }
  return "en";
}

// Global mutable — la actualiza setLang del provider. Permite usar t() fuera de
// componentes (helpers de formato) sin propagar context por toda la app.
window.__es_lang = window.__es_lang || detectLang();
function t(key) {
  const s = STRINGS[window.__es_lang];
  return (s && s[key]) ?? STRINGS.en[key] ?? key;
}

/* hook: lee el idioma actual y lo cambia (persistido). Re-renderiza al cambiar. */
function useLangState() {
  const [lang, setLangState] = useState(window.__es_lang);
  const setLang = (l) => {
    if (!SUPPORTED_LANGS.includes(l) || l === lang) return;
    window.__es_lang = l;
    try { localStorage.setItem(LANG_KEY, l); } catch (e) { /* ignore */ }
    setLangState(l);
  };
  return [lang, setLang];
}

/* ───────────── helpers ───────────── */
const fmt = (n) => Math.round(n).toLocaleString("de-DE");      // 46.455.233
const fmtRate = (n) => {
  if (n === 0) return "0";
  if (n < 1000) return n.toLocaleString("de-DE", { maximumFractionDigits: 1 });
  return Math.round(n).toLocaleString("de-DE");
};
function pad(n) { return String(n).padStart(2, "0"); }
function fmtCountdown(ms) {
  if (ms <= 0) return t("events.ended");
  let s = Math.floor(ms / 1000);
  const d = Math.floor(s / 86400); s -= d * 86400;
  const h = Math.floor(s / 3600);  s -= h * 3600;
  const m = Math.floor(s / 60);    s -= m * 60;
  if (d > 0) return `${d}d ${pad(h)}:${pad(m)}:${pad(s)}`;
  return `${pad(h)}:${pad(m)}:${pad(s)}`;
}
function fmtAgo(min) {
  if (min < 1) return t("time.now");
  if (min < 60) return `${min}${t("time.mAgo")}`;
  const h = Math.floor(min / 60);
  if (h < 24) return `${h}${t("time.hAgo")}`;
  return `${Math.floor(h / 24)}${t("time.dAgo")}`;
}

/* live ticking clock shared across countdowns */
function useNow(intervalMs = 1000) {
  const [now, setNow] = useState(() => Date.now());
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), intervalMs);
    return () => clearInterval(id);
  }, [intervalMs]);
  return now;
}

/* ───────────── chrome ───────────── */
function TopNav({ tab, setTab, lang, setLang, user }) {
  // Tabs identificados por clave estable (no por el texto traducido) para que
  // cambiar de idioma no rompa el estado seleccionado.
  const links = [
    { key: "rankings", label: t("nav.rankings") },
    { key: "activity", label: t("nav.activity") },
    { key: "records",  label: t("nav.records")  },
  ];
  return (
    <header className="es-nav">
      <div className="es-brand">
        <OwlMark />
        <span className="es-wordmark">
          {"EMPIRE SENTRY".split("").map((ch, i) => (
            <span
              key={i}
              className={"es-wm-ch" + (ch === " " ? " space" : "")}
              style={{ "--d": (i * 0.06).toFixed(2) + "s" }}
            >{ch === " " ? "\u00A0" : ch}</span>
          ))}
        </span>
        <span className="es-version-badge">v0.9 BETA</span>
      </div>
      <nav className="es-navlinks">
        {links.map((l, i) => (
          <React.Fragment key={l.key}>
            {i > 0 && <span className="es-navsep">—</span>}
            <button
              className={"es-navlink" + (tab === l.key ? " active" : "")}
              onClick={() => setTab(l.key)}
            >{l.label}</button>
          </React.Fragment>
        ))}
      </nav>
      <div className="es-auth">
        <div className="es-lang">
          {SUPPORTED_LANGS.map((l) => (
            <button
              key={l}
              className={"es-lang-opt" + (lang === l ? " active" : "")}
              onClick={() => setLang(l)}
              aria-label={"Switch to " + l.toUpperCase()}
            >{l.toUpperCase()}</button>
          ))}
        </div>
        {user && (
          <div className="es-user" title={user.username}>
            {user.avatar && <img className="es-user-av" src={user.avatar} alt="" />}
            <span className="es-user-name">{user.username}</span>
            <a className="es-user-logout" href="/auth/logout" title={t("auth.logout")}>⏻</a>
          </div>
        )}
      </div>
    </header>
  );
}

/* real brand owl (user-provided SVG) */
function OwlMark() {
  return <img className="es-logo" src="assets/owl.svg?v=6" alt="Empire Sentry" />;
}

/* owl loading spinner — used full-screen on boot and inline per section */
function OwlLoader({ label, full, minHeight }) {
  return (
    <div className={"es-loader" + (full ? " full" : "")} style={minHeight ? { minHeight } : null}>
      <div className="es-loader-owl">
        <span className="es-loader-ring"></span>
        <span className="es-loader-ring two"></span>
        <img className="es-loader-img" src="assets/owl.svg?v=6" alt="" />
      </div>
      {label && (
        <div className="es-loader-label">
          {label}<span className="es-loader-dots"><i>.</i><i>.</i><i>.</i></span>
        </div>
      )}
    </div>
  );
}

/* ───────────── control bar ───────────── */
function Dropdown({ label, value, options, onChange, className }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);
  const cur = options.find((o) => o.value === value) || options[0];
  return (
    <div className={"es-ctrl" + (className ? " " + className : "")} ref={ref}>
      <div className="es-ctrl-label">{label}</div>
      <button className="es-ctrl-btn" onClick={() => setOpen((o) => !o)}>
        <span className="es-ctrl-value">{cur ? cur.text : ""}</span>
        <span className="es-caret">▾</span>
      </button>
      {open && (
        <ul className="es-menu">
          {options.map((o) => (
            <li
              key={o.value}
              className={o.value === value ? "active" : ""}
              onClick={() => { onChange(o.value); setOpen(false); }}
            >{o.text}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

function ReadoutCell({ label, value }) {
  return (
    <div className="es-ctrl static">
      <div className="es-ctrl-label">{label}</div>
      <div className="es-ctrl-value">{value}</div>
    </div>
  );
}

/* LEVELS cell — stepper when the event has several level-tier rankings */
function LevelStepper({ tiers, index, onChange }) {
  const multi = tiers.length > 1;
  const go = (d) => onChange((index + d + tiers.length) % tiers.length);
  return (
    <div className="es-ctrl static">
      <div className="es-ctrl-label">
        {t("ctrl.levels")}{multi && <span className="es-tier-count"> · {index + 1}/{tiers.length}</span>}
      </div>
      <div className={"es-tier" + (multi ? " multi" : "")}>
        {multi && (
          <button className="es-tier-btn" aria-label="Tramo anterior"
            onClick={(e) => { e.stopPropagation(); go(-1); }}>‹</button>
        )}
        <span className="es-ctrl-value es-tier-value">{tiers[index]}</span>
        {multi && (
          <button className="es-tier-btn" aria-label="Tramo siguiente"
            onClick={(e) => { e.stopPropagation(); go(1); }}>›</button>
        )}
      </div>
    </div>
  );
}

/* generic stepper cell (arrows like LEVELS). Con una sola opción no hay flechas:
   el valor queda fijo (no se puede cambiar). */
function OptionStepper({ label, options, index, onChange }) {
  const multi = options.length > 1;
  const safeIdx = Math.min(index, options.length - 1);
  const go = (d) => onChange((safeIdx + d + options.length) % options.length);
  return (
    <div className="es-ctrl static">
      <div className="es-ctrl-label">{label}</div>
      <div className={"es-tier" + (multi ? " multi" : "")}>
        {multi && (
          <button className="es-tier-btn" aria-label="prev"
            onClick={(e) => { e.stopPropagation(); go(-1); }}>‹</button>
        )}
        <span className="es-ctrl-value es-tier-value">{options[safeIdx].label}</span>
        {multi && (
          <button className="es-tier-btn" aria-label="next"
            onClick={(e) => { e.stopPropagation(); go(1); }}>›</button>
        )}
      </div>
    </div>
  );
}

// Las labels se construyen en runtime con t() para que cambiar de idioma las
// re-traduzca. Pasamos getters por lang en vez de constantes estáticas.
function getRankTypes() {
  return [
    { key: "players",   label: t("rankType.players") },
    { key: "alliances", label: t("rankType.alliances") },
  ];
}
function getRankTypesByEntity() {
  const RT = getRankTypes();
  return { players: [RT[0]], alliances: [RT[1]], both: RT };
}
// Alias para mantener compatibilidad en sitios que solo necesitan las CLAVES.
const RANK_TYPE_KEYS = ["players", "alliances"];

/* aggregate a player ranking into an alliance ranking */
function aggregateAlliances(rows) {
  const map = {};
  for (const p of rows) {
    let a = map[p.alliance];
    if (!a) a = map[p.alliance] = {
      alliance: p.alliance, members: 0, score: 0, perMin: 0, perHour: 0, perDay: 0,
      maxHit: 0, atkHour: 0, maxAttacks: 0, updatedMin: Infinity,
      hourly: new Array(24).fill(0),
      hourlyPrev: new Array(24).fill(0),
    };
    a.members += 1;
    a.score += p.score;
    a.perMin += p.perMin;
    a.perHour += p.perHour;
    a.perDay += p.perDay;
    a.maxAttacks += p.maxAttacks || 0;
    a.atkHour += p.atkHour;
    a.maxHit = Math.max(a.maxHit, p.maxHit);
    if (p.updatedMin < a.updatedMin) a.updatedMin = p.updatedMin;
    if (p.hourly) for (let h = 0; h < 24; h++) a.hourly[h] += p.hourly[h] || 0;
    if (p.hourlyPrev) for (let h = 0; h < 24; h++) a.hourlyPrev[h] += p.hourlyPrev[h] || 0;
  }
  const arr = Object.values(map).map((a) => ({
    id: "all_" + a.alliance,
    name: a.alliance,
    alliance: a.members + " " + (a.members === 1 ? t("alliance.member") : t("alliance.members")),
    score: a.score,
    perMin: Math.round(a.perMin * 10) / 10,
    perHour: a.perHour,
    perDay: a.perDay,
    maxHit: a.maxHit,
    atkHour: Math.round(a.atkHour),
    maxAttacks: a.maxAttacks,
    updatedMin: a.updatedMin === Infinity ? 0 : a.updatedMin,
    inactive: false,
    hourly: a.hourly,
    hourlyPrev: a.hourlyPrev,
    isAlliance: true,
  }));
  arr.sort((x, y) => y.score - x.score);
  arr.forEach((a, i) => (a.rank = i + 1));
  return arr;
}

/* ───────────── events row (top) — LIVE or ENDED ───────────── */
function fmtAgoLong(sec) {
  const m = Math.floor(sec / 60);
  if (m < 60) return m + t("time.mAgo");
  const h = Math.floor(m / 60);
  if (h < 24) return h + t("time.hAgo");
  return Math.floor(h / 24) + t("time.dAgo");
}
function EventsRow({ events, selected, onSelect, now, style, view, setView }) {
  const ended = view === "ended";
  return (
    <section className={"es-events style-" + style + (ended ? " ended-view" : "")}>
      <div className="es-events-head">
        <span className="es-section-tag">{ended ? t("events.lastFinished") : t("events.inProgress")}</span>
        <div className="es-view-toggle">
          <button className={"es-view-opt" + (!ended ? " active" : "")} onClick={() => setView("live")}>{t("events.live")}</button>
          <button className={"es-view-opt" + (ended ? " active" : "")} onClick={() => setView("ended")}>{t("events.ended")}</button>
        </div>
      </div>
      <div className="es-events-track">
        {events.map((ev) => {
          const isSel = ev.key === selected;
          // noTimer: evento activo sin timer asociado (la API devuelve endsAt null).
          const noTimer = !ended && (ev.endsAt == null);
          const remaining = ended || noTimer ? 0 : ev.endsAt - now;
          const ending = !ended && !noTimer && remaining < 3 * 3600 * 1000;
          const total = ended || noTimer || !ev.rs ? 1 : ev.rs * 1000;
          const elapsed = ended ? 1 : Math.min(1, Math.max(0, 1 - remaining / total));
          return (
            <button
              key={ev.key}
              className={"es-event" + (isSel ? " selected" : "") + (ending ? " ending" : "") + (ended ? " finished" : "")}
              onClick={() => onSelect(ev.key)}
            >
              <span className="es-event-icon" aria-hidden>{ev.icon}</span>
              <span className="es-event-body">
                <span className="es-event-name">{ev.name}</span>
              </span>
              <span className="es-event-timer">
                {ended ? (
                  <React.Fragment>
                    <span className="es-event-clock finished">{t("events.final")}</span>
                    <span className="es-event-clocklbl">{fmtAgoLong(ev.endedAgo)}</span>
                  </React.Fragment>
                ) : noTimer ? (
                  <React.Fragment>
                    <span className="es-event-clock">{t("panel.live")}</span>
                    <span className="es-event-clocklbl">{t("events.noTimer")}</span>
                  </React.Fragment>
                ) : (
                  <React.Fragment>
                    <span className="es-event-clock">{fmtCountdown(remaining)}</span>
                    <span className="es-event-clocklbl">{remaining > 0 ? t("events.remaining") : t("events.timerEnded")}</span>
                  </React.Fragment>
                )}
              </span>
              {style === "progress" && !ended && !noTimer && (
                <span className="es-event-progress">
                  <span className="es-event-progressfill" style={{ width: (elapsed * 100).toFixed(1) + "%" }} />
                </span>
              )}
            </button>
          );
        })}
      </div>
    </section>
  );
}

/* ───────────── ranking table ───────────── */
// Columnas de métricas: función (no constante) para que t() se evalúe al
// renderizar y cambie con el idioma activo en ese momento.
function getRateCols(live, unit) {
  // Eventos de "giros" (ruleta): la métrica de acciones son GIROS, no ataques.
  const spins = unit === "spins";
  if (live) return [
    { key: "score",   label: t("tbl.points"),  cls: "num strong", arrow: false },
    { key: "gap",     label: t("tbl.gap"),      cls: "num gap",    arrow: false },
    { key: "maxHit",  label: t("tbl.maxHit"),  cls: "num maxhit", arrow: false },
    { key: "perHour", label: t("tbl.perHour"), cls: "num",        arrow: true },
    { key: "atkHour", label: spins ? t("tbl.spinHour") : t("tbl.atkHour"), cls: "num atk", arrow: false,
      tip: spins ? t("col.spinHourTip") : t("col.atkHourTip") },
  ];
  // Eventos terminados: datos ESTÁTICOS del final. Sin ritmo (ATK/H).
  return [
    { key: "score",      label: t("tbl.points"),  cls: "num strong", arrow: false },
    { key: "gap",        label: "GAP ▲",          cls: "num gap",    arrow: false },
    { key: "maxHit",     label: t("tbl.maxHit"),  cls: "num maxhit", arrow: false,
      tip: t("col.maxHitTip") },
    { key: "maxAttacks", label: spins ? t("tbl.maxSpin") : t("tbl.maxAtk"), cls: "num matk", arrow: false,
      tip: spins ? t("col.maxSpinTip") : t("col.maxAtkTip") },
  ];
}

// badges contextuales (uno por jugador, por prioridad)
const INACTIVE_MIN = 1.5 * 24 * 60;   // +1,5 días sin actividad
function getBadgeMeta() {
  return {
    inactive:   { label: t("badge.inactive"),   icon: "💤", tip: t("badge.inactiveTip") },
    sniper:     { label: t("badge.sniper"),      icon: "🎯", tip: t("badge.sniperTip") },
    rising:     { label: t("badge.rising"),      icon: "▲",  tip: t("badge.risingTip") },
    vulnerable: { label: t("badge.vulnerable"),  icon: "⚠",  tip: t("badge.vulnerableTip") },
  };
}
function playerBadge(p, stats, endingSoon, live) {
  if (!live) return null;
  if (p.updatedMin > INACTIVE_MIN || p.inactive) return "inactive";
  if (stats.avgPerMin <= 0) return null;
  const rising = p.perMin > stats.avgPerMin * 1.8;          // muy por encima de la media
  const vulnerable = p.perMin > 0 && p.perMin < stats.avgPerMin * 0.2; // casi sin puntuar
  // sniper: evento acabando + ritmo muy alto + muchos más ataques que la media
  if (endingSoon && rising && p.atkHour > stats.avgAtk * 1.4) return "sniper";
  if (rising) return "rising";
  if (vulnerable) return "vulnerable";
  return null;
}

// Dirección "natural" de cada columna al seleccionarla por primera vez:
// nombre/rank/gap ascendente (A→Z, 1→N, menor hueco primero); el resto
// (score, maxHit, ritmos…) descendente (mayor primero).
const naturalSortDir = (key) =>
  (key === "name" || key === "rank" || key === "gap") ? "asc" : "desc";

function RankingTable({ rows, sortBy, setSortBy, selectedPlayer, onPick, density, version, movements, loading, endingSoon, live, showBadges, entity, unit }) {
  const [page, setPage] = useState(0);
  const [query, setQuery] = useState("");
  // Dirección de orden. Se resetea a la natural al cambiar de columna; reclicar
  // la MISMA cabecera la alterna asc⇄desc.
  const [sortDir, setSortDir] = useState(() => naturalSortDir(sortBy));
  useEffect(() => { setSortDir(naturalSortDir(sortBy)); }, [sortBy]);
  // medias del ranking (solo jugadores activos) para los badges contextuales
  const stats = useMemo(() => {
    const active = rows.filter((p) => !p.inactive && p.perMin > 0 && p.updatedMin <= INACTIVE_MIN);
    const n = active.length || 1;
    return {
      avgPerMin: active.reduce((a, p) => a + p.perMin, 0) / n,
      avgAtk: active.reduce((a, p) => a + p.atkHour, 0) / n,
    };
  }, [rows, version]);
  // Página fija de 40 filas en todas las tablas (rankings, activity, records).
  const tbodyRef = useRef(null);
  const PER_PAGE = 40;

  // gap por jugador = puntos que le saca el jugador justo por encima (orden por score)
  const gapById = useMemo(() => {
    const byScore = rows.slice().sort((a, b) => b.score - a.score);
    const m = {};
    byScore.forEach((p, i) => { m[p.id] = i === 0 ? null : byScore[i - 1].score - p.score; });
    return m;
  }, [rows, version]);

  const sorted = useMemo(() => {
    const r = rows.slice();
    // Comparador base SIEMPRE ascendente; la dirección se aplica al final.
    let cmp;
    if (sortBy === "name") cmp = (a, b) => a.name.localeCompare(b.name);
    else if (sortBy === "rank") cmp = (a, b) => a.rank - b.rank;
    else if (sortBy === "gap") cmp = (a, b) => (gapById[a.id] ?? -1) - (gapById[b.id] ?? -1);
    else cmp = (a, b) => (a[sortBy] ?? 0) - (b[sortBy] ?? 0);
    r.sort(cmp);
    if (sortDir === "desc") r.reverse();
    return r;
  }, [rows, sortBy, sortDir, version, gapById]);

  // filtro de búsqueda (nombre o alianza)
  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return sorted;
    return sorted.filter((p) => p.name.toLowerCase().includes(q) || p.alliance.toLowerCase().includes(q));
  }, [sorted, query]);

  // reset de página solo al cambiar la búsqueda (no en cada poll)
  useEffect(() => { setPage(0); }, [query]);

  const totalPages = Math.max(1, Math.ceil(filtered.length / PER_PAGE));
  const pageC = Math.min(page, totalPages - 1);
  const pageRows = filtered.slice(pageC * PER_PAGE, pageC * PER_PAGE + PER_PAGE);
  const fromN = filtered.length === 0 ? 0 : pageC * PER_PAGE + 1;
  const toN = Math.min(filtered.length, pageC * PER_PAGE + PER_PAGE);

  // FLIP: filas que se deslizan a su nueva posición al reordenarse
  const rowEls = useRef(new Map());
  const prevTops = useRef(new Map());
  useLayoutEffect(() => {
    const next = new Map();
    rowEls.current.forEach((el, id) => { if (el) next.set(id, el.offsetTop); });
    next.forEach((top, id) => {
      const prev = prevTops.current.get(id);
      const el = rowEls.current.get(id);
      if (el && prev != null && prev !== top) {
        const dy = prev - top;
        el.style.transition = "none";
        el.style.transform = "translateY(" + dy + "px)";
        el.getBoundingClientRect();
        requestAnimationFrame(() => {
          el.style.transition = "transform .6s cubic-bezier(.22,.61,.36,1)";
          el.style.transform = "";
          setTimeout(() => { if (el) el.style.transition = ""; }, 660);
        });
      }
    });
    prevTops.current = next;
  }, [version, pageC, query]);

  const onHeadClick = (key) => {
    if (key === sortBy) setSortDir((d) => (d === "asc" ? "desc" : "asc"));  // toggle
    else setSortBy(key);  // el efecto resetea la dirección a la natural de la columna
  };
  const head = (key, label, cls, tip) => (
    <button
      className={"es-th " + (cls || "") + (sortBy === key ? " sorted" : "")}
      onClick={() => onHeadClick(key)}
    >
      {label}{sortBy === key && <span className="es-sortcaret">{sortDir === "asc" ? "▴" : "▾"}</span>}
      {tip && <span className="es-th-tip" role="tooltip">{tip}</span>}
    </button>
  );

  return (
    <div className={"es-table density-" + density + (entity === "alliance" ? " no-alliance" : "") + (live ? "" : " ended-table")}>
      <div className="es-table-toolbar">
        <div className="es-search">
          <span className="es-search-icon">⌕</span>
          <input
            className="es-search-input"
            type="text"
            placeholder={t("tbl.search")}
            value={query}
            onChange={(e) => setQuery(e.target.value)}
          />
          {query && <button className="es-search-clear" onClick={() => setQuery("")} aria-label="Clear">×</button>}
        </div>
        <span className="es-search-count">{filtered.length} {entity === "alliance" ? t("tbl.alliances") : t("tbl.players")}</span>
      </div>
      <div className="es-thead">
        {head("rank", "#", "rank")}
        {head("name", entity === "alliance" ? t("tbl.alliance") : t("tbl.name"), "name")}
        {entity !== "alliance" && <div className="es-th alliance">{t("tbl.alliance")}</div>}
        {getRateCols(live, unit).map((c) => <React.Fragment key={c.key}>{head(c.key, c.label, c.cls, c.tip)}</React.Fragment>)}
        <div className="es-th updated">{t("tbl.updated")}</div>
      </div>
      {loading ? (
        <OwlLoader label={t("loader.loadingRanking")} minHeight={420} />
      ) : (
      <div className="es-tbody" ref={tbodyRef}>
        {pageRows.length === 0 && (
          <div className="es-noresults">{t("tbl.noMatch")} "{query}"</div>
        )}
        {pageRows.map((p) => {
          const mv = (movements && movements[p.id]) || 0;
          const badge = showBadges ? playerBadge(p, stats, endingSoon, live) : null;
          const bm = badge && getBadgeMeta()[badge];
          return (
          <div
            key={p.id}
            ref={(el) => { if (el) rowEls.current.set(p.id, el); else rowEls.current.delete(p.id); }}
            className={
              "es-tr" +
              (p.isYou ? " you" : "") +
              (badge === "sniper" ? " sniper-row" : "") +
              (selectedPlayer === p.id ? " picked" : "") +
              (p.inactive ? " inactive" : "") +
              (mv > 0 ? " moved-up" : mv < 0 ? " moved-down" : "")
            }
            onClick={() => onPick(p.id)}
          >
            <div className="es-td rank">
              <span className="es-rank-num">{p.rank}</span>
              {mv > 0 && <span className="es-move up">▲{mv}</span>}
              {mv < 0 && <span className="es-move down">▼{-mv}</span>}
            </div>
            <div className="es-td name">
              {p.isYou && <span className="es-you-dot" title="You"></span>}
              <span className="es-name-txt">{p.name}</span>
              {bm && (
                <span className={"es-badge " + badge} title={bm.tip}>
                  <span className="es-badge-ic">{bm.icon}</span>{bm.label}
                </span>
              )}
            </div>
            {entity !== "alliance" && <div className="es-td alliance">{p.alliance}</div>}
            <div className="es-td num strong">{fmt(p.score)}</div>
            <div className={"es-td num gap" + (gapById[p.id] == null ? " zero" : "")}>
              {gapById[p.id] == null
                ? <span className="es-leader">{t("tbl.leader")}</span>
                : <span className="es-gap-val"><span className="es-gap-arrow">▲</span>{fmt(gapById[p.id])}</span>}
            </div>
            <div className="es-td num maxhit">{fmt(p.maxHit)}</div>
            {live ? (
              <div className={"es-td num" + (p.perHour === 0 ? " zero" : "")}>
                {p.perHour === 0 ? fmt(0) : <span className="es-rate-up"><span className="es-rate-arrow">▲</span>+{fmt(p.perHour)}</span>}
              </div>
            ) : (
              <div className="es-td num matk">{fmt(p.maxAttacks || 0)}</div>
            )}
            {/* ATK/H solo en vivo: en eventos terminados no hay ritmo */}
            {live && <div className={"es-td num atk" + (p.atkHour === 0 ? " zero" : "")}>{p.atkHour === 0 ? fmt(0) : p.atkHour}</div>}
            <div className="es-td updated">{fmtAgo(p.updatedMin)}</div>
          </div>
          );
        })}
      </div>
      )}
      {!loading && filtered.length > PER_PAGE && (
        <div className="es-pagination">
          <span className="es-page-info">{fromN}–{toN} {t("page.of")} {filtered.length}</span>
          <div className="es-page-ctrls">
            <button className="es-page-btn" disabled={pageC === 0} onClick={() => setPage(0)}>{t("page.first")}</button>
            <button className="es-page-btn" disabled={pageC === 0} onClick={() => setPage(pageC - 1)}>{t("page.prev")}</button>
            <span className="es-page-num">{pageC + 1} / {totalPages}</span>
            <button className="es-page-btn" disabled={pageC >= totalPages - 1} onClick={() => setPage(pageC + 1)}>{t("page.next")}</button>
            <button className="es-page-btn" disabled={pageC >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>{t("page.last")}</button>
          </div>
        </div>
      )}
    </div>
  );
}

/* ───────────── charts (detalle de jugador) ─────────────
 * Tres granularidades compartidas entre el detalle de rankings (puntos) y el de
 * CHECK ACTIVITY (señales): 5 min / 1 h / 1 día. El backend envía la SERIE CRUDA
 * de subidas del jugador ({ refMs, series:[{t,d}] }, últimos 30 días) y el
 * bucketing se hace AQUÍ — así el drill-down (mes → día → hora) recalcula al
 * instante sin volver a pedir datos. metric: 'points' suma deltas; 'signals'
 * cuenta subidas (más subidas = más activo).
 */
const EMPTY24 = new Array(24).fill(0);
const EMPTY12 = new Array(12).fill(0);
const EMPTY_ACT = { refMs: null, series: [] };
const DAY_MS = 86400000;

// "YYYY-MM-DD" en hora local (clave de día estable)
function localDayKey(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
// agrega las filas de un bucket según la métrica
function reduceRows(rows, metric) { return metric === "signals" ? rows.length : rows.reduce((a, r) => a + r.d, 0); }
// daily[30]: un valor por día natural terminando en refMs
function computeDaily(series, refMs, metric) {
  const map = new Map();
  for (const r of series) { const k = localDayKey(new Date(r.t)); (map.get(k) || map.set(k, []).get(k)).push(r); }
  const out = [];
  for (let i = 29; i >= 0; i--) {
    const k = localDayKey(new Date(refMs - i * DAY_MS));
    out.push({ date: k, value: reduceRows(map.get(k) || [], metric) });
  }
  return out;
}
// hourly[24]: las 24 horas de un día concreto
function computeHourly(series, dayKey, metric) {
  const b = Array.from({ length: 24 }, () => []);
  for (const r of series) { const d = new Date(r.t); if (localDayKey(d) === dayKey) b[d.getHours()].push(r); }
  return b.map((rows) => reduceRows(rows, metric));
}
// mins5[12]: 12 tramos de 5 min (alineados al reloj) de un día+hora concretos
function computeMins5(series, dayKey, hour, metric) {
  const b = Array.from({ length: 12 }, () => []);
  for (const r of series) {
    const d = new Date(r.t);
    if (localDayKey(d) === dayKey && d.getHours() === hour) b[Math.floor(d.getMinutes() / 5)].push(r);
  }
  return b.map((rows) => reduceRows(rows, metric));
}

// etiqueta del eje X en la vista 5 min: :00 :15 :30 :45 (tramos de 5, alineados al reloj)
function mins5ClockLabel(i) { const m = i * 5; return m % 15 === 0 ? ":" + pad(m) : ""; }
// meses cortos para heatmap / breadcrumb
const HEAT_MONTHS = { en: ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
                      es: ["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"] };
function fmtHeatDate(key) {
  const [, m, d] = key.split("-").map(Number);
  const months = HEAT_MONTHS[window.__es_lang] || HEAT_MONTHS.en;
  return `${d} ${months[m - 1]}`;
}
// etiqueta de día para el breadcrumb (HOY / AYER / "4 jun")
function dayLabel(key, refKey) {
  if (key === refKey) return t("detail.today");
  const prev = localDayKey(new Date(new Date(refKey + "T00:00:00").getTime() - DAY_MS));
  if (key === prev) return t("detail.yesterday");
  return fmtHeatDate(key);
}

/* barras verticales (5 min / 1 h). onPick(i): si se pasa, clicar una barra hace
   drill-down al siguiente nivel (1 h → 5 min de esa hora). */
function BarChart({ buckets, labelFn, fmtValue, onPick }) {
  const [hover, setHover] = useState(null);
  const max = Math.max(1, ...buckets);
  const peakIdx = buckets.indexOf(max);
  const ticks = 4;
  return (
    <div className="es-chart">
      <div className="es-chart-yaxis">
        {Array.from({ length: ticks + 1 }, (_, i) => {
          const v = (max * (ticks - i)) / ticks;
          return <span key={i}>{shortNum(v)}</span>;
        })}
      </div>
      <div className="es-chart-plot">
        <div className="es-chart-grid">
          {Array.from({ length: ticks + 1 }, (_, i) => <span key={i} />)}
        </div>
        <div className="es-bars">
          {buckets.map((v, i) => {
            const isPeak = i === peakIdx;
            const isHover = hover === i;
            return (
              <div className={"es-barcol" + (onPick ? " clickable" : "")} key={i}
                onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}
                onClick={() => onPick && onPick(i)}>
                {(isHover || (isPeak && peakIdx < buckets.length * 0.6)) && v > 0 && (
                  <span className="es-bar-tip">{fmtValue(v)}</span>
                )}
                <div
                  className={"es-bar" + (isPeak ? " peak" : "") + (isHover ? " hover" : "")}
                  style={{ height: Math.max(2, (v / max) * 90) + "%" }}
                />
                <span className="es-bar-x">{labelFn(i)}</span>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

/* cuadrícula mensual tipo heatmap (vista "1 día"): un cuadrado por día, más
   iluminado cuantos más puntos/señales. Hover → total del día en el pie; clic en un
   cuadrado → drill-down a la vista 1 h de ese día (onPick). */
function MonthHeatmap({ daily, fmtValue, onPick }) {
  const [sel, setSel] = useState(null);
  const max = Math.max(1, ...daily.map((d) => d.value));
  // padding inicial para alinear por día de la semana (lunes primero)
  const lead = daily.length ? (new Date(daily[0].date + "T00:00:00").getDay() + 6) % 7 : 0;
  const cur = sel != null ? daily[sel] : null;
  return (
    <div className="es-heatmap-wrap">
      <div className="es-heatmap">
        {Array.from({ length: lead }, (_, i) => <span key={"pad" + i} className="es-heat-pad" />)}
        {daily.map((d, i) => {
          const lvl = d.value > 0 ? (0.16 + 0.84 * (d.value / max)) : 0;
          const dow = new Date(d.date + "T00:00:00").getDay();   // 0=dom … 6=sáb
          const weekend = dow === 0 || dow === 6;
          return (
            <button
              key={d.date}
              className={"es-heat-cell" + (d.value > 0 ? " has" : "") + (weekend ? " weekend" : "") + (sel === i ? " sel" : "")}
              style={{ "--lvl": lvl.toFixed(3) }}
              title={fmtHeatDate(d.date)}
              onMouseEnter={() => setSel(i)}
              onMouseLeave={() => setSel((s) => (s === i ? null : s))}
              onClick={() => onPick && onPick(d.date)}
            >
              <span className="es-heat-num">{Number(d.date.slice(8, 10))}</span>
            </button>
          );
        })}
      </div>
      <div className="es-heat-foot">
        <span className="es-heat-info">{cur && <React.Fragment><b>{fmtValue(cur.value)}</b> · {fmtHeatDate(cur.date)}</React.Fragment>}</span>
        <span className="es-heat-legend" aria-hidden>
          {[0, 0.3, 0.55, 0.8, 1].map((l, i) => <i key={i} style={{ "--lvl": l }} />)}
        </span>
      </div>
    </div>
  );
}

/* reloj circular de 24h (vista "1 h" en CHECK ACTIVITY): barras polares, una por
   hora, cuya longitud crece con la actividad. Clic en una hora → drill a 5 min. */
function RadialClock({ hourly, fmtValue, onPick }) {
  const [hover, setHover] = useState(null);
  const max = Math.max(1, ...hourly);
  const peak = hourly.indexOf(Math.max(...hourly));
  const size = 232, c = size / 2, r0 = 34, rMax = c - 26;
  const polar = (ang, rad) => [c + rad * Math.cos(ang), c + rad * Math.sin(ang)];
  const angOf = (h) => (-90 + h * 15) * Math.PI / 180;
  return (
    <div className="es-radial">
      <svg viewBox={`0 0 ${size} ${size}`} className="es-radial-svg">
        <circle cx={c} cy={c} r={rMax} className="es-radial-ring" />
        <circle cx={c} cy={c} r={(rMax + r0) / 2} className="es-radial-ring faint" />
        <circle cx={c} cy={c} r={r0} className="es-radial-ring faint" />
        {hourly.map((v, h) => {
          const ang = angOf(h);
          const len = v > 0 ? r0 + (v / max) * (rMax - r0) : r0 + 1;
          const [x1, y1] = polar(ang, r0);
          const [x2, y2] = polar(ang, len);
          return (
            <line key={h} x1={x1} y1={y1} x2={x2} y2={y2}
              className={"es-radial-bar" + (h === peak && v > 0 ? " peak" : "") + (hover === h ? " hover" : "")} />
          );
        })}
        {/* hit-lines invisibles y anchas: facilitan hover/clic sobre cada hora */}
        {hourly.map((v, h) => {
          const ang = angOf(h);
          const [ix, iy] = polar(ang, r0);
          const [ox, oy] = polar(ang, rMax);
          return (
            <line key={"hit" + h} x1={ix} y1={iy} x2={ox} y2={oy} className="es-radial-hit"
              onMouseEnter={() => setHover(h)} onMouseLeave={() => setHover(null)}
              onClick={() => onPick && onPick(h)} />
          );
        })}
        {[0, 6, 12, 18].map((h) => {
          const [tx, ty] = polar(angOf(h), rMax + 13);
          return <text key={h} x={tx} y={ty} className="es-radial-lbl"
            textAnchor="middle" dominantBaseline="middle">{pad(h)}</text>;
        })}
        <text x={c} y={c - 6} className="es-radial-center" textAnchor="middle" dominantBaseline="middle">
          {hover != null ? fmtValue(hourly[hover]) : "24h"}
        </text>
        {hover != null && (
          <text x={c} y={c + 14} className="es-radial-sub" textAnchor="middle" dominantBaseline="middle">
            {pad(hover)}:00
          </text>
        )}
      </svg>
    </div>
  );
}

/* breadcrumb + sub-gráfica, con DRILL-DOWN (sin combobox; se navega clicando):
 *   1 día (heatmap mensual, vista por defecto) → clic en un día → 1 h de ese día
 *   1 h (reloj 24h)                            → clic en una hora → 5 min de esa hora
 * Para subir de nivel se usa el breadcrumb. El bucketing se recalcula en cliente
 * desde la serie cruda según selDay/selHour. series/refMs: serie del jugador.
 * metric: 'points' | 'signals'. Se monta con key={player.id} → al cambiar de
 * jugador se reinicia la selección. */
function DetailChart({ series, refMs, groupBy, setGroupBy, metric }) {
  const refDate = refMs ? new Date(refMs) : new Date();
  const refDayKey = localDayKey(refDate);
  const [selDay, setSelDay] = useState(refDayKey);
  const [selHour, setSelHour] = useState(refDate.getHours());

  // al llegar los datos (null→cargado), centra la selección en el último día/hora
  // con datos. No se re-dispara con cada refresco (depende solo de "cargado").
  const loaded = refMs != null;
  useEffect(() => {
    if (refMs != null) { const d = new Date(refMs); setSelDay(localDayKey(d)); setSelHour(d.getHours()); }
  }, [loaded]);

  const daily  = useMemo(() => computeDaily(series, refMs ?? Date.now(), metric), [series, refMs, metric]);
  const hourly = useMemo(() => computeHourly(series, selDay, metric), [series, selDay, metric]);
  const mins5  = useMemo(() => computeMins5(series, selDay, selHour, metric), [series, selDay, selHour, metric]);

  const title = metric === "signals" ? t("detail.signalsTitle") : t("detail.points");
  const onPickDay  = (dateKey) => { setSelDay(dateKey); setGroupBy("hour"); };
  const onPickHour = (h) => { setSelHour(h); setGroupBy("mins5"); };

  return (
    <React.Fragment>
      <div className="es-chart-head">
        <span className="es-chart-title">{title}</span>
        {/* breadcrumb del período (clic = subir de nivel); en la vista mes no aplica */}
        {groupBy !== "day" && (
          <div className="es-chart-crumb">
            <button className="es-crumb-link" onClick={() => setGroupBy("day")}>{t("detail.day")}</button>
            <span className="es-crumb-sep">›</span>
            {groupBy === "mins5" ? (
              <React.Fragment>
                <button className="es-crumb-link" onClick={() => setGroupBy("hour")}>{dayLabel(selDay, refDayKey)}</button>
                <span className="es-crumb-sep">›</span>
                <span className="es-crumb-cur">{pad(selHour)}:00</span>
              </React.Fragment>
            ) : (
              <span className="es-crumb-cur">{dayLabel(selDay, refDayKey)}</span>
            )}
          </div>
        )}
      </div>

      {groupBy === "mins5" && (
        <BarChart buckets={mins5} labelFn={mins5ClockLabel} fmtValue={fmt} />
      )}
      {groupBy === "hour" && (
        <RadialClock hourly={hourly} fmtValue={fmt} onPick={onPickHour} />
      )}
      {groupBy === "day" && (
        <MonthHeatmap daily={daily} fmtValue={fmt} onPick={onPickDay} />
      )}
    </React.Fragment>
  );
}

function ActivityPanel({ player, groupBy, setGroupBy, isYou, onToggleYou, canMark, unit }) {
  if (!player) {
    return (
      <div className="es-activity empty">
        <div className="es-activity-empty">
          <OwlMark />
          <p>{t("detail.selectPlayer")}<br /><span>{t("detail.selectPlayerHint")}</span></p>
        </div>
      </div>
    );
  }

  return (
    <div className="es-activity">
      {/* player header */}
      <div className="es-pl-head">
        <div className="es-pl-rank">#{player.rank}</div>
        <div className="es-pl-id">
          <div className="es-pl-name">
            {player.name}
            {isYou && <span className="es-tag you">{t("tag.you")}</span>}
          </div>
          <div className="es-pl-alliance">{player.alliance}</div>
        </div>
        <div className="es-pl-score">
          <div className="es-pl-score-num">{fmt(player.score)}</div>
          <div className="es-pl-score-lbl">{t("detail.totalPoints")}</div>
        </div>
      </div>

      {canMark && (
        <button
          className={"es-mark-you" + (isYou ? " active" : "")}
          onClick={() => onToggleYou(isYou ? null : player.name)}
        >
          {isYou
            ? <React.Fragment><span className="es-mark-ic">✓</span>{t("detail.unmarkYou")}</React.Fragment>
            : <React.Fragment><span className="es-mark-ic">◎</span>{t("detail.markYou")}</React.Fragment>}
        </button>
      )}

      <div className="es-pl-section">{t("detail.points")}</div>
      <div className="es-pl-rates">
        <div className="es-pl-rate"><b>{fmtRate(player.perMin)}</b><span>{t("detail.perMin")}</span></div>
        <div className="es-pl-rate"><b>{fmt(player.perHour)}</b><span>{t("detail.perHour")}</span></div>
        <div className="es-pl-rate"><b>{fmt(player.perDay)}</b><span>{t("detail.perDay")}</span></div>
      </div>

      <div className="es-pl-section">{unit === "spins" ? t("detail.spins") : t("detail.attacks")}</div>
      <div className="es-pl-rates atk">
        <div className="es-pl-rate"><b>{fmtRate(Math.round((player.atkHour || 0) / 60 * 10) / 10)}</b><span>{t("detail.perMin")}</span></div>
        <div className="es-pl-rate"><b>{fmt(player.atkHour || 0)}</b><span>{t("detail.perHour")}</span></div>
        <div className="es-pl-rate"><b>{fmt((player.atkHour || 0) * 24)}</b><span>{t("detail.perDay")}</span></div>
      </div>

      <DetailChart key={player.id} series={player.series} refMs={player.refMs}
        groupBy={groupBy} setGroupBy={setGroupBy} metric="points" />
    </div>
  );
}

/* ───────────── activity detail (CHECK ACTIVITY → señales) ───────────── */
function ActivityDetail({ player, data, groupBy, setGroupBy }) {
  if (!player) {
    return (
      <div className="es-activity empty">
        <div className="es-activity-empty">
          <OwlMark />
          <p>{t("detail.selectPlayer")}<br /><span>{t("detail.selectPlayerHint")}</span></p>
        </div>
      </div>
    );
  }
  const st = signalStatus(player.ago);
  const series = data.series || [];
  const total30d = series.length;                       // nº de señales en 30 días
  // hora del día (agregada sobre los 30 días) con más señales
  const hourAgg = EMPTY24.slice();
  for (const r of series) hourAgg[new Date(r.t).getHours()]++;
  const peakHour = hourAgg.indexOf(Math.max(...hourAgg));
  const hasHourly = series.length > 0;
  return (
    <div className="es-activity">
      {/* player header — estado de señal en vez de rank/score */}
      <div className="es-pl-head">
        <div className="es-pl-rank"><span className={"es-status-dot lg " + st.cls}></span></div>
        <div className="es-pl-id">
          <div className="es-pl-name">{player.name}</div>
          <div className="es-pl-alliance">{player.alliance}</div>
        </div>
        <div className="es-pl-score">
          <div className={"es-pl-score-num st-" + st.cls}>{st.label}</div>
          <div className="es-pl-score-lbl">{fmtSignal(player.ago)}</div>
        </div>
      </div>

      <div className="es-pl-section">{t("activity.title")}</div>
      <div className="es-pl-rates">
        <div className="es-pl-rate"><b>{fmt(player.signals24h || 0)}</b><span>{t("detail.signals24h")}</span></div>
        <div className="es-pl-rate"><b>{fmt(total30d)}</b><span>{t("detail.signalsTotal")}</span></div>
        <div className="es-pl-rate"><b>{hasHourly ? pad(peakHour) + ":00" : "—"}</b><span>{t("detail.peakHour")}</span></div>
      </div>

      <DetailChart key={player.id} series={series} refMs={data.refMs}
        groupBy={groupBy} setGroupBy={setGroupBy} metric="signals" />
    </div>
  );
}
function shortNum(v) {
  if (v >= 1e6) return (v / 1e6).toFixed(v >= 1e7 ? 0 : 1).replace(".0", "") + "M";
  if (v >= 1e3) return Math.round(v / 1e3) + "K";
  return Math.round(v);
}
function fmtEta(min) {
  if (min == null) return "";
  if (min < 1) return "<1m";
  if (min < 60) return min + "m";
  const h = Math.floor(min / 60), m = min % 60;
  return h + "h" + (m ? " " + m + "m" : "");
}

/* ───────────── alert sound engine ───────────── */
let _ac = null;
function audioCtx() {
  if (!_ac) {
    try { _ac = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; }
  }
  if (_ac && _ac.state === "suspended") _ac.resume();
  return _ac;
}
function playBeep(level, type) {
  const ac = audioCtx();
  if (!ac) return;
  const preset = SOUND_PRESETS[type] || SOUND_PRESETS.chime;
  preset.play(ac, level);
}
/* nota reutilizable: osc + armónico opcional, ataque/decaída redondeados */
function _note(ac, opts) {
  const { type = "sine", freq, at = 0, dur = 0.5, peak = 0.1, harm = 0, harmRatio = 2 } = opts;
  const t = ac.currentTime + at;
  const g = ac.createGain();
  g.connect(ac.destination);
  g.gain.setValueAtTime(0.0001, t);
  g.gain.exponentialRampToValueAtTime(peak, t + 0.04);
  g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
  const o = ac.createOscillator();
  o.type = type; o.frequency.value = freq; o.connect(g);
  o.start(t); o.stop(t + dur + 0.05);
  if (harm > 0) {
    const o2 = ac.createOscillator(), g2 = ac.createGain();
    o2.type = type; o2.frequency.value = freq * harmRatio;
    g2.gain.value = harm; o2.connect(g2); g2.connect(g);
    o2.start(t); o2.stop(t + dur + 0.05);
  }
}
/* presets de sonido seleccionables */
const SOUND_PRESETS = {
  chime: {
    label: "Chime",
    play(ac, level) {
      if (level === "high") {
        _note(ac, { freq: 587.33, dur: 0.5, peak: 0.12, harm: 0.18, harmRatio: 2.01 });
        _note(ac, { freq: 880.0, at: 0.16, dur: 0.6, peak: 0.12, harm: 0.18, harmRatio: 2.01 });
      } else {
        _note(ac, { freq: 523.25, dur: 0.55, peak: 0.09, harm: 0.18, harmRatio: 2.01 });
      }
    },
  },
  marimba: {
    label: "Marimba",
    play(ac, level) {
      const f = level === "high" ? 659.25 : 523.25;
      _note(ac, { type: "triangle", freq: f, dur: 0.3, peak: 0.14, harm: 0.22, harmRatio: 4 });
      if (level === "high") _note(ac, { type: "triangle", freq: f * 1.5, at: 0.15, dur: 0.3, peak: 0.14, harm: 0.22, harmRatio: 4 });
    },
  },
  pulse: {
    label: "Pulse",
    play(ac, level) {
      const f = level === "high" ? 440 : 320;
      _note(ac, { type: "sine", freq: f, dur: 0.42, peak: level === "high" ? 0.14 : 0.1 });
      if (level === "high") _note(ac, { type: "sine", freq: f, at: 0.24, dur: 0.42, peak: 0.14 });
    },
  },
  retro: {
    label: "Retro",
    play(ac, level) {
      const mk = (freq, at, dur) => _note(ac, { type: "square", freq, at, dur, peak: level === "high" ? 0.09 : 0.06 });
      if (level === "high") { mk(740, 0, 0.12); mk(988, 0.14, 0.14); }
      else mk(523, 0, 0.14);
    },
  },
};
const SOUND_KEYS = ["chime", "marimba", "pulse", "retro"];
/* single global alarm controller — guarantees exactly one beep loop, ever */
const Alarm = {
  timer: null,
  level: "mid",
  type: "chime",
  start(level, type) {
    this.stop();
    this.level = level; this.type = type;
    playBeep(level, type);
    this.timer = setInterval(() => playBeep(this.level, this.type), 2000);
  },
  stop() {
    if (this.timer) { clearInterval(this.timer); this.timer = null; }
    if (_ac && _ac.state === "running") { try { _ac.suspend(); } catch (e) {} }
  },
};

/* ───────────── alert rules ───────────── */
function getAlertRules() {
  return [
    { key: "proximity",  icon: "🎯", label: t("alert.proximity.label"),  desc: t("alert.proximity.desc"),  level: "high", ctrl: { type: "gap",  min: 250000, max: 8000000, step: 250000 } },
    { key: "overtaken",  icon: "⚠️", label: t("alert.overtaken.label"),  desc: t("alert.overtaken.desc"),  level: "high", ctrl: null },
    { key: "rankOut",    icon: "📉", label: t("alert.rankOut.label"),    desc: t("alert.rankOut.desc"),    level: "mid",  ctrl: { type: "rank", min: 1, max: 20, step: 1 } },
    { key: "rivalSurge", icon: "🔥", label: t("alert.rivalSurge.label"), desc: t("alert.rivalSurge.desc"), level: "mid",  ctrl: { type: "rate", min: 1000, max: 30000, step: 500 } },
    { key: "stalled",    icon: "💤", label: t("alert.stalled.label"),    desc: t("alert.stalled.desc"),    level: "mid",  ctrl: null },
    { key: "ending",     icon: "⏳", label: t("alert.ending.label"),     desc: t("alert.ending.desc"),     level: "mid",  ctrl: { type: "mins", min: 5, max: 360, step: 5 } },
  ];
}

function evalAlerts(rows, movements, curEvent, nowMs, cfg) {
  const out = [];
  const you = rows.find((r) => r.isYou);
  if (you) {
    if (cfg.proximity.on) {
      const below = rows.filter((r) => r.score < you.score).sort((a, b) => b.score - a.score);
      const chaser = below[0];
      if (chaser) {
        const gap = you.score - chaser.score;
        if (gap <= cfg.proximity.gap) {
          const closing = chaser.perMin - you.perMin;
          const eta = closing > 0 ? Math.ceil(gap / closing) : null;
          out.push({
            id: "proximity", level: "high", icon: "🎯", title: t("alert.msg.rivalClosing"),
            msg: chaser.name + " · " + shortNum(gap) + " " + t("alert.msg.ptsBehind") + (eta != null ? " · ~" + fmtEta(eta) + " " + t("alert.msg.toPass") : ""),
          });
        }
      }
    }
    if (cfg.overtaken.on && movements[you.id] < 0) {
      out.push({ id: "overtaken", level: "high", icon: "⚠️", title: t("alert.msg.overtaken"), msg: t("alert.msg.droppedTo") + you.rank });
    }
    if (cfg.rankOut.on && you.rank > cfg.rankOut.rank) {
      out.push({ id: "rankOut", level: "mid", icon: "📉", title: t("alert.msg.outOfTop") + " " + cfg.rankOut.rank, msg: t("alert.msg.youAreNow") + you.rank });
    }
    if (cfg.rivalSurge.on) {
      // el jugador inmediatamente por encima de ti (al que persigues)
      const above = rows.filter((r) => r.score > you.score).sort((a, b) => a.score - b.score)[0];
      if (above && above.perMin >= cfg.rivalSurge.rate) {
        out.push({ id: "rivalSurge", level: "mid", icon: "🔥", title: t("alert.msg.chaserSurging"), msg: above.name + " " + t("alert.msg.aboveYouAt") + " " + fmtRate(above.perMin) + t("detail.perMin") });
      }
    }
    if (cfg.stalled.on && you.perMin === 0) {
      out.push({ id: "stalled", level: "mid", icon: "💤", title: t("alert.msg.youStalled"), msg: t("alert.msg.stalledMsg") });
    }
  }
  if (cfg.ending.on) {
    const left = (curEvent.endsAt - nowMs) / 60000;
    if (left > 0 && left <= cfg.ending.mins) {
      out.push({ id: "ending", level: "mid", icon: "⏳", title: t("alert.msg.eventEndingSoon"), msg: Math.ceil(left) + " " + t("alert.msg.minLeft") });
    }
  }
  return out;
}

function ctrlLabel(type, v) {
  if (type === "gap") return shortNum(v) + " pts";
  if (type === "rank") return "#" + v;
  if (type === "rate") return fmtRate(v) + t("detail.perMin");
  if (type === "mins") return v < 60 ? v + " min" : fmtEta(v);
  return v;
}

function AlertsPanel({ cfg, setRule, soundOn, setSoundOn, soundType, setSoundType, active, silenced, onSilence, youName, youRow, onClearYou }) {
  const sounding = soundOn && !silenced && active.length > 0;
  const previewTone = (type) => { audioCtx(); playBeep("high", type); };
  return (
    <div className={"es-alerts" + (active.length ? " has-active" : "") + (!youName ? " disabled" : "")}>
      <div className="es-alerts-head">
        <span className="es-alerts-title">{t("alerts.title")}</span>
        <button
          className={"es-sound-toggle" + (soundOn ? " on" : "")}
          onClick={() => { if (!soundOn) audioCtx(); setSoundOn(!soundOn); }}
        >{soundOn ? t("alerts.soundOn") : t("alerts.soundOff")}</button>
      </div>

      {youName ? (
        <div className="es-you-banner">
          <span className="es-you-banner-dot"></span>
          <span className="es-you-banner-txt">{t("alerts.watching")} <b>{youName}</b>{youRow ? " · #" + youRow.rank : " · " + t("alerts.notInRanking")}</span>
          <button className="es-you-banner-clear" onClick={onClearYou} title="Unset your account">×</button>
        </div>
      ) : (
        <div className="es-you-warn">
          <span className="es-you-warn-ic">◎</span>
          {t("alerts.youWarn")}
        </div>
      )}

      {youName && (<React.Fragment>
      <div className="es-tone">
        <span className="es-tone-lbl">{t("alerts.tone")}</span>
        <div className="es-tone-opts">
          {SOUND_KEYS.map((k) => (
            <button
              key={k}
              className={"es-tone-opt" + (soundType === k ? " active" : "")}
              onClick={() => { setSoundType(k); previewTone(k); }}
            >{SOUND_PRESETS[k].label}</button>
          ))}
        </div>
      </div>
      </React.Fragment>)}

      {active.length > 0 && (
        <div className="es-alert-active">
          {active.map((a) => (
            <div key={a.id} className={"es-alert es-alert-" + a.level}>
              <span className="es-alert-icon">{a.icon}</span>
              <span className="es-alert-text">
                <span className="es-alert-title">{a.title}</span>
                <span className="es-alert-msg">{a.msg}</span>
              </span>
            </div>
          ))}
          {sounding && (
            <button className="es-silence" onClick={() => onSilence()}>{t("alerts.silence")}</button>
          )}
          {soundOn && silenced && (
            <div className="es-silenced-note">{t("alerts.silenced")}</div>
          )}
        </div>
      )}

      <div className="es-rules">
        {youName && getAlertRules().map((rule) => {
          const c = cfg[rule.key];
          const isActive = active.some((a) => a.id === rule.key);
          return (
            <div key={rule.key} className={"es-rule" + (c.on ? " on" : "") + (isActive ? " firing" : "")}>
              <button className="es-rule-row" onClick={() => setRule(rule.key, { on: !c.on })}>
                <span className="es-rule-icon">{rule.icon}</span>
                <span className="es-rule-main">
                  <span className="es-rule-label">{rule.label}</span>
                  <span className="es-rule-desc">{rule.desc}</span>
                </span>
                <span className={"es-switch" + (c.on ? " on" : "")}><span className="es-switch-knob"></span></span>
              </button>
              {c.on && rule.ctrl && (
                <div className="es-rule-ctrl">
                  <span className="es-rule-ctrl-lbl">
                    {rule.ctrl.type === "rank" ? t("alert.ctrl.threshold") : rule.ctrl.type === "mins" ? t("alert.ctrl.whenUnder") : rule.ctrl.type === "rate" ? t("alert.ctrl.above") : t("alert.ctrl.within")}
                  </span>
                  <input
                    type="range"
                    min={rule.ctrl.min} max={rule.ctrl.max} step={rule.ctrl.step}
                    value={c[rule.ctrl.type === "gap" ? "gap" : rule.ctrl.type === "rank" ? "rank" : rule.ctrl.type === "rate" ? "rate" : "mins"]}
                    onChange={(e) => {
                      const field = rule.ctrl.type === "gap" ? "gap" : rule.ctrl.type === "rank" ? "rank" : rule.ctrl.type === "rate" ? "rate" : "mins";
                      setRule(rule.key, { [field]: +e.target.value });
                    }}
                  />
                  <span className="es-rule-ctrl-val">
                    {ctrlLabel(rule.ctrl.type, c[rule.ctrl.type === "gap" ? "gap" : rule.ctrl.type === "rank" ? "rank" : rule.ctrl.type === "rate" ? "rate" : "mins"])}
                  </span>
                </div>
              )}
            </div>
          );
        })}
      </div>
      <div className="es-alerts-foot">
        <span className="es-apk-dot" aria-hidden></span>
        {t("alerts.mobile")}
      </div>
    </div>
  );
}

/* ───────────── check activity (server-wide live activity) ───────────── */
function fmtSignal(sec) {
  if (sec < 60) return Math.max(1, Math.floor(sec)) + "s ago";
  if (sec < 3600) return Math.floor(sec / 60) + "m ago";
  if (sec < 86400) return Math.floor(sec / 3600) + "h ago";
  return Math.floor(sec / 86400) + "d ago";
}
function signalStatus(sec) {
  if (sec < 120) return { cls: "online", label: t("activity.online") };
  if (sec < 1800) return { cls: "active", label: t("activity.active") };
  if (sec < 21600) return { cls: "idle", label: t("activity.idle") };
  return { cls: "asleep", label: t("activity.asleep") };
}

function ActivityView({ serverId, density, now }) {
  const PER_PAGE = 40;
  const [page, setPage] = useState(0);
  const [query, setQuery] = useState("");
  // estado vivo: cada jugador con su timestamp de última señal
  const [list, setList] = useState([]);
  const [loading, setLoading] = useState(true);   // loader hasta la primera carga
  const [pinged, setPinged] = useState({});   // id -> true para animación de "nueva señal"
  const moveTimer = useRef(null);
  const prevSig = useRef({});                  // player_id -> signals24h del poll anterior
  // jugador seleccionado → detalle de señales (histogramas 5min/1h/1día)
  const [picked, setPicked] = useState(null);
  const [pickedData, setPickedData] = useState(EMPTY_ACT);
  const [actGroupBy, setActGroupBy] = useState("day");

  // Carga real + polling de la actividad de saqueo del server (cada 4s).
  // El "ping" se enciende solo cuando signals24h sube de verdad entre polls.
  useEffect(() => {
    let alive = true;
    prevSig.current = {};
    setLoading(true);
    setList([]);
    const apply = (players) => {
      if (!alive) return;
      setLoading(false);
      const fresh = {};
      const seeded = players.map((p) => {
        const before = prevSig.current[p.player_id];
        if (before != null && p.signals24h > before) fresh[p.player_id] = true;
        prevSig.current[p.player_id] = p.signals24h;
        return {
          id: String(p.player_id),
          name: p.name || "—",
          alliance: p.alliance || t("alliance.noAlliance"),
          signals24h: p.signals24h,
          lastSignalAt: Date.now() - (p.lastSignalSec || 0) * 1000,
          isYou: false,
        };
      });
      setList(seeded);
      if (Object.keys(fresh).length) {
        setPinged(fresh);
        clearTimeout(moveTimer.current);
        moveTimer.current = setTimeout(() => setPinged({}), 1400);
      }
    };
    const load = () => window.ESApi.getActivity(serverId).then(apply).catch(() => {});
    setPage(0);
    load();
    const id = setInterval(load, 4000);
    return () => { alive = false; clearInterval(id); clearTimeout(moveTimer.current); };
  }, [serverId]);

  // al cambiar de servidor, limpiar selección
  useEffect(() => { setPicked(null); }, [serverId]);

  // histogramas de señales del jugador elegido (refresco ligero cada 8s)
  useEffect(() => {
    if (!picked) { setPickedData(EMPTY_ACT); return; }
    let alive = true;
    const norm = (d) => ({ refMs: d.refMs ?? null, series: d.series || [] });
    const load = () => window.ESApi.getActivityPlayer(serverId, picked)
      .then((d) => { if (alive) setPickedData(norm(d)); }).catch(() => {});
    load();
    const id = setInterval(load, 8000);
    return () => { alive = false; clearInterval(id); };
  }, [picked, serverId]);

  const withAgo = list.map((p) => ({ ...p, ago: Math.max(0, (now - p.lastSignalAt) / 1000) }));
  withAgo.sort((a, b) => a.ago - b.ago);
  const pickedRow = picked ? (withAgo.find((p) => p.id === picked) || null) : null;

  const q = query.trim().toLowerCase();
  const filtered = q
    ? withAgo.filter((p) => p.name.toLowerCase().includes(q) || p.alliance.toLowerCase().includes(q))
    : withAgo;

  const onlineCount = withAgo.filter((p) => p.ago < 120).length;
  const totalPages = Math.max(1, Math.ceil(filtered.length / PER_PAGE));
  const pageC = Math.min(page, totalPages - 1);
  const pageRows = filtered.slice(pageC * PER_PAGE, pageC * PER_PAGE + PER_PAGE);
  const fromN = filtered.length === 0 ? 0 : pageC * PER_PAGE + 1;
  const toN = Math.min(filtered.length, pageC * PER_PAGE + PER_PAGE);

  return (
    <React.Fragment>
    <section className="es-rank-panel">
      <div className="es-panel-head">
        <span className="es-event-icon sm">📡</span>
        <span className="es-panel-title">{t("activity.title")}</span>
        <span className="es-refresh" title="Señales de actividad en directo">
          <span className="es-refresh-dot" key={Math.floor(now / 4000)}></span>{t("panel.live")}
        </span>
        <span className="es-panel-sub">{onlineCount} {t("activity.onlineNow")}</span>
      </div>

      <div className={"es-table density-" + density}>
        <div className="es-table-toolbar">
          <div className="es-search">
            <span className="es-search-icon">⌕</span>
            <input
              className="es-search-input" type="text"
              placeholder={t("tbl.searchAct")}
              value={query} onChange={(e) => { setQuery(e.target.value); setPage(0); }}
            />
            {query && <button className="es-search-clear" onClick={() => setQuery("")} aria-label="Clear">×</button>}
          </div>
          <span className="es-search-count">{t("activity.sortedLatest")}</span>
        </div>

        <div className="es-thead act">
          <div className="es-th rank">#</div>
          <div className="es-th name">{t("tbl.name")}</div>
          <div className="es-th alliance">{t("tbl.alliance")}</div>
          <div className="es-th status">{t("tbl.status")}</div>
          <div className="es-th updated">{t("tbl.lastSignal")}</div>
        </div>

        {loading ? (
          <OwlLoader label={t("loader.scanningActivity")} minHeight={420} />
        ) : (
          <div className="es-tbody">
            {pageRows.length === 0 && <div className="es-noresults">{t("tbl.noMatch")} "{query}"</div>}
            {pageRows.map((p, i) => {
              const st = signalStatus(p.ago);
              const order = pageC * PER_PAGE + i + 1;
              return (
                <div
                  key={p.id}
                  className={"es-tr act" + (p.isYou ? " you" : "") +
                    (pinged[p.id] ? " pinged" : "") + (st.cls === "asleep" ? " inactive" : "") +
                    (picked === p.id ? " picked" : "")}
                  onClick={() => setPicked(p.id)}
                >
                  <div className="es-td rank"><span className="es-rank-num">{order}</span></div>
                  <div className="es-td name">
                    {p.name}
                    {p.isYou && <span className="es-tag you">{t("tag.you")}</span>}
                  </div>
                  <div className="es-td alliance">{p.alliance}</div>
                  <div className="es-td status">
                    <span className={"es-status-dot " + st.cls}></span>{st.label}
                  </div>
                  <div className="es-td updated signal">{fmtSignal(p.ago)}</div>
                </div>
              );
            })}
          </div>
        )}

        {!loading && filtered.length > PER_PAGE && (
          <div className="es-pagination">
            <span className="es-page-info">{fromN}–{toN} {t("page.of")} {filtered.length}</span>
            <div className="es-page-ctrls">
              <button className="es-page-btn" disabled={pageC === 0} onClick={() => setPage(pageC - 1)}>{t("page.prev")}</button>
              <span className="es-page-num">{pageC + 1} / {totalPages}</span>
              <button className="es-page-btn" disabled={pageC >= totalPages - 1} onClick={() => setPage(pageC + 1)}>{t("page.next")}</button>
            </div>
          </div>
        )}
      </div>
    </section>

    <aside className="es-side">
      <div className="es-side-head">
        <span className="es-side-title">{t("detail.player")}</span>
      </div>
      <ActivityDetail
        player={pickedRow}
        data={pickedData}
        groupBy={actGroupBy}
        setGroupBy={setActGroupBy}
      />
    </aside>
    </React.Fragment>
  );
}

/* ───────────── hall of records (all-time records per event) ───────────── */
function fmtDaysAgo(d) {
  if (d <= 0) return t("records.today");
  if (d === 1) return t("records.yesterday");
  if (d < 30) return d + t("time.dAgo");
  if (d < 365) return Math.floor(d / 30) + t("time.moAgo");
  return Math.floor(d / 365) + t("time.yAgo");
}
function HallOfRecords({ density }) {
  const D = window.ES_DATA;
  const events = D.RECORD_EVENTS;
  const PER_PAGE = 40;
  const [evKey, setEvKey] = useState(events[0].id);
  const [entity, setEntity] = useState("players");
  const [page, setPage] = useState(0);
  const [query, setQuery] = useState("");
  const curEv = events.find((e) => e.id === evKey) || events[0];
  const isAlliance = entity === "alliances";

  // Datos del evento+entidad. RECORDS[key] = { players:[], alliances:[] }.
  const bucket = D.RECORDS[evKey] || { players: [], alliances: [] };
  const all = bucket[entity] || [];

  // Al cambiar de evento: si el nuevo no tiene la entidad actual, cae a la que
  // tenga (preferimos jugadores). Evita listas vacías al saltar de chip.
  useEffect(() => {
    if (entity === "players" && !curEv.hasPlayers && curEv.hasAlliances) setEntity("alliances");
    else if (entity === "alliances" && !curEv.hasAlliances && curEv.hasPlayers) setEntity("players");
  }, [evKey]);

  useEffect(() => { setPage(0); }, [evKey, entity, query]);

  const q = query.trim().toLowerCase();
  const filtered = q
    ? all.filter((p) => p.name.toLowerCase().includes(q) || p.alliance.toLowerCase().includes(q) || p.server.toLowerCase().includes(q))
    : all;
  const totalPages = Math.max(1, Math.ceil(filtered.length / PER_PAGE));
  const pageC = Math.min(page, totalPages - 1);
  const pageRows = filtered.slice(pageC * PER_PAGE, pageC * PER_PAGE + PER_PAGE);
  const fromN = filtered.length === 0 ? 0 : pageC * PER_PAGE + 1;
  const toN = Math.min(filtered.length, pageC * PER_PAGE + PER_PAGE);
  const record = all[0];
  // ¿Mostrar el toggle JUGADORES/ALIANZAS? Solo si el evento tiene de ambos.
  const showEntityToggle = curEv.hasPlayers && curEv.hasAlliances;

  return (
    <section className="es-rank-panel">
      <div className="es-panel-head">
        <span className="es-event-icon sm">🏆</span>
        <span className="es-panel-title">{t("records.title")}</span>
        <span className="es-panel-sub">{t("records.top")} · {curEv.name}</span>
      </div>

      {/* event selector */}
      <div className="es-rec-events">
        {events.map((ev) => (
          <button
            key={ev.id}
            className={"es-rec-chip" + (ev.id === evKey ? " active" : "")}
            onClick={() => setEvKey(ev.id)}
          >
            <span className="es-rec-chip-icon">{ev.icon}</span>{ev.name}
          </button>
        ))}
      </div>

      {/* players / alliances toggle (solo eventos con ambos rankings) */}
      {showEntityToggle && (
        <div className="es-view-toggle" style={{ marginBottom: 14 }}>
          <button
            className={"es-view-opt" + (!isAlliance ? " active" : "")}
            onClick={() => setEntity("players")}
          >{t("rankType.players")}</button>
          <button
            className={"es-view-opt" + (isAlliance ? " active" : "")}
            onClick={() => setEntity("alliances")}
          >{t("rankType.alliances")}</button>
        </div>
      )}

      {/* absolute record banner */}
      {record && (
      <div className="es-rec-banner">
        <span className="es-rec-crown">👑</span>
        <div className="es-rec-banner-main">
          <span className="es-rec-banner-lbl">{t("records.world")} · {curEv.name}</span>
          <span className="es-rec-banner-name">{record.name} <span className="es-rec-banner-srv">{record.server}</span></span>
        </div>
        <div className="es-rec-banner-score">
          <span className="es-rec-banner-num">{fmt(record.score)}</span>
          <span className="es-rec-banner-when">{t("records.set")} {fmtDaysAgo(record.daysAgo)}</span>
        </div>
      </div>
      )}

      <div className={"es-table density-" + density}>
        <div className="es-table-toolbar">
          <div className="es-search">
            <span className="es-search-icon">⌕</span>
            <input
              className="es-search-input" type="text"
              placeholder={t("tbl.searchRec")}
              value={query} onChange={(e) => setQuery(e.target.value)}
            />
            {query && <button className="es-search-clear" onClick={() => setQuery("")} aria-label="Clear">×</button>}
          </div>
          <span className="es-search-count">{filtered.length} {t("tbl.records")}</span>
        </div>

        <div className={"es-thead rec" + (isAlliance ? " noally" : "")}>
          <div className="es-th rank">#</div>
          <div className="es-th name">{isAlliance ? t("tbl.alliance") : t("tbl.name")}</div>
          {!isAlliance && <div className="es-th alliance">{t("tbl.alliance")}</div>}
          <div className="es-th server">{t("tbl.server")}</div>
          <div className="es-th num strong">{t("tbl.record")}</div>
          <div className="es-th updated">{t("tbl.set")}</div>
        </div>
        <div className="es-tbody">
          {pageRows.length === 0 && <div className="es-noresults">{t("tbl.noMatchRec")} "{query}"</div>}
          {pageRows.map((p) => (
            <div key={p.id} className={"es-tr rec" + (isAlliance ? " noally" : "") + (p.rank === 1 ? " gold" : p.rank === 2 ? " silver" : p.rank === 3 ? " bronze" : "") + (p.isNew ? " isnew" : "")}>
              <div className="es-td rank"><span className="es-rank-num">{p.rank}</span></div>
              <div className="es-td name">
                {p.name}
                {p.isNew && <span className="es-tag new">{t("tag.new")}</span>}
              </div>
              {!isAlliance && <div className="es-td alliance">{p.alliance}</div>}
              <div className="es-td server">{p.server}</div>
              <div className="es-td num strong">{fmt(p.score)}</div>
              <div className="es-td updated">{fmtDaysAgo(p.daysAgo)}</div>
            </div>
          ))}
        </div>
        {filtered.length > PER_PAGE && (
          <div className="es-pagination">
            <span className="es-page-info">{fromN}–{toN} {t("page.of")} {filtered.length}</span>
            <div className="es-page-ctrls">
              <button className="es-page-btn" disabled={pageC === 0} onClick={() => setPage(pageC - 1)}>{t("page.prev")}</button>
              <span className="es-page-num">{pageC + 1} / {totalPages}</span>
              <button className="es-page-btn" disabled={pageC >= totalPages - 1} onClick={() => setPage(pageC + 1)}>{t("page.next")}</button>
            </div>
          </div>
        )}
      </div>
    </section>
  );
}

/* ───────────── app ───────────── */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "eventStyle": "cards",
  "accent": "#7A32E0",
  "density": "regular",
  "texture": true
}/*EDITMODE-END*/;

/* ───────── adaptadores API → shape que esperan los componentes ───────── */
function adaptRow(r) {
  return {
    id: String(r.player_id),
    rank: r.rank,
    name: r.name || "—",
    alliance: r.alliance || t("alliance.noAlliance"),
    score: r.score,
    perMin: r.perMin || 0,
    perHour: r.perHour || 0,
    perDay: r.perDay || 0,
    maxHit: r.maxHit || 0,
    atkHour: r.atkHour || 0,
    maxAttacks: r.maxAttacks || 0,
    updatedMin: r.updatedSec == null ? 0 : Math.round(r.updatedSec / 60),
    inactive: !!r.inactive,
    gap: r.gap,
    hourly: EMPTY24,
    hourlyPrev: EMPTY24,
  };
}

function adaptEvent(e) {
  return {
    key: e.key,
    name: e.name,
    short: e.short,
    icon: e.icon,
    unit: e.unit,
    eid: e.eid,
    tiers: e.tiers && e.tiers.length ? e.tiers : [{ lid: null, label: "ALL" }],
    hasAlliances: e.hasAlliances,
    entities: e.entities || "players",   // 'players' | 'alliances' | 'both'
    hasData: !!e.hasData,
    pet: e.pet ?? null,   // tipo de misión vigente (nobleza); null si no aplica
    endsAt: e.endsAt ? Date.parse(e.endsAt) : null,   // ms o null (sin timer)
    rs: e.rs,
    endedAgo: e.endedAgoSec,
    endedAt: e.endedAt,
  };
}

// Tipos de misión de la nobleza (PET = POINT_EVENT_TYPE). El Concurso de la
// Nobleza rota de misión cada día. Algunas misiones —tipo ruleta— NO publican
// un leaderboard consultable por la API del juego (el server reporta el nº de
// participantes pero no sirve la lista); en esos casos solo mostramos el tipo
// de misión, sin tabla de ranking. Ampliable conforme identifiquemos más PETs.
const NOBILITY_PET = {
  17: { en: "Spin the Wheel", es: "Girar la ruleta", noRanking: true },
};
function petInfo(pet) {
  return (pet != null && NOBILITY_PET[pet]) || null;
}
function petLabel(pet) {
  const info = petInfo(pet);
  if (!info) return null;
  return info[window.__es_lang] || info.en;
}
// ¿Esta misión (PET) se sabe que no tiene ranking público?
function petHasNoRanking(pet) {
  const info = petInfo(pet);
  return !!(info && info.noRanking);
}

const byEndsAt = (a, b) => {
  if (a.endsAt == null && b.endsAt == null) return 0;
  if (a.endsAt == null) return 1;     // los que no tienen timer, al final
  if (b.endsAt == null) return -1;
  return a.endsAt - b.endsAt;
};

// Primer evento con datos que mostrar (si no, el primero de la lista).
const firstEventKey = (list) => {
  const e = list.find((x) => x.hasData) || list[0];
  return e ? e.key : null;
};

function App({ user }) {
  const [tw, setTweak] = useTweaks(TWEAK_DEFAULTS);

  // ── catálogos (servidores y eventos) desde la API ──
  const [servers, setServers] = useState([]);
  const [serverId, setServerId] = useState(null);
  const [liveEvents, setLiveEvents] = useState([]);
  const [endedEvents, setEndedEvents] = useState([]);

  const [view, setView] = useState("live");            // "live" | "ended"
  const [eventKey, setEventKey] = useState(null);
  const [tierIdx, setTierIdx] = useState(0);
  const [sortBy, setSortBy] = useState("score");
  const [rankTypeIdx, setRankTypeIdx] = useState(0);   // índice dentro de availableRankTypes
  const [groupBy, setGroupBy] = useState("day");
  const [pickedPlayer, setPickedPlayer] = useState(null);
  const [pickedAct, setPickedAct] = useState(EMPTY_ACT);

  // filas del ranking actual (datos crudos de la API, ya adaptados)
  const [rawRows, setRawRows] = useState([]);

  // cuenta marcada como "tú" por servidor (persistida por nombre)
  const [youByServer, setYouByServer] = useState(() => {
    try { return JSON.parse(localStorage.getItem("es_you") || "{}"); } catch (e) { return {}; }
  });
  const youName = serverId ? (youByServer[serverId] || null) : null;
  const setYou = (name) => {
    setYouByServer((prev) => {
      const next = { ...prev };
      if (name) next[serverId] = name; else delete next[serverId];
      try { localStorage.setItem("es_you", JSON.stringify(next)); } catch (e) {}
      return next;
    });
  };

  const [tab, setTab] = useState("rankings");
  const [lang, setLang] = useLangState();
  const [version, setVersion] = useState(0);
  const [movements, setMovements] = useState({});
  const moveTimer = useRef(null);
  const prevRanks = useRef({});

  // ── alerts ──
  const [alertCfg, setAlertCfg] = useState({
    proximity: { on: true, gap: 1500000 },
    overtaken: { on: true },
    rankOut: { on: false, rank: 3 },
    rivalSurge: { on: false, rate: 8000 },
    stalled: { on: false },
    ending: { on: false, mins: 60 },
  });
  const setRule = (key, patch) => setAlertCfg((c) => ({ ...c, [key]: { ...c[key], ...patch } }));
  const [soundOn, setSoundOn] = useState(false);
  const [soundType, setSoundType] = useState("chime");
  const [activeAlerts, setActiveAlerts] = useState([]);
  const [silenced, setSilenced] = useState(false);
  const prevActiveRef = useRef([]);

  // loaders del búho
  const [booting, setBooting] = useState(true);
  const [bootGone, setBootGone] = useState(false);
  const [loading, setLoading] = useState(false);
  useEffect(() => {
    const id = setTimeout(() => setBooting(false), 1600);
    const id2 = setTimeout(() => setBootGone(true), 2250);
    return () => { clearTimeout(id); clearTimeout(id2); };
  }, []);

  const now = useNow(1000);

  useEffect(() => {
    document.body.classList.toggle("net-on", !!tw.texture);
  }, [tw.texture]);

  // 1) cargar la lista de servidores al inicio
  useEffect(() => {
    let alive = true;
    window.ESApi.getServers()
      .then((s) => { if (!alive) return; setServers(s); if (s.length) setServerId((c) => c || s[0].server); })
      .catch((e) => console.error("[ES] servers:", e.message));
    return () => { alive = false; };
  }, []);

  // 2) al cambiar de servidor: cargar eventos activos y terminados
  useEffect(() => {
    if (!serverId) return;
    let alive = true;
    Promise.all([
      window.ESApi.getEvents(serverId, "active"),
      window.ESApi.getEvents(serverId, "ended"),
    ]).then(([act, end]) => {
      if (!alive) return;
      const live = act.map(adaptEvent).sort(byEndsAt);
      const ended = end.map(adaptEvent).sort((a, b) => (a.endedAgo || 0) - (b.endedAgo || 0));
      setLiveEvents(live);
      setEndedEvents(ended);
      const list = view === "live" ? live : ended;
      setEventKey((k) => (list.find((e) => e.key === k) ? k : firstEventKey(list)));
    }).catch((e) => console.error("[ES] events:", e.message));
    return () => { alive = false; };
  }, [serverId]);

  const events = view === "live" ? liveEvents : endedEvents;
  const curEvent = events.find((e) => e.key === eventKey) || events[0] || null;
  const activeKey = curEvent ? curEvent.key : null;
  const tiers = curEvent ? curEvent.tiers : [{ lid: null, label: "ALL" }];
  const safeTier = Math.min(tierIdx, Math.max(0, tiers.length - 1));
  const curTier = tiers[safeTier] || { lid: null, label: "ALL" };

  // RANKING TYPE limitado a lo que ofrece el evento (jugadores / alianzas / ambos).
  // Recalcular con lang en deps para que las labels se re-traduzcan al cambiar idioma.
  const availableRankTypes = useMemo(() => {
    const map = getRankTypesByEntity();
    return (curEvent && map[curEvent.entities]) || getRankTypes();
  }, [curEvent, lang]);
  const safeRankIdx = Math.min(rankTypeIdx, availableRankTypes.length - 1);
  const rankType = availableRankTypes[safeRankIdx].key;
  // Las alianzas no se dividen por liga: en esa vista el tier no aplica (lid null).
  const isAllianceView = rankType === "alliances";
  const tierLid = isAllianceView ? null : curTier.lid;
  const levelLabels = isAllianceView ? ["ALL"] : tiers.map((x) => x.label);
  const levelIndex = isAllianceView ? 0 : safeTier;

  const switchView = (v) => {
    if (v === view) return;
    setView(v);
    const list = v === "live" ? liveEvents : endedEvents;
    setEventKey(firstEventKey(list));
    setTierIdx(0);
  };

  // 3) cargar el ranking al cambiar la selección + polling real en vivo (7s)
  useEffect(() => {
    if (!serverId || !activeKey) { setRawRows([]); return; }
    let alive = true;
    let timer = null;
    const entity = rankType === "alliances" ? "alliances" : "players";
    const fetchRanking = (isPoll) => {
      window.ESApi.getRanking(serverId, activeKey, { tier: tierLid, entity, ended: view !== "live" })
        .then((res) => {
          if (!alive) return;
          const rows = (res.rows || []).map(adaptRow);
          if (isPoll) {
            const mv = {};
            for (const p of rows) {
              const before = prevRanks.current[p.id];
              const d = (before == null ? p.rank : before) - p.rank;
              if (d !== 0) mv[p.id] = d;
            }
            setMovements(mv);
            clearTimeout(moveTimer.current);
            moveTimer.current = setTimeout(() => setMovements({}), 2800);
          }
          prevRanks.current = {};
          rows.forEach((p) => { prevRanks.current[p.id] = p.rank; });
          setRawRows(rows);
          setVersion((v) => v + 1);
          setLoading(false);
        })
        .catch((e) => { if (alive) { console.error("[ES] ranking:", e.message); setLoading(false); } });
    };
    setLoading(true);
    prevRanks.current = {};
    fetchRanking(false);
    if (view === "live") timer = setInterval(() => fetchRanking(true), 7000);
    return () => { alive = false; if (timer) clearInterval(timer); clearTimeout(moveTimer.current); };
  }, [serverId, activeKey, safeTier, rankType, view]);

  // marca "tú" derivada por nombre
  const rows = useMemo(
    () => (youName ? rawRows.map((p) => (p.name === youName ? { ...p, isYou: true } : p)) : rawRows),
    [rawRows, youName]
  );
  const tableRows = rows;   // alianzas vienen ya servidas por la API (entity=alliances)
  const picked = tableRows.find((p) => p.id === pickedPlayer) || null;
  const youRow = youName ? rows.find((p) => p.isYou) : null;

  // al cambiar de evento, resetea el tramo
  // Al cambiar de evento: arranca en el ÚLTIMO tramo de nivel (jugadores de alto
  // nivel, lo más interesante por defecto) y resetea el tipo de ranking.
  useEffect(() => {
    setTierIdx(curEvent ? Math.max(0, curEvent.tiers.length - 1) : 0);
    setRankTypeIdx(0);
  }, [activeKey]);

  // al cambiar selección, deselecciona jugador
  useEffect(() => { setPickedPlayer(null); }, [serverId, activeKey, safeTier, view]);

  // auto-selecciona tu cuenta si está en este ranking
  useEffect(() => {
    if (!youName) return;
    const you = rows.find((p) => p.isYou);
    if (you) setPickedPlayer((cur) => cur || you.id);
  }, [rawRows, youName]);

  // 4) serie cruda del jugador elegido (panel de detalle, solo en vivo). El
  // bucketing 5 min / 1 h / 1 día + drill-down se hace en DetailChart.
  useEffect(() => {
    if (!picked || !serverId || !activeKey || view !== "live") {
      setPickedAct(EMPTY_ACT);
      return;
    }
    let alive = true;
    const entity = rankType === "alliances" ? "alliances" : "players";
    window.ESApi.getPlayerActivity(serverId, activeKey, picked.id, { tier: tierLid, entity })
      .then((d) => {
        if (!alive) return;
        setPickedAct({ refMs: d.refMs ?? null, series: d.series || [] });
      })
      .catch(() => {});
    return () => { alive = false; };
  }, [pickedPlayer, serverId, activeKey, safeTier, rankType, view]);

  const pickedFull = picked
    ? { ...picked, refMs: pickedAct.refMs, series: pickedAct.series }
    : null;

  // evaluar alertas en cada refresco (solo en vivo y con cuenta marcada)
  useEffect(() => {
    if (view !== "live" || !youName || !curEvent) { prevActiveRef.current = []; setActiveAlerts([]); return; }
    const next = evalAlerts(rows, movements, curEvent, Date.now(), alertCfg);
    if (prevActiveRef.current.length === 0 && next.length > 0) setSilenced(false);
    prevActiveRef.current = next;
    setActiveAlerts(next);
  }, [version, alertCfg, serverId, activeKey, safeTier, view, youName]);

  // Sonido constante mientras haya alertas activas y no esté silenciado.
  // BUG fixed: antes el effect dependía de `activeAlerts` (referencia nueva en
  // cada render → Alarm.start() reiniciaba el bucle y disparaba un beep extra
  // p.ej. al mover un slider o al activar un toggle de regla). Ahora dependemos
  // de soundingLevel (high/mid/null) Y del conjunto de IDs activos (string
  // estable); el effect solo se re-dispara cuando el conjunto cambia de verdad.
  const soundingLevel = useMemo(() => {
    if (!soundOn || silenced || activeAlerts.length === 0) return null;
    return activeAlerts.some((a) => a.level === "high") ? "high" : "mid";
  }, [soundOn, silenced, activeAlerts]);
  const activeAlertsKey = useMemo(
    () => activeAlerts.map((a) => a.id).sort().join("|"),
    [activeAlerts]
  );
  useEffect(() => {
    if (soundingLevel) Alarm.start(soundingLevel, soundType);
    else Alarm.stop();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [soundingLevel, soundType, activeAlertsKey]);

  const serverOpts = servers.map((s) => ({ value: s.server, text: s.label }));
  // sortOpts depende de `lang` para retraducir labels al cambiar idioma.
  const sortOpts = useMemo(() => [
    { value: "score",   text: t("sort.points") },
    { value: "gap",     text: t("sort.gap") },
    { value: "maxHit",  text: t("sort.maxHit") },
    { value: "perHour", text: t("sort.perHour") },
    { value: "atkHour", text: t("sort.atkHour") },
    { value: "name",    text: t("sort.name") },
  ], [lang]);

  const liveSub = curEvent
    ? (curEvent.endsAt ? `${t("panel.ranking")} · ${fmtCountdown(curEvent.endsAt - now)} ${t("panel.left")}` : `${t("panel.ranking")} · ${t("panel.live")}`)
    : "";

  // Tipo de misión vigente (nobleza). Algunas misiones (tipo ruleta) no publican
  // leaderboard: mostramos el tipo + un aviso en vez de una tabla vacía.
  const missionLabel = curEvent ? petLabel(curEvent.pet) : null;
  const noLeaderboard = view === "live" && !!curEvent && !curEvent.hasData
    && (petHasNoRanking(curEvent.pet) || (!loading && tableRows.length === 0));

  return (
    <div
      className={"es-app" + (tw.texture ? " textured" : "")}
      style={{ "--accent": tw.accent }}
    >
      {!bootGone && (
      <div className={"es-loader full" + (booting ? "" : " done")}>
        <div className="es-loader-owl big">
          <span className="es-loader-ring"></span>
          <span className="es-loader-ring two"></span>
          <img className="es-loader-img" src="assets/owl.svg?v=6" alt="" />
        </div>
        <div className="es-loader-wordmark">EMPIRE SENTRY</div>
        <div className="es-loader-label">{t("loader.init")}<span className="es-loader-dots"><i>.</i><i>.</i><i>.</i></span></div>
      </div>
      )}
      <TopNav tab={tab} setTab={setTab} lang={lang} setLang={setLang} user={user} />

      {tab === "rankings" && (
      <React.Fragment>
      <div className="es-controls">
        <Dropdown label={t("ctrl.server")} value={serverId} options={serverOpts} onChange={setServerId} />
        <ReadoutCell label={t("ctrl.event")} value={curEvent ? curEvent.name : "—"} />
        <LevelStepper tiers={levelLabels} index={levelIndex} onChange={setTierIdx} />
        <OptionStepper label={t("ctrl.rankingType")} options={availableRankTypes} index={safeRankIdx} onChange={setRankTypeIdx} />
        <Dropdown label={t("ctrl.sortBy")} value={sortBy} options={sortOpts} onChange={setSortBy} className="es-ctrl-r" />
      </div>

      <EventsRow
        events={events}
        selected={activeKey}
        onSelect={setEventKey}
        now={now}
        style={tw.eventStyle}
        view={view}
        setView={switchView}
      />

      {curEvent ? (
      <main className="es-main">
        <section className="es-rank-panel">
          <div className="es-panel-head">
            <span className="es-event-icon sm">{curEvent.icon}</span>
            <span className="es-panel-title">{curEvent.name}</span>
            {missionLabel && (
              <span className="es-mission-chip" title={t("event.mission")}>
                {t("event.mission")}: {missionLabel}
              </span>
            )}
            {view === "live" ? (
              <React.Fragment>
                <span className="es-refresh" title="Datos en directo · auto-refresco">
                  <span className="es-refresh-dot" key={version}></span>{t("panel.live")}
                </span>
                <span className="es-panel-sub">{liveSub}</span>
              </React.Fragment>
            ) : (
              <React.Fragment>
                <span className="es-refresh final">{t("panel.finalResults")}</span>
                <span className="es-panel-sub">{t("panel.ended")} · {fmtAgoLong(curEvent.endedAgo || 0)}</span>
              </React.Fragment>
            )}
          </div>
          {noLeaderboard ? (
            <div className="es-noranking">
              <div className="es-noranking-icon">{curEvent.icon}</div>
              {missionLabel && (
                <div className="es-noranking-mission">
                  {t("event.mission")}: <strong>{missionLabel}</strong>
                </div>
              )}
              <div className="es-noranking-msg">{t("event.noRanking")}</div>
            </div>
          ) : (
          <RankingTable
            key={`${serverId}-${activeKey}-${rankType}`}
            rows={tableRows}
            sortBy={sortBy}
            setSortBy={setSortBy}
            selectedPlayer={pickedPlayer}
            onPick={setPickedPlayer}
            density={tw.density}
            version={version}
            movements={movements}
            loading={loading}
            endingSoon={view === "live" && curEvent.endsAt != null && (curEvent.endsAt - now) > 0 && (curEvent.endsAt - now) < 3 * 3600 * 1000}
            live={view === "live"}
            showBadges={view === "live" && rankType === "players"}
            entity={rankType === "alliances" ? "alliance" : "player"}
            unit={curEvent.unit}
          />
          )}
        </section>

        <aside className="es-side">
          <div className="es-side-head">
            <span className="es-side-title">{rankType === "alliances" ? t("detail.alliance") : t("detail.player")}</span>
          </div>
          {loading
            ? <OwlLoader label={t("loader.loading")} minHeight={380} />
            : <ActivityPanel
                player={pickedFull}
                groupBy={groupBy}
                setGroupBy={setGroupBy}
                isYou={!!picked && picked.name === youName}
                onToggleYou={setYou}
                canMark={view === "live" && !!picked && rankType === "players"}
                unit={curEvent.unit}
              />}
          {view === "live" && !loading && (
            <AlertsPanel
            cfg={alertCfg}
            setRule={setRule}
            soundOn={soundOn}
            setSoundOn={setSoundOn}
            soundType={soundType}
            setSoundType={setSoundType}
            active={activeAlerts}
            silenced={silenced}
            onSilence={() => setSilenced(true)}
            youName={youName}
            youRow={youRow}
            onClearYou={() => setYou(null)}
          />
          )}
        </aside>
      </main>
      ) : (
        <main className="es-main single">
          <div className="es-empty-note" style={{ padding: "60px 20px", textAlign: "center", color: "var(--muted)", letterSpacing: ".12em", textTransform: "uppercase" }}>
            {view === "live" ? t("empty.noActive") : t("empty.noEnded")}
          </div>
        </main>
      )}
      </React.Fragment>
      )}

      {tab === "activity" && serverId && (
      <React.Fragment>
      {/* compact: el wrapper no estira las cajas a ancho completo cuando hay una sola */}
      <div className="es-controls compact">
        <Dropdown label={t("ctrl.server")} value={serverId} options={serverOpts} onChange={setServerId} />
      </div>
      <main className="es-main">
        <ActivityView serverId={serverId} density={tw.density} now={now} />
      </main>
      </React.Fragment>
      )}

      {tab === "records" && (
        window.ES_DATA.RECORD_EVENTS.length
          ? <main className="es-main single"><HallOfRecords density={tw.density} /></main>
          : <main className="es-main single">
              <div className="es-empty-note" style={{ padding: "60px 20px", textAlign: "center", color: "var(--muted)", letterSpacing: ".12em", textTransform: "uppercase" }}>
                {t("empty.noRecords")}
              </div>
            </main>
      )}

      <footer className="es-footer">
        <div className="es-footer-links">
          <a href="/privacidad">{t("footer.privacy")}</a>
          <span className="es-footer-sep">·</span>
          <a href="/terminos">{t("footer.terms")}</a>
          <span className="es-footer-sep">·</span>
          <a href="/etica">{t("footer.ethics")}</a>
          <span className="es-footer-sep">·</span>
          <a href="mailto:contact@empiresentry.xyz">{t("footer.contact")}</a>
        </div>
        <div className="es-footer-note">{t("footer.note")}</div>
      </footer>

      <TweaksPanel>
        <TweakSection label="Events row" />
        <TweakRadio
          label="Layout"
          value={tw.eventStyle}
          options={["cards", "chips", "progress"]}
          onChange={(v) => setTweak("eventStyle", v)}
        />
        <TweakSection label="Theme" />
        <TweakColor
          label="Accent"
          value={tw.accent}
          options={["#7A32E0", "#2A6FDB", "#E0DC31", "#1F8A5B", "#E33A3D"]}
          onChange={(v) => setTweak("accent", v)}
        />
        <TweakToggle label="Interactive nodes" value={tw.texture} onChange={(v) => setTweak("texture", v)} />
        <TweakSection label="Table" />
        <TweakRadio
          label="Density"
          value={tw.density}
          options={["compact", "regular"]}
          onChange={(v) => setTweak("density", v)}
        />
      </TweaksPanel>
    </div>
  );
}

/* ───────────── auth gate (Root) ─────────────
 * Antes de mostrar nada de datos, comprueba con /api/me si el usuario es un
 * miembro válido. Estados: loading → (anon | denied | error | ok). Solo en "ok"
 * precarga los récords y monta <App/>. */
function AuthShell({ children }) {
  return (
    <div className="es-app es-auth-shell" style={{ "--accent": "#7A32E0" }}>
      <div className="es-authbox">
        <div className="es-loader-owl big">
          <span className="es-loader-ring"></span>
          <span className="es-loader-ring two"></span>
          <img className="es-loader-img" src="assets/owl.svg?v=6" alt="" />
        </div>
        <div className="es-loader-wordmark">EMPIRE SENTRY</div>
        {children}
      </div>
    </div>
  );
}

function Root() {
  const [lang, setLang] = useLangState();   // permite traducir las pantallas de gate
  const [status, setStatus] = useState("loading");   // loading|anon|denied|error|ok
  const [user, setUser] = useState(null);

  const check = useRef(() => {});
  check.current = () => {
    setStatus("loading");
    window.ESApi.getMe()
      .then((me) => {
        if (!me.authEnabled) { // auth desactivada (dev) → entra directo
          return window.ESApi.preloadRecords().then(() => { setUser(null); setStatus("ok"); });
        }
        if (!me.authenticated) { setStatus("anon"); return; }
        if (!me.member) { setUser(me.user); setStatus("denied"); return; }
        // miembro válido: precarga récords y entra
        return window.ESApi.preloadRecords().then(() => { setUser(me.user); setStatus("ok"); });
      })
      .catch((e) => { console.error("[auth] /api/me:", e.message); setStatus("error"); });
  };

  useEffect(() => {
    // Si una llamada gated devuelve 401/403 a mitad de uso, re-evaluamos el gate.
    window.ESApi.onUnauthorized = () => check.current();
    check.current();
    return () => { window.ESApi.onUnauthorized = null; };
  }, []);

  if (status === "ok") return <App user={user} />;

  if (status === "loading") {
    return <AuthShell><div className="es-loader-label">{t("auth.checking")}<span className="es-loader-dots"><i>.</i><i>.</i><i>.</i></span></div></AuthShell>;
  }
  if (status === "error") {
    return <AuthShell>
      <div className="es-auth-sub">{t("auth.error")}</div>
      <button className="es-auth-btn" onClick={() => check.current()}>↻</button>
    </AuthShell>;
  }
  if (status === "denied") {
    return <AuthShell>
      <div className="es-auth-title denied">{t("auth.deniedTitle")}</div>
      <div className="es-auth-sub">{t("auth.deniedSub")}</div>
      <a className="es-auth-btn ghost" href="/auth/logout">{t("auth.retry")}</a>
    </AuthShell>;
  }
  // anon
  return <AuthShell>
    <div className="es-auth-title">{t("auth.loginTitle")}</div>
    <div className="es-auth-sub">{t("auth.loginSub")}</div>
    <a className="es-auth-btn discord" href="/auth/discord">
      <span className="es-auth-dico" aria-hidden></span>{t("auth.loginBtn")}
    </a>
  </AuthShell>;
}

ReactDOM.createRoot(document.getElementById("root")).render(<Root />);
