#!/usr/bin/env python3
"""Orçamento: coleta e tratamento dos indicadores pré-registrados (tema `orcamento`).

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

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

O tema é coletado e tratado só por este script. Só biblioteca padrão (D-004). Fontes, decisões
e lacunas nas fichas e em dados/tratados/orcamento_notas.md.

Datas: AAAA (anual). Só anos fechados: o ano corrente fica fora. As séries começam em 2002
(D-002) ou no primeiro ano em que a fonte identifica o conceito.

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 (coletar_educacao.ipca_medio_anual).

% do PIB (D-027): uma única série de PIB nominal para todos os indicadores, a soma dos quatro
trimestres da tabela 1846 do SIDRA (Contas Nacionais Trimestrais, valores correntes). Ela é
publicada no CSV como `pib_nominal` (sem ficha, fora do resumo por mandato).

Publicações de versão fechada (RTN de dezembro de 2022, edições do Demonstrativo dos Gastos
Tributários, arquivo de emendas do Portal da Transparência, DCA da União) não são baixadas de
novo se já houver bruto; --rebaixar força o download. As demais fontes são consultadas a cada
dia de coleta.

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

- RTN: a planilha da série histórica muda de nome todo mês (seriehistoricajul26.xlsx); o
  endereço vem do CKAN do Tesouro Transparente. As linhas são achadas pelo código do início do
  rótulo ("3.", "4.4.2" etc.) e conferidas por uma palavra do rótulo. A tabela anual (2.2) é a
  soma exata da mensal (1.2). As despesas de combate à covid-19 identificadas pelo Tesouro
  ficaram só na edição de dezembro de 2022 da planilha (tabela 4.1); a atual não as traz.
- SIOP: a consulta SPARQL agrupa por esfera, função, GND, modalidade e identificador de
  resultado primário (RP), uma por ano (cerca de 12 s cada). A DCA da União (Siconfi) usada na
  conferência é a mesma baixada pelo tema educação.
- Portal da Transparência: o arquivo de emendas é um zip de 32 MB com três CSVs. O script baixa
  só o membro EmendasParlamentares.csv, por HTTP Range, e confere o CRC com o diretório do zip.
  O membro com favorecidos (nomes de empresários individuais) não é baixado.
- Receita Federal: as edições do Demonstrativo dos Gastos Tributários (bases efetivas) de 2016
  a 2023 têm planilhas; as de 2008 a 2015 só existem em PDF.
"""

import argparse
import csv
import datetime as dt
import json
import re
import sys
import time
import urllib.parse
from collections import Counter, defaultdict

from comum import (BRUTOS, HOJE, RAIZ, TRATADOS, escrever_csv, ler_csv, ler_fichas, ler_json, ler_manifesto,
                   salvar_bruto)
from coletar import SDMX_IMF, coletar_sgs, coletar_weo
from tratar import ler_grupos, ler_sdmx, ler_sgs, mediana_por_ano
from coletar_educacao import (FONTE_SICONFI, SPARQL_SIOP, URL_DCA, Xlsx, diretorio_zip_remoto, ipca_medio_anual,
                              limpo, membro_zip_remoto, num, pedir)

TEMA = "orcamento"
ANO_INICIAL = 2002          # D-002
ANO_CORRENTE = HOJE.year
ANOS = [str(a) for a in range(ANO_INICIAL, ANO_CORRENTE)]  # só anos fechados
ANO_BASE_REAL = "2025"      # D-015

U_PIB = "% do PIB"
U_NOM = "R$ bilhões correntes"
U_REAL = f"R$ bilhões de {ANO_BASE_REAL} (IPCA, média do ano)"
DEFLATOR = f"; reais de {ANO_BASE_REAL} pelo IPCA médio do ano (SIDRA 1737)"


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

def brutos_exatos(fonte, nome):
    """Arquivos `<nome>_<AAAA-MM-DD>[-n].<ext>` da pasta, do mais antigo ao mais recente (prefixo exato)."""
    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 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 versão fechada)."""
    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


def validar_xlsx(abas=()):
    def validar(conteudo):
        if conteudo[:2] != b"PK":
            raise RuntimeError("resposta não é um .xlsx")
        faltam = set(abas) - set(Xlsx(conteudo).planilhas)
        if faltam:
            raise RuntimeError(f"planilha sem as abas {sorted(faltam)}")
    return validar


def validar_json(conteudo):
    json.loads(conteudo)


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

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
        self.conferencias = []             # linhas do resumo impresso no fim

    def add(self, ficha_id, serie_id, dados, unidade, fonte, arquivos=(), casas=4):
        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()):
            self.linhas.append([serie_id, data, round(valor, casas), unidade, fonte])
        self.extracao[ficha_id].append((serie_id, sorted(set(arquivos)), dados))

    def add_sem_ficha(self, serie_id, dados, unidade, fonte, casas=4):
        """Série auxiliar sem ficha (o resumir.py a ignora): o PIB nominal."""
        for data, valor in sorted(dados.items()):
            self.linhas.append([serie_id, data, round(valor, casas), unidade, fonte])

    def conferiu(self, texto):
        print(f"  conferência: {texto}")
        self.conferencias.append(texto)


def atualizar_extracao(ficha, itens, notas=()):
    """Reescreve o bloco entre os marcadores de extração da ficha. Mesma tabela de
    tratar.atualizar_extracao; quando uma série usa mais de três arquivos, a célula diz quantos
    são e a lista completa vem logo depois da tabela (cada arquivo citado uma vez)."""
    manifestos = {}
    todos = []
    linhas = ["| Série | Arquivo bruto | Extraído em | Observações | Primeira | Última |",
              "|---|---|---|---|---|---|"]
    longa = False
    for serie_id, arquivos, dados in itens:
        datas = []
        for arq in arquivos:
            fonte = arq.parent.name
            manifestos.setdefault(fonte, ler_manifesto(fonte))
            datas.append(manifestos[fonte].get(arq.name, {}).get("data_extracao", "?")[:16].replace("T", " "))
            if arq not in todos:
                todos.append(arq)
        datas_txt = ", ".join(sorted(set(datas))) or "(derivada)"
        if len(set(datas)) > 1:
            datas_txt = f"{min(datas)} a {max(datas)}"
        if len(arquivos) > 3:
            longa = True
            nomes_txt = f"{len(arquivos)} arquivos (lista abaixo)"
        else:
            nomes_txt = "<br>".join(f"`{a.relative_to(RAIZ)}`" for a in arquivos) 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 longa:
        bloco += "\n\nArquivos brutos desta ficha: " + ", ".join(f"`{a.relative_to(RAIZ)}`" for a in todos) + "."
    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 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}


def bilhoes(milhoes):
    return {a: v / 1e3 for a, v in milhoes.items()}


def pct_pib(milhoes, pib):
    """{ano: R$ milhões} -> {ano: % do PIB}, com a série única de PIB nominal (D-027)."""
    return {a: 100 * v / pib[a] for a, v in milhoes.items() if a in pib}


def so_anos(d, inicio=ANO_INICIAL):
    return {a: v for a, v in d.items() if a in ANOS and int(a) >= inicio}


def desde_primeiro_nao_nulo(d):
    """Linhas do RTN que só passaram a ser separadas num certo ano trazem zeros antes disso:
    o zero ali quer dizer "não identificado", não "nada pago"."""
    anos = sorted(a for a, v in d.items() if abs(v) > 1e-9)
    return {a: v for a, v in d.items() if anos and a >= anos[0]}


# ================================================================ PIB nominal (IBGE, SIDRA)

FONTE_SIDRA = "ibge_sidra"
# Contas Nacionais Trimestrais, valores a preços correntes (R$ milhões), PIB a preços de mercado
SIDRA_PIB = "/t/1846/n1/all/v/585/p/all/c11255/90707"
NOME_PIB = "t1846_v585"
# Contas Nacionais anuais, PIB a valores correntes: só conferência
SIDRA_PIB_ANUAL = "/t/6784/n1/all/v/9808/p/all"
NOME_PIB_ANUAL = "t6784_v9808"
FONTE_PIB = "IBGE/SIDRA 1846 (Contas Nacionais Trimestrais, valores correntes), soma dos quatro trimestres"


def validar_sidra(conteudo):
    d = json.loads(conteudo)
    if not isinstance(d, list) or len(d) < 2 or "V" not in d[0]:
        raise RuntimeError("SIDRA: resposta inesperada")


def coletar_pib(args):
    for caminho, nome in ((SIDRA_PIB, NOME_PIB), (SIDRA_PIB_ANUAL, NOME_PIB_ANUAL)):
        obter(FONTE_SIDRA, nome, "json", "https://apisidra.ibge.gov.br/values" + caminho, validar=validar_sidra)


def ler_sidra_bruto(arq, periodo="Trimestre"):
    """Lista de (código do período, valor) de uma resposta da API do SIDRA."""
    linhas = ler_json(arq)
    cab = linhas[0]
    col = next(k for k, v in cab.items() if v == f"{periodo} (Código)")
    return [(l[col], num(l["V"])) for l in linhas[1:] if num(l["V"]) is not None]


def tratar_pib(saida):
    arq = bruto(FONTE_SIDRA, NOME_PIB)
    trimestres = defaultdict(dict)
    for codigo, valor in ler_sidra_bruto(arq, "Trimestre"):
        trimestres[codigo[:4]][codigo[4:]] = valor
    pib = {a: sum(t.values()) for a, t in trimestres.items() if len(t) == 4}  # R$ milhões, anos completos
    faltam = [a for a in ANOS if a not in pib]
    if faltam:
        raise RuntimeError(f"PIB trimestral sem os quatro trimestres em {faltam}")

    # conferência: Contas Nacionais anuais (tabela 6784), anos com conta anual
    arq_anual = bruto(FONTE_SIDRA, NOME_PIB_ANUAL)
    anual = dict(ler_sidra_bruto(arq_anual, "Ano"))
    difs = {a: pib[a] - v for a, v in anual.items() if a in ANOS}
    grandes = {a: d for a, d in difs.items() if abs(d) > 2}
    pior = max(abs(d / anual[a]) for a, d in difs.items())
    if pior > 0.005:
        raise RuntimeError(f"PIB trimestral difere do anual em mais de 0,5%: {grandes}")
    saida.conferiu(f"PIB, soma dos trimestres (SIDRA 1846) x Contas Nacionais anuais (SIDRA 6784), "
                   f"{min(difs)}-{max(difs)}: igual até R$ 2 milhões, exceto "
                   + "; ".join(f"{a}: {br(d / 1e3, 1)} bi ({br(100 * d / anual[a], 2)}%)" for a, d in sorted(grandes.items())))
    saida.add_sem_ficha("pib_nominal", bilhoes(so_anos(pib)), U_NOM, FONTE_PIB, casas=3)
    return pib, arq, arq_anual, grandes


# ================================================================ Resultado do Tesouro Nacional (RTN)

FONTE_RTN = "tesouro_rtn"
CKAN_RTN = "https://www.tesourotransparente.gov.br/ckan/api/3/action/package_show?id=resultado-do-tesouro-nacional"
RECURSO_RTN = "Resultado do Tesouro Nacional - Série Histórica - Mensal"
# Série histórica que acompanhou o boletim RTN de dezembro de 2022: a única com a tabela 4.1,
# "Despesas do Governo Central relacionadas ao combate ao COVID-19" (2020 a 2022). O endereço
# redireciona para o CDN do Tesouro (.../46073_1449893/anexos/19092_.../serie_historica_dez22.xlsx).
URL_RTN_DEZ22 = "https://thot-arquivos.tesouro.gov.br/publicacao-anexo/19092"
PAGINA_RTN = "https://www.tesourotransparente.gov.br/publicacoes/boletim-resultado-do-tesouro-nacional-rtn/{ano}/12"

# código no início do rótulo da tabela 2.2 -> palavra que confirma a linha
LINHAS_RTN = {
    "receita_total": ("1.", "RECEITA TOTAL"),
    "transferencias": ("2.", "TRANSF"),
    "receita_liquida": ("3.", "RECEITA LÍQUIDA"),
    "despesa_total": ("4.", "DESPESA TOTAL"),
    "beneficios": ("4.1", "Benefícios Previdenciários"),
    "pessoal": ("4.2", "Pessoal e Encargos"),
    "sent_beneficios_urbano": ("4.1.1.1", "Sent. Judiciais"),
    "sent_beneficios_rural": ("4.1.2.1", "Sent. Judiciais"),
    "sent_pessoal": ("4.2.1", "Sent. Judiciais"),
    "sent_bpc": ("4.3.05.1", "Sentenças Judiciais"),
    "sent_occ": ("4.3.14", "Sentenças Judiciais"),
    "creditos_extraordinarios": ("4.3.07", "Créditos Extraordinários"),
    "obrigatorias_controle_fluxo": ("4.4.1", "Obrigatórias com Controle de Fluxo"),
    "discricionarias": ("4.4.2", "Discricionárias"),
    "resultado": ("5.", "RESULTADO PRIMÁRIO"),
    "concessoes": ("1.4.1", "Concessões"),
    "dividendos": ("1.4.2", "Dividendos"),
    "operacoes_ativos": ("1.4.8.1", "Operações com Ativos"),
    "compensacao_rgps": ("4.3.08", "Compensação ao RGPS"),
    "ajuste_pis_pasep": ("6.3", "PIS/PASEP"),
}
COMPONENTES_PRECATORIOS = ("sent_occ", "sent_pessoal", "sent_beneficios_urbano", "sent_beneficios_rural", "sent_bpc")

# Valores publicados no boletim de dezembro de cada ano (primeira divulgação do ano fechado), em
# R$ milhões: receita líquida, despesa total e resultado primário (acima da linha). Conferência
# feita a cada tratamento; diferenças vêm de revisões posteriores do Tesouro.
RTN_PUBLICADO = {
    "2003": ((297659.3, 257817.4, 39647.2), "Tabelas.xls, tabela A1 (b), linhas III, IV e VII"),
    "2010": ((779095.0, 700128.7, 78966.3), "Tabela1 - série histórica.xls, aba Anual R$, linhas III, IV e VI"),
    "2016": ((1088117.6, 1242372.9, -154255.4), "Anexos RTN Dez16.xlsx, tabela 4.1, linhas III, IV e VI"),
    "2020": ((1203937.8, 1947025.0, -743087.3), "serie_historica_dez20.xlsx, tabela 2.1, linhas III, IV e VI"),
    "2022": ((1856083.4, 1801997.8, 54085.6), "serie_historica_dez22.xlsx, tabela 2.2, linhas 3, 4 e 5"),
    "2024": ((2161707.6, 2204711.3, -43003.7), "serie_historica_dez24.xlsx, tabela 2.2, linhas 3, 4 e 5"),
    "2025": ((2332558.4, 2394249.6, -61691.2), "serie_historica_dez25.xlsx, tabela 2.2, linhas 3, 4 e 5"),
}
TOLERANCIA_PUBLICADO = 0.015  # fração da receita líquida do ano


def coletar_rtn(args):
    def validar_ckan(c):
        recursos = json.loads(c)["result"]["resources"]
        if not any(r.get("name", "").strip() == RECURSO_RTN for r in recursos):
            raise RuntimeError(f"CKAN: recurso '{RECURSO_RTN}' não encontrado")
    ckan = obter(FONTE_RTN, "ckan_resultado_do_tesouro_nacional", "json", CKAN_RTN, validar=validar_ckan)
    (recurso,) = [r for r in ler_json(ckan)["result"]["resources"] if r.get("name", "").strip() == RECURSO_RTN]
    obter(FONTE_RTN, "rtn_serie_historica", "xlsx", recurso["url"],
          validar=validar_xlsx(("1.2", "2.2", "2.2-A", "2.3", "2.3-A")))
    obter(FONTE_RTN, "rtn_serie_historica_dez22", "xlsx", URL_RTN_DEZ22, validar=validar_xlsx(("2.2", "4.1")),
          reaproveitar=not args.rebaixar)


def tabela_anual(livro, aba, fator=1.0):
    """Aba anual do RTN -> [(rótulo, {ano: valor})]. `fator` converte frações em %."""
    linhas = livro.linhas(aba)
    for i, r in enumerate(linhas[:12]):
        anos = {c: limpo(v) for c, v in enumerate(r) if v is not None and re.fullmatch(r"\d{4}", limpo(v))}
        if len(anos) >= 5:
            break
    else:
        raise RuntimeError(f"RTN, aba {aba}: cabeçalho de anos não encontrado")
    tabela = []
    for r in linhas[i + 1:]:
        if not r or not r[0]:
            continue
        valores = {a: num(r[c]) * fator for c, a in anos.items() if c < len(r) and num(r[c]) is not None}
        tabela.append((limpo(r[0]), valores))
    return tabela


def tabela_mensal_por_ano(livro, aba):
    """Aba mensal do RTN (cabeçalho com datas do Excel) -> [(rótulo, {ano: soma dos 12 meses})]."""
    linhas = livro.linhas(aba)
    for i, r in enumerate(linhas[:12]):
        datas = {}
        for c, v in enumerate(r):
            x = num(v)
            if c and x and 20000 < x < 80000:
                datas[c] = dt.date(1899, 12, 30) + dt.timedelta(days=int(x))
        if len(datas) >= 12:
            break
    else:
        raise RuntimeError(f"RTN, aba {aba}: cabeçalho de meses não encontrado")
    tabela = []
    for r in linhas[i + 1:]:
        if not r or not r[0]:
            continue
        soma, n = defaultdict(float), Counter()
        for c, d in datas.items():
            x = num(r[c]) if c < len(r) else None
            if x is not None:
                soma[str(d.year)] += x
                n[str(d.year)] += 1
        tabela.append((limpo(r[0]), dict(soma), n))
    return tabela, datas


def valores_mensais(livro, aba, codigo, palavra):
    """{data: valor} de uma linha da aba mensal do RTN."""
    linhas = livro.linhas(aba)
    for r in linhas[:12]:
        datas = {c: dt.date(1899, 12, 30) + dt.timedelta(days=int(num(v))) for c, v in enumerate(r)
                 if c and num(v) and 20000 < num(v) < 80000}
        if len(datas) >= 12:
            break
    for r in linhas:
        if r and r[0] and re.match(re.escape(codigo) + r"\s", limpo(r[0])):
            if palavra.upper() not in limpo(r[0]).upper():
                raise RuntimeError(f"RTN, aba {aba}: a linha {codigo} é '{limpo(r[0])}'")
            return {d: num(r[c]) for c, d in datas.items() if c < len(r) and num(r[c]) is not None}
    raise RuntimeError(f"RTN, aba {aba}: linha {codigo} não encontrada")


def linha_rtn(tabela, codigo, palavra, onde):
    """Primeira linha cujo rótulo começa pelo código (seguido de espaço) e contém a palavra."""
    for item in tabela:
        if re.match(re.escape(codigo) + r"\s", item[0]):
            if palavra.upper() not in item[0].upper():
                raise RuntimeError(f"{onde}: a linha {codigo} é '{item[0]}', esperado '{palavra}'")
            return item
    raise RuntimeError(f"{onde}: linha {codigo} ({palavra}) não encontrada")


def ler_covid(arq):
    """Tabela 4.1 da série de dezembro de 2022 -> ({ano: primárias}, {ano: financeiras}, {ano: totais}), R$ milhões."""
    livro = Xlsx(str(arq))
    tabela, datas = tabela_mensal_por_ano(livro, "4.1")
    titulo = " ".join(limpo(v) for r in livro.linhas("4.1", max_linhas=3) for v in r if v)
    if "COVID" not in titulo.upper():
        raise RuntimeError(f"{arq.name}: a aba 4.1 não é a tabela de despesas com a covid-19 ({titulo[:80]})")
    (_, prim, _) = linha_rtn(tabela, "1.", "DESPESAS PRIMÁRIAS", arq.name)
    (_, fin, _) = linha_rtn(tabela, "2.", "DESPESAS FINANCEIRAS", arq.name)
    (_, tot, _) = linha_rtn(tabela, "3.", "DESPESAS TOTAIS", arq.name)
    for a in tot:
        if abs(prim.get(a, 0) + fin.get(a, 0) - tot[a]) > 0.01:
            raise RuntimeError(f"{arq.name}: primárias + financeiras ≠ totais em {a}")
    meses = sorted(datas.values())
    return prim, fin, tot, (meses[0], meses[-1])


def tratar_rtn(saida, pib, arq_pib, ipca):
    arq = bruto(FONTE_RTN, "rtn_serie_historica")
    arq_ckan = bruto(FONTE_RTN, "ckan_resultado_do_tesouro_nacional")
    livro = Xlsx(str(arq))
    t22 = tabela_anual(livro, "2.2")
    t22a = tabela_anual(livro, "2.2-A", fator=100.0)
    t23 = tabela_anual(livro, "2.3")
    t23a = tabela_anual(livro, "2.3-A", fator=100.0)
    t12, _ = tabela_mensal_por_ano(livro, "1.2")
    onde = f"{arq.name}, tabela 2.2"
    L = {chave: linha_rtn(t22, cod, pal, onde) for chave, (cod, pal) in LINHAS_RTN.items()}
    rot_inv, inv = linha_rtn(t23, "1.1", "Investimentos (GND 4)", f"{arq.name}, tabela 2.3")

    # 1) a tabela anual é a soma da mensal (mesmas linhas)
    pior = 0.0
    for chave, (rotulo, anual) in L.items():
        (_, soma, n) = linha_rtn(t12, LINHAS_RTN[chave][0], LINHAS_RTN[chave][1], f"{arq.name}, tabela 1.2")
        for a, v in anual.items():
            if n.get(a) == 12:
                pior = max(pior, abs(v - soma[a]))
    if pior > 0.01:
        raise RuntimeError(f"RTN: tabela anual difere da soma dos meses em até R$ {pior} milhões")
    saida.conferiu(f"RTN, tabela anual 2.2 = soma dos meses da tabela 1.2 nas {len(L)} linhas usadas "
                   f"(maior diferença: R$ {br(pior * 1e6, 2)})")

    # 2) % do PIB com o PIB do IBGE = % do PIB publicado pelo Tesouro (tabela 2.2-A)
    pib_tesouro = next(v for rot, v in t22a if rot.upper().startswith("PIB NOMINAL"))
    pib_tesouro = {a: v / 100 for a, v in pib_tesouro.items()}  # a linha do PIB não é fração
    dif_pib = max(abs(pib_tesouro[a] - pib[a]) for a in ANOS)
    if dif_pib > 5:
        raise RuntimeError(f"RTN: PIB do Tesouro difere do PIB do IBGE em até R$ {dif_pib} milhões")
    pior = 0.0
    for chave in ("receita_liquida", "despesa_total", "resultado", "pessoal", "beneficios"):
        (_, nominal), (_, pct_tesouro) = L[chave], linha_rtn(t22a, *LINHAS_RTN[chave], f"{arq.name}, tabela 2.2-A")
        nosso = pct_pib(so_anos(nominal), pib)
        pior = max(pior, max(abs(nosso[a] - pct_tesouro[a]) for a in nosso))
    (_, inv_pct_tesouro) = linha_rtn(t23a, "1.1", "Investimentos (GND 4)", f"{arq.name}, tabela 2.3-A")
    nosso = pct_pib(so_anos(inv), pib)
    pior = max(pior, max(abs(nosso[a] - inv_pct_tesouro[a]) for a in nosso))
    if pior > 0.001:
        raise RuntimeError(f"RTN: % do PIB difere do publicado pelo Tesouro em até {pior} p.p.")
    saida.conferiu(f"RTN, % do PIB calculado com o PIB do IBGE = % do PIB das tabelas 2.2-A e 2.3-A do Tesouro "
                   f"(maior diferença: {br(pior, 5)} p.p.; o PIB do Tesouro difere do da tabela 1846 em até "
                   f"R$ {br(dif_pib, 1)} milhão)")

    # 3) primeira divulgação de cada ano (boletins de dezembro)
    rel = []
    for ano, (valores, onde_pub) in RTN_PUBLICADO.items():
        atuais = (L["receita_liquida"][1][ano], L["despesa_total"][1][ano], L["resultado"][1][ano])
        limite = TOLERANCIA_PUBLICADO * atuais[0]
        difs = [a - p for a, p in zip(atuais, valores)]
        if any(abs(d) > limite for d in difs):
            raise RuntimeError(f"RTN {ano}: difere do boletim de dezembro além de 1,5% da receita líquida: {difs}")
        rel.append(f"{ano}: receita líquida {br(valores[0] / 1e3, 1)} bi publicada, {br(atuais[0] / 1e3, 1)} hoje; "
                   f"despesa {br(valores[1] / 1e3, 1)} e {br(atuais[1] / 1e3, 1)}; resultado {br(valores[2] / 1e3, 1)} "
                   f"e {br(atuais[2] / 1e3, 1)}")
    # a transcrição de 2022 confere com o bruto da série de dezembro de 2022
    arq_dez22 = bruto(FONTE_RTN, "rtn_serie_historica_dez22")
    t22_dez = tabela_anual(Xlsx(str(arq_dez22)), "2.2")
    for chave, publicado in zip(("receita_liquida", "despesa_total", "resultado"), RTN_PUBLICADO["2022"][0]):
        (_, v) = linha_rtn(t22_dez, *LINHAS_RTN[chave], arq_dez22.name)
        if abs(v["2022"] - publicado) > 0.06:
            raise RuntimeError(f"RTN_PUBLICADO 2022 ({chave}) não confere com {arq_dez22.name}")
    saida.conferiu("RTN x boletins de dezembro (primeira divulgação, tolerância de 1,5% da receita líquida): "
                   + "; ".join(rel))

    # ---- séries
    fonte = "Tesouro Nacional, RTN, série histórica, tabela 2.2"
    fonte_pct = fonte + "; % do PIB com o PIB nominal do IBGE (SIDRA 1846)"
    arqs, arqs_pct = [arq], [arq, arq_pib]

    def nominal(chave, inicio=ANO_INICIAL):
        return so_anos(L[chave][1], inicio)

    def publicar(ficha, chave_ou_dados, rotulo, recortes, inicio=ANO_INICIAL, fonte_extra=""):
        dados = nominal(chave_ou_dados, inicio) if isinstance(chave_ou_dados, str) else chave_ou_dados
        f, fp = fonte + fonte_extra, fonte_pct + fonte_extra
        if "pct" in recortes:
            saida.add(ficha, f"{ficha}__{rotulo}pct_pib", pct_pib(dados, pib), U_PIB, fp, arqs_pct)
        if "real" in recortes:
            saida.add(ficha, f"{ficha}__{rotulo}real", bilhoes(real(dados, ipca)), U_REAL, f + DEFLATOR, arqs, casas=3)
        saida.add(ficha, f"{ficha}__{rotulo}nominal", bilhoes(dados), U_NOM, f, arqs, casas=3)
        return dados

    publicar("receita_liquida_gc", "receita_liquida", "", ("pct",),
             fonte_extra=", linha 3 (receita total menos transferências por repartição de receita)")
    desp = publicar("despesa_primaria_gc", "despesa_total", "", ("pct", "real"), fonte_extra=", linha 4 (despesa total)")
    publicar("resultado_primario_gc", "resultado", "", ("pct",),
             fonte_extra=", linha 5 (resultado primário do Governo Central, acima da linha)")
    publicar("pessoal_uniao", "pessoal", "", ("pct",), fonte_extra=", linha 4.2 (pessoal e encargos sociais)")
    publicar("beneficios_rgps", "beneficios", "", ("pct",), fonte_extra=", linha 4.1 (benefícios previdenciários)")

    # despesa primária sem as despesas de combate à covid-19 identificadas pelo Tesouro
    covid, covid_fin, covid_tot, (m0, m1) = ler_covid(arq_dez22)
    covid = {a: v for a, v in covid.items() if abs(v) > 1e-9}
    for a, v in covid.items():
        if not 0 < v < desp[a]:
            raise RuntimeError(f"covid-19 {a}: valor fora do esperado ({v})")
    sem_covid = {a: v - covid.get(a, 0.0) for a, v in desp.items()}
    fonte_covid = ("; menos as despesas primárias de combate à covid-19 identificadas pelo Tesouro "
                   "(série histórica do RTN de dezembro de 2022, tabela 4.1)")
    for rotulo, dados, unidade, f, arqs_s, casas in (
            ("sem_covid_pct_pib", pct_pib(sem_covid, pib), U_PIB, fonte_pct + fonte_covid, [arq, arq_dez22, arq_pib], 4),
            ("sem_covid_real", bilhoes(real(sem_covid, ipca)), U_REAL, fonte + fonte_covid + DEFLATOR, [arq, arq_dez22], 3),
            ("sem_covid_nominal", bilhoes(sem_covid), U_NOM, fonte + fonte_covid, [arq, arq_dez22], 3)):
        saida.add("despesa_primaria_gc", f"despesa_primaria_gc__{rotulo}", dados, unidade, f, arqs_s, casas)

    # discricionárias do Poder Executivo (separadas desde 2008)
    disc = desde_primeiro_nao_nulo(nominal("discricionarias"))
    publicar("despesas_discricionarias", disc, "", ("real",),
             fonte_extra=", linha 4.4.2 (despesas discricionárias do Poder Executivo)")

    # sentenças judiciais e precatórios: as cinco linhas do RTN, cada uma desde que é separada
    comp = {c: desde_primeiro_nao_nulo(nominal(c)) for c in COMPONENTES_PRECATORIOS}
    inicio_total = max(min(d) for d in comp.values())
    total = {a: sum(comp[c][a] for c in COMPONENTES_PRECATORIOS) for a in ANOS if a >= inicio_total}
    publicar("precatorios", total, "total_", ("real",),
             fonte_extra=", soma das linhas 4.3.14 (custeio e capital), 4.2.1 (pessoal), 4.1.1.1 e 4.1.2.1 "
                         "(benefícios previdenciários urbanos e rurais) e 4.3.05.1 (BPC/LOAS)")
    publicar("precatorios", comp["sent_occ"], "custeio_capital_", ("real",),
             fonte_extra=", linha 4.3.14 (sentenças judiciais e precatórios de custeio e capital)")

    # investimento (GND 4) pago, com restos a pagar: tabela 2.3, desde 2008
    inv_n = desde_primeiro_nao_nulo(so_anos(inv))
    fonte_inv = "Tesouro Nacional, RTN, série histórica, tabela 2.3, linha 1.1 (investimentos, GND 4, valor pago com restos a pagar)"
    saida.add("investimento_uniao", "investimento_uniao__rtn_pct_pib", pct_pib(inv_n, pib), U_PIB,
              fonte_inv + "; % do PIB com o PIB nominal do IBGE (SIDRA 1846)", arqs_pct)
    saida.add("investimento_uniao", "investimento_uniao__rtn_real", bilhoes(real(inv_n, ipca)), U_REAL,
              fonte_inv + DEFLATOR, arqs, casas=3)
    saida.add("investimento_uniao", "investimento_uniao__rtn_nominal", bilhoes(inv_n), U_NOM, fonte_inv, arqs, casas=3)

    # ---- notas nas fichas
    manif = ler_manifesto(FONTE_RTN)
    url_rtn = manif.get(arq.name, {}).get("consulta_url", "?")
    origem = (f"Planilha do RTN: {url_rtn} (endereço lido em `{arq_ckan.relative_to(RAIZ)}`). "
              f"Linha usada: \"{{rotulo}}\".")
    for ficha, chave in (("receita_liquida_gc", "receita_liquida"), ("despesa_primaria_gc", "despesa_total"),
                         ("resultado_primario_gc", "resultado"), ("pessoal_uniao", "pessoal"),
                         ("beneficios_rgps", "beneficios"), ("despesas_discricionarias", "discricionarias")):
        texto = origem.format(rotulo=L[chave][0])
        if ficha != "despesas_discricionarias":
            texto += " Os % do PIB conferem com a tabela 2.2-A do Tesouro."
        saida.notas[ficha].append(texto)
    saida.notas["despesa_primaria_gc"].append(
        f"Despesas primárias de combate à covid-19 identificadas pelo Tesouro (tabela 4.1 da série de dezembro de "
        f"2022, meses de {m0:%m/%Y} a {m1:%m/%Y}), em R$ bilhões correntes: "
        + "; ".join(f"{a}: {br(v / 1e3, 1)} ({br(100 * v / pib[a], 2)}% do PIB)" for a, v in sorted(covid.items()))
        + ". Despesas financeiras de combate à covid-19, fora do conceito primário: "
        + "; ".join(f"{a}: {br(v / 1e3, 2)}" for a, v in sorted(covid_fin.items()) if abs(v) > 1e-9) + ".")
    saida.notas["despesa_primaria_gc"].append(
        "Créditos extraordinários pagos (linha 4.3.07 do RTN, que até 2019 não inclui os do PAC), em R$ bilhões "
        "correntes: " + "; ".join(f"{a}: {br(v / 1e3, 1)}" for a, v in nominal("creditos_extraordinarios").items()) + ".")
    saida.notas["precatorios"].append(
        "Componentes, em R$ bilhões correntes, e ano em que cada linha passa a ser separada no RTN: "
        + "; ".join(f"{LINHAS_RTN[c][0]} desde {min(d)}" for c, d in comp.items()) + ". Por ano (custeio e "
        "capital / pessoal / benefícios urbanos + rurais / BPC): "
        + "; ".join(f"{a}: {br(comp['sent_occ'][a] / 1e3, 1)} / {br(comp['sent_pessoal'][a] / 1e3, 1)} / "
                    f"{br((comp['sent_beneficios_urbano'][a] + comp['sent_beneficios_rural'][a]) / 1e3, 1)} / "
                    f"{br(comp['sent_bpc'][a] / 1e3, 1)}" for a in sorted(total)) + ".")
    dez = {}
    for c in COMPONENTES_PRECATORIOS:
        mensal = valores_mensais(livro, "1.2", *LINHAS_RTN[c])
        for ano in ("2022", "2023", "2024", "2025"):
            dez[ano] = dez.get(ano, 0.0) + mensal.get(dt.date(int(ano), 12, 1), 0.0)
    saida.notas["precatorios"].append(
        "Pago em dezembro, somadas as cinco linhas (tabela mensal 1.2 do RTN), em R$ bilhões correntes: "
        + "; ".join(f"{a}: {br(v / 1e3, 1)} de {br(total[a] / 1e3, 1)} no ano" for a, v in dez.items()) + ".")
    ativos = {a: v for a, v in nominal("operacoes_ativos").items() if abs(v) >= 50}
    pis = {a: v for a, v in nominal("ajuste_pis_pasep").items() if abs(v) >= 1}
    saida.notas["receita_liquida_gc"].append(
        "Receitas não recorrentes ligadas a eventos citados nas ressalvas, em R$ bilhões correntes (linhas do RTN): "
        "concessões e permissões (1.4.1): " + "; ".join(f"{a}: {br(v / 1e3, 1)}" for a, v in nominal("concessoes").items())
        + ". Dividendos e participações (1.4.2): "
        + "; ".join(f"{a}: {br(v / 1e3, 1)}" for a, v in nominal("dividendos").items())
        + ". Receitas de operações com ativos (1.4.8.1, anos acima de R$ 50 milhões): "
        + "; ".join(f"{a}: {br(v / 1e3, 1)}" for a, v in ativos.items())
        + ". Recursos não sacados do PIS/Pasep (EC 126/2022) contados como receita pelo Tesouro e retirados pelo "
        "Banco Central como ajuste metodológico (linha 6.3, com sinal negativo): "
        + "; ".join(f"{a}: {br(v / 1e3, 1)}" for a, v in pis.items()) + ".")
    saida.notas["investimento_uniao"].append(
        f"Linha usada na tabela 2.3: \"{rot_inv}\". Planilha: {url_rtn}. Os % do PIB conferem com a tabela 2.3-A.")
    return L, covid, sem_covid, arq


# ================================================================ despesa da União (SIOP, SPARQL)

FONTE_SIOP = "siop_sparql"
CONSULTA_SIOP = """PREFIX loa: <http://vocab.e.gov.br/2013/09/loa#>
SELECT ?esfera ?funcao ?gnd ?mod ?rp (SUM(?emp) AS ?empenhado) (SUM(?liq) AS ?liquidado) (SUM(?pag) AS ?pago)
FROM <http://orcamento.dados.gov.br/{ano}/>
WHERE {{
  ?i a loa:ItemDespesa ;
     loa:temEsfera ?e ; loa:temFuncao ?f ; loa:temGND ?g ; loa:temModalidadeAplicacao ?m ;
     loa:temResultadoPrimario ?r ;
     loa:valorEmpenhado ?emp ; loa:valorLiquidado ?liq ; loa:valorPago ?pag .
  ?e loa:codigo ?esfera . ?f loa:codigo ?funcao . ?g loa:codigo ?gnd . ?m loa:codigo ?mod . ?r loa:codigo ?rp .
}}
GROUP BY ?esfera ?funcao ?gnd ?mod ?rp ORDER BY ?esfera ?funcao ?gnd ?mod ?rp"""
NOME_SIOP = "orcamento_funcao_gnd_mod_rp_{ano}"
CABECALHO_SIOP = b'"esfera","funcao","gnd","mod","rp"'
ANO_INTRA = 2006            # primeiro ano da modalidade 91 (aplicação direta intraorçamentária)
ANO_DCA = 2014              # primeiro ano da DCA da União no Siconfi

# Funções do pré-registro (Portaria MOG 42/1999). Urbanismo e habitação são duas funções;
# encargos especiais (28) sai das "demais" (ver notas, mudança 4).
CATEGORIAS_FUNCAO = [
    ("saude", "Saúde", {"10"}),
    ("educacao", "Educação", {"12"}),
    ("assistencia_social", "Assistência Social", {"08"}),
    ("previdencia_social", "Previdência Social", {"09"}),
    ("trabalho", "Trabalho", {"11"}),
    ("defesa_nacional", "Defesa Nacional", {"05"}),
    ("seguranca_publica", "Segurança Pública", {"06"}),
    ("ciencia_tecnologia", "Ciência e Tecnologia", {"19"}),
    ("agricultura", "Agricultura", {"20"}),
    ("transporte", "Transporte", {"26"}),
    ("gestao_ambiental", "Gestão Ambiental", {"18"}),
    ("cultura", "Cultura", {"13"}),
    ("urbanismo", "Urbanismo", {"15"}),
    ("habitacao", "Habitação", {"16"}),
    ("saneamento", "Saneamento", {"17"}),
    ("encargos_especiais", "Encargos Especiais", {"28"}),
]

# Identificadores de resultado primário de emendas: ano em que entram na LDO e último ano com execução
EMENDAS_RP = {
    "6": ("individuais", "individuais (RP 6)", 2014, None),       # LDO 2014 (Lei 12.919/2013)
    "7": ("bancada", "de bancada estadual (RP 7)", 2017, None),   # LDO 2017 (Lei 13.408/2016)
    "8": ("comissao", "de comissão (RP 8)", 2020, None),          # LDO 2020; na LDO 2021, depois da derrubada do veto
    "9": ("relator", "de relator-geral (RP 9)", 2020, 2022),      # LDO 2020 a 2022; STF, ADPF 850, 19/12/2022
}


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


def coletar_siop(args):
    for ano in range(ANO_INICIAL, ANO_CORRENTE):
        def baixar_csv(ano=ano):
            corpo = pedir(url_siop(ano))
            if not corpo.startswith(CABECALHO_SIOP):
                raise RuntimeError(f"SPARQL {ano}: resposta inesperada {corpo[:200]!r}")
            time.sleep(1)
            return corpo
        obter(FONTE_SIOP, NOME_SIOP.format(ano=ano), "csv", url_siop(ano), baixar_fn=baixar_csv)
    # Conferência: DCA da União (Anexo I-E). Os arquivos do tema educação são reaproveitados.
    for ano in range(ANO_DCA, ANO_CORRENTE):
        def baixar_dca(ano=ano):
            corpo = pedir(URL_DCA.format(ano=ano))
            if not json.loads(corpo).get("items"):
                raise RuntimeError(f"DCA {ano}: resposta sem itens")
            return corpo
        obter(FONTE_SICONFI, f"dca_anexo_I-E_uniao_{ano}", "json", URL_DCA.format(ano=ano),
              baixar_fn=baixar_dca, reaproveitar=not args.rebaixar)


def ler_siop():
    """{ano: [(funcao, gnd, mod, rp, pago, empenhado)]} e a lista de arquivos."""
    anos, arquivos = {}, []
    for ano in range(ANO_INICIAL, ANO_CORRENTE):
        arq = bruto(FONTE_SIOP, NOME_SIOP.format(ano=ano))
        arquivos.append(arq)
        linhas = []
        with open(arq, encoding="utf-8") as f:
            for l in csv.DictReader(f):
                if l["esfera"] not in ("10", "20"):  # orçamentos fiscal (10) e da seguridade social (20)
                    raise RuntimeError(f"{arq.name}: esfera inesperada {l['esfera']}")
                linhas.append((l["funcao"], l["gnd"], l["mod"], l["rp"], float(l["pago"] or 0),
                               float(l["empenhado"] or 0)))
        if ano < ANO_INTRA and any(m == "91" and (p or e) for _, _, m, _, p, e in linhas):
            raise RuntimeError(f"{arq.name}: modalidade 91 com valor antes de {ANO_INTRA}")  # 2005 tem linhas zeradas
        anos[str(ano)] = linhas
    return anos, arquivos


def categoria(funcao):
    for chave, _, codigos in CATEGORIAS_FUNCAO:
        if funcao in codigos:
            return chave
    return "demais"


TOLERANCIA_DCA = 5000.0  # R$ por função e ano (os valores são de bilhões; ver notas)


def conferir_dca(saida, siop):
    """Pago exceto intraorçamentárias, por função, = "Despesas Pagas" da DCA da União (Anexo I-E)."""
    pior, onde, arqs, n = 0.0, "", [], 0

    def anotar(dif, rotulo):
        nonlocal pior, onde
        if abs(dif) > pior:
            pior, onde = abs(dif), rotulo
    for ano in range(ANO_DCA, ANO_CORRENTE):
        arq = bruto(FONTE_SICONFI, f"dca_anexo_I-E_uniao_{ano}")
        arqs.append(arq)
        # os rótulos dos totais têm parênteses até 2016: "Despesas (Intraorçamentárias)"
        pagas = {it["conta"].replace("(", "").replace(")", ""): it["valor"]
                 for it in json.loads(arq.read_text(encoding="utf-8"))["items"] if it["coluna"] == "Despesas Pagas"}
        por_funcao, intra = defaultdict(float), 0.0
        for f, g, m, rp, pago, _ in siop[str(ano)]:
            if m == "91":
                intra += pago
            else:
                por_funcao[f] += pago
        dca = {}
        for conta, valor in pagas.items():
            achou = re.match(r"(\d{2}) - ", conta)
            if achou:
                dca[achou.group(1)] = valor
        for f in set(dca) | {f for f, v in por_funcao.items() if abs(v) > 0.5}:
            anotar(por_funcao.get(f, 0.0) - dca.get(f, 0.0), f"função {f} em {ano}")
            n += 1
        anotar(intra - pagas["Despesas Intraorçamentárias"], f"intraorçamentárias em {ano}")
        anotar(sum(por_funcao.values()) - pagas["Despesas Exceto Intraorçamentárias"], f"total em {ano}")
    if pior > TOLERANCIA_DCA:
        raise RuntimeError(f"SIOP x DCA: diferença de R$ {pior:,.2f} ({onde})")
    saida.conferiu(f"SIOP, pago por função sem intraorçamentárias x DCA da União, Anexo I-E ({ANO_DCA}-{ANO_CORRENTE - 1}, "
                   f"{n} pares função-ano, mais o total e as intraorçamentárias de cada ano): maior diferença "
                   f"R$ {br(pior, 2)} ({onde})")
    return pior, onde, arqs


def conferir_temas(saida, siop):
    """Funções 12 e 18 = séries de despesa dos temas educação e meio ambiente (mesmo endpoint)."""
    rel = []
    for tema, serie, funcao in (("educacao", "despesa_educacao_federal", "12"),
                                ("meio_ambiente", "despesa_gestao_ambiental", "18")):
        tratado = defaultdict(dict)
        for l in ler_csv(TRATADOS / f"{tema}.csv"):
            if l["indicador"] in (f"{serie}__paga_total_nominal", f"{serie}__paga_exceto_intra_nominal"):
                tratado[l["indicador"].rsplit("__", 1)[1]][l["data"]] = float(l["valor"])
        pior, n = 0.0, 0
        for ano, linhas in siop.items():
            total = sum(p for f, g, m, rp, p, e in linhas if f == funcao) / 1e9
            exceto = sum(p for f, g, m, rp, p, e in linhas if f == funcao and m != "91") / 1e9
            for recorte, valor in (("paga_total_nominal", total), ("paga_exceto_intra_nominal", exceto)):
                if ano in tratado[recorte]:
                    pior = max(pior, abs(round(valor, 3) - tratado[recorte][ano]))
                    n += 1
        if n == 0 or pior > 0.0015:
            raise RuntimeError(f"função {funcao} difere de dados/tratados/{tema}.csv em até R$ {pior} bilhões ({n} anos)")
        rel.append(f"função {funcao} x `{serie}` ({tema}.csv, {n} valores): maior diferença R$ {br(pior * 1e3, 1)} milhão")
    saida.conferiu("SIOP x temas educação e meio ambiente (pago total e sem intraorçamentárias, R$ bilhões com três "
                   "casas): " + "; ".join(rel))


def tratar_siop(saida, siop, arquivos, pib, arq_pib, ipca, rtn):
    dca_pior, dca_onde, arqs_dca = conferir_dca(saida, siop)
    conferir_temas(saida, siop)

    fonte = "SIOP/SOF, Orçamento Federal em dados abertos (SPARQL), valor pago no exercício"
    chaves = [c for c, _, _ in CATEGORIAS_FUNCAO] + ["demais", "total"]
    exceto, com = defaultdict(dict), defaultdict(dict)
    rp0 = defaultdict(dict)        # pago com RP 0 por categoria (fica fora)
    intra_rp = defaultdict(lambda: defaultdict(float))  # ano -> rp -> pago intraorçamentário
    for ano, linhas in siop.items():
        e, c, z = defaultdict(float), defaultdict(float), defaultdict(float)
        for f, g, m, rp, pago, _ in linhas:
            cat = categoria(f)
            if m == "91":
                intra_rp[ano][rp] += pago
            if rp == "0":
                z[cat] += pago
                continue
            for chave in (cat, "total"):
                c[chave] += pago
                if m != "91":
                    e[chave] += pago
        for chave in chaves:
            com[chave][ano] = c[chave]
            if int(ano) >= ANO_INTRA:
                exceto[chave][ano] = e[chave]
            rp0[chave][ano] = z[chave]
        rp0["total"][ano] = sum(z.values())

    nomes = {c: n for c, n, _ in CATEGORIAS_FUNCAO} | {"demais": "demais funções", "total": "todas as funções"}
    codigos = {c: ", ".join(sorted(cods)) for c, _, cods in CATEGORIAS_FUNCAO}
    codigos["demais"] = "01, 02, 03, 04, 07, 14, 21, 22, 23, 24, 25, 27 e 99"
    codigos["total"] = "todas"
    for chave in chaves:
        base = f"{fonte}, função {nomes[chave]} ({codigos[chave]}), despesa primária (sem RP 0)"
        for sufixo, dados, txt in (("exceto_intra", exceto[chave], ", exceto intraorçamentárias (modalidade 91)"),
                                   ("com_intra", com[chave], ", com intraorçamentárias")):
            saida.add("despesa_funcao", f"despesa_funcao__{chave}_{sufixo}_real",
                      {a: v / 1e9 for a, v in real(dados, ipca).items()}, U_REAL, base + txt + DEFLATOR, arquivos, casas=3)
            saida.add("despesa_funcao", f"despesa_funcao__{chave}_{sufixo}_nominal",
                      {a: v / 1e9 for a, v in dados.items()}, U_NOM, base + txt, arquivos, casas=3)

    saida.notas["despesa_funcao"].append(
        f"Conferência: o pago sem intraorçamentárias de cada função (com RP 0) bate com \"Despesas Pagas\" da DCA da "
        f"União (Siconfi, Anexo I-E) de {ANO_DCA} a {ANO_CORRENTE - 1}; as intraorçamentárias e o total também batem. "
        f"Maior diferença: R$ {br(dca_pior, 2)} ({dca_onde}); nos demais pares, até algumas dezenas de reais "
        f"(arredondamento das somas do SPARQL). Arquivos da DCA (os mesmos do tema "
        f"educação): " + ", ".join(f"`{a.relative_to(RAIZ)}`" for a in arqs_dca) + ".")
    saida.notas["despesa_funcao"].append(
        "Pago com identificador de resultado primário 0 (despesa financeira, fora das séries), em R$ bilhões "
        "correntes, nas funções em que passou de R$ 1 bilhão em algum ano: "
        + "; ".join(f"{nomes[c]}: " + ", ".join(f"{a}: {br(v / 1e9, 1)}" for a, v in rp0[c].items() if v >= 5e7)
                    for c in chaves if c != "total" and max(rp0[c].values()) >= 1e9) + ".")
    saida.notas["despesa_funcao"].append(
        "Intraorçamentárias (modalidade 91) por identificador de resultado primário, em R$ bilhões correntes "
        "(RP 0 / RP 1 / RP 2 e demais): "
        + "; ".join(f"{a}: {br(d.get('0', 0) / 1e9, 1)} / {br(d.get('1', 0) / 1e9, 1)} / "
                    f"{br(sum(v for r, v in d.items() if r not in ('0', '1')) / 1e9, 1)}"
                    for a, d in sorted(intra_rp.items())) + ".")

    comp = {a: sum(p for f, g, m, rp, p, e in linhas if m == "91" and f == "28" and g == "3" and rp != "0")
            for a, linhas in siop.items()}
    comp_rtn = rtn["compensacao_rgps"][1]
    saida.notas["despesa_funcao"].append(
        "Intraorçamentárias primárias da função 28 (GND 3), que acompanham a compensação ao RGPS pelas desonerações "
        "da folha registrada no RTN (linha 4.3.08), em R$ bilhões correntes (SIOP / RTN): "
        + "; ".join(f"{a}: {br(v / 1e9, 1)} / {br(comp_rtn.get(a, 0) / 1e3, 1)}" for a, v in comp.items()
                    if v >= 5e8 or comp_rtn.get(a, 0) >= 500) + ".")

    # investimento: GND 4, despesa primária, pago do exercício (sem restos a pagar)
    inv, inv_intra = {}, {}
    for ano, linhas in siop.items():
        inv[ano] = sum(p for f, g, m, rp, p, e in linhas if g == "4" and rp != "0")
        inv_intra[ano] = sum(p for f, g, m, rp, p, e in linhas if g == "4" and rp != "0" and m == "91")
    f_inv = fonte + ", GND 4 (investimentos), despesa primária (sem RP 0), com intraorçamentárias, sem restos a pagar"
    milhoes = {a: v / 1e6 for a, v in inv.items()}
    saida.add("investimento_uniao", "investimento_uniao__siop_pago_exercicio_pct_pib", pct_pib(milhoes, pib), U_PIB,
              f_inv + "; % do PIB com o PIB nominal do IBGE (SIDRA 1846)", arquivos + [arq_pib])
    saida.add("investimento_uniao", "investimento_uniao__siop_pago_exercicio_real",
              {a: v / 1e9 for a, v in real(inv, ipca).items()}, U_REAL, f_inv + DEFLATOR, arquivos, casas=3)
    saida.add("investimento_uniao", "investimento_uniao__siop_pago_exercicio_nominal",
              {a: v / 1e9 for a, v in inv.items()}, U_NOM, f_inv, arquivos, casas=3)
    saida.notas["investimento_uniao"].append(
        "GND 4 intraorçamentário (modalidade 91) incluído na série do SIOP, em R$ milhões correntes: maior valor "
        f"{br(max(inv_intra.values()) / 1e6, 1)} ({max(inv_intra, key=inv_intra.get)}).")

    # emendas: pago do exercício (pré-registro) e empenhado por identificador de resultado primário.
    # O pago do exercício deixa de fora os restos a pagar, que nas emendas são a maior parte da execução
    # e mudam muito de um ano para outro; o empenhado mede o que foi comprometido no ano (revisão, D-028).
    pagos = {rp: {} for rp in EMENDAS_RP}
    empenhados = {rp: {} for rp in EMENDAS_RP}
    presentes = defaultdict(set)
    for ano, linhas in siop.items():
        soma, soma_emp = defaultdict(float), defaultdict(float)
        for f, g, m, rp, p, e in linhas:
            if rp in EMENDAS_RP:
                soma[rp] += p
                soma_emp[rp] += e
                presentes[rp].add(ano)
        for rp, (_, _, ini, fim) in EMENDAS_RP.items():
            dentro = int(ano) >= ini and (fim is None or int(ano) <= fim)
            if dentro:
                pagos[rp][ano] = soma.get(rp, 0.0)
                empenhados[rp][ano] = soma_emp.get(rp, 0.0)
            elif soma.get(rp) or soma_emp.get(rp):
                raise RuntimeError(f"SIOP {ano}: RP {rp} com valor fora do período previsto")
    sem_linhas = [f"RP {rp} em {a}" for rp in EMENDAS_RP for a in sorted(pagos[rp]) if a not in presentes[rp]]
    f_em = fonte + ", emendas parlamentares "
    for rp, (chave, texto, _, _) in EMENDAS_RP.items():
        saida.add("emendas_parlamentares", f"emendas_parlamentares__{chave}_real",
                  {a: v / 1e9 for a, v in real(pagos[rp], ipca).items()}, U_REAL, f_em + texto + DEFLATOR, arquivos, casas=3)
        saida.add("emendas_parlamentares", f"emendas_parlamentares__{chave}_nominal",
                  {a: v / 1e9 for a, v in pagos[rp].items()}, U_NOM, f_em + texto, arquivos, casas=3)
        saida.add("emendas_parlamentares", f"emendas_parlamentares__{chave}_empenhado_real",
                  {a: v / 1e9 for a, v in real(empenhados[rp], ipca).items()}, U_REAL,
                  f_em + texto + ", valor empenhado" + DEFLATOR, arquivos, casas=3)
        saida.add("emendas_parlamentares", f"emendas_parlamentares__{chave}_empenhado_nominal",
                  {a: v / 1e9 for a, v in empenhados[rp].items()}, U_NOM, f_em + texto + ", valor empenhado",
                  arquivos, casas=3)
    saida.notas["emendas_parlamentares"].append(
        "Empenhado e pago do exercício, em R$ bilhões correntes: "
        + "; ".join(f"{EMENDAS_RP[rp][0]} {a}: {br(empenhados[rp][a] / 1e9, 1)} / {br(pagos[rp][a] / 1e9, 1)}"
                    for rp in EMENDAS_RP for a in sorted(pagos[rp])) + ".")
    saida.notas["emendas_parlamentares"].append(
        "Anos dentro do período do identificador sem nenhuma linha no SIOP (valor 0 na série): "
        + (", ".join(sem_linhas) or "nenhum") + ".")
    return pagos, exceto, com, rp0


# ================================================================ emendas: Portal da Transparência (conferência)

FONTE_CGU = "cgu_portal_transparencia"
URL_EMENDAS_PORTAL = "https://portaldatransparencia.gov.br/download-de-dados/emendas-parlamentares/UNICO"
URL_EMENDAS_ZIP = ("https://dadosabertos-download.cgu.gov.br/PortalDaTransparencia/saida/emendas-parlamentares/"
                   "EmendasParlamentares.zip")
MEMBRO_EMENDAS = "EmendasParlamentares.csv"
NOME_EMENDAS = "emendas_parlamentares_csv"
TIPO_PORTAL = {"Emenda Individual": "6", "Emenda de Bancada": "7", "Emenda de Comissão": "8", "Emenda de Relator": "9"}
# O Portal classifica pelo autor; o SIOP, pelo identificador de resultado primário. De 2021 a 2024 os
# quatro tipos coincidem nas duas fontes (achado da coleta de 06/10/2026): nesses anos, qualquer
# diferença acima de 0,1% para o script. Nos outros anos, as diferenças são só relatadas.
ANOS_PORTAL_IGUAIS = ("2021", "2022", "2023", "2024")
TOLERANCIA_PORTAL = 0.001  # fração do valor pago


def coletar_emendas_portal(args):
    existentes = brutos_exatos(FONTE_CGU, NOME_EMENDAS)
    if existentes and not args.rebaixar:
        print(f"  {FONTE_CGU}/{existentes[-1].name}: reaproveitado")
        return
    membros = [m for m in diretorio_zip_remoto(URL_EMENDAS_ZIP) if m["nome"] == MEMBRO_EMENDAS]
    if len(membros) != 1:
        raise RuntimeError(f"{URL_EMENDAS_ZIP}: membro {MEMBRO_EMENDAS} não encontrado")
    conteudo = membro_zip_remoto(URL_EMENDAS_ZIP, membros[0])  # confere o CRC32 do diretório do zip
    if not conteudo.startswith('"Código da Emenda"'.encode("latin-1")):
        raise RuntimeError(f"{MEMBRO_EMENDAS}: cabeçalho inesperado")
    caminho = salvar_bruto(FONTE_CGU, NOME_EMENDAS, conteudo, f"{URL_EMENDAS_ZIP}#{MEMBRO_EMENDAS}", "csv")
    print(f"  {FONTE_CGU}/{caminho.name}: {len(conteudo):,} bytes (membro {MEMBRO_EMENDAS})")


def valor_portal(x):
    return float(x.replace(".", "").replace(",", ".")) if x else 0.0


def conferir_emendas_portal(saida, pagos):
    arq = bruto(FONTE_CGU, NOME_EMENDAS)
    soma, empenhado, rap_pago = defaultdict(float), defaultdict(float), defaultdict(float)
    with open(arq, encoding="latin-1", newline="") as f:
        for l in csv.DictReader(f, delimiter=";"):
            rp = next((r for prefixo, r in TIPO_PORTAL.items() if l["Tipo de Emenda"].startswith(prefixo)), None)
            if rp is None:
                raise RuntimeError(f"{arq.name}: tipo de emenda desconhecido: {l['Tipo de Emenda']}")
            chave = (rp, l["Ano da Emenda"])
            soma[chave] += valor_portal(l["Valor Pago"])
            empenhado[chave] += valor_portal(l["Valor Empenhado"])
            rap_pago[chave] += valor_portal(l["Valor Restos A Pagar Pagos"])
    nome = {"6": "individuais", "7": "bancada", "8": "comissão", "9": "relator"}
    iguais, diferentes, fora = [], [], []
    for rp in EMENDAS_RP:
        for ano, v in sorted(pagos[rp].items()):
            portal = soma.get((rp, ano), 0.0)
            if v == 0 and portal == 0:
                continue
            rel = abs(v - portal) / max(v, portal)
            dif = round(100 * (v - portal) / portal, 1) if portal else None
            texto = (f"{nome[rp]} {ano}: SIOP {br(v / 1e9, 3)}, Portal {br(portal / 1e9, 3)} "
                     f"({'–' if dif is None else br(dif + 0.0, 1)}%)")
            (iguais if rel <= TOLERANCIA_PORTAL else diferentes).append(texto)
            if ano in ANOS_PORTAL_IGUAIS and rel > TOLERANCIA_PORTAL:
                fora.append(texto)
    if fora:
        raise RuntimeError("emendas: SIOP difere do Portal da Transparência em ano em que as duas classificações "
                           "coincidem: " + "; ".join(fora))
    saida.conferiu(f"emendas, pago do exercício por tipo, SIOP x Portal da Transparência: {len(iguais)} pares tipo-ano "
                   f"iguais (até 0,1%), entre eles todos os de {ANOS_PORTAL_IGUAIS[0]} a {ANOS_PORTAL_IGUAIS[-1]}; "
                   f"diferentes (classificação por autor): " + "; ".join(diferentes))
    saida.notas["emendas_parlamentares"].append(
        f"Conferência com o Portal da Transparência (CGU), arquivo de emendas parlamentares (`{arq.relative_to(RAIZ)}`, "
        f"membro {MEMBRO_EMENDAS} de {URL_EMENDAS_ZIP}), coluna \"Valor Pago\" somada por \"Ano da Emenda\" e \"Tipo de "
        f"Emenda\". O Portal classifica pelo autor; o SIOP, pelo identificador. Pares iguais até 0,1% ({len(iguais)}), "
        f"em R$ bilhões correntes: " + "; ".join(iguais) + f". Pares diferentes ({len(diferentes)}): "
        + "; ".join(diferentes) + f". De {ANOS_PORTAL_IGUAIS[0]} a {ANOS_PORTAL_IGUAIS[-1]}, as duas fontes coincidem "
        "em todos os tipos; o script para se isso deixar de valer.")
    saida.notas["emendas_parlamentares"].append(
        "Execução segundo o Portal da Transparência, por ano da emenda, em R$ bilhões correntes (empenhado / pago no "
        "exercício / restos a pagar pagos nos anos seguintes, até a data do arquivo): "
        + "; ".join(f"{nome[rp]} {a}: {br(empenhado[(rp, a)] / 1e9, 2)} / {br(soma[(rp, a)] / 1e9, 2)} / "
                    f"{br(rap_pago[(rp, a)] / 1e9, 2)}" for (rp, a) in sorted(soma) if a < str(ANO_CORRENTE)) + ".")
    saida.notas["emendas_parlamentares"].append(
        "Tipos e anos que o Portal registra por autor, mas sem identificador de resultado primário próprio no "
        "orçamento (fora das séries), em R$ bilhões pagos no exercício: "
        + "; ".join(f"{nome[rp]} {a}: {br(v / 1e9, 2)}" for (rp, a), v in sorted(soma.items())
                    if a not in pagos[rp] and a < str(ANO_CORRENTE) and v > 0) + ".")
    return soma, arq


# ================================================================ juros nominais (Banco Central, SGS)

SGS_JUROS = "4616"      # NFSP sem desvalorização cambial, fluxo mensal corrente, juros nominais, total, SPC (R$ milhões)
SGS_JUROS_PCT = "5760"  # idem, % do PIB, fluxo acumulado em 12 meses (conferência)
METADADOS_BCB = "https://dadosabertos.bcb.gov.br/api/3/action/package_show?id={id}"
IDS_BCB = {
    "4616": "4616-nfsp-sem-desvalorizacao-cambial---fluxo-mensal-corrente---juros-nominais---total---setor-publi",
    "5760": "5760-nfsp-sem-desvalorizacao-cambial--pib---fluxo-acumulado-em-12-meses---juros-nominais---total---",
}


def coletar_juros(args):
    for codigo in (SGS_JUROS, SGS_JUROS_PCT):
        for arq in coletar_sgs(codigo):  # salvar_bruto não duplica arquivo idêntico do mesmo dia
            print(f"  bcb_sgs/{arq.name}")

        def validar_meta(c, codigo=codigo):
            titulo = json.loads(c)["result"]["title"]
            if "Juros nominais" not in titulo or "Setor público consolidado" not in titulo:
                raise RuntimeError(f"SGS {codigo}: título inesperado nos metadados: {titulo}")
        obter("bcb_sgs", f"metadados_sgs{codigo}", "json", METADADOS_BCB.format(id=IDS_BCB[codigo]), validar=validar_meta)


def tratar_juros(saida, pib, arq_pib):
    mensal, freq, arqs = ler_sgs(SGS_JUROS)
    if freq != "mensal":
        raise RuntimeError(f"SGS {SGS_JUROS}: frequência {freq}")
    soma, n = defaultdict(float), Counter()
    for data, v in mensal.items():
        soma[data[:4]] += v
        n[data[:4]] += 1
    anual = {a: v for a, v in soma.items() if n[a] == 12 and a in ANOS}
    pct = pct_pib(anual, pib)
    # conferência: % do PIB do próprio Banco Central em dezembro (SGS 5760, duas casas)
    pct_bcb, _, arqs_pct = ler_sgs(SGS_JUROS_PCT)
    difs = {a: pct[a] - pct_bcb[f"{a}-12"] for a in pct if f"{a}-12" in pct_bcb}
    pior = max(abs(d) for d in difs.values())
    if pior > 0.01:
        raise RuntimeError(f"juros: % do PIB difere do SGS {SGS_JUROS_PCT} em até {pior} p.p.")
    saida.conferiu(f"juros nominais, soma de 12 meses do SGS {SGS_JUROS} / PIB do IBGE x SGS {SGS_JUROS_PCT} em "
                   f"dezembro ({min(difs)}-{max(difs)}): maior diferença {br(pior, 3)} p.p.")
    meta = [bruto("bcb_sgs", f"metadados_sgs{c}") for c in (SGS_JUROS, SGS_JUROS_PCT)]
    fonte = (f"Banco Central, SGS {SGS_JUROS} (NFSP sem desvalorização cambial, juros nominais, setor público "
             f"consolidado, fluxo mensal), soma dos 12 meses")
    s = "juros_nominais_sp"
    saida.add(s, f"{s}__pct_pib", pct, U_PIB, fonte + "; % do PIB com o PIB nominal do IBGE (SIDRA 1846)",
              arqs + [arq_pib])
    saida.add(s, f"{s}__nominal", bilhoes(anual), U_NOM, fonte, arqs, casas=3)
    saida.notas[s].append(
        f"Conferência: o % do PIB calculado com o PIB do IBGE é igual ao do próprio Banco Central em dezembro "
        f"(SGS {SGS_JUROS_PCT}, fluxo acumulado em 12 meses, publicado com duas casas), com diferença máxima de "
        f"{br(pior, 3)} p.p. Arquivos: " + ", ".join(f"`{a.relative_to(RAIZ)}`" for a in arqs_pct)
        + ". Código confirmado nos metadados do Banco Central (portal de dados abertos): "
        + ", ".join(f"`{a.relative_to(RAIZ)}`" for a in meta) + ".")
    return pct


# ================================================================ gastos tributários (Receita Federal)

FONTE_RFB = "rfb_gastos_tributarios"
PAGINA_DGT = ("https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/relatorios/renuncia/"
              "gastos-tributarios-bases-efetivas")
EDICOES_DGT = {  # edição (ano-base) -> arquivo "Quadros" na página de bases efetivas
    2016: "dgt-bases-efetivas-2016-serie-2014-a-2019-quadros",
    2017: "dgt-bases-efetivas-2017-serie-2015-a-2020-quadros",
    2018: "dgt-bases-efetivas-2018-serie-2016-a-2021-quadros",
    2019: "dgt-bases-efetivas-2019-serie-2017-a-2022-quadros.xlsx",
    2020: "dgt-bases-efetivas-2020-serie-2018-a-2023-quadros.xlsx",
    2021: "dgt-bases-efetivas-2021-serie-2019-a-2024-quadros.xlsx",
    2022: "dgt-bases-efetivas-2022-serie-2020-a-2025-quadros.xlsx",
    2023: "dgt-bases-efetivas-2023-serie-2021-a-2026-quadros.xlsx",
}


def coletar_gastos_tributarios(args):
    pagina = pedir(PAGINA_DGT).decode("utf-8", "replace")
    edicoes = {int(a) for a in re.findall(r"dgt-bases-efetivas-(\d{4})-serie-\d{4}-a-\d{4}-quadros", pagina)}
    novas = sorted(e for e in edicoes if e > max(EDICOES_DGT))
    if novas:
        print(f"  ! edições novas na página da Receita Federal: {novas}; acrescente-as em EDICOES_DGT", file=sys.stderr)
    for edicao, arquivo in EDICOES_DGT.items():
        obter(FONTE_RFB, f"dgt_bases_efetivas_{edicao}_quadros", "xlsx", f"{PAGINA_DGT}/{arquivo}",
              validar=validar_xlsx(), reaproveitar=not args.rebaixar)


def ler_edicao_dgt(arq):
    """Quadro da série (efetivo e projeção) e quadro VI (ano-base, % do PIB) de uma edição."""
    livro = Xlsx(str(arq))
    serie = None
    for nome in livro.planilhas:
        topo = livro.linhas(nome, max_linhas=10)
        texto = " ".join(limpo(v) for r in topo for v in r if v).upper()
        if "EFETIV" in texto and "PROJE" in texto and "TRIBUTO" in texto and serie is None:
            linhas = livro.linhas(nome)
            i = next(i for i, r in enumerate(linhas[:12])
                     if r and sum(1 for v in r if v and re.fullmatch(r"\d{4}", limpo(v))) >= 4)
            anos = {c: limpo(v) for c, v in enumerate(linhas[i]) if v and re.fullmatch(r"\d{4}", limpo(v))}
            col_proj = min(c for r in linhas[max(0, i - 2):i + 1] for c, v in enumerate(r)
                           if v and "PROJE" in limpo(v).upper())
            total = next(r for r in linhas[i + 1:] if r and limpo(r[0]).upper() == "TOTAL")
            serie = {a: num(total[c]) for c, a in anos.items() if c < col_proj}
            serie_nome = nome
        if "CONSOLIDAÇÃO POR TIPO DE TRIBUTO" in texto and "REGIONALIZA" not in texto:
            linhas = livro.linhas(nome)
            tot = next(r for r in linhas if r and limpo(r[0]).upper() == "TOTAL")
            pib_rfb = next(r for r in linhas if r and limpo(r[0]).upper() == "PIB")
            base = (num(tot[1]), num(tot[2]), num(pib_rfb[1]), nome)
    if serie is None:
        raise RuntimeError(f"{arq.name}: quadro da série não encontrado")
    return serie, serie_nome, base


def tratar_gastos_tributarios(saida, pib, arq_pib):
    escolhido, revisoes, arqs, rel, internas = {}, defaultdict(dict), [], [], []
    for edicao in sorted(EDICOES_DGT):
        arq = bruto(FONTE_RFB, f"dgt_bases_efetivas_{edicao}_quadros")
        arqs.append(arq)
        serie, quadro, (total_base, pct_rfb, pib_rfb, quadro_vi) = ler_edicao_dgt(arq)
        # o total do ano-base é o mesmo nos dois quadros da edição (a de 2021 difere em R$ 0,55 milhão)
        if str(edicao) not in serie or abs(serie[str(edicao)] - total_base) > 1e-5 * total_base:
            raise RuntimeError(f"{arq.name}: o total do ano-base no {quadro} difere do {quadro_vi}")
        if abs(serie[str(edicao)] - total_base) > 1:
            internas.append(f"edição {edicao}: {quadro} {br(serie[str(edicao)] / 1e6, 1)} e {quadro_vi} "
                            f"{br(total_base / 1e6, 1)} (R$ milhões)")
        for ano, v in serie.items():
            revisoes[ano][edicao] = v
            escolhido[ano] = (v, edicao, quadro, arq)  # a edição mais recente com o ano como efetivo
        nosso = 100 * total_base / 1e6 / pib[str(edicao)]
        if abs(nosso - pct_rfb) > 0.01:
            raise RuntimeError(f"gastos tributários {edicao}: {nosso:.3f}% do PIB contra {pct_rfb:.3f}% da Receita")
        rel.append(f"{edicao}: {br(pct_rfb, 3)}% na Receita e {br(nosso, 3)}% com o PIB da tabela 1846")
    saida.conferiu("gastos tributários, % do PIB do ano-base de cada edição, Receita Federal (quadro VI) x cálculo "
                   "com o PIB do IBGE: " + "; ".join(rel))
    valores = {a: v / 1e6 for a, (v, _, _, _) in escolhido.items() if a in ANOS}  # R$ milhões
    fonte = ("Receita Federal, Demonstrativo dos Gastos Tributários, bases efetivas (total, inclusive contribuição "
             "previdenciária); cada ano pela edição mais recente que o traz como efetivo")
    s = "gastos_tributarios"
    saida.add(s, f"{s}__pct_pib", pct_pib(valores, pib), U_PIB, fonte + "; % do PIB com o PIB nominal do IBGE (SIDRA 1846)",
              arqs + [arq_pib])
    saida.add(s, f"{s}__nominal", bilhoes(valores), U_NOM, fonte, arqs, casas=3)
    if internas:
        saida.notas[s].append("Diferença entre o quadro da série e o quadro VI da mesma edição, no total do ano-base: "
                              + "; ".join(internas) + ". Vale o quadro da série.")
    saida.notas[s].append(
        "Edição usada para cada ano: " + "; ".join(f"{a}: edição {e} ({q})" for a, (_, e, q, _) in sorted(escolhido.items()))
        + ". Valores do mesmo ano em edições diferentes, em R$ bilhões: "
        + "; ".join(f"{a}: " + ", ".join(f"ed. {e} {br(v / 1e9, 2)}" for e, v in sorted(d.items()))
                    for a, d in sorted(revisoes.items()) if len(d) > 1 and max(d.values()) - min(d.values()) > 1e6) + ".")
    return valores


# ================================================================ comparação com pares (FMI, WEO)

INDICADORES_FMI = {"fmi_despesa_gg": "GGX_NGDP", "fmi_receita_gg": "GGR_NGDP"}
URL_WEO_ATRIBUTOS = SDMX_IMF.format(fluxo="WEO", chave="BRA.GGX_NGDP.A") + \
    "?dimensionAtObservation=TIME_PERIOD&attributes=all&measures=all"


def coletar_fmi(args):
    for indicador in INDICADORES_FMI.values():
        for arq in coletar_weo(indicador, []):
            print(f"  fmi_weo/{arq.name}")
    obter("fmi_weo", "edicao_weo_BRA_GGX_NGDP", "json", URL_WEO_ATRIBUTOS, validar=validar_json)


def edicao_weo(arq):
    d = ler_json(arq)
    attrs = d["data"]["structures"][0]["attributes"].get("dataSet", [])
    valores = d["data"]["dataSets"][0].get("attributes", [])
    lidos = {a["id"]: v for a, v in zip(attrs, valores)}
    pub, atu = lidos.get("PUBLICATION_DATE"), lidos.get("UPDATE_DATE")
    return f"publicada em {pub[0][:10] if pub else '?'}, atualizada em {atu[0][:10] if atu else '?'}"


def tratar_fmi(saida):
    arq_ed = bruto("fmi_weo", "edicao_weo_BRA_GGX_NGDP")
    for ficha, indicador in INDICADORES_FMI.items():
        arq = bruto("fmi_weo", f"weo_{indicador}")
        series, dims = ler_sdmx(arq)
        i_pais = dims.index("COUNTRY")
        por_pais = {rot[i_pais]: {a: v for a, v in obs.items() if re.fullmatch(r"\d{4}", a) and "2000" <= a < str(ANO_CORRENTE)}
                    for rot, obs in series.items()}
        fonte = f"FMI/WEO {indicador}"
        brasil = por_pais.get("BRA", {})
        saida.add(ficha, f"{ficha}__brasil", brasil, U_PIB, fonte, [arq])
        for grupo, paises in ler_grupos().items():
            med, n = mediana_por_ano(por_pais, paises)  # D-022: só com pelo menos metade do grupo
            saida.add(ficha, f"{ficha}__mediana_{grupo}", med, U_PIB, f"{fonte}, mediana de {grupo}", [arq])
            dif = {a: brasil[a] - med[a] for a in brasil if a in med}
            saida.add(ficha, f"{ficha}__brasil_menos_mediana_{grupo}", dif, "p.p.",
                      f"{fonte}, Brasil menos mediana de {grupo}", [arq])
            for a in sorted(n):
                saida.paises.append([f"{ficha}__n_{grupo}", grupo, a, n[a], "países", fonte])
        for pais, obs in sorted(por_pais.items()):
            for a, v in sorted(obs.items()):
                saida.paises.append([ficha, pais, a, round(v, 4), U_PIB, fonte])
        sem_dado = sorted({p for ps in ler_grupos().values() for p in ps} - set(por_pais))
        saida.notas[ficha].append(
            f"Edição do WEO: {edicao_weo(arq_ed)} (`{arq_ed.relative_to(RAIZ)}`). Valores do ano corrente em diante "
            f"são projeções e ficam fora (D-017). Países do grupo sem nenhum dado: {', '.join(sem_dado) or 'nenhum'}. "
            f"Valores de cada país e número de países de cada mediana (`{ficha}__n_<grupo>`) em "
            f"`dados/tratados/{TEMA}_pares_paises.csv`.")


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

def etapas():
    return [
        ("PIB nominal (IBGE, SIDRA 1846 e 6784)", coletar_pib),
        ("Resultado do Tesouro Nacional (Tesouro Transparente)", coletar_rtn),
        ("Despesa da União por função, GND, modalidade e RP (SIOP, SPARQL) e DCA (Siconfi)", coletar_siop),
        ("Emendas parlamentares (Portal da Transparência, conferência)", coletar_emendas_portal),
        ("Juros nominais do setor público consolidado (Banco Central, SGS)", coletar_juros),
        ("Gastos tributários, bases efetivas (Receita Federal)", coletar_gastos_tributarios),
        ("Despesa e receita do governo geral, Brasil e pares (FMI, WEO)", coletar_fmi),
    ]


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()
    ipca = ipca_medio_anual()
    print("Tratamento")
    pib, arq_pib, _, _ = tratar_pib(saida)
    rtn, _, _, _ = tratar_rtn(saida, pib, arq_pib, ipca)
    siop, arquivos = ler_siop()
    pagos, _, _, _ = tratar_siop(saida, siop, arquivos, pib, arq_pib, ipca, rtn)
    conferir_emendas_portal(saida, pagos)
    tratar_juros(saida, pib, arq_pib)
    tratar_gastos_tributarios(saida, pib, arq_pib)
    tratar_fmi(saida)

    saida.linhas.sort(key=lambda l: (l[0], l[1]))
    escrever_csv(TRATADOS / f"{TEMA}.csv", ["indicador", "data", "valor", "unidade", "fonte"], saida.linhas)
    escrever_csv(TRATADOS / f"{TEMA}_pares_paises.csv",
                 ["indicador", "pais", "data", "valor", "unidade", "fonte"], saida.paises)
    for ficha_id, itens in saida.extracao.items():
        if ficha_id not in fichas:
            raise RuntimeError(f"série sem ficha: {ficha_id}")
        atualizar_extracao(fichas[ficha_id], itens, saida.notas.get(ficha_id, ()))
    sem_serie = set(fichas) - set(saida.extracao)
    if sem_serie:
        raise RuntimeError(f"fichas sem série: {sorted(sem_serie)}")
    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:72s} {min(datas)} a {max(datas)} ({len(datas)})")
    print(f"\n{len(saida.conferencias)} conferências passaram.")


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 (RTN de dez/2022, DGT, "
                         "Portal da Transparência, DCA)")
    args = ap.parse_args()
    if not args.so_tratar:
        coletar(args)
    tratar()


if __name__ == "__main__":
    main()
