#!/usr/bin/env python3
"""Educação: coleta e tratamento dos indicadores pré-registrados (tema `educacao`).

Uso:
    python3 scripts/coletar_educacao.py                   # coleta e trata
    python3 scripts/coletar_educacao.py --so-tratar       # só trata os brutos mais recentes
    python3 scripts/coletar_educacao.py --rebaixar        # baixa de novo as sinopses já guardadas
    python3 scripts/resumir.py --tema educacao            # resumo por mandato (script compartilhado)
    python3 scripts/coletar_educacao.py --ajustar-resumo  # "herdou" das séries por edição

Grava:
    dados/brutos/<fonte>/...                   arquivo original, com linha no manifesto da pasta
    dados/tratados/educacao.csv                indicador,data,valor,unidade,fonte
    dados/tratados/educacao_pares_paises.csv   valor de cada país nas comparações com pares
    seção "Extração" das fichas em fontes/fichas/educacao/

O tema é coletado e tratado só por este script; `scripts/tratar.py` não conhece estas fontes
(com --tema educacao, ele remete a este script).

Ordem: este script, depois `resumir.py --tema educacao`, depois `--ajustar-resumo`. O
resumir.py usa como "herdou" o ano anterior à posse; nas séries por edição (IDEB e Saeb,
bienais; PISA, trienal) esse ano em geral não tem dado. O ajuste troca "herdou" pela última
edição antes da posse (pedido do pré-registro) e não marca como parcial o mandato cuja última
edição caiu antes do último ano.

Só biblioteca padrão (decisão D-004). Detalhes de fontes e decisões nas fichas e em
dados/tratados/educacao_notas.md. Pontos técnicos, testados em 5/10/2026:

- download.inep.gov.br não envia o certificado intermediário (RNP ICPEdu GR46 OV TLS CA
  2025), e o Python recusa a conexão. O script baixa esse intermediário do endereço que o
  próprio certificado indica (AIA, GlobalSign) e o acrescenta às autoridades do sistema; a
  verificação continua ativa até a raiz.
- As sinopses do Censo Escolar (2007 em diante) e do Censo da Educação Superior (2009 em
  diante) vêm em zip de 110 a 400 MB, com a mesma planilha em .xlsx e .ods e anexos. O
  script lê o diretório do zip por requisições parciais (HTTP Range) e baixa só o membro
  .xlsx principal, que é gravado intocado; o CRC32 do membro é conferido com o diretório
  do zip. No manifesto, a URL leva o nome do membro depois de `#`.
- Sinopses de anos fechados não mudam: se já houver bruto de um ano, o script o reaproveita
  (use --rebaixar para baixar de novo).
- A API do Banco Mundial recusa o User-Agent padrão do urllib (bloqueio do WAF); com o
  User-Agent do projeto (comum.USER_AGENT), uma única consulta com os 27 países funciona. A
  resposta pode vir com BOM (lida como utf-8-sig).
- O EdStats do Banco Mundial (indicadores LO.PISA.*) vai só até o PISA 2018. O PISA vem das
  planilhas da própria OCDE (StatLinks dos anexos B1 dos relatórios de 2025 e de 2018).
"""

import argparse
import csv
import datetime as dt
import io
import json
import re
import ssl
import statistics
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
import zlib
import xml.etree.ElementTree as ET
from collections import defaultdict

from comum import (HOJE, METODOLOGIA, RAIZ, TRATADOS, USER_AGENT, brutos_mais_recentes,
                   escrever_csv, ler_csv, ler_fichas, ler_manifesto, salvar_bruto)
from coletar import coletar_sidra, nome_sidra

TEMA = "educacao"
ANO_CORRENTE = HOJE.year


# ================================================================ rede

AIA_INEP = "http://secure.globalsign.com/cacert/rnpicpedugr46ovtlsca2025.crt"
_CONTEXTOS = {}


def contexto_ssl(url):
    """Contexto TLS para hosts que não enviam o certificado intermediário (ver docstring)."""
    host = urllib.parse.urlsplit(url).hostname or ""
    if host != "download.inep.gov.br":
        return None
    if "inep" not in _CONTEXTOS:
        der = pedir(AIA_INEP)
        ctx = ssl.create_default_context()
        ctx.load_verify_locations(cadata=ssl.DER_cert_to_PEM_cert(der))
        _CONTEXTOS["inep"] = ctx
    return _CONTEXTOS["inep"]


def pedir(url, cabecalhos=None, tentativas=5, timeout=300, devolver_cabecalhos=False, metodo="GET"):
    """GET (ou HEAD) com repetição em erro temporário. Devolve os bytes (e os cabeçalhos)."""
    erro = None
    for i in range(tentativas):
        req = urllib.request.Request(url, method=metodo,
                                     headers={"User-Agent": USER_AGENT, **(cabecalhos or {})})
        try:
            with urllib.request.urlopen(req, timeout=timeout, context=contexto_ssl(url)) as r:
                corpo = r.read()
                return (corpo, r.headers) if devolver_cabecalhos else corpo
        except urllib.error.HTTPError as e:
            erro = e
            if e.code not in (429, 500, 502, 503, 504):
                raise
        except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
            erro = e
        espera = min(10 * (i + 1), 60)
        print(f"    {erro}; nova tentativa em {espera} s", file=sys.stderr)
        time.sleep(espera)
    raise RuntimeError(f"falha ao baixar {url}: {erro}")


def faixa(url, ini, fim):
    corpo = pedir(url, {"Range": f"bytes={ini}-{fim}"})
    if len(corpo) != fim - ini + 1:
        raise RuntimeError(f"{url}: o servidor não respeitou o pedido parcial ({len(corpo)} bytes)")
    return corpo


def diretorio_zip_remoto(url):
    """Lê o diretório central de um zip remoto por HTTP Range. Devolve a lista de membros."""
    _, cab = pedir(url, devolver_cabecalhos=True, metodo="HEAD")
    total = int(cab["Content-Length"])
    cauda = faixa(url, max(0, total - 65557), total - 1)
    i = cauda.rfind(b"PK\x05\x06")
    _, _, _, _, n_membros, cd_tam, cd_ini, _ = struct.unpack("<IHHHHIIH", cauda[i:i + 22])
    if cd_ini == 0xFFFFFFFF or n_membros == 0xFFFF:  # zip64
        j = cauda.rfind(b"PK\x06\x06")
        n_membros, cd_tam, cd_ini = struct.unpack("<QQQ", cauda[j + 32:j + 56])
    cd = faixa(url, cd_ini, cd_ini + cd_tam - 1)
    membros, p = [], 0
    while cd[p:p + 4] == b"PK\x01\x02":
        (_, _, _, flag, metodo, _, _, crc, comp, orig, ln, le, lc, _, _, _, ini) = \
            struct.unpack("<IHHHHHHIIIHHHHHII", cd[p:p + 46])
        nome = cd[p + 46:p + 46 + ln]
        extra = cd[p + 46 + ln:p + 46 + ln + le]
        q = 0
        while q + 4 <= len(extra):  # campos zip64 de tamanho e posição
            hid, hsz = struct.unpack("<HH", extra[q:q + 4])
            if hid == 1:
                k, dados = 0, extra[q + 4:q + 4 + hsz]
                if orig == 0xFFFFFFFF:
                    orig = struct.unpack("<Q", dados[k:k + 8])[0]; k += 8
                if comp == 0xFFFFFFFF:
                    comp = struct.unpack("<Q", dados[k:k + 8])[0]; k += 8
                if ini == 0xFFFFFFFF:
                    ini = struct.unpack("<Q", dados[k:k + 8])[0]
            q += 4 + hsz
        nome = nome.decode("utf-8" if flag & 0x800 else "cp437", "replace")
        membros.append({"nome": nome, "metodo": metodo, "crc": crc, "comp": comp,
                        "orig": orig, "ini": ini})
        p += 46 + ln + le + lc
    return membros


def membro_zip_remoto(url, membro):
    """Baixa só um membro de um zip remoto e confere o CRC32 com o diretório do zip."""
    cab = faixa(url, membro["ini"], membro["ini"] + 29)
    ln, le = struct.unpack("<HH", cab[26:30])
    ini = membro["ini"] + 30 + ln + le
    dados = faixa(url, ini, ini + membro["comp"] - 1)
    if membro["metodo"] == 8:
        dados = zlib.decompress(dados, -15)
    elif membro["metodo"] != 0:
        raise RuntimeError(f"método de compressão {membro['metodo']} não suportado")
    if zlib.crc32(dados) & 0xFFFFFFFF != membro["crc"] or len(dados) != membro["orig"]:
        raise RuntimeError(f"{url}#{membro['nome']}: CRC ou tamanho não confere")
    return dados


# ================================================================ leitores de planilha

M = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
P_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"


def _coluna(ref):
    n = 0
    for ch in ref:
        if not ch.isalpha():
            break
        n = n * 26 + (ord(ch.upper()) - 64)
    return n - 1


class Xlsx:
    """Leitor mínimo de .xlsx (zipfile + xml.etree): sharedStrings e planilhas."""

    def __init__(self, fonte):
        self.z = zipfile.ZipFile(io.BytesIO(fonte) if isinstance(fonte, bytes) else fonte)
        wb = ET.fromstring(self.z.read("xl/workbook.xml"))
        rels = ET.fromstring(self.z.read("xl/_rels/workbook.xml.rels"))
        alvo = {r.get("Id"): r.get("Target") for r in rels.iter(P_NS + "Relationship")}
        self.planilhas = {}
        for sh in wb.iter(M + "sheet"):
            t = alvo[sh.get(R_NS + "id")]
            self.planilhas[sh.get("name")] = t.lstrip("/") if t.startswith("/") else "xl/" + t
        self._textos = None

    def textos(self):
        if self._textos is None:
            self._textos = []
            if "xl/sharedStrings.xml" in self.z.namelist():
                with self.z.open("xl/sharedStrings.xml") as f:
                    for _, el in ET.iterparse(f):
                        if el.tag == M + "si":
                            self._textos.append("".join(t.text or "" for t in el.iter(M + "t")))
                            el.clear()
        return self._textos

    def linhas(self, nome, max_linhas=None):
        """Lista de linhas (listas de valores; índice 0 = linha 1), como texto ou None."""
        textos = self.textos()
        out = []
        with self.z.open(self.planilhas[nome]) as f:
            for _, el in ET.iterparse(f):
                if el.tag != M + "row":
                    continue
                r = int(el.get("r")) if el.get("r") else len(out) + 1  # "r" é opcional no OOXML
                vals, col = {}, -1
                for c in el.iter(M + "c"):
                    t, v = c.get("t"), c.find(M + "v")
                    if t == "s" and v is not None:
                        val = textos[int(v.text)]
                    elif t == "inlineStr":
                        val = "".join(x.text or "" for x in c.iter(M + "t"))
                    else:
                        val = v.text if v is not None else None
                    col = _coluna(c.get("r")) if c.get("r") else col + 1
                    vals[col] = val
                linha = [None] * (max(vals) + 1) if vals else []
                for k, x in vals.items():
                    linha[k] = x
                while len(out) < r - 1:
                    out.append([])
                out.append(linha)
                el.clear()
                if max_linhas and r >= max_linhas:
                    break
        return out


def _cfb_fluxo(dados, nomes=("Workbook", "Book")):
    """Fluxo da pasta de trabalho dentro do contêiner OLE2/CFB de um .xls."""
    if dados[:8] != bytes.fromhex("d0cf11e0a1b11ae1"):
        raise ValueError("não é um arquivo OLE2 (.xls)")
    tam_setor = 1 << struct.unpack_from("<H", dados, 30)[0]
    tam_mini = 1 << struct.unpack_from("<H", dados, 32)[0]
    n_fat, dir_ini = struct.unpack_from("<II", dados, 44)
    corte, minifat_ini, _, difat_ini, n_difat = struct.unpack_from("<IIIII", dados, 56)

    def setor(i):
        return dados[512 + i * tam_setor:512 + (i + 1) * tam_setor]

    difat = list(struct.unpack_from("<109I", dados, 76))
    s = difat_ini
    for _ in range(n_difat):
        vals = struct.unpack(f"<{tam_setor // 4}I", setor(s))
        difat += vals[:-1]
        s = vals[-1]
    fat = []
    for i in difat[:n_fat]:
        fat += struct.unpack(f"<{tam_setor // 4}I", setor(i))

    def cadeia(ini):
        out, s, vistos = [], ini, set()
        while s < 0xFFFFFFFA and s not in vistos:
            vistos.add(s)
            out.append(s)
            s = fat[s]
        return out

    def ler(ini, tam=None):
        b = b"".join(setor(i) for i in cadeia(ini))
        return b if tam is None else b[:tam]

    diretorio = ler(dir_ini)
    entradas = []
    for k in range(len(diretorio) // 128):
        e = diretorio[k * 128:(k + 1) * 128]
        ln = struct.unpack_from("<H", e, 64)[0]
        ini, tam = struct.unpack_from("<II", e, 116)
        entradas.append((e[:max(ln - 2, 0)].decode("utf-16-le", "replace"), e[66], ini, tam))
    raiz = entradas[0]
    for nome, tipo, ini, tam in entradas:
        if tipo != 2 or nome not in nomes:
            continue
        if tam >= corte:
            return ler(ini, tam)
        mini = ler(raiz[2], raiz[3])
        minifat = []
        for i in cadeia(minifat_ini):
            minifat += struct.unpack(f"<{tam_setor // 4}I", setor(i))
        partes, s = [], ini
        while s < 0xFFFFFFFA:
            partes.append(mini[s * tam_mini:(s + 1) * tam_mini])
            s = minifat[s]
        return b"".join(partes)[:tam]
    raise ValueError("fluxo Workbook/Book não encontrado")


def _rk(v):
    if v & 2:
        x = v >> 2
        if x & (1 << 29):
            x -= 1 << 30
        return x / 100 if v & 1 else float(x)
    x = struct.unpack("<d", struct.pack("<Q", (v & 0xFFFFFFFC) << 32))[0]
    return x / 100 if v & 1 else x


def _ler_sst(partes):
    """Tabela de textos compartilhados do BIFF8, com a quebra em registros CONTINUE."""
    n_unicos = struct.unpack_from("<I", partes[0], 4)[0]
    estado = {"i": 0, "p": 8}

    def buf():
        return partes[estado["i"]]

    def proximo():
        estado["i"] += 1
        estado["p"] = 0

    def ler_bytes(n):
        out = b""
        while n > 0:
            if estado["p"] >= len(buf()):
                proximo()
            k = min(n, len(buf()) - estado["p"])
            out += buf()[estado["p"]:estado["p"] + k]
            estado["p"] += k
            n -= k
        return out

    textos = []
    for _ in range(n_unicos):
        if estado["p"] >= len(buf()):
            proximo()
        cch = struct.unpack("<H", ler_bytes(2))[0]
        flag = ler_bytes(1)[0]
        n_runs = struct.unpack("<H", ler_bytes(2))[0] if flag & 8 else 0
        n_ext = struct.unpack("<i", ler_bytes(4))[0] if flag & 4 else 0
        pedacos, falta, largo = [], cch, flag & 1
        while falta > 0:
            if estado["p"] >= len(buf()):  # caracteres continuam no CONTINUE, com novo byte de opção
                proximo()
                largo = buf()[0] & 1
                estado["p"] = 1
            k = min(falta, (len(buf()) - estado["p"]) // (2 if largo else 1))
            seg = buf()[estado["p"]:estado["p"] + k * (2 if largo else 1)]
            pedacos.append(seg.decode("utf-16-le") if largo else seg.decode("latin-1"))
            estado["p"] += len(seg)
            falta -= k
        textos.append("".join(pedacos))
        if n_runs:
            ler_bytes(4 * n_runs)
        if n_ext:
            ler_bytes(n_ext)
    return textos


def ler_xls(dados):
    """Leitor mínimo de .xls (BIFF5 e BIFF8). Devolve {planilha: [[valores], ...]}."""
    wb = _cfb_fluxo(dados)
    regs, p = [], 0
    while p + 4 <= len(wb):
        t, n = struct.unpack_from("<HH", wb, p)
        regs.append((t, p, wb[p + 4:p + 4 + n]))
        p += 4 + n
    biff8 = struct.unpack_from("<H", regs[0][2], 0)[0] == 0x0600
    codificacao, sst, folhas = "cp1252", [], []
    i = 0
    while i < len(regs):
        t, _, d = regs[i]
        if t == 0x0042:  # CODEPAGE
            cp = struct.unpack_from("<H", d, 0)[0]
            codificacao = {1200: "utf-16-le", 10000: "mac_roman", 0x8000: "mac_roman"}.get(cp, f"cp{cp}")
        elif t == 0x0085:  # BOUNDSHEET
            ini, cch = struct.unpack_from("<I", d, 0)[0], d[6]
            if biff8:
                nome = d[8:8 + cch * 2].decode("utf-16-le") if d[7] & 1 else d[8:8 + cch].decode("latin-1")
            else:
                nome = d[7:7 + cch].decode(codificacao, "replace")
            folhas.append((nome, ini))
        elif t == 0x00FC:  # SST e seus CONTINUE
            partes, j = [d], i + 1
            while j < len(regs) and regs[j][0] == 0x003C:
                partes.append(regs[j][2])
                j += 1
            sst = _ler_sst(partes)
            i = j - 1
        i += 1

    def texto(d, ini):
        cch = struct.unpack_from("<H", d, ini)[0]
        ini += 2
        if biff8:
            largo = d[ini] & 1
            ini += 1
            return d[ini:ini + cch * 2].decode("utf-16-le") if largo else d[ini:ini + cch].decode("latin-1")
        return d[ini:ini + cch].decode(codificacao, "replace")

    indice = {pos: k for k, (_, pos, _) in enumerate(regs)}
    planilhas = {}
    for nome, ini in folhas:
        celulas, k = {}, indice[ini] + 1
        while k < len(regs):
            t, _, d = regs[k]
            if t == 0x000A:  # EOF
                break
            if t == 0x00FD:  # LABELSST
                r, c, _, isst = struct.unpack_from("<HHHI", d, 0)
                celulas[(r, c)] = sst[isst]
            elif t == 0x0203:  # NUMBER
                r, c, _ = struct.unpack_from("<HHH", d, 0)
                celulas[(r, c)] = struct.unpack_from("<d", d, 6)[0]
            elif t == 0x027E:  # RK
                r, c, _, v = struct.unpack_from("<HHHI", d, 0)
                celulas[(r, c)] = _rk(v)
            elif t == 0x00BD:  # MULRK
                r, c1 = struct.unpack_from("<HH", d, 0)
                c2 = struct.unpack_from("<H", d, len(d) - 2)[0]
                for j, c in enumerate(range(c1, c2 + 1)):
                    celulas[(r, c)] = _rk(struct.unpack_from("<I", d, 4 + j * 6 + 2)[0])
            elif t in (0x0204, 0x00D6):  # LABEL, RSTRING
                r, c, _ = struct.unpack_from("<HHH", d, 0)
                celulas[(r, c)] = texto(d, 6)
            elif t == 0x0006:  # FORMULA (resultado guardado)
                r, c, _ = struct.unpack_from("<HHH", d, 0)
                res = d[6:14]
                if res[6:8] != b"\xff\xff":
                    celulas[(r, c)] = struct.unpack("<d", res)[0]
                elif res[0] == 0 and k + 1 < len(regs) and regs[k + 1][0] == 0x0207:
                    celulas[(r, c)] = texto(regs[k + 1][2], 0)
            k += 1
        n = max((r for r, _ in celulas), default=-1) + 1
        linhas = [[] for _ in range(n)]
        for (r, c), v in sorted(celulas.items()):
            while len(linhas[r]) <= c:
                linhas[r].append(None)
            linhas[r][c] = v
        planilhas[nome] = linhas
    return planilhas


# ================================================================ brutos

def bruto_existente(fonte, nome, so_hoje=False):
    arquivos = brutos_mais_recentes(fonte, nome)
    if not arquivos:
        return None
    if so_hoje and HOJE.isoformat() not in arquivos[-1].name:
        return None
    return arquivos[-1]


_ULTIMO_DOWNLOAD = [0.0]


def intervalo(segundos):
    """Espera o que faltar para `segundos` desde o último download (pausa entre requisições)."""
    falta = _ULTIMO_DOWNLOAD[0] + segundos - time.time()
    if falta > 0:
        time.sleep(falta)


def obter(fonte, nome, ext, url, baixar_fn=None, reaproveitar=False, consulta=None):
    """Baixa e grava um bruto (comum.salvar_bruto). Reaproveita o bruto do dia, ou qualquer
    bruto já guardado se `reaproveitar` (publicações de anos fechados)."""
    existente = bruto_existente(fonte, nome, so_hoje=not reaproveitar)
    if existente:
        print(f"  {fonte}/{existente.name}: reaproveitado")
        return existente
    conteudo = baixar_fn() if baixar_fn else pedir(url)
    _ULTIMO_DOWNLOAD[0] = time.time()
    caminho = salvar_bruto(fonte, nome, conteudo, consulta or url, ext)
    print(f"  {fonte}/{caminho.name}: {len(conteudo):,} bytes")
    return caminho


def obter_membro(fonte, nome, url, padrao, reaproveitar=True):
    """Bruto = o membro .xlsx de um zip remoto cujo nome casa com `padrao` (regex)."""
    existente = bruto_existente(fonte, nome, so_hoje=not reaproveitar)
    if existente:
        print(f"  {fonte}/{existente.name}: reaproveitado")
        return existente
    membros = [m for m in diretorio_zip_remoto(url) if re.search(padrao, m["nome"])]
    if len(membros) != 1:
        raise RuntimeError(f"{url}: {len(membros)} membros casam com {padrao!r}")
    m = membros[0]
    conteudo = membro_zip_remoto(url, m)
    _ULTIMO_DOWNLOAD[0] = time.time()
    caminho = salvar_bruto(fonte, nome, conteudo, f"{url}#{m['nome']}", m["nome"].rsplit(".", 1)[-1])
    print(f"  {fonte}/{caminho.name}: {len(conteudo):,} bytes (membro {m['nome']})")
    return caminho


def bruto(fonte, nome):
    arquivos = brutos_mais_recentes(fonte, nome)
    if not arquivos:
        raise FileNotFoundError(f"sem bruto {fonte}/{nome}; rode sem --so-tratar")
    return arquivos[-1]


def num(x):
    """Texto ou número de planilha -> float; '-', '..', vazio -> None."""
    if x is None:
        return None
    if isinstance(x, (int, float)):
        return float(x)
    s = str(x).strip().replace("\xa0", "")
    if s in ("", "-", "--", "...", "..", "x", "X", "*"):
        return None
    try:
        return float(s)
    except ValueError:
        return None


def limpo(x):
    return re.sub(r"\s+", " ", str(x or "")).strip()


def cabecalho_hierarquico(linhas_cab, n_colunas):
    """Rótulos de colunas em várias linhas de cabeçalho com células mescladas: cada nível
    herda o último rótulo à esquerda enquanto o nível de cima for o mesmo."""
    caminhos = [[] for _ in range(n_colunas)]
    for linha in linhas_cab:
        ultimo, pai_ultimo = None, None
        for c in range(n_colunas):
            v = limpo(linha[c]) if c < len(linha) else ""
            pai = tuple(caminhos[c])
            if v:
                ultimo, pai_ultimo = v, pai
                caminhos[c].append(v)
            elif ultimo is not None and pai == pai_ultimo:
                caminhos[c].append(ultimo)
    return [tuple(c) for c in caminhos]


# ================================================================ saída

class Saida:
    def __init__(self):
        self.linhas = []
        self.paises = []
        self.extracao = defaultdict(list)  # id da ficha -> [(série, arquivos, dados)]

    def add(self, ficha_id, serie_id, dados, unidade, fonte, arquivos=(), casas=4):
        for data, valor in sorted(dados.items()):
            v = round(valor, casas)
            self.linhas.append([serie_id, data, int(v) if casas == 0 else v, unidade, fonte])
        self.extracao[ficha_id].append((serie_id, sorted(set(arquivos)), dados))


def atualizar_extracao(ficha, itens):
    """Reescreve o bloco entre os marcadores de extração da ficha (mesmo formato de
    tratar.atualizar_extracao)."""
    manifestos = {}
    linhas = ["| Série | Arquivo bruto | Extraído em | Observações | Primeira | Última |",
              "|---|---|---|---|---|---|"]
    for serie_id, arquivos, dados in itens:
        nomes, datas = [], []
        for arq in arquivos:
            fonte = arq.parent.name
            manifestos.setdefault(fonte, ler_manifesto(fonte))
            nomes.append(f"`{arq.relative_to(RAIZ)}`")
            datas.append(manifestos[fonte].get(arq.name, {}).get("data_extracao", "?")[:16].replace("T", " "))
        datas_txt = ", ".join(sorted(set(datas))) or "(derivada)"
        if len(set(datas)) > 1:
            datas_txt = f"{min(datas)} a {max(datas)}"
        nomes_txt = "<br>".join(nomes) or "(derivada)"
        if dados:
            linhas.append(f"| `{serie_id}` | {nomes_txt} | {datas_txt} | {len(dados)} | {min(dados)} | {max(dados)} |")
        else:
            linhas.append(f"| `{serie_id}` | {nomes_txt} | {datas_txt} | 0 | | |")
    bloco = "\n".join(linhas)
    caminho = ficha["_arquivo"]
    texto = caminho.read_text(encoding="utf-8")
    novo = re.sub(r"(<!-- extracao:inicio -->\n).*?(\n<!-- extracao:fim -->)",
                  lambda m: m.group(1) + bloco + m.group(2), texto, flags=re.S)
    caminho.write_text(novo, encoding="utf-8")


# ================================================================ INEP: IDEB e Saeb

FONTE_IDEB = "inep_ideb"
EDICAO_IDEB = 2025  # divulgada em 2026; a próxima (2027) sai no segundo semestre de 2028
URL_IDEB = f"https://download.inep.gov.br/ideb/resultados/divulgacao_brasil_ideb_{EDICAO_IDEB}.zip"
PLANILHAS_IDEB = {  # planilha -> (etapa no id do IDEB, etapa no id do Saeb)
    "Brasil (Anos Iniciais)": ("anos_iniciais", "5ano_ef"),
    "Brasil (Anos Finais)": ("anos_finais", "9ano_ef"),
    "Brasil (EM)": ("ensino_medio", "3serie_em"),
}
REDES_IDEB = {"Total": "total", "Pública": "publica"}


def coletar_ideb(args):
    return obter(FONTE_IDEB, f"ideb_brasil_{EDICAO_IDEB}", "zip", URL_IDEB, reaproveitar=not args.rebaixar)


def tratar_ideb_saeb(saida):
    arq = bruto(FONTE_IDEB, f"ideb_brasil_{EDICAO_IDEB}")
    z = zipfile.ZipFile(arq)
    (membro,) = [n for n in z.namelist() if n.endswith(".xlsx")]
    livro = Xlsx(z.read(membro))
    ideb, saeb = defaultdict(dict), defaultdict(dict)
    for planilha, (etapa, etapa_saeb) in PLANILHAS_IDEB.items():
        linhas = livro.linhas(planilha)
        cab, sub = linhas[6], linhas[7]
        col_ideb, col_saeb = {}, {}
        for c, v in enumerate(cab):
            v = limpo(v)
            if m := re.match(r"IDEB (\d{4})", v):  # "IDEB 20212 (N x P)": 2021 + nota 2
                col_ideb[m.group(1)] = c
            elif m := re.match(r"Nota SAEB - (\d{4})", v):
                if (limpo(sub[c]), limpo(sub[c + 1])) != ("Matemática", "Língua Portuguesa"):
                    raise RuntimeError(f"{planilha}: colunas do Saeb fora da ordem esperada")
                col_saeb[m.group(1)] = c
        if not col_ideb or not col_saeb:
            raise RuntimeError(f"{planilha}: cabeçalho do IDEB não reconhecido")
        for linha in linhas[10:]:
            if not linha or limpo(linha[0]) != "Brasil" or limpo(linha[1]) not in REDES_IDEB:
                continue
            rede = REDES_IDEB[limpo(linha[1])]
            for ano, c in col_ideb.items():
                if (v := num(linha[c])) is not None:
                    ideb[f"ideb__{etapa}_{rede}"][ano] = v
            for ano, c in col_saeb.items():
                for k, disciplina in ((0, "matematica"), (1, "lingua_portuguesa")):
                    if (v := num(linha[c + k])) is not None:
                        saeb[f"saeb__{etapa_saeb}_{disciplina}_{rede}"][ano] = v
    fonte = f"INEP, Ideb {EDICAO_IDEB} (divulgacao_brasil_ideb_{EDICAO_IDEB}.xlsx)"
    for serie, dados in sorted(ideb.items()):
        saida.add("ideb", serie, dados, unidade="índice (0 a 10)", fonte=fonte, arquivos=[arq], casas=1)
    for serie, dados in sorted(saeb.items()):
        saida.add("saeb", serie, dados, unidade="pontos (escala Saeb)",
                  fonte=fonte + ", notas do Saeb usadas no Ideb", arquivos=[arq], casas=2)
    return ideb, saeb


# ================================================================ INEP: sinopses (matrículas)

D = "https://download.inep.gov.br/"
FONTE_ESCOLAR = "inep_censo_escolar"
FONTE_SUPERIOR = "inep_censo_superior"
_B = D + "informacoes_estatisticas/sinopses_estatisticas/sinopses_educacao_basica/"
_A = D + "dados_abertos/sinopses_estatisticas/"
SINOPSES_BASICA = {
    2002: D + "download/censo/2002/sinopse/sinopse_2002.zip",
    2005: D + "download/censo/2005/Censo2005.zip",
    2006: D + "download/censo/2006/resultados_censo_escolar2006.zip",
    **{a: _B + f"sinopse_estatistica_da_educacao_basica_{a}.zip" for a in range(2007, 2019)},
    2019: _B + "sinopse_estatistica_educacao_basica_2019.zip",
    **{a: _A + f"sinopses_estatisticas_censo_escolar_{a}.zip" for a in (2020, 2021, 2022)},
    **{a: _A + f"sinopse_estatistica_censo_escolar_{a}.zip" for a in range(2023, ANO_CORRENTE + 1)},
}
# 2003 e 2004: a sinopse só existe em PDF, com as tabelas como imagem (2003) ou desenho vetorial
# (2004), sem texto legível por máquina. Os totais impressos na apresentação estão nas notas.
_S = D + "informacoes_estatisticas/sinopses_estatisticas/sinopses_educacao_superior/"
SINOPSES_SUPERIOR = {
    2002: D + "download/censo/2002/sinopse/Sinopse_2002_censosuperior.zip",
    2003: D + "download/superior/2004/SES2003.zip",
    2004: D + "download/superior/2004/Sinopse_2004_010206.zip",
    2005: D + "download/superior/2005/Sinopse_2005a.zip",
    2006: D + "download/superior/censo/2006/Sinopse_EducacaoSuperior_2006.zip",
    2007: D + "download/superior/censo/2007/Sinopse2007.zip",
    2008: D + "download/censo/2008/sinop_sup_2008_versao_preliminar.zip",
    **{a: _S + f"sinopse_educacao_superior_{a}.zip" for a in range(2009, ANO_CORRENTE)},
}
ANO_XLSX_BASICA, ANO_XLSX_SUPERIOR = 2007, 2009  # antes disso, zip pequeno com .xls (baixado inteiro)


def publicado(url):
    try:
        pedir(url, metodo="HEAD", tentativas=2)
        return True
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return False
        raise


def coletar_sinopses(args):
    for fonte, tabela, corte, padrao in (
            (FONTE_ESCOLAR, SINOPSES_BASICA, ANO_XLSX_BASICA, r"(?:^|/)(?!~\$)[^/]+\.xlsx$"),  # sem "~$" (2018)
            (FONTE_SUPERIOR, SINOPSES_SUPERIOR, ANO_XLSX_SUPERIOR, r"Municipio_{ano}\.xlsx$")):
        prefixo = "sinopse_basica" if fonte == FONTE_ESCOLAR else "sinopse_superior"
        for ano, url in sorted(tabela.items()):
            nome = f"{prefixo}_{ano}"
            if ano >= ANO_CORRENTE - 1 and not bruto_existente(fonte, nome) and not publicado(url):
                print(f"  {fonte}/{nome}: ainda não publicado ({url})")
                continue
            if ano < corte:
                obter(fonte, nome, "zip", url, reaproveitar=not args.rebaixar)
            else:
                obter_membro(fonte, nome, url, padrao.format(ano=ano), reaproveitar=not args.rebaixar)


def _planilha_por_titulo(livro_xls, padrao):
    achadas = []
    for nome, linhas in livro_xls.items():
        titulo = " ".join(limpo(x) for linha in linhas[:6] for x in linha if isinstance(x, str))
        if re.search(padrao, titulo):
            achadas.append(nome)
    if len(achadas) != 1:
        raise RuntimeError(f"{len(achadas)} planilhas com título {padrao!r}: {achadas}")
    return livro_xls[achadas[0]]


def _linha_brasil(linhas, rotulo=r"^Brasil$", col=0):
    for linha in linhas:
        if linha and len(linha) > col and isinstance(linha[col], str) and re.match(rotulo, limpo(linha[col])):
            return linha
    raise RuntimeError(f"linha {rotulo!r} não encontrada")


def _confere_dependencias(tab, linha_brasil):
    """As tabelas antigas trazem Total, Federal, Estadual, Municipal e Privada nas colunas 1 a 5."""
    i = next(k for k, l in enumerate(tab) if l is linha_brasil)
    for l in tab[max(0, i - 4):i]:
        rotulos = [limpo(x) for x in l[2:6]] if len(l) >= 6 else []
        if rotulos[:3] == ["Federal", "Estadual", "Municipal"] and rotulos[3].startswith("Privad"):
            return
    raise RuntimeError("colunas de dependência administrativa fora da ordem esperada")


def _xls_do_zip(arq):
    z = zipfile.ZipFile(arq)
    (membro,) = [n for n in z.namelist() if n.lower().endswith(".xls")]
    return ler_xls(z.read(membro))


DEPENDENCIAS = ("federal", "estadual", "municipal", "privada")


def matriculas_basica_ate_2006(ano, arq):
    """Matrículas da educação básica por dependência, na composição de 2005–2006 (todas as
    modalidades, inclusive EJA semipresencial e educação profissional técnica)."""
    livro = _xls_do_zip(arq)
    if ano == 2002:
        tab = _planilha_por_titulo(livro, r"^1 - MATR.*1\.1 - Número de Matrículas em Todas as Modalidade")
        b = _linha_brasil(tab)
        _confere_dependencias(tab, b)
        base = dict(zip(("total",) + DEPENDENCIAS, (num(x) for x in b[1:6])))
        # A nota (1) da tabela 1.1 de 2002 lista os componentes do total: sem EJA semipresencial
        # e sem educação profissional, que entram aqui pelas tabelas 1.86 e 1.89 da mesma sinopse.
        t_semi = _planilha_por_titulo(livro, r"1\.86 - Número de Matrículas em Cursos Semipresenciais")
        t_prof = _planilha_por_titulo(livro, r"1\.89 - Número de Matrículas na Educação Profissional, por Dependência")
        semi, prof = _linha_brasil(t_semi), _linha_brasil(t_prof)
        _confere_dependencias(t_semi, semi)
        _confere_dependencias(t_prof, prof)
        partes = {}
        for k, chave in enumerate(("total",) + DEPENDENCIAS):
            partes[chave] = base[chave] + num(semi[1 + k]) + num(prof[1 + k])
        detalhe = {"publicado": base, "eja_semipresencial": num(semi[1]), "educacao_profissional": num(prof[1])}
    else:
        tab = _planilha_por_titulo(livro, r"1\.2 - Número de Matrículas de Educação Básica, por Dependência Administrativa")
        b = _linha_brasil(tab)
        _confere_dependencias(tab, b)
        partes = dict(zip(("total",) + DEPENDENCIAS, (num(x) for x in b[1:6])))
        detalhe = {"publicado": dict(partes)}
    if abs(sum(partes[d] for d in DEPENDENCIAS) - partes["total"]) > 0.5:
        raise RuntimeError(f"matrículas {ano}: dependências não somam o total")
    return partes, detalhe


def matriculas_basica_educacenso(ano, arq):
    """Tabela 1.2 da sinopse (2007 em diante): matrículas da educação básica por dependência."""
    livro = Xlsx(str(arq))
    nomes = list(livro.planilhas)
    alvo = None
    for nome in nomes[:40]:
        topo = livro.linhas(nome, max_linhas=6)
        titulo = " ".join(limpo(x) for linha in topo for x in linha if x)
        if re.search(r"1\.2\s*[–-]\s*Número de Matrículas da Educação Básica, por Localização.*Dependência Administrativa", titulo):
            alvo = nome
            break
    if alvo is None:
        raise RuntimeError(f"sinopse {ano}: tabela 1.2 não encontrada")
    linhas = livro.linhas(alvo, max_linhas=40)
    i_br = next(i for i, l in enumerate(linhas) if l and limpo(l[0]) == "Brasil")
    i_cab = next(i for i, l in enumerate(linhas) if l and limpo(l[0]).startswith("Região Geográfica"))
    n = max(len(l) for l in linhas[i_cab:i_br + 1])
    caminhos = cabecalho_hierarquico(linhas[i_cab:i_br], n)
    b = linhas[i_br]
    valores = {c: num(b[c]) for c in range(len(b)) if num(b[c]) is not None}
    col_total = min(valores)
    if "Total" not in " ".join(caminhos[col_total]):
        raise RuntimeError(f"sinopse {ano}: primeira coluna numérica não é o total")
    partes = {"total": valores[col_total]}
    for dep in DEPENDENCIAS:
        rotulos = {"privada": ("Privada", "Rede Privada")}.get(dep, (dep.capitalize(),))
        cols = []
        for c, cam in enumerate(caminhos):
            if c not in valores or not cam:
                continue
            folha = re.sub(r"\d+$", "", cam[-1]).strip()
            if folha in rotulos or (dep == "privada" and folha == "Total" and "Rede Privada" in cam):
                cols.append((c, any(x in ("Urbana", "Rural") for x in cam)))
        sem_local = [c for c, local in cols if not local]
        if sem_local:
            partes[dep] = valores[sem_local[0]]
        else:
            locais = [c for c, local in cols if local]
            rotulos_loc = sorted(next(x for x in caminhos[c] if x in ("Urbana", "Rural")) for c in locais)
            if rotulos_loc != ["Rural", "Urbana"]:
                raise RuntimeError(f"sinopse {ano}: colunas de {dep} não reconhecidas: {rotulos_loc}")
            partes[dep] = sum(valores[c] for c in locais)
    if abs(sum(partes[d] for d in DEPENDENCIAS) - partes["total"]) > 0.5:
        raise RuntimeError(f"sinopse {ano}: dependências não somam o total ({partes})")
    return partes, {"planilha": alvo}


def matriculas_superior_ate_2008(ano, arq):
    """Graduação presencial (tabela 5.1, matrículas em 30/06) e a distância (tabela 7.1)."""
    livro = _xls_do_zip(arq)
    tab = _planilha_por_titulo(livro, r"5\.1\s*-\s*Matrículas em Cursos de Graduação Presenciais")
    i_br = next(i for i, l in enumerate(tab) if l and limpo(l[0]) == "Brasil")
    i_cab = next(i for i, l in enumerate(tab[:i_br]) if any(limpo(x).startswith("Total") for x in l if isinstance(x, str)))
    col = next(c for c, x in enumerate(tab[i_cab]) if isinstance(x, str) and limpo(x).startswith("Total"))
    presencial = num(tab[i_br][col])
    federal = next(num(l[col]) for l in tab[i_br + 1:i_br + 8]
                   if len(l) > 2 and limpo(l[2]) == "Federal")
    ead_tab = _planilha_por_titulo(livro, r"7\.1\s*-\s*Número de .*Graduação a Distância")
    def e_rotulo(x):  # cabeçalho "Matrículas em 30/06", não o título da tabela
        return isinstance(x, str) and limpo(x).startswith("Matrículas") and len(limpo(x)) < 40
    i_cab = next(i for i, l in enumerate(ead_tab) if any(e_rotulo(x) for x in l))
    col_ead = next(c for c, x in enumerate(ead_tab[i_cab]) if e_rotulo(x))
    ead = num(_linha_brasil(ead_tab, r"^Total$")[col_ead])
    return {"total": presencial + (ead or 0), "federal_presencial": federal,
            "presencial": presencial, "ead": ead or 0.0}


def matriculas_superior_censup(ano, arq):
    """Tabela 5.1 (2009 em diante): matrículas de graduação, presenciais e a distância."""
    livro = Xlsx(str(arq))
    linhas = livro.linhas("5.1", max_linhas=30)
    titulo = " ".join(limpo(x) for l in linhas[:6] for x in l if x)
    if "Número de Matrículas nos Cursos de Graduação" not in titulo:
        raise RuntimeError(f"sinopse superior {ano}: tabela 5.1 inesperada")
    i_cab = next(i for i, l in enumerate(linhas) if l and limpo(l[0]).startswith("Região Geográfica"))
    i_br = next(i for i, l in enumerate(linhas)
                if l and limpo(l[0]) == "Total Brasil" and any(limpo(x) == "Graduação" for x in l if x))
    i_dados = next(i for i, l in enumerate(linhas) if i > i_cab and l and limpo(l[0]).startswith("Total Brasil"))
    n = max(len(l) for l in linhas[i_cab:i_br + 1])
    caminhos = cabecalho_hierarquico(linhas[i_cab:i_dados], n)
    b = linhas[i_br]
    col_total = next(c for c, cam in enumerate(caminhos) if cam and re.sub(r"\d", "", cam[-1]).strip() == "Total"
                     and len(cam) <= 2 and num(b[c]) is not None)
    col_fed = next(c for c, cam in enumerate(caminhos)
                   if cam and cam[-1] == "Federal" and "Total por Dependência Administrativa" in cam)
    return {"total": num(b[col_total]), "federal": num(b[col_fed])}


def tratar_matriculas(saida):
    basica = defaultdict(dict)
    arqs_b = defaultdict(list)
    for ano in sorted(SINOPSES_BASICA):
        arqs = brutos_mais_recentes(FONTE_ESCOLAR, f"sinopse_basica_{ano}")
        if not arqs:
            continue
        arq = arqs[-1]
        if ano < ANO_XLSX_BASICA:
            partes, _ = matriculas_basica_ate_2006(ano, arq)
            sufixo = "_ate_2006"
        else:
            partes, _ = matriculas_basica_educacenso(ano, arq)
            sufixo = ""
        partes["publica"] = partes["federal"] + partes["estadual"] + partes["municipal"]
        for rede, v in partes.items():
            basica[f"matriculas_basica__{rede}{sufixo}"][str(ano)] = v
            arqs_b[f"matriculas_basica__{rede}{sufixo}"].append(arq)
    for serie, dados in sorted(basica.items()):
        antiga = serie.endswith("_ate_2006")
        fonte = ("INEP, Sinopse Estatística da Educação Básica (Censo Escolar por formulário de escola; "
                 "composição de 2005-2006)" if antiga else
                 "INEP, Sinopse Estatística da Educação Básica, tabela 1.2 (Educacenso)")
        saida.add("matriculas_basica", serie, dados, unidade="matrículas", fonte=fonte,
                  arquivos=arqs_b[serie], casas=0)

    superior = defaultdict(dict)
    arqs_s = defaultdict(list)
    for ano in sorted(SINOPSES_SUPERIOR):
        arqs = brutos_mais_recentes(FONTE_SUPERIOR, f"sinopse_superior_{ano}")
        if not arqs:
            continue
        arq = arqs[-1]
        if ano < ANO_XLSX_SUPERIOR:
            r = matriculas_superior_ate_2008(ano, arq)
            pares = {"total_ate_2008": r["total"], "federal_presencial_ate_2008": r["federal_presencial"]}
        else:
            r = matriculas_superior_censup(ano, arq)
            pares = {"total": r["total"], "federal": r["federal"]}
        for k, v in pares.items():
            superior[f"matriculas_superior__{k}"][str(ano)] = v
            arqs_s[f"matriculas_superior__{k}"].append(arq)
    for serie, dados in sorted(superior.items()):
        fonte = ("INEP, Sinopse Estatística da Educação Superior, tabelas 5.1 e 7.1 (matrículas em 30/06)"
                 if serie.endswith("_ate_2008") else
                 "INEP, Sinopse Estatística da Educação Superior, tabela 5.1 (Censup)")
        saida.add("matriculas_superior", serie, dados, unidade="matrículas de graduação", fonte=fonte,
                  arquivos=arqs_s[serie], casas=0)
    return basica, superior


# ================================================================ IBGE: analfabetismo

SIDRA_PNADC = "/t/7113/n1/all/v/10267,10268/p/all/c2/6794/c58/2795"
SIDRA_PNAD = ("/t/271/n1/all/n3/11,12,13,14,15,16/v/121/p/all/c1/0,2/c2/0/c59/93024,1024"
              "/c58/1143,1144,1145,3299,3300,3301,3302")
SIDRA_IDS = "/t/1187/n1/all/v/2513/p/all/c2/6794"  # só para conferência
IDADES_15_MAIS = {"1143", "1144", "1145", "3299", "3300", "3301", "3302"}  # 15-19 ... 60 ou mais
UFS_NORTE_RURAL_FORA_ATE_2003 = {"11", "12", "13", "14", "15", "16"}  # RO AC AM RR PA AP


def coletar_analfabetismo(args):
    for caminho in (SIDRA_PNADC, SIDRA_PNAD, SIDRA_IDS):
        for arq in coletar_sidra(caminho):
            print(f"  ibge_sidra/{arq.name}")


def _sidra(caminho):
    arq = bruto("ibge_sidra", nome_sidra(caminho))
    linhas = json.loads(arq.read_text(encoding="utf-8"))
    cab = linhas[0]
    dims = {v: k[:-1] for k, v in cab.items() if k.endswith("N") and k.startswith("D")}
    return linhas[1:], dims, arq


def tratar_analfabetismo(saida):
    linhas, dims, arq = _sidra(SIDRA_PNADC)
    d_var, d_ano = dims["Variável"], dims["Ano"]
    pnadc = {l[d_ano + "C"]: float(l["V"]) for l in linhas
             if l[d_var + "C"] == "10267" and num(l["V"]) is not None}
    saida.add("analfabetismo", "analfabetismo__pnadc", pnadc, unidade="% das pessoas de 15 anos ou mais",
              fonte="IBGE, PNAD Contínua anual, SIDRA 7113", arquivos=[arq], casas=1)

    linhas, dims, arq = _sidra(SIDRA_PNAD)
    d = {k: dims[k] + "C" for k in ("Ano", "Situação do domicílio", "Alfabetização", "Grupo de idade")}
    nivel = "NC"
    soma = defaultdict(float)  # (ano, recorte, alfabetização) -> mil pessoas de 15 anos ou mais
    for l in linhas:
        if l[d["Grupo de idade"]] not in IDADES_15_MAIS or num(l["V"]) is None:
            continue
        ano, alf = l[d["Ano"]], l[d["Alfabetização"]]
        if l[nivel] == "1" and l[d["Situação do domicílio"]] == "0":
            soma[(ano, "brasil", alf)] += float(l["V"])
        elif l[nivel] == "3" and l[d["Situação do domicílio"]] == "2":
            soma[(ano, "norte_rural", alf)] += float(l["V"])
    anos = sorted({k[0] for k in soma})
    completo, restrito = {}, {}
    for ano in anos:
        tot, nao = soma[(ano, "brasil", "93024")], soma[(ano, "brasil", "1024")]
        tot_nr, nao_nr = soma.get((ano, "norte_rural", "93024"), 0.0), soma.get((ano, "norte_rural", "1024"), 0.0)
        if int(ano) >= 2004:
            completo[ano] = 100 * nao / tot
            restrito[ano] = 100 * (nao - nao_nr) / (tot - tot_nr)
        else:
            if tot_nr:  # antes de 2004 a PNAD não ia à área rural desses estados
                raise RuntimeError(f"PNAD {ano}: dado rural de RO/AC/AM/RR/PA/AP inesperado")
            restrito[ano] = 100 * nao / tot
    inicio = {a: v for a, v in restrito.items() if a >= "2002"}
    saida.add("analfabetismo", "analfabetismo__pnad", completo, unidade="% das pessoas de 15 anos ou mais",
              fonte="IBGE, PNAD, SIDRA 271 (cálculo: não alfabetizadas / total, 15 anos ou mais)",
              arquivos=[arq], casas=2)
    saida.add("analfabetismo", "analfabetismo__pnad_sem_norte_rural", inicio,
              unidade="% das pessoas de 15 anos ou mais",
              fonte="IBGE, PNAD, SIDRA 271 (cálculo, sem a área rural de RO, AC, AM, RR, PA e AP)",
              arquivos=[arq], casas=2)

    # conferência com a taxa de alfabetização publicada pelo IBGE (IDS, SIDRA 1187)
    linhas, dims, _ = _sidra(SIDRA_IDS)
    ids = {l[dims["Ano"] + "C"]: 100 - float(l["V"]) for l in linhas if num(l["V"]) is not None}
    difs = {a: round(completo[a] - ids[a], 2) for a in ids if a in completo}
    pior = max(abs(x) for x in difs.values())
    print(f"  conferência PNAD x IDS (SIDRA 1187): maior diferença {pior:.2f} p.p. "
          f"(IDS publica com uma casa decimal)")
    if pior > 0.06:
        raise RuntimeError(f"analfabetismo calculado difere do publicado pelo IBGE: {difs}")
    return completo, restrito, pnadc


# ================================================================ despesa federal (SIOP, SPARQL)

FONTE_SIOP = "siop_sparql"
SPARQL_SIOP = "https://www1.siop.planejamento.gov.br/sparql/"  # espelho de http://orcamento.dados.gov.br/sparql/
CONSULTA_SIOP = """PREFIX loa: <http://vocab.e.gov.br/2013/09/loa#>
SELECT ?esfera ?mod (SUM(?emp) AS ?empenhado) (SUM(?liq) AS ?liquidado) (SUM(?pag) AS ?pago)
FROM <http://orcamento.dados.gov.br/{ano}/>
WHERE {{
  ?i a loa:ItemDespesa ;
     loa:temFuncao <http://orcamento.dados.gov.br/id/{ano}/Funcao/12> ;
     loa:temEsfera ?e ; loa:temModalidadeAplicacao ?m ;
     loa:valorEmpenhado ?emp ; loa:valorLiquidado ?liq ; loa:valorPago ?pag .
  ?e loa:codigo ?esfera . ?m loa:codigo ?mod .
}}
GROUP BY ?esfera ?mod ORDER BY ?esfera ?mod"""
FONTE_SICONFI = "siconfi"
URL_DCA = "https://apidatalake.tesouro.gov.br/ords/siconfi/tt/dca?an_exercicio={ano}&no_anexo=DCA-Anexo%20I-E&id_ente=1"
ANO_INICIAL = 2002          # D-002
ANO_INTRA = 2006            # primeiro ano da modalidade 91 (intraorçamentária)
ANO_DCA = 2014              # primeiro ano da DCA da União no Siconfi
ANO_BASE_REAL = "2025"      # D-015


def url_siop(ano):
    return SPARQL_SIOP + "?" + urllib.parse.urlencode({"query": CONSULTA_SIOP.format(ano=ano), "format": "text/csv"})


def coletar_despesa(args):
    for ano in range(ANO_INICIAL, ANO_CORRENTE):  # só anos fechados
        def baixar_csv(ano=ano):
            corpo = pedir(url_siop(ano))
            if not corpo.startswith(b'"esfera","mod"'):
                raise RuntimeError(f"SPARQL {ano}: resposta inesperada {corpo[:200]!r}")
            return corpo
        intervalo(1)
        obter(FONTE_SIOP, f"despesa_funcao12_{ano}", "csv", url_siop(ano), baixar_fn=baixar_csv)
    for ano in range(ANO_DCA, ANO_CORRENTE):  # conferência do valor pago
        def baixar_dca(ano=ano):
            corpo = pedir(URL_DCA.format(ano=ano))
            if not json.loads(corpo).get("items"):
                raise RuntimeError(f"DCA {ano}: resposta sem itens")
            return corpo
        intervalo(1)
        obter(FONTE_SICONFI, f"dca_anexo_I-E_uniao_{ano}", "json", URL_DCA.format(ano=ano), baixar_fn=baixar_dca)


def ipca_medio_anual():
    """Média anual do número-índice do IPCA (série ipca_numero_indice de dados/tratados/economia.csv,
    SIDRA 1737), só anos com os 12 meses (D-015)."""
    por_ano = defaultdict(dict)
    for l in ler_csv(TRATADOS / "economia.csv"):
        if l["indicador"] == "ipca_numero_indice":
            por_ano[l["data"][:4]][l["data"][5:7]] = float(l["valor"])
    return {a: sum(m.values()) / 12 for a, m in por_ano.items() if len(m) == 12}


def tratar_despesa(saida):
    ipca = ipca_medio_anual()
    total, intra, arquivos = {}, {}, []
    for ano in range(ANO_INICIAL, ANO_CORRENTE):
        arq = bruto(FONTE_SIOP, f"despesa_funcao12_{ano}")
        arquivos.append(arq)
        t = i = 0.0
        with open(arq, encoding="utf-8") as f:
            for l in csv.DictReader(f):
                if l["esfera"] not in ("10", "20"):  # orçamentos fiscal (10) e da seguridade social (20)
                    raise RuntimeError(f"{arq.name}: esfera inesperada {l['esfera']}")
                t += float(l["pago"] or 0)
                if l["mod"] == "91":
                    i += float(l["pago"] or 0)
        if i and ano < ANO_INTRA:
            raise RuntimeError(f"{arq.name}: modalidade 91 antes de {ANO_INTRA}")
        total[str(ano)], intra[str(ano)] = t, i
    exceto = {a: total[a] - intra[a] for a in total if int(a) >= ANO_INTRA}

    # conferência: pago exceto intraorçamentárias = "Despesas Pagas" da função 12 na DCA da União
    difs = {}
    for ano in range(ANO_DCA, ANO_CORRENTE):
        arq = bruto(FONTE_SICONFI, f"dca_anexo_I-E_uniao_{ano}")
        itens = json.loads(arq.read_text(encoding="utf-8"))["items"]
        (dca,) = [it["valor"] for it in itens
                  if it["conta"].startswith("12 - Educa") and it["coluna"] == "Despesas Pagas"]
        difs[ano] = exceto[str(ano)] - dca
    pior = max(abs(x) for x in difs.values())
    print(f"  conferência pago exceto intra x DCA Anexo I-E ({ANO_DCA}-{ANO_CORRENTE - 1}): "
          f"maior diferença R$ {pior:,.2f}")
    if pior > 100:
        raise RuntimeError(f"despesa paga difere da DCA: {difs}")

    def bi(d):
        return {a: v / 1e9 for a, v in d.items()}

    def real(d):
        return {a: v / 1e9 * ipca[ANO_BASE_REAL] / ipca[a] for a, v in d.items()}

    fonte = "SIOP/SOF, Orçamento Federal em dados abertos (SPARQL), função 12, valor pago"
    deflator = f"; reais de {ANO_BASE_REAL} pelo IPCA médio do ano (SIDRA 1737)"
    u_real, u_nom = f"R$ bilhões de {ANO_BASE_REAL} (IPCA, média do ano)", "R$ bilhões correntes"
    s = "despesa_educacao_federal"
    saida.add(s, f"{s}__paga_exceto_intra_real", real(exceto), u_real,
              fonte + ", exceto intraorçamentárias" + deflator, arquivos, casas=3)
    saida.add(s, f"{s}__paga_exceto_intra_nominal", bi(exceto), u_nom,
              fonte + ", exceto intraorçamentárias", arquivos, casas=3)
    saida.add(s, f"{s}__paga_total_real", real(total), u_real,
              fonte + ", com intraorçamentárias" + deflator, arquivos, casas=3)
    saida.add(s, f"{s}__paga_total_nominal", bi(total), u_nom,
              fonte + ", com intraorçamentárias", arquivos, casas=3)
    return total, intra, exceto, difs


# ================================================================ bolsas (CAPES e CNPq)

FONTE_CAPES = "capes_geocapes"
GEOCAPES = "https://geocapes.capes.gov.br/geocapes/rest"
# Indicador 1 do GeoCapes: "Concessão de Bolsas de pós-graduação da Capes no Brasil" (programas da
# Diretoria de Programas e Bolsas no País). Valor = soma de dwf_concessao_bolsa.quantidade, o mesmo
# "total de bolsas concedidas" dos arquivos de concessão do portal de dados abertos da CAPES.
NIVEIS_PG_CAPES = {"DOUTORADO PLENO", "MESTRADO", "PÓS-DOUTORADO", "MESTRADO PROFISSIONAL",
                   "DOUTORADO PROFISSIONAL", "DOUTORADO PROFFISIONAL"}  # sic, no arquivo de 2025
# Mestrados profissionais em rede para professores da educação básica (ProEB): aparecem no indicador
# só em 2011-2013 e depois saem para outra base; ficam fora em todos os anos.
PROGRAMAS_PROEB = {"PROEB", "PROFMAT", "PROFBIO", "PROFLETRAS", "PROFARTES", "PROFHISTORIA",
                   "PROFFILO", "PROFQUI", "PROFFIS", "PROFSOCIO", "PROFEI", "PROFEPT", "PROFAGUA"}
FONTE_MCTI = "mcti_indicadores"
EDICAO_MCTI = 2024  # tabelas "Recursos Humanos - Bolsas" do MCTI, edição atualizada em 17/09/2026
URL_MCTI = ("https://www.gov.br/mcti/pt-br/acompanhe-o-mcti/indicadores/paginas/recursos-humanos/"
            "bolsa-formacao/arquivos/tab_{tab}_e_{edicao}.csv")
TABELAS_MCTI = {"04_01_00": "total de bolsas-ano do CNPq no país e no exterior",
                "04_02_00": "bolsas-ano do CNPq no país: doutorado, mestrado e pós-doutorado"}


def _foto_geocapes(fotos, ano):
    candidatas = [f for f in fotos if int(f["ano"]) == ano and f["situacao"]["descricao"] == "PUBLICADO"]
    return max(candidatas, key=lambda f: (f.get("dataUltimaAlteracao") or "", int(f["id"]))) if candidatas else None


def coletar_bolsas(args):
    url = f"{GEOCAPES}/fotosDados/?indicador=1"
    arq = obter(FONTE_CAPES, "geocapes_fotos_indicador1", "json", url,
                baixar_fn=lambda: pedir(url, {"Accept": "application/json"}))
    fotos = json.loads(arq.read_text(encoding="utf-8"))
    for ano in range(ANO_INICIAL, ANO_CORRENTE):
        foto = _foto_geocapes(fotos, ano)
        if foto is None:
            print(f"  {FONTE_CAPES}: sem retrato publicado de {ano}")
            continue
        # o número do retrato vai no nome: se a CAPES republicar o ano, o arquivo novo é baixado
        intervalo(1)
        obter(FONTE_CAPES, f"geocapes_concessao_{ano}_foto{foto['id']}", "xlsx",
              f"{GEOCAPES}/fotoDados/{foto['id']}/download", reaproveitar=not args.rebaixar)
    for tab in TABELAS_MCTI:
        obter(FONTE_MCTI, f"tab_{tab}_e_{EDICAO_MCTI}", "csv", URL_MCTI.format(tab=tab, edicao=EDICAO_MCTI))


def _tabela_mcti(tab):
    arq = bruto(FONTE_MCTI, f"tab_{tab}_e_{EDICAO_MCTI}")
    linhas = [l.split(";") for l in arq.read_bytes().decode("latin-1").splitlines()]
    titulo = next(l[0] for l in linhas if l and l[0].startswith("Brasil: CNPq"))
    if "bolsas-ano" not in titulo.lower():
        raise RuntimeError(f"{arq.name}: título inesperado: {titulo}")
    cab = next(l for l in linhas if l and l[0] == "Ano")
    dados = {}
    for l in linhas:
        if l and re.fullmatch(r"\d{4}", l[0]):
            dados[l[0]] = {cab[c]: float(l[c].replace(".", "")) for c in range(1, len(cab))
                           if cab[c] and l[c] not in ("", "-")}
    return dados, arq


def tratar_bolsas(saida):
    fotos = json.loads(bruto(FONTE_CAPES, "geocapes_fotos_indicador1").read_text(encoding="utf-8"))
    capes, arqs_capes, proeb = {}, [], {}
    for ano in range(ANO_INICIAL, ANO_CORRENTE):
        foto = _foto_geocapes(fotos, ano)
        if foto is None:
            continue
        arq = bruto(FONTE_CAPES, f"geocapes_concessao_{ano}_foto{foto['id']}")
        linhas = Xlsx(str(arq)).linhas("Sheet0")
        cab = [limpo(x) for x in linhas[0]]
        i_prog, i_tot = cab.index("Programa Fomento"), cab.index("Total Linha")
        i_ano = cab.index("Ano")
        todos = [c for c in range(cab.index("Grande Área") + 1, i_tot)  # colunas de nível de bolsa
                 if cab[c] not in ("Codigo IES", "Código IES")]
        niveis = [c for c in todos if cab[c] in NIVEIS_PG_CAPES]
        pg = excl = soma_niveis = soma_total = 0.0
        for l in linhas[1:]:
            if not l or num(l[i_ano]) is None:
                continue
            if int(num(l[i_ano])) != ano:
                raise RuntimeError(f"{arq.name}: linha de outro ano")
            soma_total += num(l[i_tot]) or 0
            soma_niveis += sum(num(l[c]) or 0 for c in todos if c < len(l))
            v = sum(num(l[c]) or 0 for c in niveis if c < len(l))
            if limpo(l[i_prog]).upper() in PROGRAMAS_PROEB:
                excl += v
            else:
                pg += v
        if abs(soma_niveis - soma_total) > 0.5:
            raise RuntimeError(f"{arq.name}: colunas de nível não somam o total ({soma_niveis} x {soma_total})")
        capes[str(ano)] = pg
        proeb[str(ano)] = excl
        arqs_capes.append(arq)
    saida.add("bolsas_pos_pesquisa", "bolsas_pos_pesquisa__capes_pos_graduacao_pais", capes,
              "bolsas concedidas no ano (estoque de cotas)",
              "CAPES, GeoCapes, indicador 1 (concessão de bolsas de pós-graduação no país): mestrado, "
              "doutorado e pós-doutorado, sem ProEB", arqs_capes, 0)
    print(f"  CAPES: ProEB excluído (mestrado profissional em rede): "
          f"{ {a: int(v) for a, v in proeb.items() if v} }")

    t41, a41 = _tabela_mcti("04_01_00")
    t42, a42 = _tabela_mcti("04_02_00")
    unidade = "bolsas-ano (mensalidades pagas no ano / 12)"
    total = {a: d["Total"] for a, d in t41.items() if a >= str(ANO_INICIAL)}
    pg = {a: d["Doutorado"] + d["Mestrado"] + d["Pós-Doutorado"] for a, d in t42.items() if a >= str(ANO_INICIAL)}
    saida.add("bolsas_pos_pesquisa", "bolsas_pos_pesquisa__cnpq_total", total, unidade,
              "CNPq, via MCTI, Tabela 4.1 (bolsas-ano no país e no exterior, todas as modalidades)", [a41], 0)
    saida.add("bolsas_pos_pesquisa", "bolsas_pos_pesquisa__cnpq_pos_graduacao_pais", pg, unidade,
              "CNPq, via MCTI, Tabela 4.2 (bolsas-ano no país: doutorado, mestrado e pós-doutorado)", [a42], 0)
    return capes, proeb, total, pg


# ================================================================ comparação com pares

def ler_grupos():
    grupos = defaultdict(list)
    with open(METODOLOGIA / "pares.csv", encoding="utf-8") as f:
        for linha in csv.DictReader(f):
            grupos[linha["grupo"]].append(linha["iso3"])
    return grupos


def add_pares(saida, ficha_id, base, por_pais, unidade, unidade_dif, fonte, arquivos, casas):
    """Mesma convenção de tratar.tratar_weo: <base>__brasil, __mediana_<grupo> (sem o Brasil),
    __brasil_menos_mediana_<grupo>; valores por país e número de países em <tema>_pares_paises.csv."""
    brasil = por_pais.get("BRA", {})
    saida.add(ficha_id, f"{base}__brasil", brasil, unidade, fonte, arquivos, casas)
    contagem = {}
    for grupo, paises in ler_grupos().items():
        anos = sorted({a for p in paises for a in por_pais.get(p, {})})
        med, n = {}, {}
        for ano in anos:
            vals = [por_pais[p][ano] for p in paises if ano in por_pais.get(p, {})]
            if vals:
                med[ano], n[ano] = statistics.median(vals), len(vals)
        saida.add(ficha_id, f"{base}__mediana_{grupo}", med, unidade, f"{fonte}, mediana de {grupo}", arquivos, casas)
        dif = {a: brasil[a] - med[a] for a in brasil if a in med}
        saida.add(ficha_id, f"{base}__brasil_menos_mediana_{grupo}", dif, unidade_dif,
                  f"{fonte}, Brasil menos mediana de {grupo}", arquivos, casas)
        for a in sorted(n):
            saida.paises.append([f"{base}__n_{grupo}", grupo, a, n[a], "países", fonte])
        contagem[grupo] = n
    for pais, obs in sorted(por_pais.items()):
        for a, v in sorted(obs.items()):
            saida.paises.append([base, pais, a, round(v, casas), unidade, fonte])
    return contagem


# ---------------------------------------------------------------- PISA (OCDE)

FONTE_PISA = "ocde_pisa"
PISA_ARQUIVOS = {  # nome do bruto -> StatLink (redireciona para o .xlsx)
    "pisa2025_vol1_anexo_b1": "https://stat.link/mrq53f",              # PISA 2025 Results, Vol. I, Annex B1
    "pisa2018_vol1_anexo_b1": "https://doi.org/10.1787/888934029090",  # PISA 2018 Results, Vol. I, Annex B1
}
DOMINIOS_PISA = {"mathematics": "matematica", "reading": "leitura", "science": "ciencias"}
NOMES_PISA = {
    "Argentina": "ARG", "Bolivia": "BOL", "Brazil": "BRA", "Chile": "CHL", "Colombia": "COL",
    "Costa Rica": "CRI", "Dominican Republic": "DOM", "Ecuador": "ECU", "El Salvador": "SLV",
    "Guatemala": "GTM", "Haiti": "HTI", "Honduras": "HND", "Mexico": "MEX", "Nicaragua": "NIC",
    "Panama": "PAN", "Paraguay": "PRY", "Peru": "PER", "Uruguay": "URY", "Venezuela": "VEN",
    "China": "CHN", "India": "IND", "Indonesia": "IDN", "Russia": "RUS", "Russian Federation": "RUS",
    "Saudi Arabia": "SAU", "South Africa": "ZAF", "Türkiye": "TUR", "Turkey": "TUR",
}
# Participações subnacionais (B-S-J-Z (China), Miranda (Venezuela), estados da Índia) não têm
# nome de país e ficam fora. PISA for Development (2017), lançado como "PISA 2018" na tabela
# de 2025 para Equador, Guatemala e Paraguai, também fica fora (outra avaliação).
PISA_D = {"ECU", "GTM", "PRY"}


def coletar_pisa(args):
    for nome, url in PISA_ARQUIVOS.items():
        obter(FONTE_PISA, nome, "xlsx", url, reaproveitar=not args.rebaixar)


def _tabelas_tendencia_pisa(arq):
    """{domínio: {iso3: {ano: média}}} das tabelas "Mean <domain> performance, AAAA through AAAA"."""
    livro = Xlsx(str(arq))
    out, notas = {}, {}
    for nome in livro.planilhas:
        topo = livro.linhas(nome, max_linhas=12)
        titulo = " ".join(limpo(x) for l in topo for x in l if isinstance(x, str))
        m = re.search(r"Mean (mathematics|reading|science) performance, (\d{4}) through (\d{4})", titulo)
        if not m:
            continue
        linhas = livro.linhas(nome)
        i_cab = next((i for i, l in enumerate(linhas)
                      if sum(bool(re.fullmatch(r"PISA \d{4}", limpo(x))) for x in l) >= 3), None)
        dominio = DOMINIOS_PISA[m.group(1)]
        if i_cab is None:  # tabela com só dois ciclos (por exemplo, 2015 through 2018): não é a de tendência
            continue
        anos = {c: limpo(x)[5:] for c, x in enumerate(linhas[i_cab]) if re.fullmatch(r"PISA \d{4}", limpo(x))}
        if dominio in out and len(anos) <= len(out[dominio]["_anos"]):
            continue
        if any(limpo(linhas[i_cab + 1][c]) != "Mean score" for c in anos):
            raise RuntimeError(f"{arq.name}/{nome}: cabeçalho inesperado")
        valores = {}
        for linha in linhas[i_cab + 2:]:
            if not linha or not isinstance(linha[0], str):
                continue
            pais = NOMES_PISA.get(re.sub(r"[*\d]+$", "", limpo(linha[0])).strip())
            if pais and pais not in valores:  # primeira ocorrência: o bloco principal
                valores[pais] = {a: num(linha[c]) for c, a in anos.items()
                                 if c < len(linha) and num(linha[c]) is not None}
        valores["_anos"] = sorted(anos.values())
        out[dominio] = valores
        notas[dominio] = " ".join(limpo(x) for l in linhas for x in l[:1] if isinstance(x, str))
    if set(out) != set(DOMINIOS_PISA.values()):
        raise RuntimeError(f"{arq.name}: tabelas de tendência incompletas ({sorted(out)})")
    for valores in out.values():
        del valores["_anos"]
    return out, notas


def tratar_pisa(saida):
    a25 = bruto(FONTE_PISA, "pisa2025_vol1_anexo_b1")
    a18 = bruto(FONTE_PISA, "pisa2018_vol1_anexo_b1")
    t25, notas25 = _tabelas_tendencia_pisa(a25)
    t18, _ = _tabelas_tendencia_pisa(a18)
    difs, contagens = [], {}
    for dominio in DOMINIOS_PISA.values():
        if "PISA for Development" not in notas25[dominio]:
            raise RuntimeError("nota sobre o PISA for Development não encontrada na tabela de 2025")
        por_pais = defaultdict(dict)
        for pais, obs in t18[dominio].items():  # base: tabela de 2018 (países fora de 2025)
            por_pais[pais].update(obs)
        for pais, obs in t25[dominio].items():  # a de 2025 prevalece (inclui revisões)
            for ano, v in obs.items():
                if ano == "2018" and pais in PISA_D:
                    continue
                if ano in t18[dominio].get(pais, {}):
                    difs.append((dominio, pais, ano, round(v - t18[dominio][pais][ano], 2)))
                por_pais[pais][ano] = v
        base = f"pisa__{dominio}"
        contagens[dominio] = add_pares(
            saida, "pisa", base, por_pais, "pontos (escala PISA)", "pontos",
            "OCDE, PISA (tabelas de tendência dos anexos B1 do PISA 2025 e do PISA 2018)", [a25, a18], 2)
    revisados = [d for d in difs if abs(d[3]) > 0.01]
    print(f"  PISA: {len(difs)} valores presentes nas duas tabelas da OCDE; {len(revisados)} diferem: {revisados}")
    return contagens, revisados


# ---------------------------------------------------------------- gasto público em educação (% do PIB)

FONTE_INEP_FIN = "inep_financeiro"
_F = D + "informacoes_estatisticas/investimentos_publicos_em_educacao/indicadores_financeiros_educacionais/"
INEP_INVESTIMENTO = {"investimento_pib_total": _F + "Investimento_pib_total.zip",
                     "investimento_pib_direto": _F + "Investimento_pib_direto.zip"}
FONTE_BM = "banco_mundial"
INDICADOR_BM = "SE.XPD.TOTL.GD.ZS"


def url_bm():
    with open(METODOLOGIA / "pares.csv", encoding="utf-8") as f:
        paises = sorted({l["iso3"] for l in csv.DictReader(f)})
    return (f"https://api.worldbank.org/v2/country/{';'.join(['BRA'] + paises)}/indicator/{INDICADOR_BM}"
            f"?source=2&format=json&per_page=20000&date={ANO_INICIAL - 2}:{ANO_CORRENTE}")


def coletar_gasto(args):
    for nome, url in INEP_INVESTIMENTO.items():
        obter(FONTE_INEP_FIN, nome, "zip", url, reaproveitar=not args.rebaixar)

    def baixar_bm():
        corpo = pedir(url_bm())
        dados = json.loads(corpo.decode("utf-8-sig"))  # a API às vezes manda BOM
        if not isinstance(dados, list) or len(dados) < 2 or dados[0].get("pages") != 1:
            raise RuntimeError(f"resposta inesperada do Banco Mundial: {corpo[:200]!r}")
        return corpo
    obter(FONTE_BM, f"wdi_{INDICADOR_BM}", "json", url_bm(), baixar_fn=baixar_bm)


def tratar_gasto(saida):
    unidade = "% do PIB"
    for nome, rotulo in (("investimento_pib_total", "total"), ("investimento_pib_direto", "direto")):
        arq = bruto(FONTE_INEP_FIN, nome)
        z = zipfile.ZipFile(arq)
        (membro,) = [n for n in z.namelist() if n.lower().endswith(".xlsx")]
        livro = Xlsx(z.read(membro))
        (planilha,) = list(livro.planilhas)
        linhas = livro.linhas(planilha)
        titulo = limpo(linhas[0][0])
        if "Investimento Público" not in titulo or "Produto Interno Bruto" not in titulo:
            raise RuntimeError(f"{arq.name}: título inesperado: {titulo}")
        i_cab = next(i for i, l in enumerate(linhas) if l and limpo(l[0]) == "Ano")
        col = next(c for l in linhas[i_cab:i_cab + 4] for c, x in enumerate(l) if limpo(x).startswith("Todos os Níveis"))
        serie = {}
        for l in linhas[i_cab + 1:]:
            if l and re.fullmatch(r"\d{4}", limpo(l[0])) and num(l[col]) is not None:
                serie[limpo(l[0])] = num(l[col])
        saida.add("gasto_publico_educacao_pib", f"gasto_publico_educacao_pib__investimento_{rotulo}_inep", serie,
                  unidade, f"INEP, Indicadores Financeiros Educacionais, investimento público {rotulo} em educação "
                  f"em relação ao PIB, todos os níveis de ensino", [arq], 4)

    arq = bruto(FONTE_BM, f"wdi_{INDICADOR_BM}")
    registros = json.loads(arq.read_bytes().decode("utf-8-sig"))[1]
    por_pais = defaultdict(dict)
    for r in registros:
        if r["value"] is not None and r["date"] < str(ANO_CORRENTE):
            por_pais[r["countryiso3code"]][r["date"]] = float(r["value"])
    return add_pares(saida, "gasto_publico_educacao_pib", "gasto_publico_educacao_pib", por_pais, unidade, "p.p.",
                     f"Banco Mundial, WDI {INDICADOR_BM} (dados da UNESCO-UIS)", [arq], 4)


# ================================================================ resumo por mandato

FREQ_POR_EDICAO = ("bienal", "trienal")


def ajustar_resumo():
    """Corrige, em dados/tratados/educacao_mandatos.csv (saída do resumir.py), o "herdou" das
    séries por edição: a última edição antes do primeiro ano do mandato. Também tira a marca
    de parcial quando ela só vem de o último ano do mandato não ter edição. Idempotente: usa
    os dados de educacao.csv, não os valores já ajustados."""
    fichas = {f["id"]: f for f in ler_fichas(TEMA)}
    destino = TRATADOS / f"{TEMA}_mandatos.csv"
    if not destino.exists():
        raise SystemExit("rode antes: python3 scripts/resumir.py --tema educacao")
    series = defaultdict(dict)
    for l in ler_csv(TRATADOS / f"{TEMA}.csv"):
        series[l["indicador"]][l["data"]] = float(l["valor"])
    with open(METODOLOGIA / "mandatos.csv", encoding="utf-8") as f:
        mandatos = {m["id"]: m for m in csv.DictReader(f)}
    linhas = ler_csv(destino)
    campos = list(linhas[0].keys())
    nota = "herdou = última edição antes do mandato (série por edição)"
    n = 0
    for linha in linhas:
        ficha = fichas.get(linha["indicador"].split("__")[0])
        if not ficha or ficha.get("frequencia") not in FREQ_POR_EDICAO:
            continue
        m = mandatos[linha["mandato"]]
        ini, fim = dt.date.fromisoformat(m["inicio"]), dt.date.fromisoformat(m["fim"])
        anos = [a for a in range(ini.year, fim.year + 1)
                if ini <= dt.date(a, 1, 1) and dt.date(a, 12, 31) <= fim] or [ini.year]
        pontos = series[linha["indicador"]]
        antes = [d for d in pontos if int(d) < anos[0]]
        if antes:
            h = max(antes)
            linha["herdou_periodo"], linha["herdou"] = h, round(pontos[h], 4)  # mesmo arredondamento do resumir.py
            linha["entregou_menos_herdou"] = round(float(linha["entregou"]) - pontos[h], 4)
        else:
            linha["herdou_periodo"] = linha["herdou"] = linha["entregou_menos_herdou"] = ""
        obs = [o for o in linha["observacao"].split("; ") if o and o != nota]
        em_curso = fim > HOJE
        linha["parcial"] = "sim" if em_curso or any(o.startswith("série começa") for o in obs) else ""
        linha["observacao"] = "; ".join(obs + [nota])
        n += 1
    escrever_csv(destino, campos, [[l[c] for c in campos] for l in linhas])
    print(f"{n} linhas de séries por edição ajustadas em {destino.relative_to(RAIZ)}")


# ================================================================ orquestração

def etapas():
    return [("IDEB e Saeb (INEP)", coletar_ideb, tratar_ideb_saeb),
            ("Matrículas: sinopses do Censo Escolar e do Censo da Educação Superior (INEP)",
             coletar_sinopses, tratar_matriculas),
            ("Analfabetismo (IBGE, SIDRA)", coletar_analfabetismo, tratar_analfabetismo),
            ("Despesa federal (SIOP) e conferência com a DCA (Siconfi)", coletar_despesa, tratar_despesa),
            ("Bolsas (CAPES, GeoCapes; CNPq, tabelas do MCTI)", coletar_bolsas, tratar_bolsas),
            ("PISA (OCDE)", coletar_pisa, tratar_pisa),
            ("Gasto público em educação (INEP e Banco Mundial)", coletar_gasto, tratar_gasto)]


def coletar(args):
    falhas = []
    for titulo, funcao, _ in etapas():
        print(titulo)
        try:
            funcao(args)
        except Exception as e:  # segue para a próxima fonte e reporta no fim
            print(f"  ! {e}", file=sys.stderr)
            falhas.append(titulo)
    if falhas:
        print(f"\nFalharam: {'; '.join(falhas)}", file=sys.stderr)


def tratar():
    fichas = {f["id"]: f for f in ler_fichas(TEMA)}
    saida = Saida()
    print("Tratamento")
    for _, _, funcao in etapas():
        funcao(saida)
    saida.linhas.sort(key=lambda l: (l[0], l[1]))
    escrever_csv(TRATADOS / f"{TEMA}.csv", ["indicador", "data", "valor", "unidade", "fonte"], saida.linhas)
    escrever_csv(TRATADOS / f"{TEMA}_pares_paises.csv",
                 ["indicador", "pais", "data", "valor", "unidade", "fonte"], saida.paises)
    for ficha_id, itens in saida.extracao.items():
        if ficha_id not in fichas:
            raise RuntimeError(f"série sem ficha: {ficha_id}")
        atualizar_extracao(fichas[ficha_id], itens)
    series = sorted({l[0] for l in saida.linhas})
    print(f"{len(saida.linhas)} linhas, {len(series)} séries -> dados/tratados/{TEMA}.csv")
    for s in series:
        datas = [l[1] for l in saida.linhas if l[0] == s]
        print(f"  {s:66s} {min(datas)} a {max(datas)} ({len(datas)})")


# ================================================================ main

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--so-tratar", action="store_true", help="não baixa nada; trata os brutos mais recentes")
    ap.add_argument("--rebaixar", action="store_true", help="baixa de novo as publicações já guardadas")
    ap.add_argument("--ajustar-resumo", action="store_true",
                    help="depois de resumir.py: herdou das séries por edição (IDEB, Saeb, PISA)")
    args = ap.parse_args()
    if args.ajustar_resumo:
        ajustar_resumo()
        return
    if not args.so_tratar:
        coletar(args)
    tratar()


if __name__ == "__main__":
    main()
