#!/usr/bin/env python3
"""Saúde: coleta e tratamento dos indicadores pré-registrados (tema `saude`).

Uso:
    python3 scripts/coletar_saude.py              # coleta e trata
    python3 scripts/coletar_saude.py --so-tratar  # só trata os brutos mais recentes
    python3 scripts/coletar_saude.py --rebaixar   # baixa de novo as publicações de versão fechada
    python3 scripts/resumir.py --tema saude       # depois: resumo por mandato

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

O tema é coletado e tratado só por este script; `scripts/tratar.py --tema saude` remete a ele.
Só biblioteca padrão (D-004). Fontes, decisões e lacunas nas fichas e em
dados/tratados/saude_notas.md.

Datas: AAAA (anual) e AAAA-MM (mensal, só a cobertura da atenção primária). O ano corrente,
incompleto, fica fora das séries anuais; o mês corrente, das mensais.

Valores reais (D-015): R$ do ano × (IPCA médio de 2025 / IPCA médio do ano), com a série
`ipca_numero_indice` de dados/tratados/economia.csv.

Publicações de versão fechada (projeções e tábuas do IBGE, fichas da RIPSA, RREO da União em
PDF, demonstrativos do SIOPS e anexo 14 do RREO no Siconfi) não são baixadas de novo se já
houver bruto; --rebaixar força o download. As demais fontes (TabNet, painel de vacinação,
relatórios da APS, Banco Mundial) são consultadas a cada dia de coleta.

Pontos técnicos, testados em 6/10/2026:

- TabNet (DataSUS): a tabulação só sai por POST para tabcgi.exe?<arquivo .def>, com os campos do
  formulário codificados em Latin-1 (Linha, Coluna, Incremento, Arquivos, filtros S...,
  formato=prn). GET com os mesmos parâmetros não devolve a tabela. A resposta (HTML com a tabela
  entre <PRE> e notas de rodapé) é o bruto; no manifesto, a URL leva o corpo do POST depois do
  primeiro "&" (`tabcgi.exe?<def>&<corpo>`). Os anos e competências disponíveis e as categorias
  dos filtros são lidos do formulário (deftohtm.exe?<def>).
- Coberturas vacinais até 2022: o TabNet do PNI usa outro motor (webtabx.exe?bd_pni/cpnibr.def),
  também por POST, com os valores das opções copiados do formulário dhdat.exe (cada "Incremento"
  carrega a expressão SQL da cobertura). A resposta traz os valores num bloco JavaScript
  (data.addRows).
- Coberturas desde 2023: painel "Cobertura Vacinal - Residência" do Ministério (Qlik Sense). O
  script abre uma sessão anônima no motor do Qlik: obtém o cookie da sessão na página do painel,
  o token CSRF em /qps/csrftoken (cabeçalho X-Qlik-Xrfkey) e conecta por WebSocket a
  wss://infoms.saude.gov.br/app/<id do app>?qlik-csrf-token=<token>. O bruto guarda, em JSON, as
  mensagens JSON-RPC enviadas e recebidas (OpenDoc, GetAppLayout, CreateSessionObject com o
  hipercubo Ano × Imunobiológico e GetLayout).
- ASPS: o SIOPS publica o demonstrativo do RREO da União de 2012 em diante (msu_rreo.php); de
  2002 a 2011 os dados da União "não foram homologados" no SIOPS, e os números vêm do anexo de
  saúde (ADCT, art. 77) do RREO de dezembro do Tesouro Nacional, em PDF. Os valores dos PDFs ficam
  transcritos neste script, com a página (D-020); a página de cada edição no Tesouro Transparente
  leva a um visualizador (thot-arquivos) cujo iframe aponta para o PDF no cdn.tesouro.gov.br.
- Relatórios Públicos da APS (sucessor do e-Gestor AB): API JSON em relatorioaps-prd.saude.gov.br
  (/cobertura/ab, /cobertura/pns e /cobertura/aps, com unidadeGeografica=BRASIL e intervalo de
  competências).
- Banco Mundial: API v2 com o User-Agent do projeto; a resposta pode vir com BOM (utf-8-sig).
- PDFs da RIPSA: o texto é lido dos fluxos FlateDecode (zlib) para conferir os números
  transcritos.
"""

import argparse
import base64
import csv
import html
import http.cookiejar
import io
import json
import os
import re
import socket
import ssl
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
import zipfile
import zlib
from collections import defaultdict

from comum import (BRUTOS, HOJE, METODOLOGIA, RAIZ, TRATADOS, USER_AGENT, escrever_csv, ler_csv, ler_fichas,
                   ler_manifesto, salvar_bruto)
from tratar import mediana_por_ano

TEMA = "saude"
ANO_CORRENTE = HOJE.year
MES_CORRENTE = f"{HOJE.year:04d}-{HOJE.month:02d}"
ANO_BASE_REAL = "2025"  # D-015
ANO_INICIAL = 2000      # contexto; o "herdou" de Lula 1 é 2002 (D-002)


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

def pedir(url, dados=None, cabecalhos=None, tentativas=5, timeout=300):
    """GET (ou POST, com `dados`) com repetição em erro temporário. Devolve os bytes."""
    erro = None
    for i in range(tentativas):
        req = urllib.request.Request(url, data=dados, headers={"User-Agent": USER_AGENT, **(cabecalhos or {})})
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return r.read()
        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}")


# ================================================================ brutos (copiado de coletar_meio_ambiente.py)

def brutos_exatos(fonte, nome):
    """Arquivos `<nome>_<AAAA-MM-DD>[-n].<ext>` da pasta, do mais antigo ao mais recente.

    Diferente de comum.brutos_mais_recentes, não junta nomes que começam com `nome`."""
    pasta = BRUTOS / fonte
    achados = []
    for p in pasta.glob(f"{nome}_*"):
        prefixo, carimbo = p.stem.rsplit("_", 1)
        if prefixo != nome or not re.fullmatch(r"\d{4}-\d{2}-\d{2}(-\d+)?", carimbo):
            continue
        achados.append(((carimbo[:10], int(carimbo[11:] or 1)), p))
    return [p for _, p in sorted(achados)]


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


def bruto_opcional(fonte, nome):
    arquivos = brutos_exatos(fonte, nome)
    return arquivos[-1] if arquivos else None


def obter(fonte, nome, ext, url, baixar_fn=None, reaproveitar=False, validar=None, consulta=None):
    """Baixa e grava um bruto com comum.salvar_bruto. Reaproveita o bruto de hoje; com
    `reaproveitar`, qualquer bruto já guardado (publicações de períodos fechados)."""
    existentes = brutos_exatos(fonte, nome)
    if existentes and (reaproveitar or HOJE.isoformat() in existentes[-1].name):
        print(f"  {fonte}/{existentes[-1].name}: reaproveitado")
        return existentes[-1]
    conteudo = baixar_fn() if baixar_fn else pedir(url)
    if validar:
        validar(conteudo)
    caminho = salvar_bruto(fonte, nome, conteudo, consulta or url, ext)
    print(f"  {fonte}/{caminho.name}: {len(conteudo):,} bytes")
    return caminho


# ================================================================ saída (copiado de coletar_meio_ambiente.py)

class Saida:
    def __init__(self):
        self.linhas = []
        self.paises = []
        self.extracao = defaultdict(list)  # id da ficha -> [(série, arquivos, dados)]
        self.notas = defaultdict(list)     # id da ficha -> parágrafos depois da tabela de extração

    def add(self, ficha_id, serie_id, dados, unidade, fonte, arquivos=(), casas=4, fonte_por_data=None):
        """`fonte_por_data` ({data: texto}) troca a coluna fonte de períodos específicos (por exemplo,
        dados preliminares)."""
        if not serie_id.startswith(ficha_id + "__"):
            raise ValueError(f"série {serie_id} fora da convenção <ficha>__<recorte>")
        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_por_data or {}).get(data, fonte)])
        self.extracao[ficha_id].append((serie_id, sorted(set(arquivos)), dados))


def atualizar_extracao(ficha, itens, notas=()):
    """Reescreve o bloco entre os marcadores de extração da ficha: mesma tabela de
    tratar.atualizar_extracao, com notas opcionais depois dela."""
    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)
    if notas:
        bloco += "\n\n" + "\n\n".join(notas)
    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")


def br(x, casas=0):
    """Número no formato brasileiro (1.234,5)."""
    return f"{x:,.{casas}f}".replace(",", "X").replace(".", ",").replace("X", ".")


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 num_br(x):
    """'1.234.567,89' -> 1234567.89; '-' ou vazio -> None."""
    s = (x or "").strip().replace("\xa0", "")
    if s in ("", "-", "N/A"):
        return None
    return float(s.replace(".", "").replace(",", "."))


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


def anos_fechados(anual):
    return {d: v for d, v in anual.items() if d < str(ANO_CORRENTE)}


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 real(nominal, ipca):
    """{ano: R$} -> {ano: R$ de 2025}, pela média anual do IPCA (D-015)."""
    return {a: v * ipca[ANO_BASE_REAL] / ipca[a] for a, v in nominal.items() if a in ipca}


# ================================================================ leitores de planilha e PDF

M_NS = "{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_xlsx(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), como o de coletar_educacao.py."""

    def __init__(self, caminho):
        self.z = zipfile.ZipFile(caminho)
        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_NS + "sheet"):
            t = alvo[sh.get(R_NS + "id")]
            self.planilhas[sh.get("name")] = t.lstrip("/") if t.startswith("/") else "xl/" + t
        self.textos = []
        if "xl/sharedStrings.xml" in self.z.namelist():
            for _, el in ET.iterparse(self.z.open("xl/sharedStrings.xml")):
                if el.tag == M_NS + "si":
                    self.textos.append("".join(t.text or "" for t in el.iter(M_NS + "t")))
                    el.clear()

    def linhas(self, nome):
        out = []
        for _, el in ET.iterparse(self.z.open(self.planilhas[nome])):
            if el.tag != M_NS + "row":
                continue
            vals, col = {}, -1
            for c in el.iter(M_NS + "c"):
                t, v = c.get("t"), c.find(M_NS + "v")
                if t == "s" and v is not None:
                    val = self.textos[int(v.text)]
                elif t == "inlineStr":
                    val = "".join(x.text or "" for x in c.iter(M_NS + "t"))
                else:
                    val = v.text if v is not None else None
                col = coluna_xlsx(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
            out.append(linha)
            el.clear()
        return out


def _texto_pdf_str(s):
    out, i = [], 0
    while i < len(s):
        c = s[i]
        if c == 92:  # barra invertida
            i += 1
            if i >= len(s):
                break
            c = s[i]
            if 48 <= c <= 55:  # octal
                j = i
                while j < len(s) and j < i + 3 and 48 <= s[j] <= 55:
                    j += 1
                out.append(int(s[i:j], 8))
                i = j
                continue
            out.append({110: 10, 114: 13, 116: 9, 98: 8, 102: 12}.get(c, c))
        else:
            out.append(c)
        i += 1
    return bytes(out).decode("latin-1")


def texto_pdf(conteudo):
    """Texto (sem espaços de layout, um bloco por linha) dos fluxos FlateDecode de um PDF simples.
    Serve só para conferir números transcritos; não lê fontes com codificação CID."""
    pedacos = []
    for s in re.findall(rb"stream\r?\n(.*?)\r?\nendstream", conteudo, re.S):
        try:
            d = zlib.decompress(s)
        except Exception:
            continue
        if b"Tj" not in d and b"TJ" not in d:
            continue
        for m in re.finditer(rb"\[((?:\\.|[^\]\\])*)\]\s*TJ|\(((?:\\.|[^)\\])*)\)\s*Tj|(ET)", d):
            if m.group(3):
                pedacos.append("\n")
            elif m.group(1) is not None:
                pedacos.append("".join(_texto_pdf_str(x) for x in re.findall(rb"\(((?:\\.|[^)\\])*)\)", m.group(1))))
            else:
                pedacos.append(_texto_pdf_str(m.group(2)))
    return "".join(pedacos)


def validar_pdf(conteudo):
    if conteudo[:5] != b"%PDF-":
        raise RuntimeError(f"resposta não é PDF: {conteudo[:120]!r}")


def validar_xlsx(conteudo):
    if conteudo[:2] != b"PK":
        raise RuntimeError(f"resposta não é .xlsx: {conteudo[:120]!r}")


# ================================================================ TabNet (DataSUS)

FONTE_TABNET = "datasus_tabnet"
TABNET = "http://tabnet.datasus.gov.br/cgi/"
_FORMULARIOS = {}


def formulario_tabnet(defn):
    """HTML do formulário do TabNet (deftohtm.exe), decodificado (Latin-1). Não é guardado como
    bruto: serve para listar arquivos (anos/competências) e categorias dos filtros."""
    if defn not in _FORMULARIOS:
        _FORMULARIOS[defn] = pedir(TABNET + "deftohtm.exe?" + defn).decode("latin-1")
    return _FORMULARIOS[defn]


def opcoes_tabnet(form, nome):
    """[(valor, rótulo)] das opções de um <SELECT NAME=...> do formulário."""
    i = form.find(f'NAME="{nome}"')
    if i < 0:
        raise RuntimeError(f"TabNet: campo {nome} não encontrado no formulário")
    j = form.find("</SELECT>", i)
    return [(m.group(1), html.unescape(limpo(m.group(2))))
            for m in re.finditer(r'<OPTION VALUE="([^"]*)"[^>]*>([^<\r\n]*)', form[i:j])]


def corpo_tabnet(linha, coluna, incrementos, arquivos, filtros=()):
    campos = [("Linha", linha), ("Coluna", coluna)] + [("Incremento", i) for i in incrementos]
    campos += [("Arquivos", a) for a in arquivos]
    for nome, valores in filtros:
        campos += [(nome, v) for v in valores]
    campos += [("formato", "prn"), ("mostre", "Mostra")]
    return urllib.parse.urlencode(campos, encoding="latin-1")


def consultar_tabnet(nome, defn, corpo):
    url = TABNET + "tabcgi.exe?" + defn

    def baixar():
        r = pedir(url, dados=corpo.encode("ascii"), cabecalhos={"Content-Type": "application/x-www-form-urlencoded"})
        if b"<PRE>" not in r:
            raise RuntimeError(f"TabNet {defn}: resposta sem tabela: {r[:300]!r}")
        time.sleep(1)
        return r
    return obter(FONTE_TABNET, nome, "html", url, baixar_fn=baixar, consulta=f"{url}&{corpo}")


def ler_tabnet(arq):
    """Tabela "prn" de uma resposta do TabNet -> (cabeçalho, {rótulo da linha: [valores]}, total, notas)."""
    t = arq.read_bytes().decode("latin-1")
    pre = t[t.find("<PRE>") + 5:t.find("</PRE>")]
    linhas = [l for l in csv.reader(io.StringIO(html.unescape(pre).strip()), delimiter=";") if l]
    cab = [limpo(x) for x in linhas[0]]
    dados, total = {}, None
    for l in linhas[1:]:
        if l[0] == "&":
            continue
        valores = [None if v.strip() in ("-", "") else float(v.replace(".", "").replace(",", ".")) for v in l[1:]]
        if l[0] == "Total":
            total = valores
        else:
            dados[limpo(l[0])] = valores
    rodape = t[t.find("</PRE>"):]
    notas = [limpo(html.unescape(re.sub(r"<[^>]+>", " ", n))) for n in re.findall(r"<li>(.*?)(?=<li>|</ol>)", rodape, re.S)]
    return cab, dados, total, notas


def anos_preliminares(notas):
    """Anos marcados como preliminares ou prévia nas notas de rodapé do SIM e do SINASC."""
    finais = [int(m.group(1)) for n in notas for m in [re.search(r"finais dispon\w+ at\w+ (\d{4})", n)] if m]
    prelim = {m.group(1): n for n in notas for m in [re.search(r"Dados de (\d{4}) - (Preliminares|.*pr.via)", n)] if m}
    return (max(finais) if finais else None), prelim


def arquivos_tabnet(defn, padrao, anos):
    """Arquivos .dbf do formulário cujo nome casa com `padrao` (grupo 1 = ano de 2 dígitos)."""
    form = formulario_tabnet(defn)
    disponiveis = {}
    for valor, _ in opcoes_tabnet(form, "Arquivos"):
        m = re.fullmatch(padrao, valor)
        if m:
            disponiveis[2000 + int(m.group(1))] = valor
    return [disponiveis[a] for a in sorted(disponiveis, reverse=True) if a in anos]


# ---------------------------------------------------------------- SIM e SINASC

SIM_INFANTIS = ("sim/cnv/inf10uf.def", r"infuf(\d{2})\.dbf", "Ano_do_Óbito", ["Óbitos_p/Residênc"])
SINASC = ("sinasc/cnv/nvuf.def", r"nvuf(\d{2})\.dbf", "Ano_do_nascimento", ["Nascim_p/resid.mãe"])
SIM_MATERNOS = ("sim/cnv/mat10uf.def", r"matuf(\d{2})\.dbf", "Ano_do_Óbito", ["Óbitos_maternos", "Óbitos_maternos_tardios"])
CONSULTAS_VITAIS = {"sim_obitos_infantis_ano": SIM_INFANTIS, "sinasc_nascidos_vivos_ano": SINASC,
                    "sim_obitos_maternos_ano": SIM_MATERNOS}
NT_SIM_MATERNO = "http://tabnet.datasus.gov.br/cgi/sim/Ob_Mu_Id_Fertil_Mat_1996_2012.pdf"


def coletar_vitais(args):
    anos = set(range(ANO_INICIAL, ANO_CORRENTE))  # o ano corrente (prévia) fica fora
    for nome, (defn, padrao, linha, incrementos) in CONSULTAS_VITAIS.items():
        arquivos = arquivos_tabnet(defn, padrao, anos)
        consultar_tabnet(nome, defn, corpo_tabnet(linha, "--Não-Ativa--", incrementos, arquivos))


def serie_tabnet_ano(arq, coluna=0):
    cab, dados, total, notas = ler_tabnet(arq)
    serie = {k: v[coluna] for k, v in dados.items() if re.fullmatch(r"\d{4}", k) and v[coluna] is not None}
    if total and abs(sum(v[coluna] or 0 for v in dados.values()) - total[coluna]) > 0.5:
        raise RuntimeError(f"{arq.name}: linhas não somam o total")
    return serie, cab, notas


def tratar_vitais(saida, ctx):
    arq_inf = bruto(FONTE_TABNET, "sim_obitos_infantis_ano")
    arq_nv = bruto(FONTE_TABNET, "sinasc_nascidos_vivos_ano")
    arq_mat = bruto(FONTE_TABNET, "sim_obitos_maternos_ano")
    obitos_inf, _, notas_sim = serie_tabnet_ano(arq_inf)
    nv, _, notas_nv = serie_tabnet_ano(arq_nv)
    obitos_mat, cab_mat, _ = serie_tabnet_ano(arq_mat, 0)
    tardios, _, _ = serie_tabnet_ano(arq_mat, 1)
    if cab_mat[1:] != ["Óbitos maternos", "Óbitos maternos tardios"]:
        raise RuntimeError(f"{arq_mat.name}: colunas inesperadas {cab_mat}")
    final_sim, prelim_sim = anos_preliminares(notas_sim)
    final_nv, prelim_nv = anos_preliminares(notas_nv)
    anos = sorted(a for a in set(obitos_inf) & set(nv) if a < str(ANO_CORRENTE))
    tmi = {a: 1000 * obitos_inf[a] / nv[a] for a in anos}
    rmm = {a: 100000 * obitos_mat[a] / nv[a] for a in anos if a in obitos_mat}
    prelim = sorted(a for a in set(prelim_sim) | set(prelim_nv) if a < str(ANO_CORRENTE))
    marca = " (DADOS PRELIMINARES do SIM e do SINASC)"
    ctx["nv"], ctx["obitos_inf"], ctx["obitos_mat"], ctx["tmi_direta"], ctx["rmm_direta"] = nv, obitos_inf, obitos_mat, tmi, rmm
    ctx["vitais_prelim"] = prelim

    f = "mortalidade_infantil"
    fonte = "Ministério da Saúde, SIM e SINASC (TabNet/DataSUS), óbitos de menores de 1 ano por mil nascidos vivos, por residência"
    saida.add(f, f"{f}__ms_direta", tmi, "por mil nascidos vivos", fonte, [arq_inf, arq_nv], 2,
              fonte_por_data={a: fonte + marca for a in prelim})
    saida.add(f, f"{f}__obitos_menores_1_ano", {a: obitos_inf[a] for a in anos}, "óbitos",
              "Ministério da Saúde, SIM (TabNet/DataSUS), óbitos de menores de 1 ano por residência", [arq_inf], 0,
              fonte_por_data={a: "Ministério da Saúde, SIM (TabNet/DataSUS), óbitos de menores de 1 ano" + marca for a in prelim})
    saida.add(f, f"{f}__nascidos_vivos", {a: nv[a] for a in anos}, "nascidos vivos",
              "Ministério da Saúde, SINASC (TabNet/DataSUS), nascidos vivos por residência da mãe", [arq_nv], 0,
              fonte_por_data={a: "Ministério da Saúde, SINASC (TabNet/DataSUS)" + marca for a in prelim})

    f = "mortalidade_materna"
    fonte = ("Ministério da Saúde, SIM e SINASC (TabNet/DataSUS), óbitos maternos (definição do Ministério: até 42 "
             "dias após o fim da gestação) por 100 mil nascidos vivos, por residência")
    saida.add(f, f"{f}__ms_direta", rmm, "por 100 mil nascidos vivos", fonte, [arq_mat, arq_nv], 2,
              fonte_por_data={a: fonte + marca for a in prelim})
    saida.add(f, f"{f}__obitos_maternos", {a: obitos_mat[a] for a in anos if a in obitos_mat}, "óbitos",
              "Ministério da Saúde, SIM (TabNet/DataSUS), óbitos maternos por residência", [arq_mat], 0,
              fonte_por_data={a: "Ministério da Saúde, SIM (TabNet/DataSUS), óbitos maternos" + marca for a in prelim})

    def situacao(nome, final, notas):
        return f"{nome}: dados finais até {final}; " + "; ".join(n.rstrip(".") for _, n in sorted(notas.items()))
    nota_prelim = (f"Situação dos dados nas notas de rodapé do TabNet. {situacao('SIM', final_sim, prelim_sim)}. "
                   f"{situacao('SINASC', final_nv, prelim_nv)}. O ano corrente ({ANO_CORRENTE}, prévia) fica fora; os anos "
                   f"preliminares ({', '.join(prelim)}) levam a marca \"DADOS PRELIMINARES\" na coluna `fonte`.")
    saida.notas["mortalidade_infantil"].append(nota_prelim)
    saida.notas["mortalidade_materna"].append(nota_prelim)
    saida.notas["mortalidade_materna"].append(
        "Óbitos maternos tardios (43 dias a menos de 1 ano após o parto, CID-10 O96 e O97), que não entram na razão: "
        + "; ".join(f"{a}: {br(tardios[a])}" for a in anos if a in tardios) + ". Definição de óbito materno do "
        f"TabNet: nota técnica do SIM ({NT_SIM_MATERNO}).")
    return tmi, rmm


# ================================================================ IBGE: projeções (revisão 2024) e tábuas

FONTE_IBGE_PROJ = "ibge_projecoes"
FONTE_IBGE_TABUAS = "ibge_tabuas"
URL_PROJ_TAB4 = ("https://ftp.ibge.gov.br/Projecao_da_Populacao/Projecao_da_Populacao_2024/"
                 "projecoes_2024_tab4_indicadores.xlsx")
NOME_PROJ_TAB4 = "projecoes_2024_tab4_indicadores"
URL_TABUA = "https://ftp.ibge.gov.br/Tabuas_Completas_de_Mortalidade/Tabuas_Completas_de_Mortalidade_{ano}/xlsx/ambos_os_sexos.xlsx"
ANO_TABUA_ANTIGA = 2021   # publicada em nov/2022, antes da revisão de 2024: documenta a revisão
ANO_PRIMEIRA_TABUA_REVISTA = 2023  # primeira tábua publicada depois da revisão de 2024 (nov/2024)
PUBLICADO_E0 = {"2023": 76.4, "2024": 76.6}  # comunicados do IBGE das Tábuas 2023 (29/11/2024) e 2024 (28/11/2025)


def coletar_ibge(args):
    obter(FONTE_IBGE_PROJ, NOME_PROJ_TAB4, "xlsx", URL_PROJ_TAB4, validar=validar_xlsx, reaproveitar=not args.rebaixar)
    for ano in [ANO_TABUA_ANTIGA] + list(range(2022, ANO_CORRENTE)):
        nome = f"tabua_completa_{ano}_ambos_os_sexos"
        if bruto_opcional(FONTE_IBGE_TABUAS, nome) and not args.rebaixar:
            obter(FONTE_IBGE_TABUAS, nome, "xlsx", URL_TABUA.format(ano=ano), reaproveitar=True)
            continue
        try:
            obter(FONTE_IBGE_TABUAS, nome, "xlsx", URL_TABUA.format(ano=ano), validar=validar_xlsx,
                  reaproveitar=not args.rebaixar)
        except urllib.error.HTTPError as e:
            if e.code != 404:
                raise
            print(f"  {FONTE_IBGE_TABUAS}/{nome}: ainda não publicada")


def ler_projecoes(arq):
    """{ano: {indicador: valor}} do Brasil na tabela 4 (indicadores implícitos) das projeções."""
    livro = Xlsx(str(arq))
    (aba,) = [n for n in livro.planilhas if "INDICADORES" in n.upper()]
    linhas = livro.linhas(aba)
    i_cab = next(i for i, l in enumerate(linhas) if l and limpo(l[0]) == "ANO" and "e0_T" in [limpo(x) for x in l])
    cab = [limpo(x) for x in linhas[i_cab]]
    br_ = {}
    for l in linhas[i_cab + 1:]:
        if len(l) > 3 and limpo(l[2]) == "BR" and num(l[0]):
            br_[str(int(num(l[0])))] = {c: num(l[k]) for k, c in enumerate(cab) if k < len(l)}
    return br_


def ler_tabua(arq):
    """(e0, q0 por mil) da tábua completa de ambos os sexos."""
    livro = Xlsx(str(arq))
    linhas = livro.linhas(list(livro.planilhas)[0])
    (l0,) = [l for l in linhas if l and limpo(l[0]) == "0" and len(l) >= 7 and num(l[6])]
    return num(l0[6]), num(l0[1])


def tratar_ibge(saida, ctx):
    arq = bruto(FONTE_IBGE_PROJ, NOME_PROJ_TAB4)
    proj = ler_projecoes(arq)
    tabuas = {}
    for ano in [ANO_TABUA_ANTIGA] + list(range(2022, ANO_CORRENTE)):
        a = bruto_opcional(FONTE_IBGE_TABUAS, f"tabua_completa_{ano}_ambos_os_sexos")
        if a:
            tabuas[str(ano)] = (a, *ler_tabua(a))
    revistas = [a for a in tabuas if int(a) >= ANO_PRIMEIRA_TABUA_REVISTA]
    ultimo = max(revistas)
    # conferência: tábuas publicadas depois da revisão = valores da projeção (a série vem da projeção)
    difs = []
    for a in revistas:
        _, e0, q0 = tabuas[a]
        de, dq = e0 - proj[a]["e0_T"], q0 - proj[a]["TMI_T"]
        difs.append((a, e0, proj[a]["e0_T"], q0, proj[a]["TMI_T"]))
        if abs(de) > 0.01 or abs(dq) > 0.02:
            raise RuntimeError(f"Tábua {a} difere da projeção revisão 2024: e0 {e0} x {proj[a]['e0_T']}, "
                               f"q0 {q0} x {proj[a]['TMI_T']}; decidir qual usar")
    for a, v in PUBLICADO_E0.items():
        if a in proj and abs(round(proj[a]["e0_T"], 1) - v) > 0.001:
            raise RuntimeError(f"esperança de vida {a}: {proj[a]['e0_T']} contra {v} divulgado pelo IBGE")
    anos = [a for a in sorted(proj) if str(ANO_INICIAL) <= a <= ultimo]
    e0 = {a: proj[a]["e0_T"] for a in anos}
    tmi = {a: proj[a]["TMI_T"] for a in anos}
    pop = {a: proj[a]["POP_T"] for a in sorted(proj) if str(ANO_INICIAL) <= a < str(ANO_CORRENTE)}
    ctx["pop"], ctx["e0"], ctx["tmi_ibge"] = pop, e0, tmi
    # com o ano corrente, só como denominador de séries mensais (a estimativa oficial de 2026, SIDRA 6579, é igual)
    ctx["pop_com_ano_corrente"] = {a: proj[a]["POP_T"] for a in sorted(proj) if str(ANO_INICIAL) <= a <= str(ANO_CORRENTE)}
    ctx["arq_proj"] = arq
    arqs = [arq] + [tabuas[a][0] for a in revistas]
    fonte = ("IBGE, Projeções da População, revisão 2024 (indicadores implícitos; série 2000-2022 reconstituída com o "
             "Censo 2022); anos de 2023 em diante iguais às Tábuas Completas de Mortalidade publicadas")
    saida.add("esperanca_vida", "esperanca_vida__ibge", e0, "anos", fonte + ", esperança de vida ao nascer, ambos os sexos",
              arqs, 2)
    saida.add("mortalidade_infantil", "mortalidade_infantil__ibge_estimada", tmi, "por mil nascidos vivos",
              fonte + ", taxa de mortalidade infantil (1q0)", arqs, 2)
    antiga = tabuas.get(str(ANO_TABUA_ANTIGA))
    t22 = tabuas.get("2022")
    texto_conf = "; ".join(f"{a}: e0 {br(e, 3)} na tábua e {br(p, 3)} na projeção, mortalidade infantil {br(q, 2)} e "
                           f"{br(t, 2)}" for a, e, p, q, t in difs)
    nota = (f"Série da revisão 2024 das Projeções da População (`{arq.relative_to(RAIZ)}`), cortada no último ano com "
            f"Tábua Completa publicada ({ultimo}); os anos seguintes da tabela são projeção e ficam fora (D-017). "
            f"Conferência com as tábuas publicadas depois da revisão: {texto_conf}. Comunicados do IBGE: "
            + ", ".join(f"{a}: {br(v, 1)} anos" for a, v in PUBLICADO_E0.items()) + ".")
    if antiga:
        nota += (f" Revisão: a Tábua de {ANO_TABUA_ANTIGA}, publicada antes da revisão (`{antiga[0].relative_to(RAIZ)}`), "
                 f"dava e0 = {br(antiga[1], 2)} anos e mortalidade infantil de {br(antiga[2], 2)} por mil; a série revista dá "
                 f"{br(proj[str(ANO_TABUA_ANTIGA)]['e0_T'], 2)} e {br(proj[str(ANO_TABUA_ANTIGA)]['TMI_T'], 2)}.")
    if t22:
        nota += (f" A Tábua de 2022, publicada em novembro de 2023 (`{t22[0].relative_to(RAIZ)}`), dava e0 = "
                 f"{br(t22[1], 2)} e 1q0 = {br(t22[2], 2)} por mil; a revisão de 2024 manteve e0 = "
                 f"{br(proj['2022']['e0_T'], 2)} e deu {br(proj['2022']['TMI_T'], 2)} por mil.")
    saida.notas["esperanca_vida"].append(nota)
    saida.notas["mortalidade_infantil"].append(nota)
    return e0, tmi, pop


# ================================================================ RIPSA (fichas de indicadores do Ministério)

FONTE_RIPSA = "ripsa"
URL_RIPSA = "https://www.ripsa.org.br/wp-content/uploads/fichasidb/2025/{codigo}.pdf"
# MRT.2.01 (Razão de mortalidade materna, edição 2025, publicada em 2026; doi 10.5281/zenodo.18377550),
# Tabela 1, p. 5, linha Brasil: RMM "vigilância", com fator de correção do Ministério de 2009 a 2023
RIPSA_RMM_CORRIGIDA = {"2010": 68.9, "2011": 61.8, "2012": 59.3, "2013": 62.1, "2014": 63.8, "2015": 62.0,
                       "2016": 64.4, "2017": 64.5, "2018": 59.1, "2019": 57.9, "2020": 74.7, "2021": 117.4,
                       "2022": 57.7, "2023": 55.3}
# MRT.1.01 (Taxa de mortalidade infantil, edição 2025; doi 10.5281/zenodo.18376908), Gráfico 1, p. 4: 2000-2014
# pelos indicadores implícitos das projeções do IBGE (edição 2024); 2015 em diante pelas estimativas de
# sub-registro de nascimentos e óbitos do IBGE (captura e recaptura)
RIPSA_TMI = {"2000": 28.1, "2001": 26.2, "2002": 24.8, "2003": 23.9, "2004": 22.1, "2005": 20.7, "2006": 19.1,
             "2007": 17.9, "2008": 17.0, "2009": 16.2, "2010": 15.0, "2011": 14.8, "2012": 14.3, "2013": 14.0,
             "2014": 13.5, "2015": 12.5, "2016": 12.8, "2017": 12.5, "2018": 12.3, "2019": 12.5, "2020": 11.7,
             "2021": 12.1, "2022": 12.7, "2023": 12.8}
# Fichas usadas só na conferência (leitos por habitante, esperança de vida, gasto federal per capita em ASPS),
# transcritas e conferidas com o texto do PDF pelo script
FICHAS_RIPSA = {"MRT.2.01": "ficha_MRT-2-01", "MRT.1.01": "ficha_MRT-1-01", "REC.2.01": "ficha_REC-2-01",
                "DEM.3.04": "ficha_DEM-3-04", "REC.3.04": "ficha_REC-3-04"}
# REC.2.01 (doi 10.5281/zenodo.18272967), Tabela 1, p. 5: (leitos totais, leitos SUS) por mil habitantes
RIPSA_LEITOS = {"2018": (2.17, 1.49), "2019": (2.12, 1.46), "2020": (2.21, 1.56), "2021": (2.19, 1.53), "2022": (2.17, 1.50)}
# REC.3.04 (doi 10.5281/zenodo.18273939), Tabela 1, p. 3: gasto federal com ASPS per capita, R$ correntes
RIPSA_ASPS_PER_CAPITA = {"2012": 405.03, "2013": 416.88, "2014": 459.35, "2015": 496.34, "2016": 523.46, "2017": 561.66,
                         "2018": 568.73, "2019": 596.85, "2020": 776.51, "2021": 857.23, "2022": 726.41, "2023": 861.54}
# DEM.3.04 (doi 10.5281/zenodo.18261641), Gráfico 1, p. 3: esperança de vida ao nascer, total dos dois sexos (IBGE)
RIPSA_E0 = {"2000": 71.1, "2010": 74.4, "2022": 75.4, "2024": 76.6}


def coletar_ripsa(args):
    for codigo, nome in FICHAS_RIPSA.items():
        obter(FONTE_RIPSA, nome, "pdf", URL_RIPSA.format(codigo=codigo), validar=validar_pdf,
              reaproveitar=not args.rebaixar)


def fmt1(v):
    return f"{v:.1f}".replace(".", ",")


def fmt2(v):
    return "–" if v is None else br(v, 2)


def conferir_fichas_ripsa():
    """Confere com o texto dos PDFs as transcrições das fichas usadas só na conferência."""
    def texto(codigo):
        return re.sub(r"\s+", "", texto_pdf(bruto(FONTE_RIPSA, FICHAS_RIPSA[codigo]).read_bytes()))

    def numeros(t, ini, fim):  # números com duas casas entre dois trechos do texto
        i = t.find(ini)
        j = t.find(fim, i + len(ini))
        return [] if i < 0 or j < 0 else [float(x.replace(",", ".")) for x in re.findall(r"\d+,\d{2}", t[i + len(ini):j])]
    t = texto("REC.2.01")
    t = t[t.find("Leitoshospitalares2018"):]
    # a tabela alterna leitos (por mil) e leitos de UTI (por 10 mil); ficam os leitos
    if (numeros(t, "LeitosSUS", "Leitosnão")[0::2] != [v[1] for _, v in sorted(RIPSA_LEITOS.items())]
            or numeros(t, "Totaldeleitos", "REFER")[0::2] != [v[0] for _, v in sorted(RIPSA_LEITOS.items())]):
        raise RuntimeError("ficha REC.2.01: a Tabela 1 não confere com a transcrição")
    if "Gastopercapita" + "".join(f"{v:.2f}".replace(".", ",") for _, v in sorted(RIPSA_ASPS_PER_CAPITA.items())) \
            not in texto("REC.3.04"):
        raise RuntimeError("ficha REC.3.04: a Tabela 1 não confere com a transcrição")
    if "".join(fmt1(v) for _, v in sorted(RIPSA_E0.items())) not in texto("DEM.3.04"):
        raise RuntimeError("ficha DEM.3.04: os rótulos do Gráfico 1 não conferem com a transcrição")


def tratar_ripsa(saida, ctx):
    arq_rmm = bruto(FONTE_RIPSA, FICHAS_RIPSA["MRT.2.01"])
    arq_tmi = bruto(FONTE_RIPSA, FICHAS_RIPSA["MRT.1.01"])
    t_rmm = re.sub(r"\s+", "", texto_pdf(arq_rmm.read_bytes()))
    seq = "Brasil" + "".join(fmt1(RIPSA_RMM_CORRIGIDA[a]) for a in sorted(RIPSA_RMM_CORRIGIDA))
    if seq not in t_rmm:
        raise RuntimeError(f"{arq_rmm.name}: a linha Brasil da Tabela 1 não confere com a transcrição")
    t_tmi = re.sub(r"\s+", "", texto_pdf(arq_tmi.read_bytes()))
    seq = "".join(fmt1(RIPSA_TMI[a]) for a in sorted(RIPSA_TMI) if a != "2001")  # 2001 sai em outro bloco do gráfico
    if seq not in t_tmi or "26,2" not in t_tmi:
        raise RuntimeError(f"{arq_tmi.name}: os rótulos do Gráfico 1 não conferem com a transcrição")
    conferir_fichas_ripsa()
    saida.add("mortalidade_materna", "mortalidade_materna__ms_corrigida", RIPSA_RMM_CORRIGIDA,
              "por 100 mil nascidos vivos",
              "Ministério da Saúde (CGIAE/SVSA) e RIPSA, ficha MRT.2.01 (2026), Tabela 1: razão de mortalidade materna "
              "corrigida (\"RMM vigilância\", fator de correção da investigação de óbitos de mulheres em idade fértil)",
              [arq_rmm], 1)
    saida.add("mortalidade_infantil", "mortalidade_infantil__ms_corrigida", RIPSA_TMI, "por mil nascidos vivos",
              "Ministério da Saúde (CGIAE/SVSA) e RIPSA, ficha MRT.1.01 (2026), Gráfico 1: 2000-2014 pelas projeções do "
              "IBGE (revisão 2024); desde 2015 pelas estimativas de sub-registro de nascimentos e óbitos do IBGE",
              [arq_tmi], 1)
    direta = ctx.get("rmm_direta", {})
    fatores = {a: v / direta[a] for a, v in RIPSA_RMM_CORRIGIDA.items() if a in direta}
    saida.notas["mortalidade_materna"].append(
        f"RMM corrigida transcrita da ficha MRT.2.01 da RIPSA (`{arq_rmm.relative_to(RAIZ)}`, Tabela 1, p. 5), conferida "
        "com o texto do PDF pelo script. Razão entre a corrigida e a direta calculada aqui (fator implícito): "
        + "; ".join(f"{a}: {br(f, 3)}" for a, f in sorted(fatores.items())) + ".")
    tmi_dir = ctx.get("tmi_direta", {})
    saida.notas["mortalidade_infantil"].append(
        f"TMI do Ministério transcrita da ficha MRT.1.01 da RIPSA (`{arq_tmi.relative_to(RAIZ)}`, Gráfico 1, p. 4, rótulos "
        "com uma casa decimal), conferida com o texto do PDF pelo script. Razão entre essa TMI e a direta calculada aqui: "
        + "; ".join(f"{a}: {br(v / tmi_dir[a], 3)}" for a, v in sorted(RIPSA_TMI.items()) if a in tmi_dir) + ". A ficha "
        "dá como exemplo 2023: 32.017 óbitos e 2.537.576 nascidos vivos no SIM e no SINASC (os mesmos do TabNet), fator "
        "de correção 1,0114 e TMI de 12,8.")
    e0 = ctx.get("e0", {})
    difs_e0 = {a: (v, e0.get(a)) for a, v in RIPSA_E0.items() if a not in e0 or abs(round(e0[a], 1) - v) > 0.001}
    if difs_e0:
        raise RuntimeError(f"esperança de vida difere da ficha DEM.3.04 da RIPSA: {difs_e0}")
    arq_e0 = bruto(FONTE_RIPSA, FICHAS_RIPSA["DEM.3.04"])
    saida.notas["esperanca_vida"].append(
        f"Conferência com a ficha DEM.3.04 da RIPSA (`{arq_e0.relative_to(RAIZ)}`, Gráfico 1, p. 3, esperança de vida ao "
        "nascer dos dois sexos, calculada pelo IBGE): "
        + "; ".join(f"{a}: {fmt1(v)} anos na ficha e {br(e0[a], 3)} na série" for a, v in sorted(RIPSA_E0.items()))
        + "; iguais com uma casa decimal.")
    ctx["arq_ripsa"] = {c: bruto(FONTE_RIPSA, n) for c, n in FICHAS_RIPSA.items()}
    return RIPSA_RMM_CORRIGIDA, RIPSA_TMI


# ================================================================ coberturas vacinais

FONTE_PAINEL_VACINAS = "ms_painel_vacinacao"
PNI_FORM = TABNET + "dhdat.exe?bd_pni/cpnibr.def"
PNI_URL = TABNET + "webtabx.exe?bd_pni/cpnibr.def"
PNI_VACINAS = ["Tríplice Viral D1", "DTP", "Tetravalente(DTP/Hib)(TETRA)", "Penta", "Sarampo"]
PNI_ULTIMO_ANO = 2022  # "As bases de dados disponíveis envolvem as vacinações a partir de 1994 até 2022" (rodapé)
URL_PAINEL_VACINAS = ("https://infoms.saude.gov.br/extensions/SEIDIGI_DEMAS_VACINACAO_CALENDARIO_NACIONAL_COBERTURA_"
                      "RESIDENCIA/SEIDIGI_DEMAS_VACINACAO_CALENDARIO_NACIONAL_COBERTURA_RESIDENCIA.html")
HOST_QLIK = "infoms.saude.gov.br"
APP_VACINAS = "c39051de-3ffe-47af-a43a-ae74f14be34f"  # "APP Produção" no JavaScript do painel
VACINA_TV_PAINEL, VACINA_PENTA_PAINEL = "Tríplice Viral - 1° Dose", "Penta (DTP/HepB/Hib)"
ANO_INICIAL_RNDS = 2023  # desde 2023 as coberturas só estão no painel (RNDS); até 2022, no TabNet do SI-PNI
CUBO_VACINAS = {"qInfo": {"qType": "cubo-cobertura"}, "qHyperCubeDef": {
    "qDimensions": [{"qDef": {"qFieldDefs": ["Ano"]}}, {"qDef": {"qFieldDefs": ["Imunobiológico"]}}],
    "qMeasures": [{"qDef": {"qDef": "Sum(Total_Vacina)"}}, {"qDef": {"qDef": "Sum([Qtd. População])"}}],
    "qInitialDataFetch": [{"qTop": 0, "qLeft": 0, "qHeight": 2000, "qWidth": 4}],
    "qSuppressZero": False, "qSuppressMissing": True}}


def form_pni():
    """Formulário do TabNet do PNI (dhdat.exe), com os valores das opções."""
    if PNI_FORM not in _FORMULARIOS:
        _FORMULARIOS[PNI_FORM] = pedir(PNI_FORM).decode("latin-1")
    return _FORMULARIOS[PNI_FORM]


def opcoes_pni(form, nome):
    i = form.find(f'NAME="{nome}"')
    j = form.find("</select>", i)
    return [(html.unescape(m.group(1)), limpo(html.unescape(m.group(2))))
            for m in re.finditer(r'<option value="([^"]*)"[^>]*>([^<\r\n]*)', form[i:j], re.I)]


def coletar_vacinas(args):
    form = form_pni()
    linha = [v for v, r in opcoes_pni(form, "Linha") if r == "Ano"]
    incrementos = dict((r, v) for v, r in opcoes_pni(form, "Incremento"))
    faltam = [v for v in PNI_VACINAS if v not in incrementos]
    if len(linha) != 1 or faltam:
        raise RuntimeError(f"TabNet PNI: formulário mudou (linha Ano: {linha}; vacinas sem opção: {faltam})")
    anos = [v for v, r in opcoes_pni(form, "PAno") if re.fullmatch(r"\d{4}", r) and ANO_INICIAL <= int(r) <= PNI_ULTIMO_ANO]
    campos = [("Linha", linha[0]), ("Coluna", "--Não-Ativa--")] + [("Incremento", incrementos[v]) for v in PNI_VACINAS]
    campos += [("PAno", a) for a in anos]
    campos += [(n, "TODAS_AS_CATEGORIAS__") for n in re.findall(r'NAME="(S[^"]+)" ID="S\d+"', form)]
    campos += [("nomedef", "bd_pni/cpnibr.def"), ("grafico", "")]
    corpo = urllib.parse.urlencode(campos, encoding="latin-1")

    def baixar():
        r = pedir(PNI_URL, dados=corpo.encode("ascii"), cabecalhos={"Content-Type": "application/x-www-form-urlencoded"})
        if b"data.addRows" not in r:
            raise RuntimeError(f"TabNet PNI: resposta sem tabela: {r[:300]!r}")
        return r
    obter(FONTE_TABNET, "pni_coberturas_ano", "html", PNI_URL, baixar_fn=baixar, consulta=f"{PNI_URL}&{corpo}")

    def baixar_qlik():
        return json.dumps(sessao_qlik_cobertura(), ensure_ascii=False, indent=1).encode("utf-8")
    obter(FONTE_PAINEL_VACINAS, "cobertura_residencia_ano_imunobiologico", "json", URL_PAINEL_VACINAS,
          baixar_fn=baixar_qlik,
          consulta=(f"wss://{HOST_QLIK}/app/{APP_VACINAS}?qlik-csrf-token=<token da sessão em https://{HOST_QLIK}"
                    f"/qps/csrftoken> (painel {URL_PAINEL_VACINAS}); JSON-RPC OpenDoc, GetAppLayout, CreateSessionObject "
                    + json.dumps(CUBO_VACINAS, ensure_ascii=False, separators=(",", ":")) + ", GetLayout"))


class WebSocket:
    """Cliente WebSocket mínimo (RFC 6455) sobre ssl, só para o motor do Qlik."""

    def __init__(self, host, caminho, cabecalhos, timeout=120):
        self.s = ssl.create_default_context().wrap_socket(socket.create_connection((host, 443), timeout=timeout),
                                                          server_hostname=host)
        chave = base64.b64encode(os.urandom(16)).decode()
        pedido = (f"GET {caminho} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
                  f"Sec-WebSocket-Key: {chave}\r\nSec-WebSocket-Version: 13\r\nOrigin: https://{host}\r\n"
                  f"User-Agent: {USER_AGENT}\r\n" + "".join(f"{k}: {v}\r\n" for k, v in cabecalhos.items()) + "\r\n")
        self.s.sendall(pedido.encode())
        resp = b""
        while b"\r\n\r\n" not in resp:
            pedaco = self.s.recv(4096)
            if not pedaco:
                break
            resp += pedaco
        cab, _, self.buf = resp.partition(b"\r\n\r\n")
        if b" 101 " not in cab.split(b"\r\n")[0]:
            raise RuntimeError(f"WebSocket recusado: {cab[:300]!r}")

    def _ler(self, n):
        while len(self.buf) < n:
            pedaco = self.s.recv(65536)
            if not pedaco:
                raise RuntimeError("WebSocket fechado")
            self.buf += pedaco
        out, self.buf = self.buf[:n], self.buf[n:]
        return out

    def enviar(self, obj):
        dados = json.dumps(obj).encode()
        cab = bytearray([0x81])
        n = len(dados)
        if n < 126:
            cab.append(0x80 | n)
        elif n < 65536:
            cab += bytes([0x80 | 126]) + struct.pack(">H", n)
        else:
            cab += bytes([0x80 | 127]) + struct.pack(">Q", n)
        mascara = os.urandom(4)
        self.s.sendall(bytes(cab) + mascara + bytes(b ^ mascara[i % 4] for i, b in enumerate(dados)))

    def receber(self):
        msg = b""
        while True:
            b1, b2 = self._ler(2)
            op, n = b1 & 0x0F, b2 & 0x7F
            if n == 126:
                n = struct.unpack(">H", self._ler(2))[0]
            elif n == 127:
                n = struct.unpack(">Q", self._ler(8))[0]
            if b2 & 0x80:
                self._ler(4)
            dados = self._ler(n)
            if op == 8:
                raise RuntimeError(f"WebSocket fechado pelo servidor: {dados!r}")
            if op in (9, 10):  # ping/pong
                continue
            msg += dados
            if b1 & 0x80:
                return json.loads(msg.decode("utf-8"))

    def fechar(self):
        try:
            self.s.close()
        except OSError:
            pass


def sessao_qlik_cobertura():
    """Abre uma sessão anônima no motor do Qlik e devolve as mensagens trocadas."""
    cookies = http.cookiejar.CookieJar()
    abridor = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies))
    abridor.addheaders = [("User-Agent", USER_AGENT)]
    abridor.open(URL_PAINEL_VACINAS, timeout=120).read()
    xrf = "comparacaogoverno"[:16]
    r = abridor.open(urllib.request.Request(f"https://{HOST_QLIK}/qps/csrftoken?xrfkey={xrf}",
                                            headers={"X-Qlik-Xrfkey": xrf, "User-Agent": USER_AGENT}), timeout=120)
    token = r.headers.get("qlik-csrf-token")
    if not token:
        raise RuntimeError("Qlik: token CSRF não veio no cabeçalho qlik-csrf-token")
    ws = WebSocket(HOST_QLIK, f"/app/{APP_VACINAS}?qlik-csrf-token={urllib.parse.quote(token)}",
                   {"Cookie": "; ".join(f"{c.name}={c.value}" for c in cookies)})
    pedidos, respostas, ident = [], [], [0]

    def chamar(metodo, handle, params):
        ident[0] += 1
        pedido = {"jsonrpc": "2.0", "id": ident[0], "method": metodo, "handle": handle, "params": params}
        pedidos.append(pedido)
        ws.enviar(pedido)
        while True:
            m = ws.receber()
            respostas.append(m)
            if m.get("id") == ident[0]:
                if "error" in m:
                    raise RuntimeError(f"Qlik {metodo}: {m['error']}")
                return m["result"]
    try:
        doc = chamar("OpenDoc", -1, [APP_VACINAS])["qReturn"]["qHandle"]
        chamar("GetAppLayout", doc, [])
        h = chamar("CreateSessionObject", doc, [CUBO_VACINAS])["qReturn"]["qHandle"]
        lay = chamar("GetLayout", h, [])["qLayout"]
        if lay["qHyperCube"]["qSize"]["qcy"] > CUBO_VACINAS["qHyperCubeDef"]["qInitialDataFetch"][0]["qHeight"]:
            raise RuntimeError("Qlik: hipercubo maior que a página pedida")
    finally:
        ws.fechar()
    return {"painel": URL_PAINEL_VACINAS, "app": APP_VACINAS, "pedidos": pedidos, "respostas": respostas}


def ler_pni(arq):
    """{ano: {vacina: cobertura %}} da resposta do TabNet do PNI (bloco data.addRows)."""
    t = arq.read_bytes().decode("latin-1")
    colunas = [limpo(c) for c in re.findall(r"data\.addColumn\('number','([^']*)'\)", t)]
    i = t.find("data.addRows([")
    j = t.find("]);", i)
    dados = {}
    for linha in t[i:j].splitlines():
        m = re.match(r'\s*,?\s*\["(\d{4})"\s*,(.*)\]\s*$', linha)
        if not m:
            continue
        vs = re.findall(r"\{v:\s*([-\d.eE]+)\s*,f:\s*'([^']*)'\}", m.group(2))
        if len(vs) != len(colunas):
            raise RuntimeError(f"{arq.name}: linha {m.group(1)} com {len(vs)} valores para {len(colunas)} colunas")
        dados[m.group(1)] = {c: (None if f == "-" else float(v)) for c, (v, f) in zip(colunas, vs)}
    return dados


def ler_painel_vacinas(arq):
    """{(ano, vacina): (doses, população)} e a data da última recarga do app."""
    d = json.loads(arq.read_text(encoding="utf-8"))
    recarga, cubo = None, None
    for r in d["respostas"]:
        res = r.get("result", {})
        if "qLayout" in res and "qLastReloadTime" in res["qLayout"]:
            recarga = res["qLayout"]["qLastReloadTime"]
        if "qLayout" in res and "qHyperCube" in res["qLayout"]:
            cubo = res["qLayout"]["qHyperCube"]
    valores = {}
    for linha in cubo["qDataPages"][0]["qMatrix"]:
        valores[(linha[0]["qText"], linha[1]["qText"])] = (linha[2]["qNum"], linha[3]["qNum"])
    return valores, recarga


def tratar_vacinas(saida, ctx):
    arq_pni = bruto(FONTE_TABNET, "pni_coberturas_ano")
    arq_painel = bruto(FONTE_PAINEL_VACINAS, "cobertura_residencia_ano_imunobiologico")
    pni = ler_pni(arq_pni)
    painel, recarga = ler_painel_vacinas(arq_painel)
    tv, dtp = {}, {}
    checagens = []
    for ano, v in sorted(pni.items()):
        if v["Tríplice Viral D1"] is not None:
            tv[ano] = v["Tríplice Viral D1"]
        a = int(ano)
        if a < 2002:
            dtp[ano] = v["DTP"]
        elif a <= 2003:
            # 2002-2003: a tetravalente substitui a DTP; o PNI manda somar as terceiras doses das duas
            dtp[ano] = v["DTP"] + v["Tetravalente(DTP/Hib)(TETRA)"]
        else:
            dtp[ano] = v["DTP"]  # desde 2004 o campo "DTP" já soma as vacinas com componente DTP
            if a <= 2011:
                checagens.append(0 <= v["DTP"] - v["Tetravalente(DTP/Hib)(TETRA)"] < 1)
            elif a == 2012:
                checagens.append(abs(v["DTP"] - v["Tetravalente(DTP/Hib)(TETRA)"] - v["Penta"]) < 0.05)
            else:
                checagens.append(0 <= v["DTP"] - v["Penta"] < 2.5)
    if not all(checagens):
        raise RuntimeError("TabNet PNI: o campo DTP não se comporta como total das vacinas com componente DTP")
    tv_rnds, dtp_rnds, conf = {}, {}, []
    for (ano, vacina), (doses, pop) in painel.items():
        if not re.fullmatch(r"\d{4}", ano) or not pop:
            continue
        cob = 100 * doses / pop
        if ANO_INICIAL_RNDS <= int(ano) < ANO_CORRENTE:
            if vacina == VACINA_TV_PAINEL:
                tv_rnds[ano] = cob
            elif vacina == VACINA_PENTA_PAINEL:
                dtp_rnds[ano] = cob
        if int(ano) < ANO_INICIAL_RNDS and vacina in (VACINA_TV_PAINEL, VACINA_PENTA_PAINEL):
            antigo = pni.get(ano, {}).get("Tríplice Viral D1" if vacina == VACINA_TV_PAINEL else "Penta")
            if antigo is not None:
                conf.append((ano, vacina, cob, antigo))
    pior = max(abs(c - a) for _, _, c, a in conf)
    if pior > 0.05:
        raise RuntimeError(f"painel de vacinação difere do TabNet nos anos comuns: {conf}")
    ctx["vacinas"] = (tv, dtp, tv_rnds, dtp_rnds)
    f = "cobertura_vacinal"
    u = "% das crianças da população-alvo"
    fonte_pni = "Ministério da Saúde, SI-PNI (TabNet/DataSUS, base até 2022)"
    saida.add(f, f"{f}__triplice_viral_d1_sipni", tv, u, fonte_pni + ", tríplice viral, 1ª dose, crianças de 1 ano",
              [arq_pni], 2)
    saida.add(f, f"{f}__dtp3_sipni", dtp, u,
              fonte_pni + ", vacinas com componente DTP, 3ª dose, menores de 1 ano (campo DTP; 2002-2003: DTP + tetravalente)",
              [arq_pni], 2)
    fonte_rnds = (f"Ministério da Saúde, painel Cobertura Vacinal - Residência (RNDS), extraído em "
                  f"{arq_painel.stem.rsplit('_', 1)[1]}")
    saida.add(f, f"{f}__triplice_viral_d1_rnds", tv_rnds, u, fonte_rnds + ", tríplice viral, 1ª dose, crianças de 1 ano",
              [arq_painel], 2)
    saida.add(f, f"{f}__dtp3_rnds", dtp_rnds, u,
              fonte_rnds + ", pentavalente (DTP/HepB/Hib), 3ª dose + hexavalente, 3ª dose, menores de 1 ano", [arq_painel], 2)
    denoms = {a: painel[(a, VACINA_TV_PAINEL)][1] for a in tv_rnds}
    nv = ctx.get("nv", {})
    saida.notas[f].append(
        f"Painel do Ministério: app Qlik {APP_VACINAS}, última recarga {recarga}. Doses e população-alvo usadas no "
        "cálculo (cobertura = doses / população × 100), tríplice viral D1: "
        + "; ".join(f"{a}: {br(painel[(a, VACINA_TV_PAINEL)][0])} doses / {br(denoms[a])}" for a in sorted(tv_rnds))
        + "; pentavalente D3: "
        + "; ".join(f"{a}: {br(painel[(a, VACINA_PENTA_PAINEL)][0])} / {br(painel[(a, VACINA_PENTA_PAINEL)][1])}"
                    for a in sorted(dtp_rnds))
        + ". Nascidos vivos no SINASC (TabNet) nos mesmos anos: "
        + "; ".join(f"{a}: {br(nv[a])}" for a in sorted(tv_rnds) if a in nv) + ".")
    saida.notas[f].append(
        f"Conferência entre sistemas: nos anos em que o painel e o TabNet se sobrepõem ({min(a for a, *_ in conf)} a "
        f"{max(a for a, *_ in conf)}), a tríplice viral D1 e a pentavalente do painel são iguais às do TabNet (diferença "
        f"máxima de {br(pior, 3)} ponto percentual). Campo DTP do TabNet contra tetravalente e pentavalente: "
        + "; ".join(f"{a}: DTP {fmt2(v['DTP'])}, tetra {fmt2(v['Tetravalente(DTP/Hib)(TETRA)'])}, penta "
                    f"{fmt2(v['Penta'])}" for a, v in sorted(pni.items()) if a in ("2002", "2003", "2004", "2011", "2012",
                                                                                 "2013", "2015", "2016", "2019", "2022"))
        + ". De 2013 a 2015 o campo da tetravalente continua perto de 95% a 98% depois da troca pela pentavalente (agosto de "
        "2012), o que indica outra composição do campo; por isso a série usa o campo DTP e confere a soma nos anos de "
        "transição.")
    return tv, dtp, tv_rnds, dtp_rnds


# ================================================================ ASPS da União (RREO: Tesouro e SIOPS)

FONTE_SIOPS = "siops"
URL_SIOPS = "http://siops.datasus.gov.br/msu_rreo.php?acao=a&ano={ano}&periodo=6&desc=6%BA%20Bimestre"
ANO_INICIAL_SIOPS = 2012  # de 2002 a 2011 o SIOPS diz "Os dados da União não foram homologados pelo Gestor"
FONTE_RREO_PDF = "tesouro_rreo"
PAG_RREO = "https://www.tesourotransparente.gov.br/publicacoes/relatorio-resumido-da-execucao-orcamentaria-rreo/{ano}/12"
# Anexo de saúde (ADCT, art. 77) do RREO da União de dezembro (Tesouro Nacional), "Cálculo do limite", em R$ mil:
# ano: (variação nominal do PIB usada no ano, %, aplicado no ano, aplicado no ano anterior, variação da aplicação, %,
#       página do PDF). "Aplicado" = total das despesas com ações e serviços públicos de saúde do anexo (despesas
#       liquidadas no ano, incluídos os restos a pagar inscritos, conforme a regra do 6º bimestre).
RREO_ASPS = {
    2002: (9.21, 23939100, 21659255, 10.53, 43),
    2003: (12.29, 27179332, 24735573, 9.88, 42),
    2004: (15.61, 32638719, 27132748, 20.29, 43),
    2005: (13.52, 37029236, 32638719, 13.45, 45),
    2006: (9.68, 40750155, 37029236, 10.05, 56),
    2007: (8.65, 44303491, 40750155, 8.72, 59),
    2008: (9.61, 48678681, 44303491, 9.88, 57),
    2009: (12.91, 58281095, 48678681, 19.73, 56),
    2010: (5.06, 61976698, 58281095, 6.34, 57),
    2011: (16.38, 72356648, 61976698, 16.75, 57),
    2012: (9.89, 80073777, 72356648, 10.67, 55),  # só conferência: 2012 vem do SIOPS
}
FONTE_SICONFI = "siconfi"
URL_ANEXO14 = ("https://apidatalake.tesouro.gov.br/ords/siconfi/tt/rreo?an_exercicio={ano}&nr_periodo=6"
               "&co_tipo_demonstrativo=RREO&no_anexo=RREO-Anexo%2014&id_ente=1")
ANO_INICIAL_ANEXO14 = 2015


def url_pdf_rreo(ano):
    pagina = pedir(PAG_RREO.format(ano=ano)).decode("utf-8", "replace")
    m = re.search(r"https://thot-arquivos\.tesouro\.gov\.br/publicacao/\d+", pagina)
    if not m:
        raise RuntimeError(f"RREO {ano}: link do arquivo não encontrado na página")
    visor = pedir(m.group(0)).decode("utf-8", "replace")
    m2 = re.search(r"src='([^']+\.pdf)'", visor)
    if not m2:
        raise RuntimeError(f"RREO {ano}: visualizador sem o endereço do PDF")
    return m2.group(1)


def coletar_asps(args):
    for ano in RREO_ASPS:
        nome = f"rreo_uniao_dezembro_{ano}"
        if bruto_opcional(FONTE_RREO_PDF, nome) and not args.rebaixar:
            obter(FONTE_RREO_PDF, nome, "pdf", PAG_RREO.format(ano=ano), reaproveitar=True)
            continue
        url = url_pdf_rreo(ano)
        obter(FONTE_RREO_PDF, nome, "pdf", url, validar=validar_pdf, reaproveitar=not args.rebaixar)

    def validar_siops(c):
        if b"ANEXO" not in c.upper() or b"SA\xdaDE" not in c.upper():
            raise RuntimeError(f"SIOPS: resposta sem o demonstrativo: {c[:200]!r}")
    for ano in range(ANO_INICIAL_SIOPS, ANO_CORRENTE):
        obter(FONTE_SIOPS, f"rreo_asps_uniao_{ano}", "html", URL_SIOPS.format(ano=ano), validar=validar_siops,
              reaproveitar=not args.rebaixar)
        time.sleep(1)

    def validar_siconfi(c):
        if not json.loads(c).get("items"):
            raise RuntimeError("Siconfi: anexo 14 sem itens")
    for ano in range(ANO_INICIAL_ANEXO14, ANO_CORRENTE):
        obter(FONTE_SICONFI, f"saude_rreo_anexo14_uniao_{ano}", "json", URL_ANEXO14.format(ano=ano),
              validar=validar_siconfi, reaproveitar=not args.rebaixar)


def linhas_html(arq):
    t = arq.read_bytes().decode("latin-1")
    t = re.sub(r"<(script|style).*?</\1>", "", t, flags=re.S | re.I)
    saida = []
    for r in re.findall(r"<tr.*?</tr>", t, flags=re.S | re.I):
        celulas = [limpo(html.unescape(re.sub(r"<[^>]+>", " ", c)))
                   for c in re.findall(r"<t[hd][^>]*>(.*?)</t[hd]>", r, flags=re.S | re.I)]
        if any(celulas):
            saida.append(celulas)
    return saida


def ler_siops(arq, ano):
    """(aplicado, piso, detalhes) em R$ do demonstrativo de ASPS da União no SIOPS."""
    linhas = linhas_html(arq)
    for i, l in enumerate(linhas):
        cab = " | ".join(l).upper()
        if l[0].upper().startswith("CÁLCULO DO LIMITE") and "PIB" in cab:
            dado = linhas[i + 1]
            pib, aplicado, anterior, var = num_br(dado[1]), num_br(dado[2]), num_br(dado[3]), num_br(dado[4])
            piso = anterior * (1 + pib / 100)
            return aplicado, piso, {"regra": "aplicado no ano anterior × (1 + variação nominal do PIB)",
                                    "pib": pib, "anterior": anterior, "variacao": var}
        if l[0].upper().startswith("APLICAÇÕES EM AÇÕES E SERVIÇOS PÚBLICOS DE SAÚDE") or \
                l[0].upper().startswith("APURAÇÃO DO CUMPRIMENTO DO LIMITE MÍNIMO"):
            dado = linhas[i + 1]
            escala = 1000 if "MILHARES" in cab else 1
            rot = [x.upper() for x in l[1:]]
            vals = [num_br(x) for x in dado[1:]]
            if len(vals) != len(rot):
                raise RuntimeError(f"{arq.name}: {len(vals)} valores para {len(rot)} colunas")
            def col(pred):
                ks = [k for k, r in enumerate(rot) if pred(r)]
                if len(ks) != 1:
                    raise RuntimeError(f"{arq.name}: coluna ambígua ou ausente: {rot}")
                return vals[ks[0]] * escala
            if any("EMPENHADAS" in r for r in rot):
                aplicado = col(lambda r: "EMPENHADAS" in r)
            else:  # 2013 a 2015: "despesas liquidadas até o bimestre" = executadas (liquidadas + RP não processados)
                aplicado = col(lambda r: "LIQUIDADAS" in r and str(ano) in r)
            piso = col(lambda r: "MÍNIMO" in r and "ANTERIOR" not in r and "ANTIGA" not in r and "PERCENTUAL" not in r)
            det = {"colunas": rot, "valores": vals, "escala": escala}
            difs = [v for r, v in zip(rot, vals) if "DIFERENÇA" in r]
            if difs and abs(aplicado - piso - difs[0] * escala) > 2 * escala:
                raise RuntimeError(f"{arq.name}: aplicado - piso não bate com a diferença publicada")
            if any("REGRA NOVA" in r for r in rot):
                det["regra"] = "piso do ano anterior corrigido pelo IPCA (EC 95/2016)"
            elif any("PERCENTUAL MÍNIMO" in r for r in rot) or any("15% DA RCL" in r or "15% RCL" in r for r in rot):
                det["regra"] = "percentual da receita corrente líquida"
            elif any("IPCA" in r for r in rot):
                det["regra"] = "piso do ano anterior corrigido pelo IPCA (EC 95/2016)"
            elif any("PIB" in r for r in rot):
                det["regra"] = "aplicado no ano anterior × (1 + variação nominal do PIB) (LC 141/2012, art. 5º)"
            return aplicado, piso, det
    raise RuntimeError(f"{arq.name}: quadro de aplicação mínima não encontrado")


REGRAS_PISO = {  # regra vigente em cada ano, para a coluna fonte
    **{a: "EC 29/2000 (ADCT, art. 77): aplicado no ano anterior corrigido pela variação nominal do PIB" for a in range(2002, 2012)},
    **{a: "LC 141/2012, art. 5º: empenhado no ano anterior corrigido pela variação nominal do PIB" for a in range(2012, 2016)},
    2016: "EC 86/2015: 13,2% da receita corrente líquida",
    2017: "EC 95/2016 (ADCT, art. 110, I): 15% da receita corrente líquida",
    **{a: "EC 95/2016 (ADCT, art. 110, II): piso do ano anterior corrigido pelo IPCA de julho a junho" for a in range(2018, 2021)},
    **{a: "EC 95/2016 com a EC 113/2021: piso do ano anterior corrigido pelo IPCA de janeiro a dezembro do ano anterior"
       for a in range(2021, 2024)},
}
# Relatório Anual de Gestão 2024 do Ministério da Saúde, Quadro 3 ("Histórico do Cálculo da Aplicação Mínima
# Constitucional em Saúde"), p. 7, em R$ bilhões: ano: (piso, aplicação total em ASPS computada no piso). Conferência.
URL_RAG2024 = "https://bvsms.saude.gov.br/bvs/publicacoes/relatorio_anual_gestao_2024.pdf"
RAG2024_ASPS = {"2017": (109.1, 114.7), "2018": (112.4, 116.8), "2019": (117.3, 122.3), "2020": (121.2, 161.0),
                "2021": (127.0, 179.1), "2022": (139.8, 151.9), "2023": (147.9, 179.8), "2024": (214.5, 215.9)}


def regra_piso(ano):
    return REGRAS_PISO.get(ano, "CF, art. 198, § 2º, I (restabelecido com o fim do teto de gastos, EC 126/2022 e "
                                "LC 200/2023): 15% da receita corrente líquida")


def tratar_asps(saida, ctx):
    ipca = ipca_medio_anual()
    aplicado, piso, arquivos, detalhes = {}, {}, {}, {}
    for ano in range(2002, ANO_INICIAL_SIOPS):
        pib, apl, ant, var, pag = RREO_ASPS[ano]
        if abs(round((apl / ant - 1) * 100, 2) - var) > 0.011:
            raise RuntimeError(f"RREO {ano}: variação da aplicação transcrita não confere")
        aplicado[str(ano)] = apl * 1000
        piso[str(ano)] = ant * 1000 * (1 + pib / 100)
        arquivos[str(ano)] = bruto(FONTE_RREO_PDF, f"rreo_uniao_dezembro_{ano}")
        detalhes[str(ano)] = f"RREO dez/{ano}, anexo de saúde, PDF p. {pag}"
    regras_siops, anterior_siops = {}, {}
    for ano in range(ANO_INICIAL_SIOPS, ANO_CORRENTE):
        arq = bruto(FONTE_SIOPS, f"rreo_asps_uniao_{ano}")
        apl, p, det = ler_siops(arq, ano)
        aplicado[str(ano)], piso[str(ano)] = apl, p
        arquivos[str(ano)] = arq
        regras_siops[ano] = det.get("regra")
        detalhes[str(ano)] = "SIOPS, demonstrativo do RREO da União (6º bimestre)"
        if "anterior" in det:  # 2012: quadro "cálculo do limite"
            anterior_siops[ano] = det["anterior"]
        for r, v in zip(det.get("colunas", []), det.get("valores", [])):
            if "EXECUTADAS ATÉ O ÚLTIMO BIMESTRE" in r:  # 2013 a 2015: base do piso = executado no ano anterior
                anterior_siops[ano] = v * det["escala"]
    # conferência 1: RREO de 2012 em PDF contra o SIOPS de 2012
    pib, apl12, ant12, var12, _ = RREO_ASPS[2012]
    dif_2012 = aplicado["2012"] - apl12 * 1000
    # conferência 2: continuidade (aplicado de t−1 publicado no RREO de t contra o publicado no RREO de t−1), até 2015,
    # quando o piso ainda partia do aplicado no ano anterior
    revisoes = {a: RREO_ASPS[a][2] * 1000 - aplicado[str(a - 1)] for a in range(2003, ANO_INICIAL_SIOPS)}
    revisoes.update({a: v - aplicado[str(a - 1)] for a, v in anterior_siops.items()})
    # conferência 3: anexo 14 do RREO (Siconfi). As colunas mudam de conteúdo conforme o ano (percentual da RCL,
    # valor em reais, valores trocados em 2019), então a conferência só relata, sem parar o script.
    conf14 = []
    for ano in range(ANO_INICIAL_ANEXO14, ANO_CORRENTE):
        arq14 = bruto_opcional(FONTE_SICONFI, f"saude_rreo_anexo14_uniao_{ano}")
        if not arq14:
            continue
        itens = [x for x in json.loads(arq14.read_text(encoding="utf-8"))["items"]
                 if "Saude" in x["cod_conta"] or "Saúde" in x["conta"]]
        minimo = [x["valor"] for x in itens if x["coluna"].startswith("% Mínimo")]
        apurado = [x["valor"] for x in itens if x["coluna"].startswith("Valor Apurado")]
        conf14.append((ano, minimo[0] if minimo else None, apurado[0] if apurado else None))

    def descreve14(ano, m14, v14):
        p, a = piso[str(ano)], aplicado[str(ano)]
        perto = lambda x, y: x is not None and abs(x - y) <= max(1000.0, 1e-6 * y)
        partes = []
        for rot, x in (("mínimo", m14), ("valor apurado", v14)):
            if x is None:
                continue
            if x < 1000:
                partes.append(f"{rot} {br(x, 2)} (percentual)")
            elif perto(x, p):
                partes.append(f"{rot} R$ {br(x / 1e9, 3)} bi = piso do SIOPS")
            elif perto(x, a):
                partes.append(f"{rot} R$ {br(x / 1e9, 3)} bi = aplicado do SIOPS")
            else:
                partes.append(f"{rot} R$ {br(x / 1e9, 3)} bi (piso R$ {br(p / 1e9, 3)} bi, aplicado R$ {br(a / 1e9, 3)} bi "
                              "no SIOPS)")
        return f"{ano}: " + ", ".join(partes)
    # conferência 4: Relatório Anual de Gestão 2024 do Ministério (R$ bilhões, uma casa)
    for a, (p_rag, a_rag) in RAG2024_ASPS.items():
        if abs(piso[a] / 1e9 - p_rag) > 0.051 or abs(aplicado[a] / 1e9 - a_rag) > 0.051:
            raise RuntimeError(f"ASPS {a}: piso {piso[a]} e aplicado {aplicado[a]} contra {p_rag} e {a_rag} no RAG 2024")
    ctx["asps"] = (aplicado, piso)
    s = "despesa_asps_uniao"
    fontes = {a: f"{detalhes[a]}; piso: {regra_piso(int(a))}" for a in aplicado}
    u_real, u_nom = f"R$ bilhões de {ANO_BASE_REAL} (IPCA, média do ano)", "R$ bilhões correntes"
    defl = f"; reais de {ANO_BASE_REAL} pelo IPCA médio do ano (SIDRA 1737)"
    arqs = sorted(set(arquivos.values()))
    dif = {a: aplicado[a] - piso[a] for a in aplicado}
    for rotulo, dados, texto in (("aplicado", aplicado, "despesa empenhada em ASPS computada no mínimo constitucional"),
                                 ("piso", piso, "aplicação mínima constitucional do ano"),
                                 ("aplicado_menos_piso", dif, "aplicado menos piso")):
        saida.add(s, f"{s}__{rotulo}_real", {a: v / 1e9 for a, v in real(dados, ipca).items()}, u_real,
                  f"RREO da União, ASPS, {texto}{defl}", arqs, 3,
                  fonte_por_data={a: f"{texto}{defl}; {fontes[a]}" for a in dados})
        saida.add(s, f"{s}__{rotulo}_nominal", {a: v / 1e9 for a, v in dados.items()}, u_nom,
                  f"RREO da União, ASPS, {texto}", arqs, 3,
                  fonte_por_data={a: f"{texto}; {fontes[a]}" for a in dados})
    pop = ctx.get("pop", {})
    saida.notas[s].append(
        "Aplicado e piso em R$ bilhões correntes (fonte de cada ano): "
        + "; ".join(f"{a}: {br(aplicado[a] / 1e9, 3)} e {br(piso[a] / 1e9, 3)} ({detalhes[a]})" for a in sorted(aplicado)) + ".")
    saida.notas[s].append(
        "Revisões do valor de um ano no RREO seguinte, que é a base do piso até 2015 (aplicado do ano publicado no RREO do "
        "ano seguinte menos o publicado no RREO do próprio ano, R$ milhões): "
        + "; ".join(f"{a - 1}: {br(v / 1e6, 1)}" for a, v in sorted(revisoes.items()))
        + f". RREO de dezembro de 2012 em PDF contra o SIOPS de 2012: aplicado de R$ {br(apl12 / 1e3, 1)} milhões no PDF e "
        f"R$ {br(aplicado['2012'] / 1e6, 1)} milhões no SIOPS (diferença de R$ {br(dif_2012 / 1e6, 1)} milhões); vale o SIOPS.")
    saida.notas[s].append(
        "Conferência com o anexo 14 (demonstrativo simplificado) do RREO da União no Siconfi, linha das despesas com ASPS, "
        "colunas \"% Mínimo a Aplicar no Exercício\" e \"Valor Apurado Até o Bimestre\" (o conteúdo das colunas muda conforme "
        "o ano): " + "; ".join(descreve14(*c) for c in conf14) + ".")
    saida.notas[s].append(
        f"Conferência com o Relatório Anual de Gestão 2024 do Ministério da Saúde ({URL_RAG2024}, Quadro 3, p. 7, R$ bilhões): "
        + "; ".join(f"{a}: piso {br(p, 1)} e aplicado {br(x, 1)} no relatório, {br(piso[a] / 1e9, 1)} e "
                    f"{br(aplicado[a] / 1e9, 1)} aqui" for a, (p, x) in RAG2024_ASPS.items())
        + ". O mesmo relatório (Quadro 2, p. 6) dá o piso de 2024 em R$ 214.505.348.100 (15% da RCL de R$ 1.430.035.654.000); "
        f"o demonstrativo do SIOPS traz R$ {br(piso['2024'], 2)}, uma diferença de R$ {br(214505348100 - piso['2024'], 2)} "
        "(vale o SIOPS, fonte da série). Em 2021 o demonstrativo usa como base o piso de 2020 recalculado pela EC 113/2021 "
        "(IPCA de janeiro a dezembro), e não o piso publicado no RREO de 2020; a série guarda o piso publicado em cada ano.")
    if pop:
        saida.notas[s].append(
            "Conferência com a ficha REC.3.04 da RIPSA (2026, Tabela 1, p. 3, gasto federal com ASPS per capita, R$ correntes): "
            + "; ".join(f"{a}: {br(RIPSA_ASPS_PER_CAPITA[a], 2)} na ficha e {br(aplicado[a] / pop[a], 2)} pelo aplicado do "
                        f"SIOPS ÷ população das projeções" for a in sorted(RIPSA_ASPS_PER_CAPITA) if a in aplicado and a in pop)
            + ". A ficha soma ao aplicado as despesas pagas com a disponibilidade dos restos a pagar cancelados (por "
            "exemplo, R$ 2,57 bilhões em 2023 e R$ 1,32 bilhão em 2022, linhas do próprio demonstrativo do SIOPS), que não "
            "contam para o mínimo.")
    return aplicado, piso


# ================================================================ CNES: leitos e médicos

LEITOS = ("cnes/cnv/leiintbr.def", r"ltbr(\d{2})12\.dbf")
MEDICOS_CBO2002 = ("cnes/cnv/prid02br.def", r"pfbr(\d{2})12\.dbf")
MEDICOS_CBO1994 = ("cnes/cnv/profidbr.def", r"pfbr(\d{2})12\.dbf")
IDB_LEITOS = ("idb2012/e03b.def", r"leit(\d{2})\.dbf")  # IDB 2012 da RIPSA, indicador E.3 (leitos, CNES/MS): conferência


def coletar_cnes(args):
    anos = set(range(ANO_INICIAL, ANO_CORRENTE))
    defn, padrao = LEITOS
    consultar_tabnet("cnes_leitos_internacao_dezembro", defn,
                     corpo_tabnet("Ano/mês_compet.", "--Não-Ativa--", ["Quantidade_existente", "Quantidade_SUS"],
                                  arquivos_tabnet(defn, padrao, anos)))
    for nome, (defn, padrao) in (("cnes_medicos_cbo2002_dezembro", MEDICOS_CBO2002),
                                 ("cnes_medicos_cbo1994_dezembro", MEDICOS_CBO1994)):
        form = formulario_tabnet(defn)
        medicos = [v for v, _ in opcoes_tabnet(form, "SMédicos") if v != "TODAS_AS_CATEGORIAS__"]
        consultar_tabnet(nome, defn, corpo_tabnet("Ano/mês_compet.", "--Não-Ativa--", ["Quantidade"],
                                                  arquivos_tabnet(defn, padrao, anos), [("SMédicos", medicos)]))
    # conferências: médicos do último dezembro por região (cada indivíduo contado uma vez: a soma dá o total) e
    # leitos do IDB 2012 (CNES/MS), publicação da RIPSA
    defn, padrao = MEDICOS_CBO2002
    form = formulario_tabnet(defn)
    medicos = [v for v, _ in opcoes_tabnet(form, "SMédicos") if v != "TODAS_AS_CATEGORIAS__"]
    ultimo = arquivos_tabnet(defn, padrao, anos)[:1]
    consultar_tabnet("cnes_medicos_cbo2002_regiao_ultimo_dezembro", defn,
                     corpo_tabnet("Região", "--Não-Ativa--", ["Quantidade"], ultimo, [("SMédicos", medicos)]))
    defn, padrao = IDB_LEITOS
    consultar_tabnet("idb2012_leitos_cnes_ano", defn,
                     corpo_tabnet("Ano", "--Não-Ativa--", ["Leitos_existentes", "Leitos_SUS"],
                                  arquivos_tabnet(defn, padrao, anos)))


def serie_cnes(arq, coluna=0):
    cab, dados, total, _ = ler_tabnet(arq)
    serie = {}
    for k, v in dados.items():
        m = re.fullmatch(r"(\d{4})/Dez", k)
        if not m:
            raise RuntimeError(f"{arq.name}: competência inesperada {k}")
        serie[m.group(1)] = v[coluna]
    return serie, cab


def tratar_cnes(saida, ctx):
    pop = ctx["pop"]
    arq_pop = ctx["arq_proj"]
    arq_l = bruto(FONTE_TABNET, "cnes_leitos_internacao_dezembro")
    total, cab = serie_cnes(arq_l, 0)
    sus, _ = serie_cnes(arq_l, 1)
    if cab[1:] != ["Quantidade existente", "Quantidade SUS"]:
        raise RuntimeError(f"{arq_l.name}: colunas inesperadas {cab}")
    arq_m = bruto(FONTE_TABNET, "cnes_medicos_cbo2002_dezembro")
    arq_m94 = bruto(FONTE_TABNET, "cnes_medicos_cbo1994_dezembro")
    med, _ = serie_cnes(arq_m)
    med94, _ = serie_cnes(arq_m94)
    por_mil = lambda d: {a: 1000 * v / pop[a] for a, v in d.items() if a in pop}
    ctx["leitos"], ctx["medicos"] = (total, sus), (med, med94)
    f = "leitos"
    fonte = "Ministério da Saúde, CNES (TabNet/DataSUS), leitos de internação, competência de dezembro"
    fpop = "; população: IBGE, Projeções da População, revisão 2024 (1º de julho)"
    saida.add(f, f"{f}__total_por_mil_hab", por_mil(total), "leitos por mil habitantes", fonte + ", quantidade existente" + fpop,
              [arq_l, arq_pop], 3)
    saida.add(f, f"{f}__sus_por_mil_hab", por_mil(sus), "leitos por mil habitantes", fonte + ", quantidade SUS" + fpop,
              [arq_l, arq_pop], 3)
    saida.add(f, f"{f}__total", total, "leitos", fonte + ", quantidade existente", [arq_l], 0)
    saida.add(f, f"{f}__sus", sus, "leitos", fonte + ", quantidade SUS", [arq_l], 0)
    f = "medicos"
    fonte = "Ministério da Saúde, CNES (TabNet/DataSUS), profissionais (indivíduos) nas ocupações de médico, dezembro"
    saida.add(f, f"{f}__cnes_por_mil_hab", por_mil(med), "médicos por mil habitantes", fonte + ", CBO 2002" + fpop,
              [arq_m, arq_pop], 3)
    saida.add(f, f"{f}__cnes_cbo1994_por_mil_hab", por_mil(med94), "médicos por mil habitantes", fonte + ", CBO 1994" + fpop,
              [arq_m94, arq_pop], 3)
    saida.add(f, f"{f}__cnes", med, "médicos", fonte + ", CBO 2002", [arq_m], 0)
    saida.add(f, f"{f}__cnes_cbo1994", med94, "médicos", fonte + ", CBO 1994", [arq_m94], 0)
    arq_idb = bruto_opcional(FONTE_TABNET, "idb2012_leitos_cnes_ano")
    if arq_idb:
        _, idb, _, _ = ler_tabnet(arq_idb)
        saida.notas["leitos"].append(
            "Conferência com o indicador E.3 dos Indicadores e Dados Básicos de 2012 da RIPSA (\"Número de leitos hospitalares "
            f"por habitante - CNES/MS\", TabNet idb2012/e03b.def, `{arq_idb.relative_to(RAIZ)}`): "
            + "; ".join(f"{a}: {br(v[0])} leitos existentes e {br(v[1])} SUS no IDB, {br(total[a])} e {br(sus[a])} aqui"
                        f" ({br(100 * (total[a] / v[0] - 1), 2)}% e {br(100 * (sus[a] / v[1] - 1), 2)}%)"
                        for a, v in sorted(idb.items()) if a in total)
            + ". O IDB usa outra competência ou versão da base do CNES; só 2005 coincide exatamente.")
    arq_reg = bruto_opcional(FONTE_TABNET, "cnes_medicos_cbo2002_regiao_ultimo_dezembro")
    if arq_reg:
        _, reg, tot_reg, _ = ler_tabnet(arq_reg)
        ultimo_ano = max(med)
        saida.notas["medicos"].append(
            f"Conferência interna: em dezembro de {ultimo_ano}, a consulta por região (`{arq_reg.relative_to(RAIZ)}`) soma "
            f"{br(sum(v[0] for v in reg.values()))} médicos ({'; '.join(f'{k}: {br(v[0])}' for k, v in reg.items())}), "
            f"o mesmo total da série ({br(med[ultimo_ano])}): na consulta \"Profissionais - Indivíduos\" cada pessoa entra "
            "uma vez.")
        if abs(sum(v[0] for v in reg.values()) - med[ultimo_ano]) > 0.5:
            raise RuntimeError("CNES: médicos por região não somam o total do Brasil")
    saida.notas["leitos"].append(
        "Conferência com a ficha REC.2.01 da RIPSA (2026, Tabela 1, p. 5), que usa outro recorte (competência de julho; "
        "inclui os leitos de UTI e exclui reabilitação, acolhimento noturno e leitos de covid-19): "
        + "; ".join(f"{a}: {br(t, 2)} leitos por mil (SUS {br(s_, 2)}) na ficha e {br(1000 * total[a] / pop[a], 2)} (SUS "
                    f"{br(1000 * sus[a] / pop[a], 2)}) aqui" for a, (t, s_) in sorted(RIPSA_LEITOS.items()) if a in total)
        + ".")
    return total, sus, med, med94


# ================================================================ atenção primária (Relatórios Públicos da APS)

FONTE_APS = "ms_relatorios_aps"
# Relatório de Gestão 2019 do Ministério da Saúde, "Evolução da cobertura populacional (em %)" das equipes de Saúde da
# Família, p. 31 (PDF p. 32); 2019 = "64,34%", o valor de outubro de 2019, último mês disponível na redação. Conferência.
URL_RG2019 = "https://bvsms.saude.gov.br/bvs/publicacoes/relatorio_gestao_ministerio_saude_2019.pdf"
RG2019_SF = {"2014-12": 61.07, "2015-12": 62.50, "2016-12": 62.62, "2017-12": 63.91, "2018-12": 64.19, "2019-10": 64.34}
API_APS = "https://relatorioaps-prd.saude.gov.br/cobertura/{tipo}?unidadeGeografica=BRASIL&nuCompInicio=200701&nuCompFim={fim}"
TIPOS_APS = {"ab": "cobertura_ab_brasil", "pns": "cobertura_pns_brasil", "aps": "cobertura_aps_potencial_brasil"}
MESES_EN = {m: i + 1 for i, m in enumerate(["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"])}


def coletar_aps(args):
    fim = f"{ANO_CORRENTE}12"

    def validar(c):
        d = json.loads(c)
        if not isinstance(d, list) or not d:
            raise RuntimeError(f"Relatórios da APS: resposta vazia {c[:200]!r}")
    for tipo, nome in TIPOS_APS.items():
        url = API_APS.format(tipo=tipo, fim=fim)
        obter(FONTE_APS, nome, "json", url, validar=validar)


def comp_aps(texto):
    """'200712', '12/2007' ou 'DEC/2007' -> '2007-12'."""
    t = texto.strip().upper()
    if re.fullmatch(r"\d{6}", t):
        return f"{t[:4]}-{t[4:]}"
    m = re.fullmatch(r"(\d{2})/(\d{4})", t)
    if m:
        return f"{m.group(2)}-{m.group(1)}"
    m = re.fullmatch(r"([A-Z]{3})/(\d{4})", t)
    if m:
        return f"{m.group(2)}-{MESES_EN[m.group(1)]:02d}"
    raise RuntimeError(f"competência inesperada: {texto}")


def num_aps(x):
    return float(str(x).replace(",", "")) if x not in (None, "") else None


def tratar_aps(saida, ctx):
    arqs = {t: bruto(FONTE_APS, n) for t, n in TIPOS_APS.items()}
    ab = json.loads(arqs["ab"].read_text(encoding="utf-8"))
    pns = json.loads(arqs["pns"].read_text(encoding="utf-8"))
    aps = json.loads(arqs["aps"].read_text(encoding="utf-8"))
    cob_ab = {comp_aps(r["nuComp"]): num_aps(r["pcCoberturaAb"]) for r in ab}
    cob_sf = {comp_aps(r["nuComp"]): num_aps(r["pcCoberturaSf"]) for r in ab}
    # MAY/2024 vem com zero cadastro e zero cobertura: competência sem dado, fica fora
    cob_pns = {comp_aps(r["nuComp"]): float(r["vlCobertura"]) for r in pns if r["qtTotalCadastroApsPaga"]}
    cob_aps = {comp_aps(r["nuComp"]): float(r["qtCobertura"]) for r in aps}
    # conferência interna: a cobertura publicada é a população coberta estimada / população
    pior = max(abs(100 * num_aps(r["qtCoberturaAb"]) / num_aps(r["qtPopulacao"]) - num_aps(r["pcCoberturaAb"])) for r in ab)
    pior_aps = max(abs(100 * r["qtCapacidadeEquipe"] / r["qtPopulacao"] - r["qtCobertura"]) for r in aps)
    sem_corrente = lambda d: {m: v for m, v in d.items() if m < MES_CORRENTE and v is not None}
    # apoio: os mesmos numeradores sobre uma só série de população (revisão 2024 das projeções do IBGE), porque o
    # denominador oficial muda de base no meio das séries (ver a ficha)
    pop = ctx["pop_com_ano_corrente"]
    denominadores = defaultdict(dict)  # série -> {ano: população da primeira competência do ano}
    for chave, registros, campo in (("ab", ab, lambda r: num_aps(r["qtPopulacao"])), ("aps", aps, lambda r: r["qtPopulacao"])):
        for r in sorted(registros, key=lambda r: comp_aps(r["nuComp"])):
            denominadores[chave].setdefault(comp_aps(r["nuComp"])[:4], campo(r))
    ab_pop = {comp_aps(r["nuComp"]): 100 * num_aps(r["qtCoberturaAb"]) / pop[comp_aps(r["nuComp"])[:4]] for r in ab}
    sf_pop = {comp_aps(r["nuComp"]): 100 * num_aps(r["qtCoberturaSf"]) / pop[comp_aps(r["nuComp"])[:4]] for r in ab}
    aps_pop = {comp_aps(r["nuComp"]): 100 * r["qtCapacidadeEquipe"] / pop[comp_aps(r["nuComp"])[:4]] for r in aps}
    f = "cobertura_atencao_primaria"
    u = "% da população"
    base = "Ministério da Saúde (SAPS), Relatórios Públicos da APS (antigo e-Gestor AB)"
    saida.add(f, f"{f}__atencao_basica", sem_corrente(cob_ab), u,
              base + ", Cobertura da Atenção Básica (eSF × 3.450 + equipes de atenção básica parametrizadas e equivalentes "
              "× 3.000, sobre a população), 2007-2020", [arqs["ab"]], 2)
    saida.add(f, f"{f}__saude_da_familia", sem_corrente(cob_sf), u,
              base + ", Cobertura da Estratégia Saúde da Família (eSF × 3.450, sobre a população), 2007-2020", [arqs["ab"]], 2)
    saida.add(f, f"{f}__potencial_aps", sem_corrente(cob_aps), u,
              base + ", Cobertura Potencial Estimada da APS (NT 2/2025-SAPS: eSF × 3.500, eAP 20h × 1.750, eAP 30h × 2.625 e "
              "cadastros de eCR, eSFR e eAPP, sobre a população), desde 2021", [arqs["aps"]], 2)
    saida.add(f, f"{f}__cadastro_pns", sem_corrente(cob_pns), u,
              base + ", Cobertura da APS do Plano Nacional de Saúde 2020-2023 (cadastros vinculados às equipes pagas pelo "
              "Previne Brasil, sobre a população)", [arqs["pns"]], 2)
    arq_pop = ctx["arq_proj"]
    fpop = (" ÷ população residente da revisão 2024 das Projeções da População do IBGE (1º de julho do ano da competência); "
            "série de apoio calculada pelo projeto")
    saida.add(f, f"{f}__atencao_basica_pop_ibge2024", sem_corrente(ab_pop), u,
              base + ", Cobertura da Atenção Básica: população coberta estimada" + fpop, [arqs["ab"], arq_pop], 2)
    saida.add(f, f"{f}__saude_da_familia_pop_ibge2024", sem_corrente(sf_pop), u,
              base + ", Cobertura da Saúde da Família: população coberta estimada" + fpop, [arqs["ab"], arq_pop], 2)
    saida.add(f, f"{f}__potencial_aps_pop_ibge2024", sem_corrente(aps_pop), u,
              base + ", Cobertura Potencial da APS: capacidade das equipes" + fpop, [arqs["aps"], arq_pop], 2)
    ctx["aps"] = (cob_ab, cob_sf, cob_aps, cob_pns)

    difs_rg = {m: cob_sf.get(m) for m, v in RG2019_SF.items() if cob_sf.get(m) is None or abs(cob_sf[m] - v) > 0.005}
    if difs_rg:
        raise RuntimeError(f"cobertura da Saúde da Família difere do Relatório de Gestão 2019: {difs_rg}")
    saida.notas[f].append(
        f"Conferência com o Relatório de Gestão 2019 do Ministério da Saúde ({URL_RG2019}, p. 31, cobertura populacional das "
        "equipes de Saúde da Família): " + "; ".join(f"{m}: {br(v, 2)}" for m, v in RG2019_SF.items())
        + ", iguais à série (o relatório dá para 2019 o valor de outubro, o último disponível quando foi escrito; dezembro de "
        f"2019 é {br(cob_sf['2019-12'], 2)}). O Relatório Anual de Gestão 2024 informa outra medida para dezembro de 2024 "
        "(72%, indicador do Plano Nacional de Saúde 2024–2027, Portaria GM/MS 3.493/2024), que não tem série publicada.")
    saltos, anterior = [], None
    for r in sorted(aps, key=lambda r: comp_aps(r["nuComp"])):
        if anterior and abs(r["qtPopulacao"] - anterior[1]) > 5e6:
            saltos.append(f"{anterior[0]} para {comp_aps(r['nuComp'])}: {br(anterior[1] / 1e6, 1)} para "
                          f"{br(r['qtPopulacao'] / 1e6, 1)} milhões")
        anterior = (comp_aps(r["nuComp"]), r["qtPopulacao"])

    def lista_denominadores(chave):
        return "; ".join(f"{ano}: {br(v / 1e6, 1)} milhões" for ano, v in sorted(denominadores[chave].items()))
    saida.notas[f].append(
        "População usada pelo Ministério no denominador (primeira competência de cada ano): cobertura da atenção básica, "
        + lista_denominadores("ab") + "; cobertura potencial, " + lista_denominadores("aps")
        + ". Saltos de mais de 5 milhões entre competências seguidas na cobertura potencial: " + "; ".join(saltos)
        + " (em 2023 e 2024 o denominador é a contagem do Censo 2022; desde 2025, as estimativas revistas). As séries "
        "`_pop_ibge2024` dividem os mesmos numeradores pela população da revisão 2024 das projeções, a mesma dos leitos e "
        "médicos, para retirar esse efeito.")
    saida.notas[f].append(
        f"Conferências internas: na série de 2007-2020, a cobertura publicada é a população coberta estimada dividida pela "
        f"população (diferença máxima de {br(pior, 3)} ponto percentual); na cobertura potencial, a capacidade das equipes "
        f"dividida pela população (diferença máxima de {br(pior_aps, 3)}). Valores de dezembro: "
        + "; ".join(f"{a}: AB {br(cob_ab[f'{a}-12'], 2)}, SF {br(cob_sf[f'{a}-12'], 2)}" for a in range(2007, 2021)
                    if f"{a}-12" in cob_ab)
        + "; potencial: " + "; ".join(f"{a}: {br(cob_aps[f'{a}-12'], 2)}" for a in range(2021, ANO_CORRENTE) if f"{a}-12" in cob_aps)
        + "; cadastro (PNS): " + "; ".join(f"{a}: {br(cob_pns[f'{a}-12'], 2)}" for a in range(2020, ANO_CORRENTE)
                                          if f"{a}-12" in cob_pns) + ".")
    return cob_ab, cob_sf, cob_aps, cob_pns


# ================================================================ comparação com pares (Banco Mundial, WDI)

FONTE_WDI = "bm_wdi"
INDICADORES_WDI = {  # código -> (ficha, base da série, unidade, unidade da diferença, casas)
    "SP.DYN.IMRT.IN": ("bm_mortalidade_infantil", "bm_mortalidade_infantil", "por mil nascidos vivos", "por mil nascidos vivos", 2),
    "SH.STA.MMRT": ("bm_mortalidade_materna", "bm_mortalidade_materna", "por 100 mil nascidos vivos", "por 100 mil nascidos vivos", 0),
    "SP.DYN.LE00.IN": ("bm_esperanca_vida", "bm_esperanca_vida", "anos", "anos", 2),
    "SH.IMM.MEAS": ("bm_vacinacao", "bm_vacinacao__sarampo", "% das crianças de 12 a 23 meses", "p.p.", 0),
    "SH.IMM.IDPT": ("bm_vacinacao", "bm_vacinacao__dtp3", "% das crianças de 12 a 23 meses", "p.p.", 0),
    "SH.XPD.GHED.GD.ZS": ("bm_gasto_publico_saude", "bm_gasto_publico_saude", "% do PIB", "p.p.", 4),
}
ORIGEM_WDI = {"SP.DYN.IMRT.IN": "UN IGME", "SH.STA.MMRT": "estimativa modelada do MMEIG (OMS, Unicef, UNFPA, Banco Mundial e ONU)",
              "SP.DYN.LE00.IN": "Divisão de População da ONU e institutos nacionais", "SH.IMM.MEAS": "OMS e Unicef (WUENIC)",
              "SH.IMM.IDPT": "OMS e Unicef (WUENIC)", "SH.XPD.GHED.GD.ZS": "OMS, Global Health Expenditure Database"}


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 url_wdi():
    paises = sorted({p for ps in ler_grupos().values() for p in ps})
    return (f"https://api.worldbank.org/v2/country/{';'.join(['BRA'] + paises)}/indicator/"
            f"{';'.join(INDICADORES_WDI)}?source=2&format=json&per_page=20000&date={ANO_INICIAL}:{ANO_CORRENTE}")


def coletar_wdi(args):
    def validar(c):
        d = json.loads(c.decode("utf-8-sig"))  # a API às vezes manda BOM
        if not (isinstance(d, list) and len(d) == 2 and isinstance(d[1], list)):
            raise RuntimeError(f"resposta inesperada do Banco Mundial: {c[:300]!r}")
        if d[0].get("total") != len(d[1]):
            raise RuntimeError(f"resposta paginada ({d[0]}); aumente per_page")
    obter(FONTE_WDI, "wdi_saude", "json", url_wdi(), validar=validar)
    for codigo in INDICADORES_WDI:  # um indicador por consulta: com vários a resposta de metadados vem vazia
        url = f"https://api.worldbank.org/v2/indicator/{codigo}?format=json"
        obter(FONTE_WDI, f"wdi_metadados_{codigo.replace('.', '-')}", "json", url)
        time.sleep(1)


def tratar_wdi(saida, ctx):
    arq = bruto(FONTE_WDI, "wdi_saude")
    cab, registros = json.loads(arq.read_bytes().decode("utf-8-sig"))
    por_ind = defaultdict(lambda: defaultdict(dict))
    for r in registros:
        if r["value"] is not None and r["date"] < str(ANO_CORRENTE):
            por_ind[r["indicator"]["id"]][r["countryiso3code"]][r["date"]] = float(r["value"])
    grupos = ler_grupos()
    resumo = {}
    for codigo, (ficha, base, unidade, unidade_dif, casas) in INDICADORES_WDI.items():
        arq_meta = bruto_opcional(FONTE_WDI, f"wdi_metadados_{codigo.replace('.', '-')}")
        arqs = [arq] + ([arq_meta] if arq_meta else [])
        por_pais = por_ind[codigo]
        fonte = f"Banco Mundial, WDI {codigo} ({ORIGEM_WDI[codigo]}), atualizado em {cab.get('lastupdated')}"
        brasil = por_pais.get("BRA", {})
        saida.add(ficha, f"{base}__brasil", brasil, unidade, fonte, arqs, casas)
        ns = {}
        for grupo, membros in grupos.items():
            med, n = mediana_por_ano(por_pais, membros)  # D-022: só com pelo menos metade do grupo
            saida.add(ficha, f"{base}__mediana_{grupo}", med, unidade, f"{fonte}, mediana de {grupo}", arqs, casas)
            dif = {a: brasil[a] - med[a] for a in brasil if a in med}
            saida.add(ficha, f"{base}__brasil_menos_mediana_{grupo}", dif, unidade_dif,
                      f"{fonte}, Brasil menos mediana de {grupo}", arqs, casas)
            for a in sorted(n):
                saida.paises.append([f"{base}__n_{grupo}", grupo, a, n[a], "países", fonte])
            ns[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, 4), unidade, fonte])
        resumo[codigo] = (base, brasil, ns)
        meta = ""
        if arq_meta:
            m = json.loads(arq_meta.read_bytes().decode("utf-8-sig"))
            if len(m) > 1 and m[1]:
                org = limpo(m[1][0].get("sourceOrganization", ""))
                if len(org) > 400:
                    org = org[:400].rsplit(";", 1)[0] + "; (…)"
                meta = f" Fonte declarada nos metadados: {org.rstrip('.')}."
        saida.notas[ficha].append(
            f"`{base}`: Brasil de {min(brasil)} a {max(brasil)}; países com dado em cada mediana: "
            + "; ".join(f"{g}: {min(n.values())} a {max(n.values())} por ano ({min(n)} a {max(n)})" if n else f"{g}: nenhum"
                        for g, n in ns.items())
            + f". Valores de cada país e número de países de cada ano (`{base}__n_<grupo>`) em "
            f"`dados/tratados/{TEMA}_pares_paises.csv`.{meta}")
    ctx["wdi"] = resumo
    return resumo


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

def etapas():
    return [
        ("Mortalidade infantil e materna e nascidos vivos (SIM e SINASC, TabNet)", coletar_vitais, tratar_vitais),
        ("Esperança de vida, mortalidade infantil estimada e população (IBGE)", coletar_ibge, tratar_ibge),
        ("Razão de mortalidade materna corrigida e TMI do Ministério (fichas da RIPSA)", coletar_ripsa, tratar_ripsa),
        ("Coberturas vacinais (SI-PNI no TabNet e painel do Ministério)", coletar_vacinas, tratar_vacinas),
        ("Despesa da União em ASPS e piso (RREO: Tesouro, SIOPS e Siconfi)", coletar_asps, tratar_asps),
        ("Leitos e médicos (CNES, TabNet)", coletar_cnes, tratar_cnes),
        ("Cobertura da atenção primária (Relatórios Públicos da APS)", coletar_aps, tratar_aps),
        ("Comparação com pares (Banco Mundial, WDI)", coletar_wdi, tratar_wdi),
    ]


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()
    ctx = {}
    print("Tratamento")
    for _, _, funcao in etapas():
        funcao(saida, ctx)
    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, saida.notas.get(ficha_id, ()))
    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)})")


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 de versão fechada já guardadas (IBGE, RIPSA, RREO, SIOPS, Siconfi)")
    args = ap.parse_args()
    if not args.so_tratar:
        coletar(args)
    tratar()


if __name__ == "__main__":
    main()
