/*
  app.jsx — Zurullitos Armados main app
*/

const APP_CSS = `
/* === Layout === */
.wrap {
  max-width: 1080px;
  margin: 0 auto;
  padding: 28px 20px 80px;
}
@media (min-width: 800px) {
  .wrap { padding: 36px 32px 100px; }
}

/* === Hero mascot === */
.hero-mascot {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px 0;
}
.hero-mascot > svg {
  filter: drop-shadow(0 16px 30px rgba(0,0,0,0.4));
  animation: bob 4s ease-in-out infinite;
}
@keyframes bob {
  0%, 100% { transform: translateY(0) rotate(4deg); }
  50% { transform: translateY(-10px) rotate(-2deg); }
}
.hero-mascot-bubble {
  position: absolute;
  top: 10%;
  right: 4%;
  background: var(--paper);
  color: var(--ink);
  padding: 8px 14px;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 14px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  transform: rotate(6deg);
  box-shadow: 0 6px 20px rgba(0,0,0,0.3);
  border-radius: 2px;
  white-space: nowrap;
}
.hero-mascot-bubble::after {
  content: "";
  position: absolute;
  bottom: -8px;
  left: 18px;
  width: 0; height: 0;
  border-left: 8px solid transparent;
  border-right: 8px solid transparent;
  border-top: 10px solid var(--paper);
}
@media (max-width: 899px) {
  .hero-mascot { display: none; }
}

/* === Top bar === */
.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding-bottom: 24px;
  border-bottom: 1px dashed rgba(236, 226, 200, 0.18);
  margin-bottom: 28px;
}
.brand {
  display: flex;
  align-items: center;
  gap: 12px;
  text-decoration: none;
  color: var(--paper);
}
.brand-mark {
  width: 52px;
  height: 52px;
  display: grid;
  place-items: center;
  position: relative;
  flex-shrink: 0;
}
.brand-name {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 22px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  line-height: 1;
}
.brand-sub {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--tape);
  margin-top: 4px;
  opacity: 0.85;
}
.topbar-meta {
  display: flex;
  gap: 10px;
  align-items: center;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--tape);
  opacity: 0.75;
}
.topbar-meta .dot {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--plaza); display: inline-block;
  box-shadow: 0 0 0 3px rgba(77,107,52,0.22);
  animation: pulse 2s ease-in-out infinite;
}
.alpha-badge {
  margin-left: 4px;
  padding: 2px 6px;
  background: var(--reserva);
  color: var(--ink);
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 10px;
  letter-spacing: 0.16em;
  border-radius: 1px;
  opacity: 0.95;
}
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

/* === Hero === */
.hero {
  display: grid;
  grid-template-columns: 1fr;
  gap: 28px;
  align-items: stretch;
  margin-bottom: 28px;
}
@media (min-width: 900px) {
  .hero { grid-template-columns: 1.1fr 1fr; gap: 36px; }
  .hero.hero-no-mascot { grid-template-columns: 1fr; }
}
.hero-title {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(38px, 7vw, 78px);
  letter-spacing: 0.01em;
  line-height: 0.9;
  color: var(--paper);
  text-transform: uppercase;
  margin: 0;
}
.hero-title em {
  font-style: normal;
  display: block;
  color: var(--reserva);
}
.hero-title em::before { content: "·  "; opacity: 0.5; }
.hero-lede {
  margin-top: 18px;
  font-size: 16px;
  line-height: 1.55;
  color: var(--tape);
  max-width: 44ch;
}
.hero-meta {
  margin-top: 24px;
  display: flex;
  flex-wrap: wrap;
  gap: 8px 14px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--paper);
  opacity: 0.7;
}
.hero-meta span::before { content: "▪ "; color: var(--reserva); margin-right: 6px; }

/* === Search panel === */
.search-card {
  background: var(--paper);
  color: var(--ink);
  padding: 24px;
  border-radius: 2px;
  position: relative;
  box-shadow: 0 18px 40px -12px rgba(0,0,0,0.5);
  border: 1px solid var(--paper-edge);
}
.search-card::before {
  content: "FORMULARIO 001 / CONSULTA NIO";
  position: absolute;
  top: -10px;
  left: 14px;
  background: var(--bg);
  color: var(--paper);
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.18em;
  padding: 2px 8px;
}
.search-label {
  display: block;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 6px;
}
.search-input-wrap {
  position: relative;
  display: flex;
  align-items: stretch;
  border: 2px solid var(--ink);
  background: var(--highlight);
}
.search-input {
  flex: 1;
  width: 100%;
  border: 0;
  outline: 0;
  background: transparent;
  padding: 16px 14px;
  font-family: var(--font-mono);
  font-size: clamp(20px, 4vw, 28px);
  letter-spacing: 0.04em;
  color: var(--ink);
  font-weight: 500;
}
.search-input::placeholder { color: rgba(26,31,20,0.3); letter-spacing: 0.08em; }
.search-go {
  border: 0;
  background: var(--ink);
  color: var(--paper);
  padding: 0 24px;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 18px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 8px;
  transition: background 0.15s ease;
}
.search-go:hover { background: var(--olive); }
.search-go:disabled { opacity: 0.4; cursor: not-allowed; }
.search-hint {
  margin-top: 10px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.04em;
  color: var(--muted);
  display: flex;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 8px;
}
.search-hint .err { color: var(--rechazo-deep); font-weight: 700; }
.demo-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 18px;
  align-items: center;
}
.demo-row .demo-label {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
  margin-right: 4px;
}
.demo-btn {
  background: var(--paper-2);
  border: 1px solid var(--paper-edge);
  padding: 6px 10px;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.02em;
  color: var(--ink);
  cursor: pointer;
  border-radius: 2px;
  transition: all 0.15s ease;
}
.demo-btn:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.demo-btn .demo-state {
  display: inline-block;
  width: 8px; height: 8px;
  border-radius: 50%;
  margin-right: 5px;
  vertical-align: middle;
}
.demo-btn .demo-state.plaza { background: var(--plaza); }
.demo-btn .demo-state.reserva { background: var(--reserva); }
.demo-btn .demo-state.rechazo { background: var(--rechazo); }

/* === Reveal / suspense === */
.reveal {
  min-height: 320px;
  display: grid;
  place-items: center;
  padding: 60px 20px;
  text-align: center;
}
.reveal-text {
  font-family: var(--font-mono);
  font-size: 14px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--paper);
  margin-bottom: 16px;
}
.reveal-text .cursor {
  display: inline-block;
  width: 0.7ch;
  background: var(--paper);
  height: 1em;
  vertical-align: text-bottom;
  animation: blink 0.6s steps(2, end) infinite;
}
@keyframes blink { 0%,49%{ opacity: 1 } 50%,100%{ opacity: 0 } }
.reveal-bar {
  width: min(420px, 80vw);
  height: 6px;
  background: rgba(236,226,200,0.15);
  position: relative;
  overflow: hidden;
}
.reveal-bar::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, var(--reserva), transparent);
  animation: scan 1.2s ease-in-out infinite;
}
@keyframes scan {
  0% { transform: translateX(-100%); }
  100% { transform: translateX(100%); }
}

/* === Result card === */
.result {
  position: relative;
  margin-bottom: 28px;
  animation: result-in 0.6s cubic-bezier(.16,.84,.36,1);
}
@keyframes result-in {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}
.result-header {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 20px;
  align-items: start;
  margin-bottom: 24px;
}
@media (max-width: 700px) {
  .result-header { grid-template-columns: 1fr; }
}
.result-kicker {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 6px;
}
.result-headline {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(34px, 5.4vw, 56px);
  line-height: 0.95;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink);
  margin: 0;
}
.result-headline em {
  font-style: normal;
}
.result-headline em.plaza { color: var(--plaza-deep); }
.result-headline em.reserva { color: var(--reserva-deep); }
.result-headline em.rechazo { color: var(--rechazo-deep); }
.result-sub {
  margin-top: 12px;
  font-size: 16px;
  line-height: 1.5;
  color: var(--ink-soft);
  max-width: 50ch;
}
.result-stamp {
  align-self: start;
  justify-self: end;
  position: relative;
  animation: stamp-slam 0.5s cubic-bezier(.36,.04,.42,1.5) 0.2s backwards;
}
@keyframes stamp-slam {
  0% { opacity: 0; transform: scale(2.5) rotate(15deg); }
  60% { opacity: 1; transform: scale(0.85) rotate(-9deg); }
  80% { transform: scale(1.08) rotate(-6deg); }
  100% { opacity: 1; transform: scale(1) rotate(-7deg); }
}

.result-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 0 28px;
  padding: 18px 0;
  border-top: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule);
}

.result-actions {
  margin-top: 20px;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/* === Buttons === */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 16px;
  border: 1px solid var(--ink);
  background: transparent;
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  cursor: pointer;
  border-radius: 2px;
  transition: all 0.15s ease;
  text-decoration: none;
}
.btn:hover { background: var(--ink); color: var(--paper); }
.btn-ink { background: var(--ink); color: var(--paper); }
.btn-ink:hover { background: transparent; color: var(--ink); }
.btn-ghost { border-color: var(--paper-edge); color: var(--ink-soft); }
.btn-ghost:hover { background: var(--paper-2); color: var(--ink); border-color: var(--ink); }
.btn-dark { background: var(--bg-2); color: var(--paper); border-color: var(--bg-2); }
.btn-dark:hover { background: var(--ink); border-color: var(--ink); }
.btn-sm { padding: 6px 10px; font-size: 10px; }
.btn-lg { padding: 14px 22px; font-size: 12px; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }

/* === Reserve gauge === */
.gauge {
  margin: 20px 0;
}
.gauge-track {
  position: relative;
  height: 36px;
  background: var(--paper-2);
  border: 1px solid var(--paper-edge);
  border-radius: 2px;
  overflow: hidden;
}
.gauge-plaza {
  position: absolute;
  top: 0; left: 0; bottom: 0;
  background: repeating-linear-gradient(
    45deg,
    rgba(77,107,52,0.5),
    rgba(77,107,52,0.5) 6px,
    rgba(77,107,52,0.7) 6px,
    rgba(77,107,52,0.7) 12px
  );
  border-right: 2px dashed var(--plaza-deep);
}
.gauge-zone {
  position: absolute;
  top: 0; bottom: 0;
  pointer-events: none;
}
.gauge-marker {
  position: absolute;
  top: -8px;
  bottom: -8px;
  width: 2px;
  background: var(--reserva-deep);
  transform: translateX(-50%);
}
.gauge-marker::before {
  content: "▼";
  position: absolute;
  top: -10px;
  left: 50%;
  transform: translateX(-50%);
  color: var(--reserva-deep);
  font-size: 10px;
}
.gauge-labels {
  display: flex;
  justify-content: space-between;
  margin-top: 6px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.1em;
  color: var(--muted);
  text-transform: uppercase;
}

/* === Competencias table === */
.comp-table {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--font-mono);
  font-size: 12.5px;
}
.comp-table thead th {
  text-align: left;
  font-weight: 500;
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
  padding: 8px 10px;
  border-bottom: 1px solid var(--rule);
}
.comp-table tbody td {
  padding: 12px 10px;
  border-bottom: 1px solid var(--rule);
  color: var(--ink);
  vertical-align: middle;
}
.comp-table tbody tr.row-plaza { background: rgba(77,107,52,0.10); }
.comp-table tbody tr.row-reserva { background: rgba(182,128,46,0.10); }
.comp-table tbody tr.row-rechazo { background: rgba(168,58,44,0.05); }
.comp-table tbody tr.row-removed { background: rgba(26,31,20,0.04); color: var(--muted); }
.comp-table tbody tr.row-far { background: transparent; }
.comp-table tbody tr.row-highlight td.vac-code { color: var(--reserva-deep); }
.comp-table tbody tr.row-highlight { box-shadow: inset 3px 0 0 var(--reserva); }
.comp-table td.right { text-align: right; }
.comp-table td.center { text-align: center; }
.comp-table .vac-code {
  font-weight: 700;
  font-size: 13px;
  letter-spacing: 0.04em;
}
.comp-table .rank-cell { font-weight: 700; }
.comp-table tr.row-plaza .status { color: var(--plaza-deep); }
.comp-table tr.row-reserva .status { color: var(--reserva-deep); }
.comp-table tr.row-rechazo .status { color: var(--rechazo-deep); }
@media (max-width: 700px) {
  .comp-table thead { display: none; }
  .comp-table tbody td { display: block; border: 0; padding: 4px 12px; }
  .comp-table tbody td::before {
    content: attr(data-label);
    display: inline-block;
    width: 95px;
    color: var(--muted);
    font-size: 10px;
    letter-spacing: 0.1em;
    text-transform: uppercase;
  }
  .comp-table tbody tr {
    display: block;
    padding: 12px 0;
    border-bottom: 1px solid var(--rule);
  }
}

/* === Tabs === */
.tabs {
  display: flex;
  border-bottom: 1px solid rgba(236,226,200,0.18);
  margin: 36px 0 24px;
  overflow-x: auto;
  gap: 0;
}
.tab-btn {
  background: transparent;
  border: 0;
  padding: 14px 18px;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 16px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--tape);
  cursor: pointer;
  position: relative;
  white-space: nowrap;
  opacity: 0.75;
}
.tab-btn:hover { opacity: 1; }
.tab-btn.active { color: var(--paper); opacity: 1; }
.tab-btn.active::after {
  content: "";
  position: absolute;
  left: 14px; right: 14px; bottom: -1px;
  height: 3px;
  background: var(--reserva);
}

/* === Section header === */
.sec-header {
  display: flex;
  align-items: baseline;
  gap: 14px;
  margin-bottom: 18px;
}
.sec-num {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.16em;
  color: var(--reserva);
  text-transform: uppercase;
}
.sec-title {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(24px, 4vw, 34px);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--paper);
  margin: 0;
  line-height: 1;
}
.sec-sub {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}

/* === Footer === */
.foot {
  margin-top: 60px;
  padding-top: 28px;
  border-top: 1px dashed rgba(236,226,200,0.18);
  display: flex;
  flex-wrap: wrap;
  gap: 24px;
  align-items: flex-start;
  justify-content: space-between;
  color: var(--tape);
  font-size: 12.5px;
  line-height: 1.6;
}
.foot-block { max-width: 320px; }
.foot strong {
  font-family: var(--font-display);
  font-weight: 900;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--paper);
  font-size: 14px;
  display: block;
  margin-bottom: 6px;
}
.foot .links { display: flex; flex-wrap: wrap; gap: 14px; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; }
.foot a { color: var(--paper); opacity: 0.8; text-decoration: none; border-bottom: 1px dotted; }
.foot a:hover { opacity: 1; }
`;

(function injectAppCSS() {
  if (document.getElementById('za-app-css')) return;
  const el = document.createElement('style');
  el.id = 'za-app-css';
  el.textContent = APP_CSS;
  document.head.appendChild(el);
})();


/* ---------- Result computation ---------- */

/*
  buildDepuratedMap — derives, per vacante, the official "Relación Complementaria"
  ranking directly from the entries in data.comp. Each comp entry carries a status:
    s=1 → titular in that vacante (got plaza here)
    s=2 → official reserva in that vacante (appears in the Complementaria PDF)
    s=0 → ghost: passed Fase 2 but eliminated (renuncia, no-apto, alegación)
  For every vacante we compute:
    positions:        { nio -> official complementaria position (1-based) }
    reserveTotal:     how many NIOs remain in the official complementaria
    classifiedHere:   titulares assigned to this vacante (s=1 here)
    classifiedOther:  applicants who got plaza elsewhere (s=1 in another vacante)
    droppedHere:      ghost entries for this vacante (renuncias / no-apto / alegaciones)
*/
function buildDepuratedMap(data) {
  if (!data) return {};
  const perVac = {};
  for (const nio of Object.keys(data.comp)) {
    for (const c of data.comp[nio]) {
      const [vac, rank, nota, op, s] = c;
      if (!perVac[vac]) perVac[vac] = [];
      perVac[vac].push({ nio, rank, nota, op, s: s ?? 0 });
    }
  }

  const out = {};
  for (const vac of Object.keys(perVac)) {
    const list = perVac[vac].sort((a, b) => a.rank - b.rank);
    const positions = {};
    let depPos = 0;
    let classifiedHere = 0, classifiedOther = 0, droppedHere = 0;
    for (const e of list) {
      if (e.s === 1) {
        // titular: their cls entry tells us where they got plaza
        const cls = data.cls[e.nio];
        if (cls && cls.v === vac) classifiedHere++; else classifiedOther++;
      } else if (e.s === 2) {
        depPos++;
        positions[e.nio] = depPos;
      } else {
        droppedHere++;
      }
    }
    out[vac] = {
      positions,
      reserveTotal: depPos,
      classifiedHere,
      classifiedOther,
      droppedHere,
      originalTotal: list.length,
    };
  }
  return out;
}

function computeResult(nio, data, dep) {
  if (!data || !nio) return null;
  const cls = data.cls[nio];
  const comp = data.comp[nio];

  // === PLAZA ===
  if (cls) {
    const stats = data.vac[cls.v] || { p: 1, t: 1, b: cls.b };
    const enriched = (comp || [])
      .map(([vac, rank, nota, op, s]) => {
        const vstats = data.vac[vac] || { p: 0, t: 0, b: 'TIERRA', n: '', e: '' };
        const dvac = (dep || {})[vac];
        return {
          vac, rank, nota, op, s: s ?? 0,
          plazas: vstats.p, total: vstats.t, branch: vstats.b,
          unidad: vstats.n, especialidad: vstats.e,
          depRank: null,
          reserveTotal: dvac?.reserveTotal ?? 0,
          isWinner: vac === cls.v,
        };
      });
    return {
      kind: 'plaza',
      nio,
      vac: cls.v,
      branch: cls.b,
      nota: cls.n,
      op: cls.o,
      plazas: stats.p,
      totalCompet: stats.t,
      unidad: stats.n,
      especialidad: stats.e,
      competencias: enriched,
    };
  }

  // Partition comp entries by status
  const hasReserva = comp && comp.some(c => (c[4] ?? 0) === 2);

  // === RESERVA OFICIAL ===
  if (hasReserva) {
    const enriched = comp.map(([vac, rank, nota, op, s]) => {
      const stats = data.vac[vac] || { p: 0, t: 0, b: 'TIERRA', n: '', e: '' };
      const dvac = (dep || {})[vac];
      const depRank = dvac?.positions[nio] ?? null;
      return {
        vac, rank, nota, op, s: s ?? 0,
        plazas: stats.p, total: stats.t, branch: stats.b,
        unidad: stats.n, especialidad: stats.e,
        depRank,
        reserveTotal: dvac?.reserveTotal ?? 0,
        classifiedHere: dvac?.classifiedHere ?? 0,
        classifiedOther: dvac?.classifiedOther ?? 0,
        droppedHere: dvac?.droppedHere ?? 0,
      };
    });

    // Headline: the vacante where they're closest to entry (smallest depRank among status=2)
    const closest = [...enriched]
      .filter(x => x.s === 2 && x.depRank != null)
      .sort((a, b) => a.depRank - b.depRank)[0] || enriched[0];

    const firstChoice = [...enriched].sort((a, b) => a.op - b.op)[0];

    return {
      kind: 'reserva',
      nio,
      vac: closest.vac,
      branch: closest.branch,
      nota: closest.nota,
      op: closest.op,
      plazas: closest.plazas,
      totalCompet: closest.total,
      unidad: closest.unidad,
      especialidad: closest.especialidad,
      rank: closest.rank,
      reservePos: closest.depRank,
      reserveTotal: closest.reserveTotal,
      classifiedOtherAbove: closest.classifiedOther,
      droppedAbove: closest.droppedHere,
      firstChoice,
      competencias: enriched,
    };
  }

  // === DESCARTADO === — passed Fase 2 but not in any official list
  if (comp && comp.length > 0) {
    const enriched = comp.map(([vac, rank, nota, op, s]) => {
      const stats = data.vac[vac] || { p: 0, t: 0, b: 'TIERRA', n: '', e: '' };
      const dvac = (dep || {})[vac];
      return {
        vac, rank, nota, op, s: s ?? 0,
        plazas: stats.p, total: stats.t, branch: stats.b,
        unidad: stats.n, especialidad: stats.e,
        depRank: null,
        reserveTotal: dvac?.reserveTotal ?? 0,
      };
    });
    return { kind: 'descartado', nio, competencias: enriched };
  }

  return { kind: 'rechazo', nio, competencias: [] };
}

function probabilityBand(reservePos, reserveTotal) {
  // Reserve position is now DEPURATED (against people who don't have plaza elsewhere).
  // Historical movement on these complementary lists ranges roughly 8–25 % of the
  // total reserve size depending on the vacante and the year. We map position vs
  // total into qualitative bands; the exact percentages are advisory.
  if (reservePos == null || reservePos <= 0) return { label: '—', pct: 0, tone: 'neutral', note: 'Sin posición depurada calculable.' };
  const ratio = reservePos / Math.max(reserveTotal, 1);
  if (ratio <= 0.08) return { label: 'ALTA',     pct: 70, tone: 'plaza',   note: 'Estás dentro del 8 % superior de la lista depurada — el movimiento histórico de renuncias suele cubrir esta franja.' };
  if (ratio <= 0.20) return { label: 'MEDIA',    pct: 40, tone: 'reserva', note: 'Posible si hay renuncias o bajas tempranas en la fase de formación.' };
  if (ratio <= 0.40) return { label: 'BAJA',     pct: 15, tone: 'reserva', note: 'Sólo accederías ante una rotación importante en tu vacante. Mantente disponible.' };
  return                  { label: 'MUY BAJA', pct: 4,  tone: 'rechazo', note: 'La lista no suele moverse hasta esta profundidad. Apunta directamente al siguiente ciclo.' };
}

const BRANCH_LABEL = {
  TIERRA: 'Ejército de Tierra',
  ARMADA: 'Armada',
  AIRE: 'Ejército del Aire y del Espacio',
};


/* ---------- Reveal Suspense ---------- */
function Reveal({ nio, onDone }) {
  const STAGES = [
    'VERIFICANDO N.I.O.',
    'CRUZANDO LISTAS OFICIALES',
    'COMPROBANDO RESERVA',
    'GENERANDO RESOLUCIÓN',
  ];
  const [stage, setStage] = useState(0);
  useEffect(() => {
    if (stage >= STAGES.length) {
      const t = setTimeout(onDone, 250);
      return () => clearTimeout(t);
    }
    const t = setTimeout(() => setStage(s => s + 1), 480);
    return () => clearTimeout(t);
  }, [stage]);

  return (
    <Paper tape style={{ minHeight: 280 }}>
      <div className="reveal">
        <div className="reveal-text" style={{ color: 'var(--ink-soft)' }}>
          {STAGES[Math.min(stage, STAGES.length - 1)]}
          <span className="cursor" style={{ background: 'var(--ink-soft)' }} />
        </div>
        <div className="reveal-bar" style={{ background: 'rgba(26,31,20,0.08)' }} />
        <div style={{ marginTop: 18, fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.1em', color: 'var(--muted)' }}>
          N.I.O: {nio}
        </div>
      </div>
    </Paper>
  );
}

/* ---------- Result card ---------- */
function ResultCard({ result, onShare, onNotify, onReset }) {
  if (!result) return null;
  const k = result.kind;

  let headline, kicker, sub, stamp;
  if (k === 'plaza') {
    headline = (<>HAS <em className="plaza">SACADO</em> PLAZA.</>);
    kicker = '— RESOLUCIÓN DEFINITIVA · BOD 24-04-2026';
    sub = <>Felicidades. Estás clasificado como alumno para la fase de formación militar general. Tu Subdelegación de Defensa te comunicará en breve el centro de formación, la fecha y la hora de presentación.</>;
    stamp = <Stamp label="PLAZA" sublabel="CLASIFICADO" tone="plaza" rotate={-7} />;
  } else if (k === 'reserva') {
    const pos = result.reservePos;
    headline = (<>EN <em className="reserva">RESERVA</em> · Nº {pos}</>);
    kicker = '— RELACIÓN COMPLEMENTARIA · APTO NO CLASIFICADO';
    sub = <>Has superado todo el proceso de selección pero no había plaza para tu vacante preferida. Quedas en lista complementaria; serás llamado por orden de nota si se producen renuncias, bajas o no presentaciones.</>;
    stamp = <Stamp label="RESERVA" sublabel={`Nº ${pos}`} tone="reserva" rotate={-7} />;
  } else if (k === 'descartado') {
    headline = (<>FUERA DE <em className="rechazo">LISTAS OFICIALES</em>.</>);
    kicker = '— APROBASTE LA FASE 2 PERO NO FIGURAS EN LAS LISTAS DEFINITIVAS';
    sub = <>Tu N.I.O aparece en la selección previa de la Fase 2 pero no consta ni como titular ni como reserva en los listados oficiales de 24-04-2026 / 30-04-2026. Causas habituales: renuncia voluntaria, declarado no apto en pruebas posteriores (médico, físico), o eliminado por resolución de alegaciones.</>;
    stamp = <Stamp label="DESCARTADO" sublabel="POST FASE 2" tone="rechazo" rotate={-7} />;
  } else {
    headline = (<>SIN <em className="rechazo">PLAZA</em> EN ESTE CICLO.</>);
    kicker = '— N.I.O NO LOCALIZADO EN LAS LISTAS';
    sub = <>Tu N.I.O no aparece como clasificado ni en la lista complementaria del Ciclo II. Si crees que es un error, contacta con tu Subdelegación de Defensa. El próximo ciclo se publica en septiembre.</>;
    stamp = <Stamp label="NO" sublabel="ADMITIDO" tone="rechazo" rotate={-7} />;
  }

  return (
    <Paper tape>
      <div className="result">
        <div className="result-header">
          <div>
            <div className="result-kicker">{kicker}</div>
            <h2 className="result-headline">{headline}</h2>
            <p className="result-sub">{sub}</p>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 14 }}>
              <Pill tone="ink" size="md">N.I.O · {result.nio}</Pill>
              {result.branch && <Pill tone="olive" size="md">{BRANCH_LABEL[result.branch]}</Pill>}
              {result.vac && <Pill tone={k} size="md">VACANTE {result.vac}{result.especialidad ? ` · ${result.especialidad}` : ''}</Pill>}
            </div>
            {result.unidad && (
              <div style={{ marginTop: 8, fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--ink-soft)', letterSpacing: '0.02em' }}>
                {result.unidad}
              </div>
            )}
          </div>
          <div className="result-stamp">{stamp}</div>
        </div>

        {k === 'plaza' && <PlazaDetails result={result} />}
        {k === 'reserva' && <ReservaDetails result={result} />}
        {k === 'descartado' && <DescartadoDetails result={result} />}
        {k === 'rechazo' && <RechazoDetails result={result} />}

        <div className="result-actions">
          <button className="btn btn-ink" onClick={onShare}>
            <IconShare size={14} /> Compartir resultado
          </button>
          <button className="btn" onClick={onNotify}>
            <IconBell size={14} /> {k === 'reserva' ? 'Avísame si me muevo' : 'Avisos del proceso'}
            <span style={{
              marginLeft: 4,
              fontSize: 9, letterSpacing: '0.12em',
              opacity: 0.6, fontStyle: 'italic',
            }}>(próximamente)</span>
          </button>
          <button className="btn btn-ghost" onClick={onReset}>Consultar otro N.I.O</button>
        </div>
      </div>
    </Paper>
  );
}

function PlazaDetails({ result }) {
  const removedCount = result.competencias?.filter(c => c.vac !== result.vac).length || 0;
  return (
    <div>
      <div className="result-grid">
        <Stat label="Vacante" mono size="lg" value={result.vac} />
        <Stat label="Nota final" mono size="lg" value={result.nota?.toFixed(3).replace('.', ',')} />
        <Stat label="Orden de petición" mono value={`${result.op}ª preferencia`} hint={result.op === 1 ? 'tu primera elección' : 'no era tu primera, pero entras'} />
        <Stat label="Plazas adjudicadas" mono value={`${result.plazas} de ${result.totalCompet}`} hint="aspirantes presentados" />
      </div>

      <Banner tone="success" icon={<IconCheck />}>
        <strong>Próximo paso:</strong> espera la llamada o correo de tu Subdelegación de Defensa con el centro de formación asignado, día y hora de presentación. No abandones tu domicilio durante las próximas 72 horas hábiles.
      </Banner>

      {result.competencias?.length > 0 && (
        <details style={{ marginTop: 18 }}>
          <summary style={{
            cursor: 'pointer',
            fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.16em',
            textTransform: 'uppercase', color: 'var(--muted)',
            padding: '8px 0',
          }}>
            Ver tus {result.competencias.length} peticiones · {removedCount > 0 ? `retirado de ${removedCount} listas tras adjudicación` : ''}
          </summary>
          <p style={{ margin: '8px 0 4px', fontSize: 13, color: 'var(--ink-soft)' }}>
            Al adjudicársete plaza en <strong>{result.vac}</strong>, has sido <em>retirado de las listas de reserva del resto de tus vacantes</em> — los aspirantes que ibas por delante en esas listas suben una posición.
          </p>
          <CompetenciasTable list={result.competencias} winnerVac={result.vac} mode="plaza" />
        </details>
      )}
    </div>
  );
}

function ReservaDetails({ result }) {
  const prob = probabilityBand(result.reservePos, result.reserveTotal);
  const enriched = result.competencias || [];

  // Build comparison: original puesto vs depurated
  const originalRank = result.rank;
  const depRank = result.reservePos;
  const removedAbove = (originalRank - 1) - (depRank - 1); // people above me who were classified and thus removed

  return (
    <div>
      <div className="result-grid">
        <Stat label="Tu posición (depurada)" mono size="lg" value={`Nº ${depRank}`} hint={`de ${result.reserveTotal} en la complementaria de ${result.vac}`} />
        <Stat label="Nota final" mono size="lg" value={result.nota?.toFixed(3).replace('.', ',')} />
        <Stat label="Puesto en el PDF" mono value={`Nº ${originalRank} / ${result.totalCompet}`} hint={removedAbove > 0 ? `subes ${removedAbove} posiciones tras depurar` : 'sin movimiento al depurar'} />
        <Stat label="Probabilidad estimada" size="lg">
          <Pill tone={prob.tone} size="lg">{prob.label}</Pill>
        </Stat>
      </div>

      <div className="gauge">
        <div className="gauge-track">
          {/* The "alta" zone — top 8 % of the depurated list */}
          <div
            className="gauge-zone"
            style={{ width: `${(0.08) * 100}%`, background: 'rgba(77,107,52,0.35)' }}
            title="Franja con alta probabilidad de llamamiento (≤8 %)"
          />
          <div
            className="gauge-zone"
            style={{
              left: '8%', width: `${(0.20 - 0.08) * 100}%`,
              background: 'rgba(182,128,46,0.25)',
            }}
            title="Probabilidad media (8–20 %)"
          />
          <div
            className="gauge-marker"
            style={{ left: `${(depRank / Math.max(result.reserveTotal, 1)) * 100}%` }}
            title={`Tu posición depurada: ${depRank}`}
          />
        </div>
        <div className="gauge-labels">
          <span>Cabeza de reserva</span>
          <span>Tú · Nº {depRank} / {result.reserveTotal}</span>
        </div>
      </div>

      <Banner tone="warn" icon={<IconWarn />}>
        <strong>{prob.label} probabilidad de llamamiento.</strong> {prob.note} Estimación orientativa basada en tu posición depurada; no es vinculante.
      </Banner>

      {removedAbove > 0 && (
        <Banner tone="info" icon={<IconCheck />}>
          <strong>Subes {removedAbove} posiciones respecto a la Fase 2.</strong> {removedAbove} aspirantes que iban por delante de ti en esta vacante ya no figuran en la lista complementaria oficial: se clasificaron en otras vacantes, renunciaron, fueron declarados no aptos o se retiraron tras alegaciones. Tu Nº {depRank} es la posición real que cuenta para los llamamientos.
        </Banner>
      )}

      {enriched.length > 1 && (
        <div style={{ marginTop: 24 }}>
          <div className="sec-header" style={{ marginBottom: 8 }}>
            <span className="sec-num">B.</span>
            <h3 className="sec-title" style={{ color: 'var(--ink)', fontSize: 22 }}>Todas tus vacantes</h3>
            <span className="sec-sub" style={{ color: 'var(--muted)' }}>{enriched.length} peticiones · listas depuradas</span>
          </div>
          <CompetenciasTable list={enriched} mode="reserva" highlightVac={result.vac} />
        </div>
      )}
    </div>
  );
}

function DescartadoDetails({ result }) {
  const enriched = result.competencias || [];
  return (
    <div>
      <Banner tone="error" icon={<IconCross />}>
        <strong>Pasaste la Fase 2 pero no figuras en las listas oficiales del Ciclo II.</strong> Las listas publicadas el 24-04-2026 (titulares) y el 30-04-2026 (complementaria modificada tras alegaciones) no contienen tu N.I.O en ninguna de tus vacantes. Comprueba tu resolución personal en la sede electrónica del Ministerio de Defensa.
      </Banner>

      <div style={{ marginTop: 18 }}>
        <h4 style={{
          fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: 18,
          letterSpacing: '0.04em', textTransform: 'uppercase',
          margin: '0 0 12px', color: 'var(--ink)',
        }}>POSIBLES MOTIVOS</h4>
        <ul style={{ paddingLeft: 18, lineHeight: 1.7, fontSize: 14.5, color: 'var(--ink-soft)' }}>
          <li>Renuncia voluntaria al proceso.</li>
          <li>No apto en pruebas posteriores (reconocimiento médico o pruebas físicas).</li>
          <li>Eliminado por resolución de alegaciones publicada el 30-04-2026.</li>
          <li>Si crees que es un error, dirígete a tu Subdelegación de Defensa.</li>
        </ul>
      </div>

      {enriched.length > 0 && (
        <div style={{ marginTop: 24 }}>
          <div className="sec-header" style={{ marginBottom: 8 }}>
            <span className="sec-num">A.</span>
            <h3 className="sec-title" style={{ color: 'var(--ink)', fontSize: 22 }}>Vacantes a las que optabas en Fase 2</h3>
            <span className="sec-sub" style={{ color: 'var(--muted)' }}>{enriched.length} peticiones · ninguna activa</span>
          </div>
          <CompetenciasTable list={enriched} mode="descartado" />
        </div>
      )}
    </div>
  );
}

function RechazoDetails({ result }) {
  return (
    <div>
      <Banner tone="error" icon={<IconCross />}>
        <strong>Tu N.I.O no aparece en ninguna de las dos listas de este ciclo.</strong> Esto puede deberse a no haber superado la fase 1, a un error en la cita previa, o a que el dato introducido no coincide con el oficial. Comprueba en la sede electrónica del Ministerio tu resolución personal.
      </Banner>

      <div style={{ marginTop: 18 }}>
        <h4 style={{
          fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: 18,
          letterSpacing: '0.04em', textTransform: 'uppercase',
          margin: '0 0 12px', color: 'var(--ink)',
        }}>QUÉ HACER AHORA</h4>
        <ul style={{ paddingLeft: 18, lineHeight: 1.7, fontSize: 14.5, color: 'var(--ink-soft)' }}>
          <li>Revisa la resolución personal en la sede electrónica de Defensa.</li>
          <li>Si tenías puntos de baremo (CFGS, idiomas, deportes), <strong>se conservan</strong> para el próximo ciclo.</li>
          <li>Próxima convocatoria estimada: <strong>septiembre 2026</strong> — Ciclo III.</li>
          <li>Mantén tu reconocimiento médico vigente; vale 12 meses.</li>
        </ul>
      </div>
    </div>
  );
}


/* ---------- Competencias table ---------- */
function CompetenciasTable({ list, winnerVac, highlightVac, mode = 'reserva' }) {
  return (
    <div style={{ overflowX: 'auto', marginTop: 12 }}>
      <table className="comp-table">
        <thead>
          <tr>
            <th>Pref</th>
            <th>Vacante · Unidad</th>
            <th>Ejército</th>
            <th>Nota</th>
            <th>Puesto PDF</th>
            <th>Plazas</th>
            <th>Estado en lista depurada</th>
          </tr>
        </thead>
        <tbody>
          {list.map((c, i) => {
            const isWinner = winnerVac === c.vac;
            const isHighlight = highlightVac === c.vac;
            // Status from data: s=1 titular, s=2 reserva oficial, s=0 ghost (descartado)
            let statusEl, rowClass;
            if (isWinner || c.s === 1) {
              statusEl = <strong>● PLAZA en esta vacante</strong>;
              rowClass = 'row-plaza';
            } else if (winnerVac) {
              statusEl = <span style={{ opacity: 0.65 }}>↩ Retirado (clasificado en {winnerVac})</span>;
              rowClass = 'row-removed';
            } else if (c.s === 2 && c.depRank != null) {
              const inCalupZone = c.depRank / Math.max(c.reserveTotal, 1) <= 0.20;
              statusEl = (
                <span>
                  <strong>Nº {c.depRank}</strong>
                  <span style={{ color: 'var(--muted)' }}> / {c.reserveTotal} en complementaria</span>
                  {inCalupZone && <span style={{ marginLeft: 6, color: 'var(--plaza-deep)' }}>· cabeza</span>}
                </span>
              );
              rowClass = inCalupZone ? 'row-reserva' : 'row-far';
            } else {
              statusEl = <span style={{ opacity: 0.55 }}>— No figura en lista oficial</span>;
              rowClass = 'row-rechazo';
            }

            return (
              <tr key={i} className={classNames(rowClass, isHighlight && 'row-highlight')}>
                <td data-label="Preferencia" className="center">
                  <span style={{
                    display: 'inline-grid', placeItems: 'center',
                    width: 22, height: 22, borderRadius: '50%',
                    background: isHighlight ? 'var(--reserva)' : 'var(--ink)',
                    color: isHighlight ? 'var(--ink)' : 'var(--paper)',
                    fontSize: 11, fontWeight: 700,
                  }}>{c.op}</span>
                </td>
                <td data-label="Vacante · Unidad" className="vac-code">
                  <div>{c.vac}{c.especialidad ? <span style={{ color: 'var(--muted)', fontWeight: 400 }}> · {c.especialidad}</span> : null}</div>
                  {c.unidad && <div style={{ fontFamily: 'var(--font-ui)', fontWeight: 400, fontSize: 11.5, color: 'var(--ink-soft)', marginTop: 2, letterSpacing: 'normal' }}>{c.unidad}</div>}
                </td>
                <td data-label="Ejército">{BRANCH_LABEL[c.branch] || c.branch}</td>
                <td data-label="Nota">{c.nota?.toFixed(3).replace('.', ',')}</td>
                <td data-label="Puesto PDF" className="rank-cell">{c.rank}<span style={{ color: 'var(--muted)' }}> / {c.total}</span></td>
                <td data-label="Plazas">{c.plazas}</td>
                <td data-label="Estado" className="status">{statusEl}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}


/* ---------- Search panel ---------- */
function SearchPanel({ data, onSubmit, demos }) {
  const [val, setVal] = useState('');
  const [err, setErr] = useState(null);
  const inputRef = useRef(null);

  const handleChange = (e) => {
    setErr(null);
    setVal(normalizeNIO(e.target.value));
  };
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!isValidNIO(val)) {
      setErr('Formato no válido. Usa AAAAC-PP-NNNNN (ej. 20261-28-01433).');
      return;
    }
    setErr(null);
    onSubmit(val);
  };
  const useDemo = (nio) => {
    setVal(nio);
    setErr(null);
    onSubmit(nio);
  };

  return (
    <form className="search-card" onSubmit={handleSubmit}>
      <label className="search-label" htmlFor="nio-input">Introduce tu Número de Identificación del Opositor</label>
      <div className="search-input-wrap">
        <input
          ref={inputRef}
          id="nio-input"
          className="search-input"
          value={val}
          onChange={handleChange}
          placeholder="20261-28-00000"
          autoComplete="off"
          spellCheck={false}
          inputMode="numeric"
        />
        <button className="search-go" type="submit" disabled={!val}>
          <span>Consultar</span>
          <IconChevron size={16} />
        </button>
      </div>
      <div className="search-hint">
        <span>{err ? <span className="err">⚠ {err}</span> : 'AAAAC-PP-NNNNN · 12 dígitos'}</span>
        <span>{data ? `${data.meta.totalAspirantes.toLocaleString('es-ES')} aspirantes en BBDD` : 'Cargando…'}</span>
      </div>

      {demos.length > 0 && (
        <div className="demo-row">
          <span className="demo-label">Probar con:</span>
          {demos.map(d => (
            <button
              key={d.nio}
              type="button"
              className="demo-btn"
              onClick={() => useDemo(d.nio)}
              title={d.label}
            >
              <span className={`demo-state ${d.kind}`} />
              {d.nio}
            </button>
          ))}
        </div>
      )}
    </form>
  );
}


/* ---------- Main App ---------- */
function App() {
  const [data, setData] = useState(null);
  const [loadError, setLoadError] = useState(null);
  const [phase, setPhase] = useState('idle'); // idle | revealing | result
  const [submittedNio, setSubmittedNio] = useState(null);
  const [result, setResult] = useState(null);
  const [tab, setTab] = useState('faq');
  const [shareOpen, setShareOpen] = useState(false);
  const [notifyOpen, setNotifyOpen] = useState(false);

  // Tweaks state
  const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
    "palette": "olivo",
    "showAnimation": true,
    "boldHero": true,
    "showMascot": true
  }/*EDITMODE-END*/;
  const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);

  // Apply palette
  useEffect(() => {
    document.documentElement.setAttribute('data-palette', tweaks.palette);
  }, [tweaks.palette]);

  // Pre-compute the depurated map (reserve lists after removing classified-elsewhere candidates)
  const depMap = useMemo(() => buildDepuratedMap(data), [data]);

  // Load data
  useEffect(() => {
    fetch('data/data.json')
      .then(r => { if (!r.ok) throw new Error('No se pudo cargar la base de datos'); return r.json(); })
      .then(d => {
        setData(d);
        // Hide boot splash
        const boot = document.getElementById('boot');
        if (boot) {
          boot.classList.add('gone');
          setTimeout(() => boot.remove(), 500);
        }
      })
      .catch(err => setLoadError(err.message));
  }, []);

  // Demo NIOs — one of each kind, picked from real data
  const demos = useMemo(() => {
    if (!data) return [];
    const plazaNio = Object.keys(data.cls)[0];

    // Reserva: prefer someone at position #1 of any vacante's complementaria
    let reservaNio = null;
    let descartadoNio = null;
    const compNios = Object.keys(data.comp);
    for (const nio of compNios) {
      if (data.cls[nio]) continue;
      const entries = data.comp[nio];
      const hasReserva = entries.some(e => (e[4] ?? 0) === 2);
      if (hasReserva && !reservaNio) {
        // pick someone with depRank=1 if possible
        const isFirstReserva = entries.some(e => {
          if ((e[4] ?? 0) !== 2) return false;
          const dvac = depMap[e[0]];
          return dvac?.positions[nio] === 1;
        });
        if (isFirstReserva) reservaNio = nio;
      }
      if (!hasReserva && !descartadoNio) descartadoNio = nio;
      if (reservaNio && descartadoNio) break;
    }
    if (!reservaNio) {
      // fallback: any reserva
      reservaNio = compNios.find(n => !data.cls[n] && data.comp[n].some(e => (e[4] ?? 0) === 2));
    }
    const rechazoNio = '20261-99-12345';
    return [
      { nio: plazaNio, kind: 'plaza', label: 'Ejemplo: ha sacado plaza' },
      { nio: reservaNio || '20261-28-00000', kind: 'reserva', label: 'Ejemplo: reserva oficial' },
      { nio: descartadoNio || '20261-28-99999', kind: 'descartado', label: 'Ejemplo: aprobó Fase 2 pero descartado' },
      { nio: rechazoNio, kind: 'rechazo', label: 'Ejemplo: NIO no encontrado' },
    ];
  }, [data, depMap]);

  const handleSubmit = (nio) => {
    setSubmittedNio(nio);
    setResult(null);
    if (tweaks.showAnimation) {
      setPhase('revealing');
    } else {
      const r = computeResult(nio, data, depMap);
      setResult(r);
      setPhase('result');
    }
  };

  const handleRevealDone = () => {
    const r = computeResult(submittedNio, data, depMap);
    setResult(r);
    setPhase('result');
  };

  const reset = () => {
    setPhase('idle');
    setResult(null);
    setSubmittedNio(null);
    setTimeout(() => {
      document.getElementById('nio-input')?.focus();
    }, 100);
  };

  return (
    <div className="wrap">
      <TopBar data={data} />
      <Hero data={data} bold={tweaks.boldHero} mascot={tweaks.showMascot} />

      <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 20 }}>
        {phase === 'idle' && (
          <SearchPanel data={data} onSubmit={handleSubmit} demos={demos.filter(d => d.nio)} />
        )}
        {phase === 'revealing' && submittedNio && (
          <Reveal nio={submittedNio} onDone={handleRevealDone} />
        )}
        {phase === 'result' && result && (
          <>
            <ResultCard
              result={result}
              onShare={() => setShareOpen(true)}
              onNotify={() => setNotifyOpen(true)}
              onReset={reset}
            />
          </>
        )}

        {loadError && (
          <Paper><Banner tone="error" icon={<IconCross />}>{loadError}</Banner></Paper>
        )}
      </div>

      {/* === Tabs / secondary content === */}
      <div className="tabs">
        {[
          { id: 'faq', label: 'Preguntas frecuentes' },
          { id: 'forum', label: 'Foro de opositores · próximamente' },
          { id: 'cycle', label: 'Sobre la convocatoria' },
        ].map(t => (
          <button
            key={t.id}
            className={classNames('tab-btn', tab === t.id && 'active')}
            onClick={() => setTab(t.id)}
          >{t.label}</button>
        ))}
      </div>

      {tab === 'faq' && (
        <div>
          <div className="sec-header">
            <span className="sec-num">§ 01</span>
            <h2 className="sec-title">Lo que se pregunta todo cristo</h2>
            <span className="sec-sub">{FAQ_DATA.length} preguntas</span>
          </div>
          <FAQ />
        </div>
      )}

      {tab === 'forum' && (
        <div>
          <div className="sec-header">
            <span className="sec-num">§ 02</span>
            <h2 className="sec-title">Sala de tropa</h2>
            <span className="sec-sub">próximamente · maqueta de previsualización</span>
          </div>
          <Paper style={{ marginBottom: 16 }}>
            <Banner tone="warn" icon={<IconWarn />}>
              <strong>Próximamente.</strong> Estamos cocinando el foro de opositores — lo que ves abajo es una maqueta de previsualización con posts ficticios para enseñar cómo quedará. Aún no es funcional.
            </Banner>
          </Paper>
          <Forum myState={result} />
        </div>
      )}

      {tab === 'cycle' && data && (
        <div>
          <div className="sec-header">
            <span className="sec-num">§ 03</span>
            <h2 className="sec-title">Datos de esta convocatoria</h2>
            <span className="sec-sub">Resolución {data.meta.convocatoria}</span>
          </div>
          <CycleSection data={data} />
        </div>
      )}

      <Footer data={data} />

      {/* Modals */}
      <ShareModal open={shareOpen} onClose={() => setShareOpen(false)} result={result} />
      <NotifyModal open={notifyOpen} onClose={() => setNotifyOpen(false)} result={result} />

      {/* Tweaks panel */}
      <TweaksPanel title="Tweaks" noDeckControls={true}>
        <TweakSection label="Paleta">
          <TweakRadio
            label="Estilo"
            value={tweaks.palette}
            onChange={(v) => setTweak('palette', v)}
            options={[
              { value: 'olivo', label: 'Olivo' },
              { value: 'navy', label: 'Marino' },
              { value: 'desert', label: 'Desierto' },
            ]}
          />
        </TweakSection>
        <TweakSection label="Comportamiento">
          <TweakToggle
            label="Animación de suspense"
            value={tweaks.showAnimation}
            onChange={(v) => setTweak('showAnimation', v)}
          />
          <TweakToggle
            label="Titular en dos líneas"
            value={tweaks.boldHero}
            onChange={(v) => setTweak('boldHero', v)}
          />
          <TweakToggle
            label="Mascota grande en el hero"
            value={tweaks.showMascot}
            onChange={(v) => setTweak('showMascot', v)}
          />
        </TweakSection>
      </TweaksPanel>
    </div>
  );
}


/* ---------- Top bar ---------- */
function TopBar({ data }) {
  return (
    <div className="topbar">
      <a href="#" className="brand">
        <div className="brand-mark" style={{ background: 'transparent', padding: 0 }}>
          <PoopHelmet size={48} />
        </div>
        <div>
          <div className="brand-name">Zurullitos Armados</div>
          <div className="brand-sub">Consulta no oficial · BOD 24-04-2026</div>
        </div>
      </a>
      <div className="topbar-meta">
        <span className="dot"></span>
        <span>Datos del BOD vigente</span>
        <span className="alpha-badge" title="Versión de pruebas — los alumnos están comprobando si la lógica calcula bien">ALPHA</span>
      </div>
    </div>
  );
}

/* ---------- Hero ---------- */
function Hero({ data, bold, mascot }) {
  return (
    <div className={classNames('hero', !mascot && 'hero-no-mascot')}>
      <div>
        <div style={{ marginBottom: 18 }}>
          <Wordmark size={42} />
        </div>
        <h1 className="hero-title">
          {bold ? <>¿Tienes<br />plaza,</> : <>¿Tienes plaza,</>}
          <em>o a esperar?</em>
        </h1>
        <p className="hero-lede">
          Mete tu N.I.O y te decimos en dos segundos si has entrado, si estás en reserva (y en qué puesto) o si toca preparar la próxima. Cruzamos en directo las dos listas oficiales del Ciclo II — la de clasificados y la complementaria.
        </p>
        <div className="hero-meta">
          {data ? (
            <>
              <span>{data.meta.totalPlazas.toLocaleString('es-ES')} plazas adjudicadas</span>
              <span>{data.meta.totalAspirantes.toLocaleString('es-ES')} aspirantes</span>
              <span>{data.meta.totalVacantes} vacantes · 3 ejércitos</span>
            </>
          ) : <span>Cargando datos…</span>}
        </div>
      </div>
      {mascot && (
        <div className="hero-mascot" aria-hidden="true">
          <PoopHelmet size={280} rotate={4} />
          <div className="hero-mascot-bubble">
            <span>¡Suerte, recluta!</span>
          </div>
        </div>
      )}
    </div>
  );
}

/* ---------- Cycle section ---------- */
function CycleSection({ data }) {
  // Compute branch breakdowns
  const branchCount = { TIERRA: 0, ARMADA: 0, AIRE: 0 };
  const branchPlazas = { TIERRA: 0, ARMADA: 0, AIRE: 0 };
  for (const c of Object.values(data.cls)) {
    branchCount[c.b]++;
  }
  for (const v of Object.values(data.vac)) {
    branchPlazas[v.b] = (branchPlazas[v.b] || 0) + v.p;
  }
  const total = data.meta.totalPlazas;
  return (
    <Paper>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 18 }}>
        <Stat label="Resolución" value="452/38557/2025" mono />
        <Stat label="Ciclo" value="Segundo" />
        <Stat label="Publicación" value="24 abr 2026" mono />
        <Stat label="Plazas adjudicadas" value={data.meta.totalPlazas.toLocaleString('es-ES')} mono size="lg" />
      </div>

      <Rule label="REPARTO POR EJÉRCITO" />

      <div style={{ display: 'grid', gap: 14 }}>
        {['TIERRA', 'ARMADA', 'AIRE'].map(b => {
          const pct = (branchCount[b] / total) * 100;
          return (
            <div key={b}>
              <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
                <span style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: 14, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
                  {BRANCH_LABEL[b]}
                </span>
                <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--ink-soft)' }}>
                  {branchCount[b].toLocaleString('es-ES')} <span style={{ color: 'var(--muted)' }}>· {pct.toFixed(1)}%</span>
                </span>
              </div>
              <div style={{ height: 10, background: 'var(--paper-2)', overflow: 'hidden', borderRadius: 1 }}>
                <div style={{
                  width: `${pct}%`,
                  height: '100%',
                  background: b === 'TIERRA' ? 'var(--olive)' : b === 'ARMADA' ? 'var(--plaza-deep)' : 'var(--reserva-deep)',
                }} />
              </div>
            </div>
          );
        })}
      </div>

      <Rule label="NOTAS LEGALES" />

      <div style={{ fontSize: 13.5, lineHeight: 1.6, color: 'var(--ink-soft)' }}>
        <p style={{ margin: '0 0 10px' }}>
          Los Secretarios Generales / Áreas de Reclutamiento de las Subdelegaciones de Defensa comunicarán a los aspirantes admitidos la plaza, puntuación, centro de formación, y fecha y hora de presentación.
        </p>
        <p style={{ margin: '0 0 10px' }}>
          La reposición de renuncias, no presentaciones y bajas durante la fase de orientación se realiza con los aspirantes de mayor nota de la <em>Relación Complementaria de Aspirantes Aptos No Clasificados</em> de la vacante donde se produzca la disminución.
        </p>
        <p style={{ margin: 0, fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--muted)' }}>
          Fuente: Comisión Permanente de Selección · Subdirección General de Reclutamiento — BOD 24-04-2026
        </p>
      </div>
    </Paper>
  );
}

/* ---------- Footer ---------- */
function Footer({ data }) {
  return (
    <div className="foot">
      <div className="foot-block">
        <strong>Zurullitos Armados</strong>
        Web no oficial mantenida por opositores. Datos extraídos de los listados publicados en el BOD por la Comisión Permanente de Selección.
      </div>
      <div className="foot-block">
        <strong>Última actualización</strong>
        24-04-2026 · Ciclo Segundo<br/>
        Resolución 452/38557/2025
      </div>
      <div className="foot-block">
        <strong>Enlaces oficiales</strong>
        <div className="links">
          <a href="https://reclutamiento.defensa.gob.es/" target="_blank" rel="noopener noreferrer">Reclutamiento Defensa</a>
          <a href="https://publicaciones.defensa.gob.es/" target="_blank" rel="noopener noreferrer">BOD</a>
          <a href="https://sede.defensa.gob.es/" target="_blank" rel="noopener noreferrer">Sede electrónica</a>
        </div>
      </div>
    </div>
  );
}


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