#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Painel de apuração — Eleições 2026 (1º turno, 4 de outubro)

Servidor local sem dependências (só biblioteca padrão do Python 3.8+).
Busca os arquivos JSON públicos do TSE, normaliza e serve o painel em
http://localhost:8000 . O navegador nunca fala direto com o TSE: o servidor
faz cache (15 s), usa ETag e evita rajadas de requisições (limite do TSE:
100 req/s por IP; muitos 404 seguidos podem bloquear o IP por 10 min).

Uso:
    python3 painel_eleicoes_2026.py                     # ambiente oficial
    python3 painel_eleicoes_2026.py --ambiente simulado # testes do TSE
    python3 painel_eleicoes_2026.py --check             # testa a conexão e sai
    python3 painel_eleicoes_2026.py --host 0.0.0.0      # abrir para a rede/servidor

Códigos de eleição (TSE, 1º turno 04/10/2026): federal 6257 (Presidente),
estadual 6259 (Senador). Para o 2º turno, informe --fed e --est com os novos
códigos (arquivo comum/config/ele-c.json do TSE).
"""
import argparse
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse

UFS = {
    "AC": "Acre", "AL": "Alagoas", "AP": "Amapá", "AM": "Amazonas", "BA": "Bahia",
    "CE": "Ceará", "DF": "Distrito Federal", "ES": "Espírito Santo", "GO": "Goiás",
    "MA": "Maranhão", "MT": "Mato Grosso", "MS": "Mato Grosso do Sul",
    "MG": "Minas Gerais", "PA": "Pará", "PB": "Paraíba", "PR": "Paraná",
    "PE": "Pernambuco", "PI": "Piauí", "RJ": "Rio de Janeiro",
    "RN": "Rio Grande do Norte", "RS": "Rio Grande do Sul", "RO": "Rondônia",
    "RR": "Roraima", "SC": "Santa Catarina", "SP": "São Paulo", "SE": "Sergipe",
    "TO": "Tocantins",
}

AMBIENTES = {
    "oficial": {
        "base": "https://resultados.tse.jus.br/oficial/ele2026",
        "fed": "6257",
        "est": "6259",
    },
    "simulado": {
        "base": "https://resultados-sim.tse.jus.br/simulado/simulado2026/ele2026",
        "fed": "21270",
        "est": "21272",
    },
}

CFG = {"ambiente": "oficial", "base": "", "fed": "", "est": "", "ttl": 15, "refresh": 20}


def url_presidente():
    f = CFG["fed"]
    return f"{CFG['base']}/{f}/dados/br/br-c0001-e{int(f):06d}-u.json"


def url_senador(uf):
    e = CFG["est"]
    u = uf.lower()
    return f"{CFG['base']}/{e}/dados/{u}/{u}-c0005-e{int(e):06d}-u.json"


# ---------------------------------------------------------------- busca + cache
class Fetcher:
    def __init__(self, ttl=15, neg_ttl=60, timeout=12):
        self.ttl, self.neg_ttl, self.timeout = ttl, neg_ttl, timeout
        self.cache, self.locks = {}, {}
        self.lock = threading.Lock()

    def _fresh(self, ent):
        if not ent:
            return False
        limite = self.ttl if ent["status"] == 200 and not ent.get("stale") else (
            5 if ent.get("stale") else self.neg_ttl)
        return time.time() - ent["ts"] < limite

    def get(self, url):
        with self.lock:
            ent = self.cache.get(url)
            lk = self.locks.setdefault(url, threading.Lock())
        if self._fresh(ent):
            return ent
        with lk:
            ent = self.cache.get(url)
            if self._fresh(ent):
                return ent
            headers = {"User-Agent": "PainelEleicoes2026/1.0", "Accept": "application/json"}
            if ent and ent["status"] == 200 and ent.get("etag"):
                headers["If-None-Match"] = ent["etag"]
            req = urllib.request.Request(url, headers=headers)
            try:
                with urllib.request.urlopen(req, timeout=self.timeout) as r:
                    new = {"status": 200, "body": r.read(), "etag": r.headers.get("ETag"),
                           "ts": time.time(), "fetched": time.time()}
            except urllib.error.HTTPError as e:
                if e.code == 304 and ent:
                    new = dict(ent, ts=time.time(), fetched=time.time(), stale=False)
                elif ent and ent["status"] == 200:
                    new = dict(ent, ts=time.time(), stale=True, error=f"HTTP {e.code}")
                else:
                    new = {"status": e.code, "body": b"", "ts": time.time(), "error": f"HTTP {e.code}"}
            except Exception as e:  # rede fora, DNS, timeout...
                if ent and ent["status"] == 200:
                    new = dict(ent, ts=time.time(), stale=True, error=str(e))
                else:
                    new = {"status": 0, "body": b"", "ts": time.time() - self.neg_ttl + 10,
                           "error": str(e)}
            with self.lock:
                self.cache[url] = new
            return new


F = Fetcher()


# ------------------------------------------------------------- normalização
def _i(x):
    try:
        return int(str(x).strip())
    except Exception:
        return 0


def _p(x):
    try:
        return float(str(x).strip().replace(",", "."))
    except Exception:
        return None


def normalize(raw):
    """Reduz o arquivo EA20 do TSE ao que o painel usa."""
    carg = (raw.get("carg") or [{}])[0]
    cands = []
    for agr in carg.get("agr", []) or []:
        for par in agr.get("par", []) or []:
            for c in par.get("cand", []) or []:
                cands.append({
                    "n": c.get("n"), "nm": c.get("nm"), "nmu": c.get("nmu") or c.get("nm"),
                    "sg": par.get("sg"), "vap": _i(c.get("vap")), "pvap": _p(c.get("pvapn") or c.get("pvap")),
                    "dvt": c.get("dvt"), "e": c.get("e"), "st": c.get("st"), "seq": _i(c.get("seq")),
                })
    s, e, v = raw.get("s") or {}, raw.get("e") or {}, raw.get("v") or {}
    return {
        "dt": raw.get("dt"), "ht": raw.get("ht"), "dg": raw.get("dg"), "hg": raw.get("hg"),
        "and": raw.get("and"), "vagas": _i(carg.get("nv")) or None,
        "s": {"ts": _i(s.get("ts")), "st": _i(s.get("st")), "pst": _p(s.get("pstn") or s.get("pst"))},
        "e": {"te": _i(e.get("te")), "est": _i(e.get("est")), "c": _i(e.get("c")),
              "pc": _p(e.get("pcn") or e.get("pc")), "a": _i(e.get("a")), "pa": _p(e.get("pan") or e.get("pa"))},
        "v": {"tv": _i(v.get("tv")), "vvc": _i(v.get("vvc")), "vb": _i(v.get("vb")),
              "pvb": _p(v.get("pvbn") or v.get("pvb")), "tvn": _i(v.get("tvn") or v.get("vn")),
              "ptvn": _p(v.get("ptvnn") or v.get("ptvn")), "vansj": _i(v.get("vansj")),
              "van": _i(v.get("van"))},
        "cands": cands,
    }


def wrap(ent):
    if ent["status"] != 200:
        return {"ok": False, "status": ent["status"], "error": ent.get("error")}
    if "norm" not in ent:
        try:
            ent["norm"] = normalize(json.loads(ent["body"].decode("utf-8-sig")))
        except Exception as ex:
            return {"ok": False, "status": -1, "error": f"JSON inesperado: {ex}"}
    return {"ok": True, "data": ent["norm"], "stale": bool(ent.get("stale")),
            "fetchedAt": ent.get("fetched")}


def api_presidente():
    return wrap(F.get(url_presidente()))


def api_senado():
    ufs = list(UFS)
    with ThreadPoolExecutor(max_workers=9) as ex:
        res = list(ex.map(lambda u: wrap(F.get(url_senador(u))), ufs))
    return {"ufs": {u: r for u, r in zip(ufs, res)}, "nomes": UFS}


# ------------------------------------------------------------------ HTTP
HTML = r"""<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Apuração 2026 — Presidente e Senado</title>
<style>
:root{
  --bg:#edf0f3; --surface:#fff; --ink:#16212c; --muted:#5b6875; --line:#d3dae1;
  --accent:#0e6b67; --accent-soft:#dcefed; --blue:#26418f; --gray:#8a97a3; --amber:#b07a1c;
  --abst:#c9d1d9; --bar:#c3ccd5; --warn:#a65f00;
  --sans:"IBM Plex Sans","Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",sans-serif;
  --serif:"Source Serif 4","Iowan Old Style",Georgia,"Times New Roman",serif;
}
@media (prefers-color-scheme:dark){
  :root{--bg:#0f171e; --surface:#17212a; --ink:#e6ecf1; --muted:#93a1ae; --line:#2a3743;
    --accent:#45c0b5; --accent-soft:#12312f; --blue:#7f9cf0; --gray:#7d8b97; --amber:#dba53f;
    --abst:#3b4956; --bar:#38485a; --warn:#e0a247;}
}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 var(--sans);font-variant-numeric:tabular-nums}
a{color:inherit}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.wrap{max-width:1180px;margin:0 auto;padding:0 20px}

/* topo */
header.top{background:var(--surface);border-bottom:1px solid var(--line)}
.top .wrap{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;justify-content:space-between;padding-top:14px;padding-bottom:14px}
h1{font:600 24px/1.15 var(--serif);margin:0;letter-spacing:-.01em}
h1 small{display:block;font:400 13px/1.3 var(--sans);color:var(--muted);margin-top:3px;letter-spacing:0}
.status{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;font-size:13px;color:var(--muted)}
.badge{display:inline-block;padding:2px 9px;border-radius:99px;font-weight:600;font-size:12px;border:1px solid var(--line)}
.badge.sim{color:var(--warn);border-color:var(--warn)}
.badge.ofi{color:var(--accent);border-color:var(--accent)}
.btn{font:inherit;font-size:13px;background:none;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:4px 10px;cursor:pointer}
.btn:hover{border-color:var(--muted)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--accent);display:inline-block;margin-right:6px}
.dot.off{background:var(--gray)}
.dot.err{background:#c0392b}

/* progresso geral */
.progress{background:var(--surface);border-bottom:1px solid var(--line)}
.progress .wrap{display:flex;flex-wrap:wrap;align-items:center;gap:8px 20px;padding-top:12px;padding-bottom:12px}
.progress .lbl{font-size:13px;color:var(--muted);min-width:170px}
.progress .lbl b{color:var(--ink);font-size:20px;font-family:var(--serif);font-weight:600;margin-right:6px}
.track{flex:1 1 260px;height:8px;background:var(--line);border-radius:4px;overflow:hidden}
.track i{display:block;height:100%;background:var(--accent);transition:width .6s ease}

/* abas */
nav.tabs{display:flex;gap:4px;margin:18px 0 14px;border-bottom:1px solid var(--line)}
nav.tabs button{font:inherit;font-weight:600;background:none;border:0;border-bottom:3px solid transparent;color:var(--muted);padding:8px 14px;cursor:pointer;margin-bottom:-1px}
nav.tabs button[aria-selected=true]{color:var(--ink);border-bottom-color:var(--accent)}
.view[hidden]{display:none}

/* cartões */
.card{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
.card h2{font:600 17px/1.2 var(--serif);margin:0 0 4px}
.card .sub{color:var(--muted);font-size:13px;margin:0 0 14px}
.grid-pres{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(0,1fr);gap:16px;align-items:start}
@media (max-width:900px){.grid-pres{grid-template-columns:1fr}}

/* candidatos */
ol.cands{list-style:none;margin:0;padding:0}
.cand{display:grid;grid-template-columns:26px minmax(0,1fr) auto 76px;column-gap:10px;align-items:baseline;padding:9px 0;border-top:1px solid var(--line)}
.cand:first-child{border-top:0}
.cand .pos{color:var(--muted);font-size:13px;text-align:right}
.cand .nm{font-weight:600}
.cand .meta{color:var(--muted);font-size:13px;margin-left:8px}
.cand .votes{font-size:14px;text-align:right}
.cand .pct{font-weight:700;font-size:16px;text-align:right}
.cand.leader .nm{font-size:17px}
.cand .bar{grid-column:2 / -1;position:relative;height:7px;background:var(--line);border-radius:4px;margin-top:6px;overflow:visible}
.cand .bar i{position:absolute;left:0;top:0;bottom:0;background:var(--bar);border-radius:4px;min-width:2px;transition:width .6s ease}
.cand.leader .bar i{background:var(--accent)}
.cand .bar b{position:absolute;top:-3px;bottom:-3px;width:0;border-left:1px dashed var(--muted)}
.tag{display:inline-block;font-size:11px;font-weight:600;padding:1px 7px;border-radius:99px;margin-left:8px;background:var(--accent-soft);color:var(--accent);vertical-align:1px}
.tag.warn{background:none;border:1px solid var(--warn);color:var(--warn)}
.note{color:var(--muted);font-size:12.5px;margin:12px 0 0}

/* números-chave */
.kpis{display:grid;gap:0}
.kpi{display:grid;grid-template-columns:1fr auto;column-gap:12px;padding:10px 0;border-top:1px solid var(--line)}
.kpi:first-child{border-top:0;padding-top:0}
.kpi .k{font-size:13px;color:var(--muted)}
.kpi .n{font-size:14px;text-align:right}
.kpi .p{font:600 22px/1.1 var(--serif);text-align:right}
.kpi .base{grid-column:1/-1;font-size:12px;color:var(--muted)}
.comp{display:flex;height:14px;border-radius:4px;overflow:hidden;margin:16px 0 8px;background:var(--line)}
.comp i{display:block;height:100%;transition:width .6s ease}
.legend{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;color:var(--muted)}
.legend span::before{content:"";display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;background:var(--c)}

/* espera / erro */
.wait{padding:34px 22px;text-align:center}
.wait h2{font:600 20px/1.2 var(--serif);margin:0 0 8px}
.wait p{margin:4px auto;max-width:560px;color:var(--muted)}
.wait .count{font:600 34px/1.2 var(--serif);color:var(--ink);margin:10px 0}

/* senado */
.tools{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:14px}
.tools input,.tools select{font:inherit;font-size:14px;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:6px 10px}
.tools input{flex:1 1 220px;min-width:0}
.sen-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:14px}
.uf{padding:14px 16px 12px}
.uf header{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin-bottom:6px}
.uf h3{margin:0;font:600 16px/1.2 var(--serif)}
.uf h3 span{font:600 12px var(--sans);color:var(--muted);margin-left:6px}
.uf .ap{font-size:12.5px;color:var(--muted);white-space:nowrap}
.uf .ap b{color:var(--ink)}
.uf .mini{height:4px;background:var(--line);border-radius:2px;overflow:hidden;margin-bottom:8px}
.uf .mini i{display:block;height:100%;background:var(--accent);transition:width .6s ease}
.sc{display:grid;grid-template-columns:18px minmax(0,1fr) auto 62px;column-gap:8px;align-items:baseline;padding:5px 0;border-top:1px solid var(--line);font-size:14px}
.sc:first-of-type{border-top:0}
.sc .pos{color:var(--muted);font-size:12px;text-align:right}
.sc .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sc .meta{color:var(--muted);font-size:12px;margin-left:6px}
.sc .v{color:var(--muted);font-size:13px;text-align:right}
.sc .pc{font-weight:700;text-align:right}
.sc .bar{grid-column:2/-1;height:4px;background:var(--line);border-radius:2px;overflow:hidden}
.sc .bar i{display:block;height:100%;background:var(--bar);transition:width .6s ease}
.sc.vaga .bar i{background:var(--accent)}
.sc.vaga .nm{font-weight:600}
.cut{border-top:2px solid var(--accent)!important}
.uf .foot{display:flex;flex-wrap:wrap;gap:2px 14px;margin-top:8px;padding-top:8px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}
.uf .foot b{color:var(--ink);font-weight:600}
.more{font:inherit;font-size:12.5px;background:none;border:0;color:var(--accent);padding:6px 0 0;cursor:pointer;font-weight:600}
.empty{color:var(--muted);padding:8px 0;font-size:13.5px}

/* mudança de valor */
@keyframes tick{from{background:var(--accent-soft)}to{background:transparent}}
.tick{animation:tick 2.2s ease-out}
@media (prefers-reduced-motion:reduce){.tick{animation:none}.track i,.cand .bar i,.comp i,.uf .mini i,.sc .bar i{transition:none}}
footer{color:var(--muted);font-size:12.5px;padding:22px 0 40px}
footer p{margin:4px 0;max-width:820px}
</style>
</head>
<body>
<header class="top">
  <div class="wrap">
    <h1>Apuração — Eleições 2026<small>1º turno · domingo, 4 de outubro · Presidente e Senado</small></h1>
    <div class="status" aria-live="polite">
      <span id="modo" class="badge"></span>
      <span><i id="dot" class="dot off"></i><span id="upd">aguardando primeira leitura…</span></span>
      <button class="btn" id="pausa" type="button">Pausar</button>
    </div>
  </div>
</header>
<div class="progress"><div class="wrap">
  <div class="lbl"><b id="pg-pct">–</b><span id="pg-txt">seções apuradas no Brasil</span></div>
  <div class="track"><i id="pg-bar" style="width:0"></i></div>
</div></div>

<main class="wrap">
  <nav class="tabs" role="tablist">
    <button role="tab" id="tab-presidente" aria-selected="true" aria-controls="view-presidente">Presidente</button>
    <button role="tab" id="tab-senado" aria-selected="false" aria-controls="view-senado">Senado</button>
  </nav>
  <section id="view-presidente" class="view" role="tabpanel"></section>
  <section id="view-senado" class="view" role="tabpanel" hidden>
    <div class="tools">
      <input id="busca" type="search" placeholder="Filtrar por estado, sigla ou candidato" aria-label="Filtrar">
      <select id="ordem" aria-label="Ordem">
        <option value="alfa">Ordem alfabética</option>
        <option value="apur">Mais apuradas primeiro</option>
      </select>
      <button class="btn" id="expandir" type="button">Mostrar todos os candidatos</button>
    </div>
    <div id="sen-grid" class="sen-grid"></div>
  </section>
  <footer>
    <p>Fonte: arquivos oficiais de divulgação do TSE (resultados.tse.jus.br). Percentuais dos candidatos são sobre votos válidos; brancos e nulos, sobre o comparecimento; abstenção, sobre o eleitorado apurado — a mesma base usada pelo TSE.</p>
    <p>Números parciais mudam conforme os boletins de urna são totalizados. No Senado há 2 vagas por estado: o destaque marca os 2 mais votados <em>no momento</em>, não é projeção. Só o TSE atribui "Eleito".</p>
  </footer>
</main>

<script>
const CFG = __CONFIG__;
const $ = s => document.querySelector(s);
const nf = new Intl.NumberFormat('pt-BR');
const fmtN = v => (v == null ? '–' : nf.format(v));
const fmtP = v => (v == null || isNaN(v)) ? '–' : v.toLocaleString('pt-BR', {minimumFractionDigits:2, maximumFractionDigits:2}) + '%';
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const norm = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
const PART = new Set(['da','de','do','das','dos','e']);
const titulo = s => String(s || '').toLocaleLowerCase('pt-BR').split(' ').map((w,i) => (i && PART.has(w)) ? w : w.charAt(0).toLocaleUpperCase('pt-BR') + w.slice(1)).join(' ');

// ---- destaque de valores que mudaram entre leituras
let prev = {}, next = {};
function tk(key, val, html) {
  next[key] = val;
  const changed = key in prev && prev[key] !== val;
  return `<span class="${changed ? 'tick' : ''}">${html}</span>`;
}

// ---- estado
const S = { pres: null, sen: null, presAt: null, senAt: null, pausado: false, aba: 'presidente',
            expandidos: new Set(), tudo: false, proxima: Date.now() + CFG.refresh * 1000, falha: false };

async function getJSON(url) {
  const r = await fetch(url, {cache: 'no-store'});
  if (!r.ok) throw new Error('HTTP ' + r.status);
  return r.json();
}

async function atualizar() {
  try {
    const pres = await getJSON('/api/presidente');
    S.pres = pres; S.presAt = new Date();
    if (S.aba === 'senado' || S.sen) { S.sen = await getJSON('/api/senado'); S.senAt = new Date(); }
    S.falha = false;
  } catch (e) { S.falha = true; }
  S.proxima = Date.now() + CFG.refresh * 1000;
  desenhar();
}

// ---- cabeçalho / progresso
function cabecalho() {
  const modo = $('#modo');
  modo.textContent = CFG.ambiente === 'oficial' ? 'Ambiente oficial' : 'Simulado do TSE';
  modo.className = 'badge ' + (CFG.ambiente === 'oficial' ? 'ofi' : 'sim');
  const dot = $('#dot'), upd = $('#upd');
  if (S.falha) { dot.className = 'dot err'; upd.textContent = 'sem resposta do servidor local'; }
  else if (!S.presAt) { dot.className = 'dot off'; upd.textContent = 'aguardando primeira leitura…'; }
  else {
    const stale = S.pres && S.pres.stale;
    dot.className = 'dot' + (S.pausado || stale ? ' off' : '');
    const t = S.presAt.toLocaleTimeString('pt-BR');
    const seg = Math.max(0, Math.round((S.proxima - Date.now()) / 1000));
    upd.textContent = 'lido às ' + t + (S.pausado ? ' · pausado' : ' · próxima em ' + seg + ' s') + (stale ? ' · TSE instável, mostrando último dado' : '');
  }
  const p = S.pres;
  if (p && p.ok) {
    const s = p.data.s;
    $('#pg-pct').textContent = fmtP(s.pst);
    $('#pg-txt').textContent = `seções apuradas no Brasil (${fmtN(s.st)} de ${fmtN(s.ts)})` + (p.data.and === 'f' ? ' · totalização final' : '');
    $('#pg-bar').style.width = Math.min(100, s.pst || 0) + '%';
  } else { $('#pg-pct').textContent = '–'; $('#pg-bar').style.width = '0'; }
}

// ---- espera
function espera(res, alvo) {
  const antes = Date.UTC(2026, 9, 4, 20, 0, 0) - Date.now();   // 17h de Brasília
  let h = '<div class="card wait">';
  if (res && res.status === 404) {
    h += '<h2>O TSE ainda não publicou os resultados</h2>';
    h += '<p>Os arquivos aparecem a partir das 17h (Brasília) de 4 de outubro, depois do fim da votação. O painel continua tentando sozinho.</p>';
    if (CFG.ambiente === 'oficial' && antes > 0) {
      const hh = Math.floor(antes / 3600000), mm = Math.floor(antes % 3600000 / 60000);
      h += `<div class="count">${hh >= 48 ? Math.floor(hh / 24) + ' dias e ' + (hh % 24) + ' h' : hh + ' h ' + String(mm).padStart(2, '0') + ' min'}</div><p>para a divulgação</p>`;
    } else if (CFG.ambiente === 'simulado') {
      h += '<p>No simulado, os arquivos só existem nas janelas de teste do TSE (28 e 29/09, das 14h às 16h).</p>';
    }
  } else if (res && res.status === 0) {
    h += '<h2>Sem conexão com o TSE</h2><p>O servidor local não conseguiu alcançar resultados.tse.jus.br. Confira a internet; o painel tenta de novo a cada leitura.</p>';
    if (res.error) h += `<p>${esc(res.error)}</p>`;
  } else if (res && res.status === 403) {
    h += '<h2>Acesso negado pelo TSE (403)</h2><p>Pode ser bloqueio temporário do seu IP por excesso de requisições (dura 10 minutos) ou uma rede que barra o site. Não reinicie o painel várias vezes seguidas; espere alguns minutos.</p>';
  } else if (res) {
    h += `<h2>Resposta inesperada do TSE</h2><p>${esc(res.error || ('código ' + res.status))}</p>`;
  } else h += '<h2>Carregando…</h2>';
  return h + '</div>';
}

// ---- presidente
function desenharPresidente() {
  const root = $('#view-presidente'), r = S.pres;
  if (!r || !r.ok) { root.innerHTML = espera(r); return; }
  const d = r.data, v = d.v, e = d.e;
  const cands = d.cands.slice().sort((a, b) => b.vap - a.vap || a.seq - b.seq);
  const lider = cands.length && cands[0].vap > 0 ? cands[0] : null;
  const houve2t = cands.some(c => c.e === 's' && /2.\s*turno/i.test(c.st || ''));
  const eleito = cands.find(c => c.e === 's' && /^eleito/i.test(c.st || ''));

  let lista = '';
  cands.forEach((c, i) => {
    const pc = c.pvap == null ? 0 : c.pvap;
    const tags = (c.e === 's' ? `<span class="tag">${esc(c.st)}</span>` : '') +
                 (c.dvt && c.dvt !== 'Válido' ? `<span class="tag warn">${esc(c.dvt)}</span>` : '');
    lista += `<li class="cand${lider && c === lider ? ' leader' : ''}">
      <div class="pos">${i + 1}</div>
      <div><span class="nm">${esc(titulo(c.nmu))}</span><span class="meta">${esc(c.sg)} · ${esc(c.n)}</span>${tags}</div>
      <div class="votes">${tk('p:v:' + c.n, c.vap, fmtN(c.vap))}</div>
      <div class="pct">${tk('p:p:' + c.n, pc, fmtP(c.pvap))}</div>
      <div class="bar"><i style="width:${Math.min(100, pc)}%"></i><b style="left:50%" title="Maioria absoluta: mais de 50% dos válidos"></b></div>
    </li>`;
  });

  const est = e.est || 0;
  const validos = Math.max(0, v.tv - v.vb - v.tvn);
  const w = x => est ? (100 * x / est).toFixed(3) : 0;

  root.innerHTML = `<div class="grid-pres">
    <div class="card">
      <h2>Votação para presidente</h2>
      <p class="sub">${houve2t ? 'Segundo turno em 25 de outubro para os marcados. ' : ''}${eleito ? esc(titulo(eleito.nmu)) + ' aparece como eleito(a) no arquivo do TSE. ' : ''}Percentual sobre votos válidos; a linha tracejada marca 50%.</p>
      <ol class="cands">${lista || '<li class="empty">Sem candidatos no arquivo do TSE.</li>'}</ol>
      <p class="note">Totalização do TSE: ${esc(d.dt || '–')} ${esc(d.ht || '')} · ${d.and === 'f' ? 'final' : 'em andamento'}</p>
    </div>
    <div class="card">
      <h2>Brancos, nulos e abstenção</h2>
      <p class="sub">Sobre o eleitorado já apurado: ${fmtN(e.est)} de ${fmtN(e.te)}.</p>
      <div class="kpis">
        <div class="kpi"><span class="k">Votos brancos</span><span class="p">${tk('k:vb', v.vb, fmtP(v.pvb))}</span><span class="n" style="grid-column:1/-1">${tk('k:vbn', v.vb, fmtN(v.vb))} votos</span><span class="base">% do comparecimento</span></div>
        <div class="kpi"><span class="k">Votos nulos</span><span class="p">${tk('k:vn', v.tvn, fmtP(v.ptvn))}</span><span class="n" style="grid-column:1/-1">${tk('k:vnn', v.tvn, fmtN(v.tvn))} votos</span><span class="base">% do comparecimento</span></div>
        <div class="kpi"><span class="k">Abstenção</span><span class="p">${tk('k:a', e.a, fmtP(e.pa))}</span><span class="n" style="grid-column:1/-1">${tk('k:an', e.a, fmtN(e.a))} eleitores</span><span class="base">% do eleitorado apurado</span></div>
        <div class="kpi"><span class="k">Comparecimento</span><span class="p">${tk('k:c', e.c, fmtP(e.pc))}</span><span class="n" style="grid-column:1/-1">${tk('k:cn', e.c, fmtN(e.c))} eleitores</span><span class="base">% do eleitorado apurado</span></div>
      </div>
      <div class="comp" role="img" aria-label="Composição do eleitorado apurado">
        <i style="width:${w(validos)}%;background:var(--blue)"></i><i style="width:${w(v.vb)}%;background:var(--gray)"></i><i style="width:${w(v.tvn)}%;background:var(--amber)"></i><i style="width:${w(e.a)}%;background:var(--abst)"></i>
      </div>
      <div class="legend">
        <span style="--c:var(--blue)">Válidos ${fmtN(validos)}</span><span style="--c:var(--gray)">Brancos</span><span style="--c:var(--amber)">Nulos</span><span style="--c:var(--abst)">Abstenção</span>
      </div>
      ${v.vansj ? `<p class="note">${fmtN(v.vansj)} votos estão "anulados sub judice": dependem de decisão da Justiça Eleitoral.</p>` : ''}
    </div>
  </div>`;
}

// ---- senado
function cartaoUF(uf, nome, r) {
  const head = (ap) => `<header><h3>${esc(nome)}<span>${uf}</span></h3><div class="ap">${ap}</div></header>`;
  if (!r || !r.ok) {
    const msg = r && r.status === 404 ? 'Aguardando o TSE' : 'Sem dados';
    return `<article class="card uf" data-uf="${uf}">${head('–')}<div class="empty">${msg}</div></article>`;
  }
  const d = r.data, vagas = d.vagas || 2;
  const cands = d.cands.slice().sort((a, b) => b.vap - a.vap || a.seq - b.seq);
  const max = Math.max(1, ...cands.map(c => c.vap));
  const aberto = S.tudo || S.expandidos.has(uf);
  const visiveis = aberto ? cands : cands.slice(0, Math.max(3, vagas + 1));
  let linhas = '';
  visiveis.forEach((c, i) => {
    const vaga = i < vagas && c.vap > 0;
    const tags = (c.e === 's' ? `<span class="tag">${esc(c.st)}</span>` : '') + (c.dvt && c.dvt !== 'Válido' ? `<span class="tag warn">${esc(c.dvt)}</span>` : '');
    linhas += `<div class="sc${vaga ? ' vaga' : ''}${i === vagas ? ' cut' : ''}">
      <div class="pos">${i + 1}</div>
      <div class="nm" title="${esc(titulo(c.nmu))}">${esc(titulo(c.nmu))}<span class="meta">${esc(c.sg)}</span>${tags}</div>
      <div class="v">${tk('s:v:' + uf + c.n, c.vap, fmtN(c.vap))}</div>
      <div class="pc">${tk('s:p:' + uf + c.n, c.pvap, fmtP(c.pvap))}</div>
      <div class="bar"><i style="width:${(100 * c.vap / max).toFixed(2)}%"></i></div>
    </div>`;
  });
  const podeExpandir = !S.tudo && cands.length > Math.max(3, vagas + 1);
  const mais = podeExpandir
    ? `<button class="more" data-uf="${uf}" type="button">${aberto ? 'Mostrar menos' : 'Ver todos os ' + cands.length + ' candidatos'}</button>` : '';
  const v = d.v, e = d.e;
  return `<article class="card uf" data-uf="${uf}">
    ${head('<b>' + fmtP(d.s.pst) + '</b> apurado')}
    <div class="mini"><i style="width:${Math.min(100, d.s.pst || 0)}%"></i></div>
    ${linhas || '<div class="empty">Sem candidatos no arquivo.</div>'}
    ${mais}
    <div class="foot"><span>Brancos <b>${tk('s:b:' + uf, v.vb, fmtN(v.vb))}</b> (${fmtP(v.pvb)})</span><span>Nulos <b>${tk('s:n:' + uf, v.tvn, fmtN(v.tvn))}</b> (${fmtP(v.ptvn)})</span><span>Abstenção <b>${fmtP(e.pa)}</b></span></div>
  </article>`;
}

function desenharSenado() {
  const grid = $('#sen-grid'), r = S.sen;
  if (!r) { grid.innerHTML = '<div class="card wait"><h2>Carregando…</h2></div>'; return; }
  const q = norm($('#busca').value);
  let ufs = Object.keys(r.nomes);
  if (q) ufs = ufs.filter(u => {
    const x = r.ufs[u];
    const nomes = x && x.ok ? x.data.cands.map(c => c.nmu + ' ' + c.nm).join(' ') : '';
    return norm(u + ' ' + r.nomes[u] + ' ' + nomes).includes(q);
  });
  if ($('#ordem').value === 'apur') ufs.sort((a, b) => ((r.ufs[b].ok ? r.ufs[b].data.s.pst : -1) - (r.ufs[a].ok ? r.ufs[a].data.s.pst : -1)) || r.nomes[a].localeCompare(r.nomes[b], 'pt-BR'));
  else ufs.sort((a, b) => r.nomes[a].localeCompare(r.nomes[b], 'pt-BR'));
  const todos404 = Object.values(r.ufs).every(x => !x.ok && x.status === 404);
  grid.innerHTML = todos404 ? espera({status: 404}).replace('class="card wait"', 'class="card wait" style="grid-column:1/-1"')
                            : (ufs.map(u => cartaoUF(u, r.nomes[u], r.ufs[u])).join('') || '<div class="empty">Nenhum resultado para o filtro.</div>');
}

function desenhar() {
  next = {};
  cabecalho();
  if (S.aba === 'presidente') desenharPresidente(); else desenharSenado();
  prev = Object.assign({}, prev, next);
}

// ---- eventos
function abrir(aba) {
  S.aba = aba;
  for (const a of ['presidente', 'senado']) {
    $('#tab-' + a).setAttribute('aria-selected', a === aba);
    $('#view-' + a).hidden = a !== aba;
  }
  history.replaceState(null, '', '#' + aba);
  if (aba === 'senado' && !S.sen) atualizar(); else desenhar();
}
$('#tab-presidente').onclick = () => abrir('presidente');
$('#tab-senado').onclick = () => abrir('senado');
$('#pausa').onclick = e => { S.pausado = !S.pausado; e.target.textContent = S.pausado ? 'Retomar' : 'Pausar'; if (!S.pausado) atualizar(); else desenhar(); };
$('#busca').oninput = desenhar; $('#ordem').onchange = desenhar;
$('#expandir').onclick = e => { S.tudo = !S.tudo; e.target.textContent = S.tudo ? 'Mostrar só os líderes' : 'Mostrar todos os candidatos'; desenhar(); };
$('#sen-grid').addEventListener('click', ev => {
  const b = ev.target.closest('.more'); if (!b) return;
  const uf = b.dataset.uf; S.expandidos.has(uf) ? S.expandidos.delete(uf) : S.expandidos.add(uf); desenhar();
});

setInterval(() => { if (!S.pausado && Date.now() >= S.proxima) atualizar(); else cabecalho(); }, 1000);
const ini = location.hash === '#senado' ? 'senado' : 'presidente';
abrir(ini);
if (ini !== 'senado') atualizar();
</script>
</body>
</html>
"""


def pagina():
    cfg = {"ambiente": CFG["ambiente"], "refresh": CFG["refresh"]}
    return HTML.replace("__CONFIG__", json.dumps(cfg)).encode("utf-8")


class Handler(BaseHTTPRequestHandler):
    server_version = "PainelEleicoes/1.0"

    def _send(self, code, body, ctype):
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def _json(self, obj, code=200):
        self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
                   "application/json; charset=utf-8")

    def do_GET(self):
        path = urlparse(self.path).path
        try:
            if path in ("/", "/index.html"):
                self._send(200, pagina(), "text/html; charset=utf-8")
            elif path == "/api/presidente":
                self._json(api_presidente())
            elif path == "/api/senado":
                self._json(api_senado())
            elif path == "/healthz":
                self._json({"ok": True, "ambiente": CFG["ambiente"]})
            else:
                self._send(404, b"nao encontrado", "text/plain; charset=utf-8")
        except (BrokenPipeError, ConnectionResetError):
            pass
        except Exception as ex:
            self._json({"ok": False, "status": -1, "error": str(ex)}, 500)

    def log_message(self, fmt, *args):
        if os.environ.get("PAINEL_LOG"):
            sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))


def checar():
    print(f"Ambiente: {CFG['ambiente']}")
    ok = True
    for nome, url in (("Presidente (BR)", url_presidente()), ("Senado (SP)", url_senador("SP"))):
        ent = F.get(url)
        print(f"- {nome}: {url}")
        if ent["status"] == 200:
            d = normalize(json.loads(ent["body"].decode("utf-8-sig")))
            print(f"    OK · {len(d['cands'])} candidatos · seções apuradas {d['s']['pst']}% · totalização {d['dt']} {d['ht']}")
        else:
            ok = False
            print(f"    sem arquivo (status {ent['status']}) {ent.get('error') or ''}")
            if ent["status"] == 404:
                print("    Normal antes da divulgação: o TSE só publica após o fim da votação (17h de Brasília).")
    return ok


def main():
    ap = argparse.ArgumentParser(description="Painel de apuração das eleições 2026 (TSE)")
    ap.add_argument("--ambiente", choices=list(AMBIENTES), default="oficial")
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=8000)
    ap.add_argument("--fed", help="código da eleição federal (Presidente)")
    ap.add_argument("--est", help="código da eleição estadual (Senador)")
    ap.add_argument("--ttl", type=int, default=15, help="cache no servidor, em segundos (mín. 5)")
    ap.add_argument("--refresh", type=int, default=20, help="intervalo de atualização no navegador, em segundos")
    ap.add_argument("--check", action="store_true", help="testa a conexão com o TSE e sai")
    a = ap.parse_args()

    amb = AMBIENTES[a.ambiente]
    CFG.update(ambiente=a.ambiente, base=os.environ.get("TSE_BASE", amb["base"]),
               fed=a.fed or amb["fed"], est=a.est or amb["est"],
               ttl=max(5, a.ttl), refresh=max(10, a.refresh))
    F.ttl = CFG["ttl"]

    if a.check:
        sys.exit(0 if checar() else 1)

    srv = ThreadingHTTPServer((a.host, a.port), Handler)
    print(f"Painel no ar: http://{'localhost' if a.host in ('127.0.0.1', '0.0.0.0') else a.host}:{a.port}"
          f"  (ambiente: {a.ambiente}; Presidente {CFG['fed']}, Senado {CFG['est']})")
    print("Ctrl+C para encerrar.")
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print("\nEncerrado.")


if __name__ == "__main__":
    main()
