// ── Cosmetics & Achievements ─────────────────────────────────────────────────
// The gem economy is 100% cosmetic + achievement based. Gems can ONLY be spent
// on avatars and avatar frames (this file). They can never unlock AI or any
// paid/real-cost feature — Atlas Coach AI is a separate paid product.
//
// All avatars and frames are drawn here as ORIGINAL inline SVG (geometric
// glyphs + gradient discs / rings). No third-party art assets are used.

const RARITY = {
  comun:      { label: 'Común',      color: '#8B93A5' },
  raro:       { label: 'Raro',       color: '#2A6FDB' },
  epico:      { label: 'Épico',      color: '#8B5CF6' },
  legendario: { label: 'Legendario', color: '#F59E0B' },
};

// ── Avatars ──────────────────────────────────────────────────────────────────
// glyph(color) returns SVG children drawn on a 100×100 canvas, centred ~ (50,50).
const AVATARS = [
  { id: 'default', name: 'Inicial',  rarity: 'comun', cost: 0,    bg: ['#1A2845', '#0F1A2E'], initial: true },
  { id: 'atlas',   name: 'Atlas',    rarity: 'comun', cost: 150,  bg: ['#3B82F6', '#0F1A2E'],
    glyph: (c) => <path d="M50 26 L68 74 L58 74 L54 62 L46 62 L42 74 L32 74 Z M48 53 L52 53 L50 45 Z" fill={c} /> },
  { id: 'flame',   name: 'Llama',    rarity: 'raro', cost: 350,   bg: ['#FB923C', '#B45309'],
    glyph: (c) => <path d="M50 24 C58 38 66 42 62 56 C74 52 66 30 66 30 C74 44 76 60 64 70 C56 77 44 77 37 70 C28 61 33 49 40 44 C40 52 45 54 48 52 C44 44 46 32 50 24 Z" fill={c} /> },
  { id: 'bolt',    name: 'Rayo',     rarity: 'raro', cost: 350,   bg: ['#38BDF8', '#1D4ED8'],
    glyph: (c) => <path d="M54 24 L34 54 L48 54 L44 76 L66 44 L52 44 Z" fill={c} /> },
  { id: 'shield',  name: 'Escudo',   rarity: 'epico', cost: 700,  bg: ['#A78BFA', '#6D28D9'],
    glyph: (c) => <path d="M50 24 L70 32 V52 C70 64 61 72 50 77 C39 72 30 64 30 52 V32 Z M50 34 L40 38 V52 C40 59 45 64 50 67 C55 64 60 59 60 52 V38 Z" fill={c} /> },
  { id: 'crown',   name: 'Corona',   rarity: 'legendario', cost: 1200, bg: ['#FCD34D', '#B45309'],
    glyph: (c) => <path d="M28 44 L36 56 L50 34 L64 56 L72 44 L68 72 L32 72 Z" fill={c} /> },
];

// ── Frames (rings around the avatar) ─────────────────────────────────────────
const FRAMES = [
  { id: 'none',   name: 'Sin marco', rarity: 'comun', cost: 0 },
  { id: 'steel',  name: 'Acero',     rarity: 'comun', cost: 150,  colors: ['#CBD5E1', '#64748B'], width: 6 },
  { id: 'azure',  name: 'Azur',      rarity: 'raro', cost: 350,   colors: ['#7DD3FC', '#2563EB'], width: 7 },
  { id: 'violet', name: 'Amatista',  rarity: 'epico', cost: 700,  colors: ['#D8B4FE', '#7C3AED'], width: 8, deco: 'dots' },
  { id: 'gold',   name: 'Áureo',     rarity: 'legendario', cost: 1200, colors: ['#FDE68A', '#F59E0B'], width: 9, deco: 'gems' },
];

const getAvatar = (id) => AVATARS.find(a => a.id === id) || AVATARS[0];
const getFrame  = (id) => FRAMES.find(f => f.id === id) || null;

// Reusable avatar+frame badge. Renders the equipped avatar disc inside the
// equipped frame ring. Falls back to the name initial for the default avatar.
function AvatarBadge({ avatarId = 'default', frameId = 'none', name = '', size = 52, style }) {
  const uid = React.useId().replace(/:/g, '');
  const av = getAvatar(avatarId);
  const frRaw = getFrame(frameId);
  const fr = (frRaw && frRaw.colors) ? frRaw : null;   // 'none' has no ring
  const initial = ((name || 'A').trim()[0] || 'A').toUpperCase();
  const rAvatar = fr ? 40 : 47;
  const gid = `avg-${uid}`;
  const rid = `frg-${uid}`;
  return (
    <div style={{ position: 'relative', width: size, height: size, flexShrink: 0, lineHeight: 0, ...style }}>
      <svg viewBox="0 0 100 100" width={size} height={size} style={{ display: 'block', overflow: 'visible' }}>
        <defs>
          <radialGradient id={gid} cx="38%" cy="32%" r="80%">
            <stop offset="0%"  stopColor={av.bg[0]} />
            <stop offset="100%" stopColor={av.bg[1]} />
          </radialGradient>
          {fr && (
            <linearGradient id={rid} x1="0" y1="0" x2="1" y2="1">
              <stop offset="0%"  stopColor={fr.colors[0]} />
              <stop offset="100%" stopColor={fr.colors[1]} />
            </linearGradient>
          )}
        </defs>
        <circle cx="50" cy="50" r={rAvatar} fill={`url(#${gid})`} />
        {av.initial ? (
          <text x="50" y="50" textAnchor="middle" dominantBaseline="central"
            fill="#FAFAF7" fontFamily='"Inter",system-ui' fontWeight="800"
            fontSize={rAvatar * 0.95}>{initial}</text>
        ) : av.glyph(av.rarity === 'legendario' ? '#3A2A08' : '#FAFAF7')}
        {fr && (
          <>
            <circle cx="50" cy="50" r={47 - fr.width / 2} fill="none"
              stroke={`url(#${rid})`} strokeWidth={fr.width} />
            {fr.deco === 'dots' && [0, 90, 180, 270].map(a => {
              const rad = (a * Math.PI) / 180, R = 47;
              return <circle key={a} cx={50 + R * Math.cos(rad)} cy={50 + R * Math.sin(rad)} r={fr.width * 0.6} fill={fr.colors[0]} />;
            })}
            {fr.deco === 'gems' && [ -90, 30, 150 ].map(a => {
              const rad = (a * Math.PI) / 180, R = 47;
              const x = 50 + R * Math.cos(rad), y = 50 + R * Math.sin(rad);
              return <rect key={a} x={x - fr.width * 0.7} y={y - fr.width * 0.7} width={fr.width * 1.4} height={fr.width * 1.4}
                transform={`rotate(45 ${x} ${y})`} fill={fr.colors[0]} stroke={fr.colors[1]} strokeWidth="1" rx="1" />;
            })}
          </>
        )}
      </svg>
    </div>
  );
}

// ── Achievements (earned by TRAINING, never bought) ──────────────────────────
const ACHIEVEMENTS = [
  // Sesiones
  { id: 'primera-sesion', cat: 'Sesiones', icon: '🏋️', title: 'Primera sesión',  desc: 'Registra tu primera sesión',            test: c => c.completed >= 1 },
  { id: 'sesiones-10',    cat: 'Sesiones', icon: '🔟', title: 'Constante',        desc: 'Completa 10 sesiones',                  test: c => c.completed >= 10 },
  { id: 'sesiones-25',    cat: 'Sesiones', icon: '💪', title: 'Dedicado',         desc: 'Completa 25 sesiones',                  test: c => c.completed >= 25 },
  { id: 'sesiones-50',    cat: 'Sesiones', icon: '🎯', title: 'Veterano',         desc: 'Completa 50 sesiones',                  test: c => c.completed >= 50 },
  { id: 'sesiones-100',   cat: 'Sesiones', icon: '👑', title: 'Centurión',        desc: 'Completa 100 sesiones',                 test: c => c.completed >= 100 },
  // Constancia (semanas activas: semanas consecutivas cumpliendo el objetivo de días)
  { id: 'racha-7',        cat: 'Constancia', icon: '🔥', title: 'Mes constante',    desc: '4 semanas activas seguidas',           test: c => c.activeWeeks >= 4 },
  { id: 'racha-30',       cat: 'Constancia', icon: '⚡', title: 'Trimestre sólido', desc: '12 semanas activas seguidas',          test: c => c.activeWeeks >= 12 },
  { id: 'racha-100',      cat: 'Constancia', icon: '🏆', title: 'Año de constancia',desc: '52 semanas activas seguidas',          test: c => c.activeWeeks >= 52 },
  // Grupos (primer día de cada grupo)
  { id: 'grupo-pierna',   cat: 'Grupos',   icon: '🦵', title: 'Día de pierna',    desc: 'Entrena pierna por primera vez',        test: c => c.groups.has('pierna') },
  { id: 'grupo-espalda',  cat: 'Grupos',   icon: '🎽', title: 'Espalda ancha',    desc: 'Entrena espalda por primera vez',       test: c => c.groups.has('espalda') },
  { id: 'grupo-pecho',    cat: 'Grupos',   icon: '🛡️', title: 'Pecho de acero',   desc: 'Entrena pecho por primera vez',         test: c => c.groups.has('pecho') },
  { id: 'grupo-hombro',   cat: 'Grupos',   icon: '🔺', title: 'Hombros 3D',       desc: 'Entrena hombro por primera vez',        test: c => c.groups.has('hombro') },
  { id: 'grupo-brazo',    cat: 'Grupos',   icon: '💥', title: 'Brazos de titán',  desc: 'Entrena brazo por primera vez',         test: c => c.groups.has('brazo') },
  { id: 'grupo-core',     cat: 'Grupos',   icon: '🧱', title: 'Core sólido',      desc: 'Entrena core por primera vez',          test: c => c.groups.has('core') },
  // Hábitos
  { id: 'madrugador',     cat: 'Hábitos',  icon: '🌅', title: 'Madrugador',       desc: 'Entrena antes de las 8:00',             test: c => c.early },
  { id: 'nocturno',       cat: 'Hábitos',  icon: '🌙', title: 'Búho nocturno',    desc: 'Entrena después de las 22:00',          test: c => c.night },
  { id: 'finde-activo',   cat: 'Hábitos',  icon: '🌤️', title: 'Finde activo',     desc: 'Entrena un sábado o domingo',           test: c => c.weekend },
  { id: 'semana-full',    cat: 'Hábitos',  icon: '📅', title: 'Semana completa',  desc: '3 sesiones en una misma semana',        test: c => c.maxWeek >= 3 },
  // Aula
  { id: 'primer-articulo', cat: 'Aula',    icon: '📖', title: 'Primera lectura',  desc: 'Lee tu primer artículo del Aula',       test: c => c.readCount >= 1 },
  { id: 'cinco-articulos', cat: 'Aula',    icon: '📚', title: 'Mente curiosa',    desc: 'Lee 5 artículos del Aula',              test: c => c.readCount >= 5 },
  // Planes
  { id: 'primer-plan',    cat: 'Planes',   icon: '🧭', title: 'Primer plan creado', desc: 'Guarda tu primer plan de entrenamiento', test: c => c.hasPlan || c.savedCount >= 1 },
];

const ACHIEVEMENT_CATS = ['Sesiones', 'Constancia', 'Grupos', 'Hábitos', 'Aula', 'Planes'];
const getAchievement = (id) => ACHIEVEMENTS.find(a => a.id === id);

// Classify a logged exercise into one or more big muscle groups, from its
// stored group id + name + muscle names. Used only for the "grupo-*" badges.
function _norm(s) { return (s || '').toString().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); }
// \u00bfEl ejercicio registrado tiene al menos una serie realmente completada?
// (una serie con reps/kg/segundos reales o marcada como hecha). Un ejercicio
// solo planificado \u2014 sin ninguna serie hecha \u2014 no cuenta para los logros.
function _exHasCompletedSet(ex) {
  return Array.isArray(ex && ex.sets) && ex.sets.some(s =>
    s && (s.done === true || Number(s.reps) > 0 || Number(s.kg) > 0 || Number(s.sec) > 0));
}
// Nº de series REALES completadas de un ejercicio (para el mínimo de los logros).
function _completedSetCount(ex) {
  if (!Array.isArray(ex && ex.sets)) return 0;
  return ex.sets.filter(s =>
    s && (s.done === true || Number(s.reps) > 0 || Number(s.kg) > 0 || Number(s.sec) > 0)).length;
}
// Id de región del mapa → grupo de logro. Misma partición que usa el mapa muscular.
const ACH_ID_TO_GROUP = {
  pecho: 'pecho',
  delt_ant: 'hombro', delt_lat: 'hombro', delt_post: 'hombro',
  biceps: 'brazo', triceps: 'brazo', antebrazo: 'brazo',
  dorsal: 'espalda', trapecio: 'espalda', lumbar: 'espalda', erectores: 'espalda',
  cuadriceps: 'pierna', isquio: 'pierna', gluteos: 'pierna', gemelos: 'pierna',
  aductores: 'pierna', abductores: 'pierna', tibial: 'pierna',
  core: 'core', oblicuos: 'core',
};
// Grupos de un ejercicio por sus músculos PRIMARIOS, resueltos con el MISMO
// diccionario que el mapa (AtlasScientificEngine), para que logros y mapa
// coincidan. Respaldo: el heurístico por texto para datos antiguos sin músculos
// reconocibles.
function exPrimaryGroups(ex) {
  const se = (typeof window !== 'undefined') ? window.AtlasScientificEngine : null;
  const out = new Set();
  if (se && typeof se.getExerciseMuscles === 'function' && typeof se.resolveMuscleIds === 'function') {
    const { primary } = se.getExerciseMuscles(ex);
    (primary || []).forEach(m => se.resolveMuscleIds(m).forEach(id => {
      const gr = ACH_ID_TO_GROUP[id]; if (gr) out.add(gr);
    }));
  }
  return out.size ? out : exGroups(ex);
}
function exGroups(ex) {
  const g = _norm(ex.group);
  const text = _norm((ex.name || '') + ' ' + (Array.isArray(ex.muscles) ? ex.muscles.join(' ') : (ex.muscles || '')));
  const out = new Set();
  if (g === 'piernas' || g === 'gluteos') out.add('pierna');
  if (g === 'hombros') out.add('hombro');
  if (g === 'brazos') out.add('brazo');
  if (g === 'core') out.add('core');
  if (/pectoral|pecho|press de banca|press banca|apertura|fondos|cruce|contractor/.test(text)) out.add('pecho');
  if (/dorsal|espalda|remo|jalon|dominada|trapecio|lumbar|pull|erector|romboide/.test(text)) out.add('espalda');
  if (/cuadri|femoral|gluteo|gemelo|isquio|aductor|abductor|sentadilla|prensa|zancada|pantorrilla|tibial|hip thrust|peso muerto/.test(text)) out.add('pierna');
  if (/deltoi|hombro|elevacion lateral|elevacion frontal|pajaro|face pull/.test(text)) out.add('hombro');
  if (/biceps|tricep|antebrazo|martillo|press frances|curl de b/.test(text)) out.add('brazo');
  if (/abdom|oblicuo|plancha|crunch|rueda ab| ab /.test(text)) out.add('core');
  return out;
}

function _isoWeek(d) {
  const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
  const day = t.getUTCDay() || 7;
  t.setUTCDate(t.getUTCDate() + 4 - day);
  const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
  const week = Math.ceil(((t - yearStart) / 86400000 + 1) / 7);
  return `${t.getUTCFullYear()}-${week}`;
}

// ── Weekly consistency engine (Phase 2) ──────────────────────────────────────
// The reward system rewards weekly constancy, not daily streaks. A "week" runs
// Monday–Sunday in the user's LOCAL time; a week is "active" when the number of
// distinct training days in it meets the profile's days-per-week goal.

// Days-per-week goal from the profile (atlas.profile.v1 → trainingDays).
// Defaults to 3 and is clamped to 1..7.
function atlasWeeklyGoal() {
  try {
    const p = JSON.parse(localStorage.getItem('atlas.profile.v1') || 'null');
    const n = parseInt(p?.trainingDays, 10);
    if (Number.isFinite(n) && n >= 1 && n <= 7) return n;
  } catch (_) {}
  return 3;
}

// Monday 00:00 (local) of the week containing ts.
function _weekStartMon(ts) {
  const x = new Date(ts); x.setHours(0, 0, 0, 0);
  const back = (x.getDay() + 6) % 7;   // 0=Mon … 6=Sun
  x.setDate(x.getDate() - back);
  return x;
}
function _weekKey(ts) {
  const s = _weekStartMon(ts);
  return `${s.getFullYear()}-${s.getMonth()}-${s.getDate()}`;
}

// Consecutive active weeks, counting back from the current week. A rest DAY
// never breaks it (granularity is the week); an in-progress current week that
// hasn't reached the goal yet is neutral (doesn't count, doesn't break). A
// fully elapsed week that missed the goal breaks the streak.
function computeActiveWeeks(log, goal) {
  goal = goal || atlasWeeklyGoal();
  const weeks = new Map();   // weekStartMs → Set(dayString)
  for (const e of (log || [])) {
    const ts = e.dateTs || Date.parse(e.date);
    if (!ts || isNaN(ts)) continue;
    const ws = _weekStartMon(ts).getTime();
    if (!weeks.has(ws)) weeks.set(ws, new Set());
    weeks.get(ws).add(e.date || new Date(ts).toDateString());
  }
  const met = (ms) => (weeks.get(ms)?.size || 0) >= goal;
  const curWS = _weekStartMon(Date.now()).getTime();
  let streak = 0;
  let cursor = new Date(curWS);
  if (!met(curWS)) cursor.setDate(cursor.getDate() - 7);   // skip in-progress week
  while (met(_weekStartMon(cursor).getTime())) {
    streak++;
    cursor.setDate(cursor.getDate() - 7);
  }
  return { streak, goal, currentWeekDays: weeks.get(curWS)?.size || 0, metCurrentWeek: met(curWS), currentWeekKey: _weekKey(Date.now()) };
}

// ── Personal records (Phase 2) ───────────────────────────────────────────────
// A PR is beating your own best on the SAME exercise. The first time an exercise
// is logged never counts (no prior record). Isometrics compare by time; unilateral
// by side. +10 per exercise with a PR, capped at +20 per session.
function _exKey(name) { return (name || '').toString().trim().toLowerCase(); }
function _prBests(log) {
  const m = new Map();   // key → best metrics
  for (const e of (log || [])) {
    for (const ex of (e.exercises || [])) {
      const k = _exKey(ex.name); if (!k) continue;
      const b = m.get(k) || { w: 0, r: 0, sec: 0, lw: 0, lr: 0, rw: 0, rr: 0 };
      for (const s of (ex.sets || [])) {
        b.w = Math.max(b.w, parseFloat(s.kg) || 0);
        b.r = Math.max(b.r, parseInt(s.reps) || 0);
        b.sec = Math.max(b.sec, parseInt(s.sec) || 0);
        if (s.left)  { b.lw = Math.max(b.lw, parseFloat(s.left.kg)  || 0); b.lr = Math.max(b.lr, parseInt(s.left.reps)  || 0); }
        if (s.right) { b.rw = Math.max(b.rw, parseFloat(s.right.kg) || 0); b.rr = Math.max(b.rr, parseInt(s.right.reps) || 0); }
      }
      m.set(k, b);
    }
  }
  return m;
}
function computeSessionPRBonus(priorLog, exercises) {
  const bests = _prBests(priorLog);
  let prs = 0;
  for (const ex of (exercises || [])) {
    const prev = bests.get(_exKey(ex.name));
    if (!prev) continue;   // first time logging this exercise → not a PR
    let w = 0, r = 0, sec = 0, lw = 0, lr = 0, rw = 0, rr = 0;
    for (const s of (ex.sets || [])) {
      w = Math.max(w, parseFloat(s.kg) || 0);
      r = Math.max(r, parseInt(s.reps) || 0);
      sec = Math.max(sec, parseInt(s.sec) || 0);
      if (s.left)  { lw = Math.max(lw, parseFloat(s.left.kg)  || 0); lr = Math.max(lr, parseInt(s.left.reps)  || 0); }
      if (s.right) { rw = Math.max(rw, parseFloat(s.right.kg) || 0); rr = Math.max(rr, parseInt(s.right.reps) || 0); }
    }
    const isTime = ex.unit === 'time' || (sec > 0 && w === 0 && r === 0);
    const isUni  = !!ex.unilateral || lw || lr || rw || rr;
    let pr;
    if (isTime)     pr = prev.sec > 0 && sec > prev.sec;
    else if (isUni) pr = (lw > prev.lw) || (lr > prev.lr) || (rw > prev.rw) || (rr > prev.rr);
    else            pr = (w > prev.w) || (r > prev.r);
    if (pr) prs++;
  }
  return Math.min(prs, 2) * 10;   // +10 each, max +20 per session
}

// ── Levels & XP (Phase 3) ────────────────────────────────────────────────────
// XP equals the gross gems earned over time (spending never lowers it). Early
// levels come fast to hook; the top levels take months of constancy. Max = Atlas.
const LEVELS = [
  { level: 1,  xp: 0,    title: 'Aprendiz' },
  { level: 2,  xp: 40,   title: 'Iniciado' },
  { level: 3,  xp: 100,  title: 'Practicante' },
  { level: 4,  xp: 220,  title: 'Habitual' },
  { level: 5,  xp: 420,  title: 'Constante' },
  { level: 6,  xp: 720,  title: 'Disciplinado' },
  { level: 7,  xp: 1150, title: 'Metódico' },
  { level: 8,  xp: 1800, title: 'Riguroso' },
  { level: 9,  xp: 3300, title: 'Veterano' },
  { level: 10, xp: 5200, title: 'Atlas' },
];

function xpToLevel(xp) {
  xp = Math.max(0, Math.round(xp || 0));
  let cur = LEVELS[0];
  for (const l of LEVELS) { if (xp >= l.xp) cur = l; else break; }
  const next = LEVELS.find(l => l.level === cur.level + 1) || null;
  const span = next ? next.xp - cur.xp : 0;
  const into = xp - cur.xp;
  return {
    level: cur.level, title: cur.title, xp,
    currentThreshold: cur.xp,
    nextThreshold: next ? next.xp : null,
    nextTitle: next ? next.title : null,
    xpIntoLevel: into,
    xpForNext: next ? (next.xp - xp) : 0,
    progress: next ? Math.max(0, Math.min(1, into / span)) : 1,
    isMax: !next,
  };
}

// Count of historical weeks (Mon–Sun, local) that met the goal — all of them,
// not just the current streak. Used for the retroactive XP baseline.
function _metWeeksCount(log, goal) {
  goal = goal || atlasWeeklyGoal();
  const weeks = new Map();
  for (const e of (log || [])) {
    const ts = e.dateTs || Date.parse(e.date);
    if (!ts || isNaN(ts)) continue;
    const ws = _weekStartMon(ts).getTime();
    if (!weeks.has(ws)) weeks.set(ws, new Set());
    weeks.get(ws).add(e.date || new Date(ts).toDateString());
  }
  let n = 0;
  for (const set of weeks.values()) if (set.size >= goal) n++;
  return n;
}

// One-time retroactive XP for existing users, from durable records only, so
// nobody starts Phase 3 at zero. Conservative (PRs aren't recomputed):
//   training days × 20  +  weeks that met the goal × 40  +  articles read × 15
function computeRetroXP(state) {
  const days = (state?.sessions?.completed) || new Set((state?.log || []).map(e => e.date)).size || 0;
  const metWeeks = _metWeeksCount(state?.log || []);
  const articles = Array.isArray(state?.reading?.completed) ? state.reading.completed.length : 0;
  return days * 20 + metWeeks * 40 + articles * 15;
}

// Pure: given the store state, return the full set of unlocked achievement ids
// (union with any already-unlocked, so non-training badges are preserved).
// Mínimo de series PRIMARIAS acumuladas para desbloquear un logro por grupo.
// Evita que un grupo se desbloquee por una exposición trivial (1–2 series).
const MIN_GROUP_SETS = 3;

function evaluateAchievements(state) {
  const log = state.log || [];
  const s = state.sessions || {};
  const groupSets = {};   // grupo → series primarias acumuladas
  let early = false, night = false, weekend = false;
  const weekSets = {};
  for (const e of log) {
    // Los logros por grupo muscular solo cuentan ejercicios con SERIES REALES
    // completadas en una sesión guardada — nunca por generar o abrir un plan
    // (un ejercicio planificado sin serie hecha no desbloquea su grupo). Se usa
    // la MISMA resolución de músculos que el mapa (primarios) y se acumulan las
    // series para exigir un mínimo.
    (e.exercises || [])
      .filter(_exHasCompletedSet)
      .forEach(ex => {
        const n = _completedSetCount(ex) || 1;
        exPrimaryGroups(ex).forEach(gr => { groupSets[gr] = (groupSets[gr] || 0) + n; });
      });
    const d = new Date(e.dateTs || e.date);
    if (!isNaN(d.getTime())) {
      const h = d.getHours();
      if (h < 8) early = true;
      if (h >= 22) night = true;
      const wd = d.getDay();
      if (wd === 0 || wd === 6) weekend = true;
      const wk = _isoWeek(d);
      (weekSets[wk] = weekSets[wk] || new Set()).add(e.date || d.toDateString());
    }
  }
  const maxWeek = Object.values(weekSets).reduce((m, set) => Math.max(m, set.size), 0);
  // Un grupo cuenta como "entrenado" solo si acumula el mínimo de series primarias.
  const groups = new Set(Object.entries(groupSets).filter(([, n]) => n >= MIN_GROUP_SETS).map(([g]) => g));
  const readCount = Array.isArray(state.reading?.completed) ? state.reading.completed.length : 0;
  const hasPlan = !!state.plan;
  const savedCount = Array.isArray(state.savedRoutines) ? state.savedRoutines.length : 0;
  const activeWeeks = computeActiveWeeks(log).streak;
  const ctx = { completed: s.completed || 0, streak: s.streak || 0, activeWeeks, groups, early, night, weekend, maxWeek, readCount, hasPlan, savedCount };
  const out = new Set(state.achievements || []);
  for (const a of ACHIEVEMENTS) { try { if (a.test(ctx)) out.add(a.id); } catch (_) {} }
  return [...out];
}

// Small celebratory toast shown when an achievement unlocks. Mounted once in
// the app root; it seeds "seen" from the first render so historical unlocks
// (backfilled on load) don't fire, only genuinely new ones during the session.
function AchievementToasts() {
  const { state } = useStore();
  const seenRef = React.useRef(null);
  const [queue, setQueue] = React.useState([]);

  React.useEffect(() => {
    const cur = state.achievements || [];
    if (seenRef.current === null) { seenRef.current = new Set(cur); return; }
    const fresh = cur.filter(id => !seenRef.current.has(id));
    if (fresh.length) {
      fresh.forEach(id => seenRef.current.add(id));
      const items = fresh.map(getAchievement).filter(Boolean);
      if (items.length) setQueue(q => [...q, ...items]);
    }
  }, [state.achievements]);

  React.useEffect(() => {
    if (!queue.length) return;
    const t = setTimeout(() => setQueue(q => q.slice(1)), 4200);
    return () => clearTimeout(t);
  }, [queue]);

  if (!queue.length) return null;
  const a = queue[0];
  return (
    <div style={{ position: 'fixed', left: 0, right: 0, bottom: 'calc(76px + env(safe-area-inset-bottom))', zIndex: 400, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
      <div onClick={() => setQueue(q => q.slice(1))}
        style={{ pointerEvents: 'auto', cursor: 'pointer', maxWidth: 360, margin: '0 16px',
          display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', borderRadius: 16,
          background: '#0F1A2E', color: '#FAFAF7', boxShadow: '0 12px 40px rgba(15,26,46,0.42)',
          border: '1px solid rgba(59,130,246,0.4)', animation: 'fadeIn .35s ease' }}>
        <div style={{ fontSize: 30, lineHeight: 1 }}>{a.icon}</div>
        <div style={{ minWidth: 0 }}>
          <div style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 9, fontWeight: 700, letterSpacing: 1.4, color: '#93C5FD' }}>¡LOGRO DESBLOQUEADO!</div>
          <div style={{ fontFamily: '"Inter",system-ui', fontSize: 15, fontWeight: 800 }}>{a.title}</div>
          <div style={{ fontFamily: '"Inter",system-ui', fontSize: 11.5, opacity: 0.7 }}>{a.desc}</div>
        </div>
      </div>
    </div>
  );
}

// Immediate feedback when gems are earned: a small "+N 💎 · motivo" toast.
// Watches the gem history and fires only for genuinely new positive entries
// during this session (seeds "seen" on first render so historical gains stay
// silent). Spends (negative amounts) never toast.
function GemToasts() {
  const { state } = useStore();
  const seenRef = React.useRef(null);
  const [queue, setQueue] = React.useState([]);

  React.useEffect(() => {
    const hist = state.gems?.history || [];
    if (seenRef.current === null) { seenRef.current = new Set(hist.map(h => h.id)); return; }
    const fresh = hist.filter(h => h.amount > 0 && !seenRef.current.has(h.id));
    if (fresh.length) {
      fresh.forEach(h => seenRef.current.add(h.id));
      // hist is newest-first; show oldest of the fresh batch first.
      setQueue(q => [...q, ...fresh.slice().reverse()]);
    }
  }, [state.gems?.history]);

  React.useEffect(() => {
    if (!queue.length) return;
    const t = setTimeout(() => setQueue(q => q.slice(1)), 2600);
    return () => clearTimeout(t);
  }, [queue]);

  if (!queue.length) return null;
  const g = queue[0];
  return (
    <div style={{ position: 'fixed', left: 0, right: 0, top: 'calc(12px + env(safe-area-inset-top))', zIndex: 420, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
      <div onClick={() => setQueue(q => q.slice(1))}
        style={{ pointerEvents: 'auto', cursor: 'pointer', maxWidth: 360, margin: '0 16px',
          display: 'flex', alignItems: 'center', gap: 9, padding: '9px 16px', borderRadius: 999,
          background: '#0F1A2E', color: '#FAFAF7', boxShadow: '0 10px 32px rgba(15,26,46,0.4)',
          border: '1px solid rgba(59,130,246,0.4)', animation: 'fadeIn .3s ease' }}>
        <span style={{ fontSize: 16, lineHeight: 1 }}>💎</span>
        <span style={{ fontFamily: '"Inter",system-ui', fontSize: 14, fontWeight: 800 }}>+{g.amount}</span>
        <span style={{ fontFamily: '"Inter",system-ui', fontSize: 12.5, opacity: 0.75 }}>· {g.reason}</span>
      </div>
    </div>
  );
}

// Level-up celebration — fires when the user's level increases during a session
// (seeds "seen" from the first render so the initial/retroactive level is silent).
function LevelUpToast() {
  const { state } = useStore();
  const level = xpToLevel(state.xp || 0).level;
  const seenRef = React.useRef(null);
  const [shown, setShown] = React.useState(null);

  React.useEffect(() => {
    // Baseline only once the retroactive XP seed has settled, so revealing a
    // user's earned level on first load doesn't fire a celebration. From then
    // on, only genuine level-ups (from earning XP) celebrate.
    if (seenRef.current === null) {
      if (!state.xpSeeded) return;
      seenRef.current = level;
      return;
    }
    if (level > seenRef.current) {
      seenRef.current = level;
      setShown(xpToLevel(state.xp || 0));
    }
  }, [level, state.xpSeeded]);

  React.useEffect(() => {
    if (!shown) return;
    const t = setTimeout(() => setShown(null), 4600);
    return () => clearTimeout(t);
  }, [shown]);

  if (!shown) return null;
  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 460, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
      <div onClick={() => setShown(null)}
        style={{ pointerEvents: 'auto', cursor: 'pointer', width: 300, maxWidth: '86vw', textAlign: 'center',
          padding: '26px 24px', borderRadius: 22, background: '#0F1A2E', color: '#FAFAF7',
          border: '1px solid rgba(59,130,246,0.5)', boxShadow: '0 24px 70px rgba(15,26,46,0.55)', animation: 'fadeIn .35s ease' }}>
        <div style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 10, fontWeight: 700, letterSpacing: 1.6, color: '#93C5FD' }}>HAS SUBIDO DE NIVEL</div>
        <div style={{ fontFamily: '"Inter",system-ui', fontSize: 44, fontWeight: 900, letterSpacing: -1.5, margin: '6px 0 2px' }}>{shown.level}</div>
        <div style={{ fontFamily: '"Inter",system-ui', fontSize: 18, fontWeight: 800 }}>{shown.title}</div>
        <div style={{ fontFamily: '"Inter",system-ui', fontSize: 12, opacity: 0.7, marginTop: 6 }}>
          {shown.isMax ? 'Has alcanzado el nivel máximo.' : `${shown.xpForNext} XP para ${shown.nextTitle}`}
        </div>
      </div>
    </div>
  );
}

// ── "Cómo funcionan las gemas" — shared help panel + opener ───────────────────
const GEMS_HELP_SECTIONS = [
  { h: null, p: 'Las gemas premian lo que de verdad importa: entrenar con constancia y progresar. Se ganan entrenando y se canjean por cosméticos. No compran funciones de la app.' },
  { h: 'Cómo se ganan', list: [
    ['Completar una sesión (en el player):', [
      'Sesión completa (≥60% de las series y ≥15 min): +20 💎',
      'Sesión parcial (30–60%): +10 💎',
      'Menos del 30%: sin recompensa',
      'Máximo una recompensa de sesión al día.',
    ]],
    ['Objetivo semanal: +40 💎 al alcanzar tus días por semana (una vez por semana).'],
    ['Marca personal: +10 💎 al superar tu mejor registro en un ejercicio (peso, repeticiones, tiempo o por lado). Máximo +20 por sesión. La primera vez que haces un ejercicio sirve como punto de partida.'],
    ['Leer un artículo del Aula: +15 💎 (la primera vez).'],
  ], note: 'Registrar una sesión desde el Builder cuenta para tu objetivo semanal, pero no da gemas por sí misma.' },
  { h: 'Constancia semanal', p: 'Tu semana va de lunes a domingo. Una semana es activa cuando entrenas los días que marca tu objetivo (3 por defecto). Tu racha cuenta semanas activas seguidas: descansar un día no la rompe, lo que cuenta es la semana completa.' },
  { h: 'Niveles y XP', p: 'Cada gema que ganas suma también 1 de experiencia (XP). Gastar gemas en la tienda no baja tu nivel: el XP refleja todo lo que has logrado. Subes de nivel al acumular XP; los primeros niveles llegan rápido y los últimos exigen meses de constancia. El nivel máximo es Atlas. Puedes ver tu nivel y tu progreso en el Perfil.' },
  { h: 'En qué se gastan', p: 'Por ahora, avatares y marcos para tu perfil. Los cosméticos no afectan a tu entrenamiento ni a tus datos.' },
];

function GemsHelpPanel({ onClose }) {
  if (typeof ReactDOM === 'undefined') return null;
  const isMobile = (typeof useIsMobile === 'function') ? useIsMobile() : false;
  if (typeof useEscClose === 'function') useEscClose(onClose);
  return ReactDOM.createPortal(
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 5200, background: 'rgba(15,26,46,0.42)', display: 'flex', alignItems: isMobile ? 'flex-end' : 'center', justifyContent: 'center', animation: 'fadeIn .15s ease' }}>
      <div role="dialog" aria-modal="true" aria-label="Cómo funcionan las gemas" onClick={e => e.stopPropagation()} style={{ width: isMobile ? '100%' : 520, maxWidth: '100%', maxHeight: isMobile ? '88vh' : '86vh', overflowY: 'auto',
        background: '#FFFFFF', color: '#0F1A2E', borderRadius: isMobile ? '20px 20px 0 0' : 20, border: '1px solid rgba(15,26,46,0.08)',
        boxShadow: '0 -10px 50px rgba(15,26,46,0.2)', padding: isMobile ? '22px 20px calc(24px + env(safe-area-inset-bottom))' : '28px 30px' }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
          <h2 style={{ fontFamily: '"Inter",system-ui', fontSize: 22, fontWeight: 800, letterSpacing: -0.6, margin: 0 }}>Cómo funcionan las gemas</h2>
          <button onClick={onClose} aria-label="Cerrar" style={{ border: 'none', background: 'rgba(15,26,46,0.05)', borderRadius: 10, width: 32, height: 32, cursor: 'pointer', fontSize: 16, color: '#5C6477' }}>✕</button>
        </div>
        {GEMS_HELP_SECTIONS.map((s, i) => (
          <div key={i} style={{ marginBottom: 18 }}>
            {s.h && <div style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 10, fontWeight: 700, letterSpacing: 1.2, color: '#1E40AF', textTransform: 'uppercase', marginBottom: 8 }}>{s.h}</div>}
            {s.p && <p style={{ fontFamily: '"Inter",system-ui', fontSize: 14, color: '#3A4257', lineHeight: 1.6, margin: 0 }}>{s.p}</p>}
            {s.list && (
              <ul style={{ margin: '2px 0 0', paddingLeft: 18, display: 'flex', flexDirection: 'column', gap: 7 }}>
                {s.list.map((it, j) => (
                  <li key={j} style={{ fontFamily: '"Inter",system-ui', fontSize: 14, color: '#3A4257', lineHeight: 1.55 }}>
                    {it[0]}
                    {it[1] && (
                      <ul style={{ margin: '5px 0 0', paddingLeft: 16, display: 'flex', flexDirection: 'column', gap: 4, listStyle: 'none' }}>
                        {it[1].map((sub, k) => <li key={k} style={{ color: '#5C6477', position: 'relative' }}><span style={{ color: '#6B7280', marginRight: 6 }}>·</span>{sub}</li>)}
                      </ul>
                    )}
                  </li>
                ))}
              </ul>
            )}
            {s.note && <p style={{ fontFamily: '"Inter",system-ui', fontSize: 12.5, color: '#6B7280', lineHeight: 1.5, margin: '10px 0 0', fontStyle: 'italic' }}>{s.note}</p>}
          </div>
        ))}
      </div>
    </div>,
    document.body
  );
}

// Small inline opener used in the Store and the Profile.
function GemsHelpButton({ style, label = '¿Cómo funcionan las gemas?' }) {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer',
        fontFamily: '"Inter",system-ui', fontSize: 12.5, fontWeight: 700, color: '#1a4fa0', padding: 0, ...style }}>
        <span style={{ fontSize: 13 }}>ⓘ</span> {label}
      </button>
      {open && <GemsHelpPanel onClose={() => setOpen(false)} />}
    </>
  );
}

Object.assign(window, {
  RARITY, AVATARS, FRAMES, getAvatar, getFrame, AvatarBadge,
  ACHIEVEMENTS, ACHIEVEMENT_CATS, getAchievement, evaluateAchievements, exGroups,
  AchievementToasts, GemToasts, LevelUpToast,
  atlasWeeklyGoal, computeActiveWeeks, computeSessionPRBonus,
  LEVELS, xpToLevel, computeRetroXP, GemsHelpPanel, GemsHelpButton,
});
