#!/usr/bin/env python3
"""Meio ambiente: coleta e tratamento dos indicadores pré-registrados (tema `meio_ambiente`).

Uso:
    python3 scripts/coletar_meio_ambiente.py              # coleta e trata
    python3 scripts/coletar_meio_ambiente.py --so-tratar  # só trata os brutos mais recentes
    python3 scripts/resumir.py --tema meio_ambiente       # depois: resumo por mandato

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

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

Datas: AAAA (anual) e AAAA-MM (mensal). PRODES: a data é o ano em que o período de agosto a
julho termina (ano PRODES, D-025). O mês corrente e o ano corrente, incompletos, ficam fora
das séries mensais e anuais.

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).

Publicações de versão fechada (GFW v1.13, MapBiomas Fogo, SEEG 13.0, inventário do MCTI, lista
de municípios do IBGE, amostras de conferência) 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:

- SIOP: a consulta SPARQL agrupa por esfera, modalidade, GND, resultado primário e subfunção,
  para separar intraorçamentárias (modalidade 91) e o financiamento reembolsável do Fundo
  Clima (GND 5 com resultado primário 0, ação 00J4). A DCA da União (Siconfi) usada na
  conferência é a mesma baixada pelo tema educação.
- PRODES: o arquivo de taxas da Amazônia Legal muda de nome a cada ano (rates2025.json); o
  script procura do ano corrente para trás. O JSON não diz se a taxa é estimada ou consolidada:
  o último ano consolidado fica em PRODES_AMZ_ULTIMO_CONSOLIDADO, com a nota técnica.
- DETER: o GeoServer do TerraBrasilis às vezes responde 400 "pool error Timeout waiting for idle
  object"; o script espera e repete. Limite declarado: 10 GetFeature por minuto.
- MapBiomas: a API da plataforma exige o cabeçalho `tenant-id: mapbiomas`; as chaves de tema e
  legenda têm sufixo aleatório e são descobertas pelo nome em português.
- SEEG: a planilha nacional tem 185 MB (880 MB de XML na aba Dados) e é lida linha a linha
  (cerca de 35 s).
- Ibama: os CSVs têm campos de geometria com mais de 200 mil caracteres (csv.field_size_limit).
  Os arquivos brutos trazem nome e CPF/CNPJ dos autuados; o projeto publica só agregados.
"""

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

from comum import (BRUTOS, HOJE, METODOLOGIA, RAIZ, TRATADOS, escrever_csv, ler_fichas, ler_manifesto,
                   salvar_bruto, ultimo_dia_do_mes)
from tratar import mediana_por_ano
from coletar_educacao import (FONTE_SICONFI, SPARQL_SIOP, URL_DCA, Xlsx, ipca_medio_anual, limpo, num,
                              pedir)

TEMA = "meio_ambiente"
ANO_CORRENTE = HOJE.year
MES_CORRENTE = f"{HOJE.year:04d}-{HOJE.month:02d}"
ANO_BASE_REAL = "2025"  # D-015


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

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` (por
    exemplo, `prodes_amazonia` e `prodes_amazonia_estados`)."""
    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 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

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,
        a taxa PRODES ainda estimada)."""
        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 sem_mes_corrente(mensal):
    return {d: v for d, v in mensal.items() if d < MES_CORRENTE}


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


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}


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

FONTE_SIOP = "siop_sparql"
CONSULTA_SIOP = """PREFIX loa: <http://vocab.e.gov.br/2013/09/loa#>
SELECT ?esfera ?mod ?gnd ?rp ?subfuncao (SUM(?emp) AS ?empenhado) (SUM(?liq) AS ?liquidado) (SUM(?pag) AS ?pago)
FROM <http://orcamento.dados.gov.br/{ano}/>
WHERE {{
  ?i a loa:ItemDespesa ;
     loa:temFuncao <http://orcamento.dados.gov.br/id/{ano}/Funcao/18> ;
     loa:temEsfera ?e ; loa:temModalidadeAplicacao ?m ; loa:temGND ?g ;
     loa:temResultadoPrimario ?r ; loa:temSubfuncao ?s ;
     loa:valorEmpenhado ?emp ; loa:valorLiquidado ?liq ; loa:valorPago ?pag .
  ?e loa:codigo ?esfera . ?m loa:codigo ?mod . ?g loa:codigo ?gnd . ?r loa:codigo ?rp . ?s loa:codigo ?subfuncao .
}}
GROUP BY ?esfera ?mod ?gnd ?rp ?subfuncao ORDER BY ?esfera ?mod ?gnd ?rp ?subfuncao"""
ANO_INICIAL_DESPESA = 2002  # D-002
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


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


def coletar_despesa(args):
    for ano in range(ANO_INICIAL_DESPESA, ANO_CORRENTE):  # só anos fechados
        def baixar_csv(ano=ano):
            corpo = pedir(url_siop(ano))
            if not corpo.startswith(b'"esfera","mod","gnd","rp","subfuncao"'):
                raise RuntimeError(f"SPARQL {ano}: resposta inesperada {corpo[:200]!r}")
            time.sleep(1)
            return corpo
        obter(FONTE_SIOP, f"despesa_funcao18_{ano}", "csv", url_siop(ano), baixar_fn=baixar_csv)
    # Conferência: a DCA (Anexo I-E) traz todas as funções. Os arquivos já baixados pelo tema
    # educação (mesma consulta) são reaproveitados; se faltarem, são baixados.
    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_despesa():
    """{ano: {"total", "intra", "reembolsavel", "empenhado", "por_subfuncao": {...}}} e arquivos."""
    anos, arquivos = {}, []
    for ano in range(ANO_INICIAL_DESPESA, ANO_CORRENTE):
        arq = bruto(FONTE_SIOP, f"despesa_funcao18_{ano}")
        arquivos.append(arq)
        r = {"total": 0.0, "intra": 0.0, "reembolsavel": 0.0, "por_subfuncao": defaultdict(float),
             "empenhado": 0.0}
        with open(arq, encoding="utf-8") as f:
            for l in csv.DictReader(f):
                if l["esfera"] not in ("10", "20"):  # orçamentos fiscal (10) e da seguridade social (20)
                    raise RuntimeError(f"{arq.name}: esfera inesperada {l['esfera']}")
                pago = float(l["pago"] or 0)
                r["total"] += pago
                r["empenhado"] += float(l["empenhado"] or 0)
                r["por_subfuncao"][l["subfuncao"]] += pago
                if l["mod"] == "91":
                    r["intra"] += pago
                elif l["gnd"] == "5" and l["rp"] == "0":
                    # inversão financeira com resultado primário 0 (despesa financeira): o financiamento
                    # reembolsável do Fundo Clima (ação 00J4), um empréstimo, não um gasto final
                    r["reembolsavel"] += pago
        if r["intra"] and ano < ANO_INTRA:
            raise RuntimeError(f"{arq.name}: modalidade 91 antes de {ANO_INTRA}")
        anos[str(ano)] = r
    return anos, arquivos


def tratar_despesa(saida):
    ipca = ipca_medio_anual()
    anos, arquivos = ler_despesa()
    total = {a: r["total"] for a, r in anos.items()}
    exceto = {a: r["total"] - r["intra"] for a, r in anos.items() if int(a) >= ANO_INTRA}
    total_sem_reemb = {a: r["total"] - r["reembolsavel"] for a, r in anos.items()}
    exceto_sem_reemb = {a: r["total"] - r["intra"] - r["reembolsavel"] for a, r in anos.items()
                        if int(a) >= ANO_INTRA}

    # conferência: pago exceto intraorçamentárias = "Despesas Pagas" da função 18 na DCA da União
    difs, arqs_dca, rap = {}, [], {}
    for ano in range(ANO_DCA, ANO_CORRENTE):
        arq = bruto(FONTE_SICONFI, f"dca_anexo_I-E_uniao_{ano}")
        arqs_dca.append(arq)
        itens = json.loads(arq.read_text(encoding="utf-8"))["items"]
        colunas = {it["coluna"]: it["valor"] for it in itens if it["conta"].startswith("18 - Gest")}
        difs[ano] = exceto[str(ano)] - colunas["Despesas Pagas"]
        inscritos = [v for c, v in colunas.items() if c.startswith("Inscrição de Restos a Pagar")]
        if inscritos:  # a DCA só traz as colunas de restos a pagar a partir de 2017
            rap[ano] = sum(inscritos)
    pior = max(abs(x) for x in difs.values())
    print(f"  conferência função 18, pago exceto intra x DCA Anexo I-E ({ANO_DCA}-{ANO_CORRENTE - 1}): "
          f"maior diferença R$ {pior:,.2f}")
    if pior > 100:
        raise RuntimeError(f"despesa paga difere da DCA: {difs}")

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

    s = "despesa_gestao_ambiental"
    fonte = "SIOP/SOF, Orçamento Federal em dados abertos (SPARQL), função 18, valor pago"
    deflator = f"; reais de {ANO_BASE_REAL} pelo IPCA médio do ano (SIDRA 1737)"
    u_real, u_nom = f"R$ bilhões de {ANO_BASE_REAL} (IPCA, média do ano)", "R$ bilhões correntes"
    recortes = (
        ("paga_total", total, ", com intraorçamentárias"),
        ("paga_exceto_intra", exceto, ", exceto intraorçamentárias"),
        ("paga_total_exceto_reembolsavel", total_sem_reemb,
         ", com intraorçamentárias, exceto financiamento reembolsável (inversão financeira de resultado financeiro)"),
        ("paga_exceto_intra_e_reembolsavel", exceto_sem_reemb,
         ", exceto intraorçamentárias e financiamento reembolsável (inversão financeira de resultado financeiro)"),
    )
    for rotulo, dados, texto in recortes:
        saida.add(s, f"{s}__{rotulo}_real", {a: v / 1e9 for a, v in real(dados, ipca).items()}, u_real,
                  fonte + texto + deflator, arquivos, casas=3)
        saida.add(s, f"{s}__{rotulo}_nominal", bi(dados), u_nom, fonte + texto, arquivos, casas=3)

    saida.notas[s].append(
        f"Conferência: o pago exceto intraorçamentárias bate com \"Despesas Pagas\" da função 18 na DCA da União "
        f"(Siconfi, Anexo I-E) de {ANO_DCA} a {ANO_CORRENTE - 1}, com diferença máxima de R$ {br(pior, 2)} por ano. "
        f"Arquivos da DCA (os mesmos do tema educação, reaproveitados): "
        + ", ".join(f"`{a.relative_to(RAIZ)}`" for a in arqs_dca) + ".")
    saida.notas[s].append(
        "Restos a pagar inscritos no fim do ano na função 18 (processados e não processados, sem intraorçamentárias), "
        "pela mesma DCA, em R$ bilhões correntes: "
        + "; ".join(f"{a}: {br(v / 1e9, 2)}" for a, v in rap.items()) + ".")
    saida.notas[s].append(
        "Financiamento reembolsável (GND 5 com resultado primário 0; ação 00J4 do Fundo Nacional sobre Mudança do "
        "Clima), em R$ bilhões correntes pagos: "
        + "; ".join(f"{a}: {br(r['reembolsavel'] / 1e9, 3)}" for a, r in anos.items() if r["reembolsavel"]) + ".")
    saida.notas[s].append(
        "Participação da subfunção 544 (Recursos Hídricos) no pago total sem o financiamento reembolsável, em %: "
        + "; ".join(f"{a}: {br(100 * r['por_subfuncao'].get('544', 0) / (r['total'] - r['reembolsavel']), 1)}"
                    for a, r in anos.items()) + ".")
    return anos, difs


# ================================================================ PRODES (INPE, TerraBrasilis)

FONTE_TERRABRASILIS = "inpe_terrabrasilis"
PAINEL_PRODES = "https://terrabrasilis.dpi.inpe.br/app/prodes/dashboard/deforestation/files/"
# O arquivo de taxas muda de nome a cada ano PRODES (rates2025.json hoje; rates2024.json não existe mais):
# o script tenta do ano corrente para trás.
URL_TAXAS_AMZ = PAINEL_PRODES + "rates{ano}.json"
URL_NOMES_AMZ = PAINEL_PRODES + "config/loinames/prodes_legal_amazon.json"
URL_INCREMENTOS_CERRADO = PAINEL_PRODES + "data/prodes_cerrado.json"
URL_NOMES_CERRADO = PAINEL_PRODES + "config/loinames/prodes_cerrado.json"
# Taxas oficiais da Amazônia Legal, para conferência e para marcar o último ano consolidado. O JSON do
# painel não diz se o número é estimativa ou consolidado; a mesma URL é sobrescrita quando o INPE consolida.
PRODES_AMZ_PUBLICADO = {
    "2004": 27772, "2008": 12911, "2012": 4571, "2019": 10129, "2020": 10851, "2021": 13038,
    # Metodologia PRODES/DETER, 2ª ed., tabela A.5:
    # https://data.inpe.br/biomasbr/wp-content/uploads/sites/3/2026/04/METODOLOGIA-UTILIZADA-NOS-SISTEMAS-PRODES.pdf
    "2023": 9064,  # nota técnica da taxa consolidada de 2024 (traz 2023)
    "2024": 6518,  # https://data.inpe.br/biomasbr/wp-content/uploads/sites/3/2025/07/Nota_tecnica_PRODES_2024_consolidada.pdf
    "2025": 5731,  # https://data.inpe.br/wp-content/uploads/sites/3/2026/03/A-taxa-consolidada-de-desmatamento-para-os-nove-estados-da-Amazonia-Legal-para-2025-e-de-5.731-km2.pdf
}
PRODES_AMZ_ULTIMO_CONSOLIDADO = "2025"  # nota técnica de 27/02/2026; estimativa de out/2025 era 5.796 km²
PRODES_CERRADO_PUBLICADO = {  # notas técnicas do PRODES Cerrado de 2024 e 2025 (km², duas casas)
    "2023": 11011.69, "2024": 8174.17, "2025": 7235.27,
}


def coletar_prodes(args):
    def validar_json(c):
        d = json.loads(c)
        if not d.get("periods") and not d.get("lois"):
            raise RuntimeError("PRODES: JSON sem períodos")
    for ano in range(ANO_CORRENTE, ANO_CORRENTE - 4, -1):
        url = URL_TAXAS_AMZ.format(ano=ano)
        try:
            corpo = pedir(url, tentativas=2)
        except urllib.error.HTTPError as e:
            if e.code == 404:
                continue
            raise
        obter(FONTE_TERRABRASILIS, "prodes_amazonia_legal_taxas", "json", url, baixar_fn=lambda c=corpo: c,
              validar=validar_json)
        break
    else:
        raise RuntimeError("PRODES: arquivo de taxas da Amazônia Legal não encontrado")
    obter(FONTE_TERRABRASILIS, "prodes_amazonia_legal_nomes", "json", URL_NOMES_AMZ, validar=validar_json)
    obter(FONTE_TERRABRASILIS, "prodes_cerrado_incrementos", "json", URL_INCREMENTOS_CERRADO, validar=validar_json)
    obter(FONTE_TERRABRASILIS, "prodes_cerrado_nomes", "json", URL_NOMES_CERRADO, validar=validar_json)


def nomes_uf(arq):
    (ufs,) = [l for l in json.loads(arq.read_text(encoding="utf-8"))["lois"] if l["name"] == "uf"]
    return {x["gid"]: x["loiname"] for x in ufs["loinames"]}


def periodos_prodes(arq):
    """[(início, fim, {gid da UF: km²})] dos períodos do painel; só o recorte por UF (loi 1)."""
    saida = []
    for p in json.loads(arq.read_text(encoding="utf-8"))["periods"]:
        ini = dt.date(p["startDate"]["year"], p["startDate"]["month"], p["startDate"]["day"])
        fim = dt.date(p["endDate"]["year"], p["endDate"]["month"], p["endDate"]["day"])
        por_uf = defaultdict(float)
        for f in p["features"]:
            if f["loi"] == 1:
                if len(f["areas"]) != 1 or f["areas"][0]["type"] != 1:
                    raise RuntimeError(f"{arq.name}: área de tipo inesperado em {fim}")
                por_uf[f["loiname"]] += f["areas"][0]["area"]
        saida.append((ini, fim, dict(por_uf)))
    return saida


def tratar_prodes(saida):
    # Amazônia Legal: taxa anual oficial = soma das nove UFs
    arq, arq_nomes = bruto(FONTE_TERRABRASILIS, "prodes_amazonia_legal_taxas"), \
        bruto(FONTE_TERRABRASILIS, "prodes_amazonia_legal_nomes")
    ufs = nomes_uf(arq_nomes)
    manifesto = ler_manifesto(FONTE_TERRABRASILIS).get(arq.name, {})
    taxas = {}
    for ini, fim, por_uf in periodos_prodes(arq):
        if (fim.month, fim.day) != (7, 31) or (ini.month, ini.day, ini.year) != (8, 1, fim.year - 1):
            raise RuntimeError(f"{arq.name}: período fora do ano PRODES: {ini} a {fim}")
        if set(por_uf) != set(ufs):
            raise RuntimeError(f"{arq.name}: UFs inesperadas em {fim.year}: {sorted(por_uf)}")
        taxas[str(fim.year)] = sum(por_uf.values())
    difs = {a: taxas.get(a) for a, v in PRODES_AMZ_PUBLICADO.items() if taxas.get(a) != v}
    if difs:
        raise RuntimeError(f"PRODES Amazônia Legal difere das taxas oficiais publicadas: {difs}")
    estimados = sorted(a for a in taxas if a > PRODES_AMZ_ULTIMO_CONSOLIDADO)
    f_id = "desmatamento_amazonia"
    fonte = "INPE, PRODES Amazônia Legal, taxa anual (soma das nove UFs)"
    saida.add(f_id, f"{f_id}__taxa", taxas, "km² por ano PRODES", fonte + ", consolidada", [arq, arq_nomes], 0,
              fonte_por_data={a: fonte + ", ESTIMATIVA (taxa preliminar, sujeita a revisão)" for a in estimados})
    saida.notas[f_id].append(
        f"Arquivo de taxas usado: {manifesto.get('consulta_url', '?')}. Último ano PRODES no arquivo: {max(taxas)}; "
        + (f"anos marcados como estimativa: {', '.join(estimados)}." if estimados else
           f"todos consolidados (último consolidado: {PRODES_AMZ_ULTIMO_CONSOLIDADO}, nota técnica do INPE de "
           f"27/02/2026; a estimativa de outubro de 2025 era 5.796 km²).")
        + " Conferência com as taxas oficiais publicadas pelo INPE ("
        + ", ".join(f"{a}: {br(v)}" for a, v in PRODES_AMZ_PUBLICADO.items()) + " km²): iguais.")

    # Cerrado: incrementos mapeados (não há "taxa" do Cerrado), bienais até 2012 e anuais desde 2013
    arq_c, arq_cn = bruto(FONTE_TERRABRASILIS, "prodes_cerrado_incrementos"), \
        bruto(FONTE_TERRABRASILIS, "prodes_cerrado_nomes")
    ufs_c = nomes_uf(arq_cn)
    anual, bienal, acumulado = {}, {}, None
    for ini, fim, por_uf in periodos_prodes(arq_c):
        if not set(por_uf) <= set(ufs_c):
            raise RuntimeError(f"{arq_c.name}: UF desconhecida em {fim}")
        total = sum(por_uf.values())
        meses = (fim.year - ini.year) * 12 + fim.month - ini.month + 1
        if ini.year < 2000:
            acumulado = (fim, total)  # desmatamento acumulado até 2000, não é incremento anual
        elif meses == 24 and (ini.month, ini.day) == (8, 1):
            bienal[str(fim.year)] = total / 2
        elif meses == 12 and (ini.month, ini.day) == (8, 1):
            anual[str(fim.year)] = total
        else:
            raise RuntimeError(f"{arq_c.name}: período inesperado {ini} a {fim}")
    difs = {a: round(anual.get(a, 0), 2) for a, v in PRODES_CERRADO_PUBLICADO.items() if round(anual.get(a, 0), 2) != v}
    if difs:
        raise RuntimeError(f"PRODES Cerrado difere dos valores oficiais publicados: {difs}")
    f_id = "desmatamento_cerrado"
    fonte = "INPE, PRODES Cerrado, incremento de desmatamento mapeado (soma das UFs do bioma)"
    saida.add(f_id, f"{f_id}__anual", anual, "km² por ano PRODES", fonte + ", anual", [arq_c, arq_cn], 2)
    saida.add(f_id, f"{f_id}__bienal_media_anual", bienal, "km² por ano (média do biênio PRODES)",
              fonte + ", bienal: metade do incremento de agosto de t-2 a julho de t", [arq_c, arq_cn], 2)
    saida.notas[f_id].append(
        f"O primeiro período do arquivo ({acumulado[0].year if acumulado else '?'}: {br(acumulado[1] if acumulado else 0)} "
        f"km²) é o desmatamento acumulado até julho de 2000 e fica fora. Incrementos bienais totais (km²): "
        + "; ".join(f"ago/{int(a) - 2}–jul/{a}: {br(2 * v, 2)}" for a, v in bienal.items())
        + ". Conferência com as notas técnicas do PRODES Cerrado de 2024 e 2025 ("
        + ", ".join(f"{a}: {br(v, 2)}" for a, v in PRODES_CERRADO_PUBLICADO.items()) + " km²): iguais.")
    return taxas, anual, bienal


# ================================================================ DETER (INPE, TerraBrasilis)

URL_DETER_MENSAL = "https://terrabrasilis.dpi.inpe.br/file-delivery/download/deter-amz/monthly"
CLASSES_DESMATAMENTO = {"DESMATAMENTO_CR", "DESMATAMENTO_VEG", "MINERACAO"}
CLASSES_DEGRADACAO = {"CICATRIZ_DE_QUEIMADA", "CS_DESORDENADO", "CS_GEOMETRICO", "DEGRADACAO", "CORTE_SELETIVO"}
# Conferência: alertas individuais de um ano PRODES fechado pelo WFS público (só atributos)
ANO_PRODES_AMOSTRA_DETER = 2025
URL_DETER_WFS = ("https://terrabrasilis.dpi.inpe.br/geoserver/deter-amz/ows?service=WFS&version=2.0.0"
                 "&request=GetFeature&typeNames=deter-amz:deter_amz&propertyName=classname,view_date,areamunkm,uf"
                 "&outputFormat=csv&CQL_FILTER=")
DETER_PUBLICADO = {"2026": 2874.38}  # MMA, 07/08/2026: alertas de desmatamento de ago/2025 a jul/2026, bioma Amazônia


def url_deter_wfs(ano_prodes):
    filtro = (f"view_date >= '{ano_prodes - 1}-08-01' AND view_date <= '{ano_prodes}-07-31' AND "
              f"classname IN ({','.join(repr(c) for c in sorted(CLASSES_DESMATAMENTO))})")
    return URL_DETER_WFS + urllib.parse.quote(filtro)


def coletar_deter(args):
    def validar(c):
        d = json.loads(c)
        if not d.get("features") or not d.get("updated_date"):
            raise RuntimeError("DETER: resposta sem alertas")
    obter(FONTE_TERRABRASILIS, "deter_amz_mensal", "json", URL_DETER_MENSAL, validar=validar)

    def validar_csv(c):
        if not c.startswith(b"FID,"):
            raise RuntimeError(f"DETER WFS: resposta inesperada {c[:200]!r}")

    def baixar_wfs():
        # o GeoServer às vezes responde 400 com "pool error Timeout waiting for idle object" (conexões ao
        # banco esgotadas); é temporário: espera e repete. Limite declarado: 10 GetFeature por minuto.
        url = url_deter_wfs(ANO_PRODES_AMOSTRA_DETER)
        for i in range(6):
            try:
                return pedir(url)
            except urllib.error.HTTPError as e:
                corpo = e.read()
                if e.code != 400 or b"Timeout waiting for idle object" not in corpo:
                    raise
                print(f"    WFS ocupado (pool de conexões); nova tentativa em {30 * (i + 1)} s", file=sys.stderr)
                time.sleep(30 * (i + 1))
        raise RuntimeError("DETER WFS: servidor ocupado depois de 6 tentativas")
    obter(FONTE_TERRABRASILIS, f"deter_amz_wfs_desmatamento_{ANO_PRODES_AMOSTRA_DETER}", "csv",
          url_deter_wfs(ANO_PRODES_AMOSTRA_DETER), baixar_fn=baixar_wfs, validar=validar_csv,
          reaproveitar=not args.rebaixar)


def tratar_deter(saida):
    arq = bruto(FONTE_TERRABRASILIS, "deter_amz_mensal")
    d = json.loads(arq.read_text(encoding="utf-8"))
    atualizado = dt.date.fromisoformat(d["updated_date"][:10])
    desm, degr, classes = defaultdict(float), defaultdict(float), set()
    for f in d["features"]:
        p = f["properties"]
        mes = f"{2000 + int(p['y'])}-{p['m']}"
        classes.add(p["cl"])
        if p["cl"] in CLASSES_DESMATAMENTO:
            desm[mes] += p["ar"]
        elif p["cl"] in CLASSES_DEGRADACAO:
            degr[mes] += p["ar"]
        else:
            raise RuntimeError(f"{arq.name}: classe desconhecida {p['cl']}")
    # só meses inteiros: o último dia do mês já está nos dados públicos (que vão até `updated_date`)
    primeiro = min(set(desm) | set(degr))
    meses, a, mm = [], int(primeiro[:4]), int(primeiro[5:])
    while ultimo_dia_do_mes(a, mm) <= atualizado and f"{a:04d}-{mm:02d}" < MES_CORRENTE:
        meses.append(f"{a:04d}-{mm:02d}")
        a, mm = (a + 1, 1) if mm == 12 else (a, mm + 1)
    vazios = [m for m in meses if m not in desm and m not in degr]
    if vazios:
        raise RuntimeError(f"{arq.name}: meses sem nenhum alerta no arquivo: {vazios}")
    serie_desm = {m: desm.get(m, 0.0) for m in meses}
    serie_degr = {m: degr.get(m, 0.0) for m in meses}
    f_id = "alertas_deter"
    fonte = "INPE, DETER-B, bioma Amazônia, área de alertas por mês de detecção (arquivo mensal do painel)"
    saida.add(f_id, f"{f_id}__desmatamento", serie_desm, "km² por mês",
              fonte + "; classes DESMATAMENTO_CR, DESMATAMENTO_VEG e MINERACAO", [arq], 2)
    saida.add(f_id, f"{f_id}__degradacao", serie_degr, "km² por mês",
              fonte + "; classes de degradação (cicatriz de queimada, corte seletivo, degradação)", [arq], 2)
    ano_prodes, degr_ano_prodes = {}, {}
    for ap in range(int(meses[0][:4]) + 1, int(meses[-1][:4]) + 1):
        janela = [f"{ap - 1}-{k:02d}" for k in range(8, 13)] + [f"{ap}-{k:02d}" for k in range(1, 8)]
        if all(m in serie_desm for m in janela):
            ano_prodes[str(ap)] = sum(serie_desm[m] for m in janela)
            degr_ano_prodes[str(ap)] = sum(serie_degr[m] for m in janela)
    saida.add(f_id, f"{f_id}__desmatamento_ano_prodes", ano_prodes, "km² por ano PRODES",
              fonte + "; soma de agosto de t-1 a julho de t", [arq], 2)
    # degradação no mesmo recorte do desmatamento, para a página mostrar as duas (revisão de 06/10/2026)
    saida.add(f_id, f"{f_id}__degradacao_ano_prodes", degr_ano_prodes, "km² por ano PRODES",
              fonte + "; classes de degradação; soma de agosto de t-1 a julho de t", [arq], 2)

    # conferências: total oficial divulgado e alertas individuais do WFS
    for ap, v in DETER_PUBLICADO.items():
        if abs(ano_prodes.get(ap, 0) - v) > 0.006:
            raise RuntimeError(f"DETER {ap}: {ano_prodes.get(ap)} km² contra {v} divulgados")
    arq_wfs = bruto(FONTE_TERRABRASILIS, f"deter_amz_wfs_desmatamento_{ANO_PRODES_AMOSTRA_DETER}")
    wfs = defaultdict(float)
    with open(arq_wfs, encoding="utf-8", newline="") as f:
        for l in csv.DictReader(f):
            wfs[l["view_date"][:7]] += float(l["areamunkm"])
    janela = sorted(wfs)
    tot_wfs, tot_mensal = sum(wfs.values()), sum(serie_desm[m] for m in janela)
    pior = max(abs(wfs[m] - serie_desm[m]) for m in janela)
    print(f"  conferência DETER ano PRODES {ANO_PRODES_AMOSTRA_DETER}: WFS {tot_wfs:.2f} km² x arquivo mensal "
          f"{tot_mensal:.2f} km² (maior diferença mensal {pior:.2f} km²); oficial 2025/26 = {DETER_PUBLICADO['2026']}")
    if abs(tot_wfs / tot_mensal - 1) > 0.005:
        raise RuntimeError("DETER: alertas do WFS não batem com o arquivo mensal")
    saida.notas[f_id].append(
        f"Dados públicos atualizados até {atualizado.isoformat()} (campo `updated_date`); só entram meses inteiros "
        f"até essa data e anteriores ao mês corrente. Último mês: {meses[-1]}. Classes no arquivo: "
        f"{', '.join(sorted(classes))}.")
    saida.notas[f_id].append(
        f"Conferências: (1) a soma de agosto de 2025 a julho de 2026 dá {br(ano_prodes.get('2026', 0), 2)} km², igual "
        f"ao total divulgado pelo MMA em 07/08/2026 (2.874,38 km²); (2) os alertas individuais do ano PRODES "
        f"{ANO_PRODES_AMOSTRA_DETER} no WFS público (`{arq_wfs.relative_to(RAIZ)}`, só polígonos de 6,25 ha ou mais, "
        f"área por município arredondada em 4 casas) somam {br(tot_wfs, 2)} km², contra {br(tot_mensal, 2)} km² no "
        f"arquivo mensal (maior diferença mensal: {br(pior, 2)} km²).")
    return serie_desm, ano_prodes


# ================================================================ focos de calor (INPE, Programa Queimadas)

FONTE_QUEIMADAS = "inpe_queimadas"
# CSV gerado pelo botão "Download dos dados em CSV" das páginas de estatística
# (https://data.inpe.br/queimadas/estatisticas/?tipo=paises&item=brasil e vizinhas)
URL_QUEIMADAS = "https://data.inpe.br/queimadas/portal/csv/download/historico-mensal/{tipo}/{slug}.csv"
RECORTES_QUEIMADAS = {  # id da série -> (tipo, slug) da página do INPE
    "brasil": ("paises", "brasil"),
    "amazonia": ("biomas", "amazonia"),                       # bioma Amazônia (IBGE)
    "amazonia_legal": ("regioes_especiais", "amazonia_legal"),
}
MESES_PT = ["Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho", "Julho", "Agosto", "Setembro",
            "Outubro", "Novembro", "Dezembro"]
# Satélite de referência (FAQ do Programa Queimadas, pergunta 7): NOAA-12 de 01/06/1998 a 03/07/2002;
# AQUA_M-T (MODIS, passagem do início da tarde) desde 04/07/2002. Julho de 2002 mistura os dois.
ULTIMO_MES_NOAA12, PRIMEIRO_MES_AQUA = "2002-06", "2002-08"
# Conferência: focos individuais do satélite de referência de um ano, contados pelo projeto
ANO_AMOSTRA_FOCOS = 2009
URL_FOCOS_ANO = ("https://dataserver-coids.inpe.br/queimadas/queimadas/focos/csv/anual/Brasil_sat_ref/"
                 "focos_br_ref_{ano}.zip")
UFS_AMAZONIA_LEGAL = {"ACRE", "AMAPÁ", "AMAZONAS", "MATO GROSSO", "PARÁ", "RONDÔNIA", "RORAIMA", "TOCANTINS"}


def coletar_queimadas(args):
    def validar(c):
        if not c.lstrip(b"\xef\xbb\xbf").startswith(b"Ano,Janeiro"):
            raise RuntimeError(f"Queimadas: resposta não é a tabela esperada: {c[:120]!r}")
    for recorte, (tipo, slug) in RECORTES_QUEIMADAS.items():
        obter(FONTE_QUEIMADAS, f"historico_mensal_{recorte}", "csv", URL_QUEIMADAS.format(tipo=tipo, slug=slug),
              validar=validar)

    def validar_zip(c):
        if c[:2] != b"PK":
            raise RuntimeError("Queimadas: arquivo de focos não é zip")
    obter(FONTE_QUEIMADAS, f"focos_br_ref_{ANO_AMOSTRA_FOCOS}", "zip", URL_FOCOS_ANO.format(ano=ANO_AMOSTRA_FOCOS),
          validar=validar_zip, reaproveitar=not args.rebaixar)


def ler_historico_focos(arq):
    """CSV mês × ano do INPE -> ({AAAA-MM: focos}, {AAAA: total publicado})."""
    linhas = list(csv.reader(io.StringIO(arq.read_bytes().decode("utf-8-sig"))))
    cab = [limpo(x) for x in linhas[0]]
    if cab != ["Ano"] + MESES_PT + ["Total"]:
        raise RuntimeError(f"{arq.name}: cabeçalho inesperado {cab}")
    mensal, totais = {}, {}
    for l in linhas[1:]:
        if not l or not re.fullmatch(r"\d{4}", l[0].strip()):
            continue  # linhas Mínimo*, Média* e Máximo*
        ano = l[0].strip()
        for i in range(12):
            v = l[1 + i].strip()
            if v not in ("-", ""):  # "-": sem dado (antes de jun/1998 e meses futuros)
                mensal[f"{ano}-{i + 1:02d}"] = float(v)
        totais[ano] = float(l[13])
        soma = sum(v for m, v in mensal.items() if m.startswith(ano + "-"))
        if soma != totais[ano]:
            raise RuntimeError(f"{arq.name}: meses de {ano} somam {soma}, total publicado {totais[ano]}")
    return mensal, totais


def tratar_queimadas(saida):
    f_id = "focos_queimadas"
    unidade_m, unidade_a = "focos por mês", "focos por ano"
    publicados = {}
    for recorte in RECORTES_QUEIMADAS:
        arq = bruto(FONTE_QUEIMADAS, f"historico_mensal_{recorte}")
        mensal, totais = ler_historico_focos(arq)
        publicados[recorte] = (mensal, totais)
        mensal = sem_mes_corrente(mensal)
        aqua = {m: v for m, v in mensal.items() if m >= PRIMEIRO_MES_AQUA}
        noaa = {m: v for m, v in mensal.items() if m <= ULTIMO_MES_NOAA12}
        fonte = "INPE, Programa Queimadas, focos do satélite de referência"
        saida.add(f_id, f"{f_id}__{recorte}_mensal", aqua, unidade_m, f"{fonte} (AQUA_M-T, desde ago/2002)", [arq], 0)
        saida.add(f_id, f"{f_id}__{recorte}_noaa12_mensal", noaa, unidade_m,
                  f"{fonte} (NOAA-12, jun/1998 a jun/2002)", [arq], 0)
        anual_aqua, anual_noaa = {}, {}
        for ano in sorted({m[:4] for m in mensal}):
            meses = [f"{ano}-{k:02d}" for k in range(1, 13)]
            if not all(m in mensal for m in meses):
                continue  # 1998 começa em junho; o ano corrente está incompleto
            if all(m >= PRIMEIRO_MES_AQUA for m in meses):
                anual_aqua[ano] = sum(mensal[m] for m in meses)
            elif all(m <= ULTIMO_MES_NOAA12 for m in meses):
                anual_noaa[ano] = sum(mensal[m] for m in meses)
            # 2002 mistura os dois satélites e fica fora das séries anuais
        saida.add(f_id, f"{f_id}__{recorte}", anual_aqua, unidade_a, f"{fonte} (AQUA_M-T); soma dos meses", [arq], 0)
        saida.add(f_id, f"{f_id}__{recorte}_noaa12", anual_noaa, unidade_a, f"{fonte} (NOAA-12); soma dos meses",
                  [arq], 0)

    # conferência: focos individuais de um ano, contados por mês, contra a tabela publicada
    arq = bruto(FONTE_QUEIMADAS, f"focos_br_ref_{ANO_AMOSTRA_FOCOS}")
    z = zipfile.ZipFile(arq)
    (membro,) = [n for n in z.namelist() if n.endswith(".csv")]
    contagem = defaultdict(lambda: defaultdict(int))
    with z.open(membro) as f:
        for l in csv.DictReader(io.TextIOWrapper(f, encoding="utf-8")):
            l = {k.strip(): (v or "").strip() for k, v in l.items()}
            mes = l["data_pas"][:7]
            contagem["brasil"][mes] += 1
            if l["bioma"] == "Amazônia":
                contagem["amazonia"][mes] += 1
            if l["estado"] in UFS_AMAZONIA_LEGAL or (l["estado"] == "MARANHÃO" and float(l["lon"]) < -44):
                contagem["amazonia_legal"][mes] += 1  # aproximação: Maranhão a oeste do meridiano 44°
    linhas_conf = []
    for recorte in RECORTES_QUEIMADAS:
        mensal = publicados[recorte][0]
        meses = [f"{ANO_AMOSTRA_FOCOS}-{k:02d}" for k in range(1, 13)]
        difs = [int(contagem[recorte][m] - mensal[m]) for m in meses]
        linhas_conf.append(f"{recorte}: {br(sum(contagem[recorte][m] for m in meses))} focos contados contra "
                           f"{br(sum(mensal[m] for m in meses))} publicados (maior diferença num mês: "
                           f"{abs(max(difs, key=abs))})")
        if recorte != "amazonia_legal" and any(difs):
            raise RuntimeError(f"focos individuais de {ANO_AMOSTRA_FOCOS} não batem com a tabela ({recorte}): {difs}")
    texto = "; ".join(linhas_conf)
    print(f"  conferência focos individuais {ANO_AMOSTRA_FOCOS} x tabela do INPE: {texto}")
    saida.notas[f_id].append(
        f"Conferência: os focos individuais do satélite de referência de {ANO_AMOSTRA_FOCOS} "
        f"(`{arq.relative_to(RAIZ)}`), contados por mês pelo projeto, contra a tabela publicada — {texto}. "
        f"A Amazônia Legal não existe como campo nos focos individuais; a contagem usa os oito estados inteiros e o "
        f"Maranhão a oeste de 44° W, uma aproximação.")
    ultimo = max(m for m in publicados["brasil"][0])
    saida.notas[f_id].append(
        f"Satélite de referência: NOAA-12 de 01/06/1998 a 03/07/2002 e AQUA_M-T desde 04/07/2002. Julho de 2002, "
        f"que mistura os dois, e os anos de 1998 (só junho a dezembro) e 2002 ficam fora das séries anuais; o mês "
        f"corrente ({MES_CORRENTE}, publicado até a véspera da extração) e o ano corrente ficam fora. Último mês na "
        f"tabela baixada: {ultimo}.")
    return publicados, contagem


# ================================================================ área queimada (MapBiomas Fogo)

FONTE_MAPBIOMAS = "mapbiomas_fogo"
# Plataforma MapBiomas: API JSON pública (sem cadastro nem chave; exige o cabeçalho tenant-id). Em 06/10/2026
# não havia planilha de estatísticas da Coleção 5 ou 5.1 ("serão disponibilizadas em breve"); a única
# planilha estática é da Coleção 3 (1985-2023).
API_MAPBIOMAS = "https://prd.plataforma.mapbiomas.org/api/v1/brazil/"
CABECALHO_MAPBIOMAS = {"tenant-id": "mapbiomas", "Accept": "application/json"}
BIOMAS = {"Amazônia": "amazonia", "Caatinga": "caatinga", "Cerrado": "cerrado", "Mata Atlântica": "mata_atlantica",
          "Pampa": "pampa", "Pantanal": "pantanal"}
ANO_INICIAL_FOGO = 1985
# RAF 2025 (Relatório Anual do Fogo, Coleção 5), tabelas 4 e 5, em ha, para conferência:
# https://brasil.mapbiomas.org/wp-content/uploads/sites/3/2026/08/RAF2025_17.07.26_v2-1.pdf
RAF2025 = {"brasil": {"2019": 19333139, "2020": 20496937, "2023": 16030842, "2024": 30222945, "2025": 12703585},
           "amazonia": {"2025": 3141528}, "caatinga": {"2025": 504754}, "cerrado": {"2025": 8709873},
           "mata_atlantica": {"2025": 216863}, "pampa": {"2025": 9977}, "pantanal": {"2025": 121177}}


def api_mapbiomas(caminho, params=()):
    url = API_MAPBIOMAS + caminho + ("?" + urllib.parse.urlencode(list(params)) if params else "")
    return url, (lambda: pedir(url, CABECALHO_MAPBIOMAS))


def coletar_mapbiomas(args):
    def validar(c):
        json.loads(c)
    url, fn = api_mapbiomas("themes", [("page", 1), ("pageSize", 1000), ("expand", "false")])
    arq = obter(FONTE_MAPBIOMAS, "temas", "json", url, baixar_fn=fn, validar=validar)
    tema, anual = subtema_fogo_anual(json.loads(arq.read_text(encoding="utf-8")))
    url, fn = api_mapbiomas("legends", [("subthemeKey", anual["key"])])
    arq = obter(FONTE_MAPBIOMAS, "legendas_anual", "json", url, baixar_fn=fn, validar=validar)
    legenda = legenda_total(json.loads(arq.read_text(encoding="utf-8")))
    url, fn = api_mapbiomas("territories", [("page", 1), ("pageSize", 1000), ("categoryId", 4), ("orderBy", "name")])
    arq = obter(FONTE_MAPBIOMAS, "biomas", "json", url, baixar_fn=fn, validar=validar)
    biomas = ids_biomas(json.loads(arq.read_text(encoding="utf-8")))
    anos = [("year", a) for a in range(ANO_INICIAL_FOGO, ANO_CORRENTE)]
    base = [("spatialMethod", "union"), ("subthemeKey", anual["key"]), ("legendKey", legenda)] + anos + \
        [("pixelValue", 1)]

    def validar_area(c):
        d = json.loads(c)
        if d.get("unit") != "ha" or not d.get("statistic"):
            raise RuntimeError(f"MapBiomas: resposta inesperada {c[:200]!r}")
    url, fn = api_mapbiomas("statistics/area", base + [("territoryCategoryId", 213)])
    obter(FONTE_MAPBIOMAS, "area_queimada_anual_brasil", "json", url, baixar_fn=fn, validar=validar_area)
    for nome, tid in biomas.items():
        url, fn = api_mapbiomas("statistics/area", base + [("territoryId", tid)])
        obter(FONTE_MAPBIOMAS, f"area_queimada_anual_{BIOMAS[nome]}", "json", url, baixar_fn=fn,
              validar=validar_area)
        time.sleep(1)


def subtema_fogo_anual(temas):
    """Tema "Fogo" e seu subtema "Anual" (área queimada anual). As chaves têm sufixo aleatório e podem mudar;
    a busca é pelo nome em português."""
    lista = temas
    achados = []

    def andar(x, pai=None):
        if isinstance(x, dict):
            nome = (x.get("name") or {}).get("pt-BR") if isinstance(x.get("name"), dict) else None
            if nome == "Fogo" and str(x.get("key", "")).startswith("fogo"):
                achados.append(("tema", x))
            if nome == "Anual" and x.get("dataType") == "annual" and str(x.get("key", "")).startswith("fogo"):
                achados.append(("anual", x))
            for v in x.values():
                andar(v, x)
        elif isinstance(x, list):
            for v in x:
                andar(v, pai)
    andar(lista)
    temas_f = [x for t, x in achados if t == "tema"]
    anuais = [x for t, x in achados if t == "anual"]
    if len(temas_f) != 1 or len(anuais) != 1:
        raise RuntimeError(f"MapBiomas: tema Fogo/Anual não identificado ({len(temas_f)}, {len(anuais)})")
    return temas_f[0], anuais[0]


def legenda_total(legendas):
    lista = legendas.get("legends", []) if isinstance(legendas, dict) else legendas
    chaves = [l["key"] for l in lista if (l.get("name") or {}).get("pt-BR") == "Total queimado"]
    if len(chaves) != 1:
        raise RuntimeError(f"MapBiomas: legenda 'Total queimado' não identificada: {chaves}")
    return chaves[0]


def ids_biomas(territorios):
    lista = territorios.get("territories", []) if isinstance(territorios, dict) else territorios
    ids = {}
    for t in lista:
        nome = t["name"]["pt-BR"] if isinstance(t.get("name"), dict) else t.get("name")
        if nome in BIOMAS:
            ids[nome] = t["id"]
    if set(ids) != set(BIOMAS):
        raise RuntimeError(f"MapBiomas: biomas não identificados: {sorted(set(BIOMAS) - set(ids))}")
    return ids


def ler_area_mapbiomas(arq):
    d = json.loads(arq.read_text(encoding="utf-8"))
    serie = {}
    for s in d["statistic"]:
        (ano,) = s["year"]
        serie[str(ano)] = float(s["total"])
    return serie


def tratar_mapbiomas(saida):
    arq_t = bruto(FONTE_MAPBIOMAS, "temas")
    tema, anual = subtema_fogo_anual(json.loads(arq_t.read_text(encoding="utf-8")))
    colecao = re.search(r"Cole(?:ção|ction) (\d+(?:\.\d+)?)", json.dumps(tema, ensure_ascii=False))
    colecao = colecao.group(1) if colecao else "?"
    selos = ", ".join(tema.get("badges") or [])
    arq_l, arq_b = bruto(FONTE_MAPBIOMAS, "legendas_anual"), bruto(FONTE_MAPBIOMAS, "biomas")
    f_id = "area_queimada"
    fonte = f"MapBiomas Fogo, Coleção {colecao}, área queimada anual (legenda Total queimado), Plataforma MapBiomas"
    series, arquivos = {}, {}
    for recorte in ["brasil"] + list(BIOMAS.values()):
        arq = bruto(FONTE_MAPBIOMAS, f"area_queimada_anual_{recorte}")
        series[recorte] = anos_fechados(ler_area_mapbiomas(arq))
        arquivos[recorte] = arq
        saida.add(f_id, f"{f_id}__{recorte}", series[recorte], "ha", fonte, [arq, arq_t, arq_l, arq_b], 0)
    # conferências: soma dos biomas x Brasil; valores do RAF 2025 (Coleção 5)
    pior_soma = max(abs(sum(series[b][a] for b in BIOMAS.values()) / series["brasil"][a] - 1)
                    for a in series["brasil"])
    if pior_soma > 0.001:
        raise RuntimeError(f"MapBiomas: soma dos biomas difere do Brasil em {pior_soma:.4%}")
    difs = []
    for recorte, valores in RAF2025.items():
        for ano, v in valores.items():
            difs.append((recorte, ano, series[recorte][ano] / v - 1))
    brasil_max = max(abs(d) for r, _, d in difs if r == "brasil")
    if brasil_max > 0.003:
        raise RuntimeError(f"MapBiomas: Brasil difere do RAF 2025 em mais de 0,3%: {difs}")
    texto = "; ".join(f"{r} {a}: {d:+.2%}".replace(".", ",") for r, a, d in difs)
    print(f"  conferência MapBiomas x RAF 2025 (Coleção 5): {texto}")
    saida.notas[f_id].append(
        f"Coleção usada: {colecao} (selo da plataforma: {selos or '?'}; texto do tema no arquivo `{arq_t.relative_to(RAIZ)}`). "
        f"Chave do subtema anual na API: `{anual['key']}`. Soma dos seis biomas contra o total do Brasil: diferença "
        f"máxima de {br(100 * pior_soma, 3)}% por ano.")
    saida.notas[f_id].append(
        "Conferência com o RAF 2025 (Relatório Anual do Fogo, Coleção 5, tabelas 4 e 5), diferença relativa da "
        f"Coleção {colecao}: {texto}.")
    return series


# ================================================================ emissões (MCTI e SEEG)

FONTE_MCTI = "mcti_sirene"
URL_MCTI_NIR = ("https://www.gov.br/mcti/pt-br/acompanhe-o-mcti/sirene/arquivos/2024/"
                "nir_2024_1990-2022_totalbrasil.xlsx")
NOME_MCTI = "nir_2024_1990-2022_totalbrasil"
FONTE_SEEG = "seeg"
PAGINA_SEEG = "https://seeg.eco.br/dados/"
URL_SEEG_PADRAO = "https://seeg.eco.br/wp-content/uploads/2025/12/Dados-nacionais-13.0.xlsx"
GAS_SEEG = "CO2e (t) GWP-AR5"
SETOR_MUT = "Mudança de Uso da Terra e Floresta"
ANO_INICIAL_SEEG = 1990  # mudança de uso da terra só é estimada a partir de 1990 (zero antes)
# Valores publicados, em Mt CO2e (GWP-AR5), para conferência
PUBLICADO_MCTI = {"2020": 1824.760, "2022": 2039.236}  # NIR 2024, tabela 3.1 (líquidas)
PUBLICADO_SEEG = {  # SEEG 13, relatório analítico (https://seeg.eco.br/wp-content/uploads/2026/03/SEEG13-relatorio-analitico-BR.pdf)
    "bruta_total": {"2023": 2576, "2024": 2145}, "liquida_total": {"2023": 1920, "2024": 1489},
    "bruta_mut": {"2023": 1341, "2024": 906}, "liquida_mut": {"2024": 249},
}

M_NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"


def iterar_linhas(livro, nome):
    """Linhas de uma planilha de um coletar_educacao.Xlsx, uma a uma (para planilhas grandes)."""
    textos = livro.textos()
    with livro.z.open(livro.planilhas[nome]) as f:
        pilha = ET.iterparse(f, events=("start", "end"))
        dados = None
        for evento, el in pilha:
            if evento == "start":
                if el.tag == M_NS + "sheetData":
                    dados = el
                continue
            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 = 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
                ref = c.get("r")
                col = coluna_xlsx(ref) if ref else col + 1
                vals[col] = val
            linha = [None] * (max(vals) + 1) if vals else []
            for k, x in vals.items():
                linha[k] = x
            yield linha
            if dados is not None:
                dados.remove(el)  # libera a memória da linha já lida


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


def url_seeg_atual():
    """Endereço da planilha nacional mais recente, lido na página de dados do SEEG."""
    try:
        pagina = pedir(PAGINA_SEEG, tentativas=2).decode("utf-8", "replace")
    except Exception as e:  # a página é só para descobrir a versão; sem ela, vale a conhecida
        print(f"  ! página do SEEG indisponível ({e}); usando {URL_SEEG_PADRAO}", file=sys.stderr)
        return URL_SEEG_PADRAO
    links = set(re.findall(r"https://seeg\.eco\.br/wp-content/uploads/\d{4}/\d{2}/Dados-nacionais-[\d.]+\.xlsx", pagina))
    if not links:
        return URL_SEEG_PADRAO
    return max(links, key=lambda u: [int(x) for x in re.search(r"-([\d.]+)\.xlsx", u).group(1).split(".")])


def versao_seeg(url):
    return re.search(r"Dados-nacionais-([\d.]+)\.xlsx", url).group(1)


def bruto_seeg():
    """Planilha nacional do SEEG da coleção mais recente já baixada e o número da coleção."""
    versoes = {}
    for p in (BRUTOS / FONTE_SEEG).glob("dados_nacionais_*.xlsx"):
        m = re.match(r"dados_nacionais_(\d+(?:-\d+)*)_\d{4}-\d{2}-\d{2}", p.name)
        if m:
            versoes[tuple(int(x) for x in m.group(1).split("-"))] = m.group(1)
    if not versoes:
        raise FileNotFoundError("sem bruto do SEEG; rode sem --so-tratar")
    rotulo = versoes[max(versoes)]
    return bruto(FONTE_SEEG, f"dados_nacionais_{rotulo}"), rotulo.replace("-", ".")


def coletar_emissoes(args):
    def validar_xlsx(c):
        if c[:2] != b"PK":
            raise RuntimeError("emissões: arquivo não é .xlsx")
    obter(FONTE_MCTI, NOME_MCTI, "xlsx", URL_MCTI_NIR, validar=validar_xlsx, reaproveitar=not args.rebaixar)
    url = url_seeg_atual()
    obter(FONTE_SEEG, f"dados_nacionais_{versao_seeg(url).replace('.', '-')}", "xlsx", url,
          baixar_fn=lambda: pedir(url, timeout=900), validar=validar_xlsx, reaproveitar=not args.rebaixar)


def tratar_emissoes(saida):
    f_id = "emissoes_gee"
    unidade = "Mt CO₂e (GWP-100, AR5)"

    # MCTI: inventário nacional do 1º Relatório Bienal de Transparência (NIR 2024), emissões líquidas
    arq = bruto(FONTE_MCTI, NOME_MCTI)
    livro = Xlsx(str(arq))
    aba = next(n for n in livro.planilhas if n.strip() == "CO2e_GWP_AR5")
    linhas = livro.linhas(aba)
    i_cab = next(i for i, l in enumerate(linhas) if l and l[0] and "GWP AR5" in l[0] and "(kt)" in l[0])
    anos = {c: str(int(float(x))) for c, x in enumerate(linhas[i_cab]) if c and num(x)}
    rotulos = {}
    for l in linhas[i_cab + 1:]:
        if l and l[0]:
            rotulos[limpo(l[0])] = {anos[c]: num(l[c]) / 1000 for c in anos if c < len(l) and num(l[c]) is not None}
    total = rotulos["Total"]
    (chave_lulucf,) = [k for k in rotulos if k.startswith("4.")]
    lulucf = rotulos[chave_lulucf]
    soma_setores = {a: sum(v[a] for k, v in rotulos.items() if re.match(r"\d\. ", k)) for a in total}
    if max(abs(soma_setores[a] - total[a]) for a in total) > 0.01:
        raise RuntimeError(f"{arq.name}: setores não somam o total")
    for a, v in PUBLICADO_MCTI.items():
        if abs(total[a] - v) > 0.001:
            raise RuntimeError(f"MCTI {a}: {total[a]} contra {v} publicado no NIR")
    fonte = "MCTI, Inventário Nacional (1º Relatório Bienal de Transparência, 2024), emissões líquidas"
    saida.add(f_id, f"{f_id}__mcti_liquida_total", total, unidade, fonte + ", total", [arq], 3)
    saida.add(f_id, f"{f_id}__mcti_liquida_lulucf", lulucf, unidade,
              fonte + ", uso da terra, mudança do uso da terra e florestas (LULUCF)", [arq], 3)

    # SEEG: emissões brutas e líquidas por setor, planilha nacional
    arq_s, versao = bruto_seeg()
    livro = Xlsx(str(arq_s))
    linhas = iterar_linhas(livro, "Dados")
    cab = [limpo(x) for x in next(linhas)]
    i_tipo, i_gas, i_setor = cab.index("Emissão/Remoção/Bunker"), cab.index("Gás"), cab.index("Setor de emissão")
    col_ano = {c: x for c, x in enumerate(cab) if re.fullmatch(r"\d{4}", x)}
    acum = {k: defaultdict(float) for k in ("bruta_total", "bruta_mut", "liquida_total", "liquida_mut", "remocao")}
    tipos = defaultdict(int)
    for l in linhas:
        if len(l) <= i_setor or l[i_gas] != GAS_SEEG:
            continue
        tipo, setor = l[i_tipo], l[i_setor]
        tipos[tipo] += 1
        if tipo not in ("Emissão", "Remoção"):
            continue  # NCI (fora do inventário) e bunker ficam de fora
        for c, ano in col_ano.items():
            v = num(l[c]) if c < len(l) else None
            if not v or int(ano) < ANO_INICIAL_SEEG:
                continue
            acum["liquida_total"][ano] += v
            if setor == SETOR_MUT:
                acum["liquida_mut"][ano] += v
            if tipo == "Emissão":
                acum["bruta_total"][ano] += v
                if setor == SETOR_MUT:
                    acum["bruta_mut"][ano] += v
            else:
                acum["remocao"][ano] += v
    if max(acum["remocao"].values()) > 0:
        raise RuntimeError("SEEG: remoções com sinal positivo")
    mt = {k: {a: v / 1e6 for a, v in d.items()} for k, d in acum.items()}
    difs = []
    for k, valores in PUBLICADO_SEEG.items():
        for a, v in valores.items():
            difs.append((k, a, mt[k][a], v))
            if abs(mt[k][a] - v) > 0.6:  # o relatório publica em Mt arredondado
                raise RuntimeError(f"SEEG {k} {a}: {mt[k][a]:.1f} contra {v} publicado")
    fonte = f"SEEG {versao} (Observatório do Clima), planilha de dados nacionais, {GAS_SEEG}"
    for k, rotulo in (("bruta_total", "emissões brutas (tipo Emissão), todos os setores"),
                      ("bruta_mut", "emissões brutas de mudança de uso da terra e floresta"),
                      ("liquida_total", "emissões líquidas (Emissão + Remoção), todos os setores"),
                      ("liquida_mut", "emissões líquidas de mudança de uso da terra e floresta")):
        saida.add(f_id, f"{f_id}__seeg_{k}", mt[k], unidade, f"{fonte}, {rotulo}", [arq_s], 3)
    print("  conferência SEEG x relatório analítico: " + "; ".join(
        f"{k} {a}: {x:.1f} x {v}" for k, a, x, v in difs))
    saida.notas[f_id].append(
        f"MCTI: `{arq.relative_to(RAIZ)}`, aba `{aba.strip()}` (kt CO₂e convertidos em Mt). Conferência com o NIR "
        f"2024 (tabela 3.1): total líquido de 2020 = {br(total['2020'], 3)} Mt e de 2022 = {br(total['2022'], 3)} Mt, "
        f"iguais aos publicados; os cinco setores somam o total em todos os anos.")
    saida.notas[f_id].append(
        f"SEEG: coleção {versao}, `{arq_s.relative_to(RAIZ)}`, aba `Dados`, gás `{GAS_SEEG}`; linhas por tipo nesse gás: "
        + ", ".join(f"{t}: {br(n)}" for t, n in sorted(tipos.items())) + ". Ficam fora `Emissão NCI` e `Remoção NCI` "
        "(fontes não incluídas no inventário nacional) e `Bunker`. Conferência com o relatório analítico do SEEG 13 (Mt): "
        + "; ".join(f"{k} {a}: {br(x, 1)} (publicado {br(v)})" for k, a, x, v in difs) + ".")
    return total, lulucf, mt


# ================================================================ autos de infração (Ibama)

FONTE_IBAMA = "ibama"
CKAN_IBAMA = "https://dadosabertos.ibama.gov.br/api/3/action/package_show?id=fiscalizacao-auto-de-infracao"
BLOB_IBAMA = "https://stibamadadosabertosprd.blob.core.windows.net/dados-abertos/dados/"
URL_AUTOS = BLOB_IBAMA + "SIFISC/auto_infracao/auto_infracao/auto_infracao_csv.zip"
URL_MULTAS_BENS = (BLOB_IBAMA + "MULTAS_AMBIENTAIS_DISTRIBUIDAS_POR_BENS_TUTELADOS/"
                   "multas_ambientais_distribuidas_por_bens_tutelados_csv.zip")
FONTE_IBGE_AMZ = "ibge_amazonia_legal"
URL_AMZ_LEGAL = ("https://geoftp.ibge.gov.br/organizacao_do_territorio/estrutura_territorial/amazonia_legal/2024/"
                 "Municipios_da_Amazonia_Legal_2024.ods")
UFS_AMZ_INTEIRAS = {"AC", "AM", "AP", "MT", "PA", "RO", "RR", "TO"}  # o Maranhão entra pelos municípios do IBGE
ANO_INICIAL_AUTOS = 2002
# Relatórios de Gestão do Ibama (número de autos; valores em R$), para conferência
RG_IBAMA = {"2019": (12363, None, "RG 2021, p. 39"), "2022": (12393, 3080649015.92, "RG 2022, p. 41"),
            "2024": (11724, 4707673155.62, "RG 2024, p. 148"), "2025": (14644, 3683860060.17, "RG 2025, p. 9")}
RG_IBAMA_FLORA_AMZ = {"2022": (3219, 2120793929.20, "RG 2022, p. 41")}


def coletar_ibama(args):
    def validar_zip(c):
        if c[:2] != b"PK":
            raise RuntimeError("Ibama: arquivo não é zip")
    def validar_ckan(c):
        if not json.loads(c).get("success"):
            raise RuntimeError("Ibama: CKAN respondeu sem sucesso")
    obter(FONTE_IBAMA, "ckan_fiscalizacao_auto_de_infracao", "json", CKAN_IBAMA, validar=validar_ckan)
    obter(FONTE_IBAMA, "auto_infracao_csv", "zip", URL_AUTOS, baixar_fn=lambda: pedir(URL_AUTOS, timeout=900),
          validar=validar_zip)
    obter(FONTE_IBAMA, "multas_distribuidas_bens_tutelados_csv", "zip", URL_MULTAS_BENS,
          baixar_fn=lambda: pedir(URL_MULTAS_BENS, timeout=900), validar=validar_zip)
    obter(FONTE_IBGE_AMZ, "municipios_amazonia_legal_2024", "ods", URL_AMZ_LEGAL, validar=validar_zip,
          reaproveitar=not args.rebaixar)


def municipios_amazonia_legal(arq):
    """{código IBGE de 7 dígitos: sigla da UF} da planilha do IBGE (ODS: zipfile + xml.etree)."""
    T = "{urn:oasis:names:tc:opendocument:xmlns:table:1.0}"
    X = "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}"
    raiz = ET.fromstring(zipfile.ZipFile(arq).read("content.xml"))
    tabela = next(raiz.iter(T + "table"))
    linhas = []
    for tr in tabela.iter(T + "table-row"):
        celulas = []
        for td in tr.iter(T + "table-cell"):
            rep = int(td.get(T + "number-columns-repeated", "1"))
            texto = " ".join("".join(p.itertext()) for p in td.iter(X + "p"))
            celulas.extend([texto] * min(rep, 50))
        linhas.append(celulas)
    cab = linhas[0]
    i_cod, i_uf = cab.index("CD_MUN"), cab.index("SIGLA_UF")
    return {l[i_cod]: l[i_uf] for l in linhas[1:] if len(l) > i_uf and re.fullmatch(r"\d{7}", l[i_cod] or "")}


def chave_auto(numero, serie, data):
    return (numero.strip().upper(), serie.strip().upper(), data[:10])


def tipos_bens_tutelados(arq):
    """{(número, série, data): tipo de infração} do arquivo de multas distribuídas por bens tutelados, que traz
    o tipo em todos os anos. Usado só para preencher o tipo que falta nos autos antigos."""
    tipos, conflitos = {}, 0
    with zipfile.ZipFile(arq) as z:
        for nome in sorted(z.namelist()):
            if not nome.endswith(".csv"):
                continue
            with z.open(nome) as f:
                for l in csv.DictReader(io.TextIOWrapper(f, encoding="utf-8-sig", newline=""), delimiter=";"):
                    numero, _, serie = (l.get("Nº AI") or "").partition(" - ")
                    d, m, a = (l.get("Data Auto") or "//").split("/")[:3]
                    if not a or not l.get("Tipo Infração"):
                        continue
                    k = chave_auto(numero, serie, f"{a[:4]}-{m}-{d}")
                    t = l["Tipo Infração"].strip()
                    if k in tipos and tipos[k] != t:
                        conflitos += 1
                    tipos.setdefault(k, t)
    return tipos, conflitos


def tratar_ibama(saida):
    csv.field_size_limit(2 ** 31 - 1)  # há campos de geometria (WKT) com mais de 200 mil caracteres
    arq = bruto(FONTE_IBAMA, "auto_infracao_csv")
    arq_mb = bruto(FONTE_IBAMA, "multas_distribuidas_bens_tutelados_csv")
    arq_ckan = bruto(FONTE_IBAMA, "ckan_fiscalizacao_auto_de_infracao")
    arq_amz = bruto(FONTE_IBGE_AMZ, "municipios_amazonia_legal_2024")
    mun_amz = municipios_amazonia_legal(arq_amz)
    mun_ma = {c for c, uf in mun_amz.items() if uf == "MA"}
    tipos_mb, conflitos = tipos_bens_tutelados(arq_mb)
    ag = defaultdict(lambda: [0, 0.0])  # (recorte, ano) -> [autos, R$]
    diag = defaultdict(int)
    vistos = set()
    atualizacao = set()
    with zipfile.ZipFile(arq) as z:
        for nome in sorted(z.namelist()):
            m = re.search(r"auto_infracao_(\d{4})\.csv$", nome)
            if not m or int(m.group(1)) < ANO_INICIAL_AUTOS:
                continue
            with z.open(nome) as f:
                for l in csv.DictReader(io.TextIOWrapper(f, encoding="utf-8", newline=""), delimiter=";"):
                    data = (l["DAT_HORA_AUTO_INFRACAO"] or "").strip()
                    ano = data[:4]
                    atualizacao.add((l.get("ULTIMA_ATUALIZACAO_RELATORIO") or "")[:10])
                    if ano != m.group(1):
                        raise RuntimeError(f"{nome}: auto com data {data} fora do ano do arquivo")
                    if l["SIT_CANCELADO"].strip() != "N":
                        diag[("cancelado", ano)] += 1
                        continue
                    k = chave_auto(l["NUM_AUTO_INFRACAO"], l["SER_AUTO_INFRACAO"], data)
                    if k in vistos:
                        diag[("duplicado", ano)] += 1
                        continue
                    vistos.add(k)
                    valor = num((l["VAL_AUTO_INFRACAO"] or "").replace(",", ".")) or 0.0
                    lancamento = (l.get("DT_LANCAMENTO") or "").strip()[:4]
                    if lancamento.isdigit() and lancamento > ano:
                        diag[("lancado_depois", ano)] += 1
                    tipo = (l["TIPO_INFRACAO"] or "").strip()
                    if not tipo:
                        diag[("sem_tipo", ano)] += 1
                        tipo = tipos_mb.get(k, "")
                        diag[("tipo_preenchido" if tipo else "tipo_ausente", ano)] += 1
                    uf = (l["UF"] or "").strip()
                    diag[("uf", ano, uf)] += 1
                    amz = uf in UFS_AMZ_INTEIRAS or (uf == "MA" and l["COD_MUNICIPIO"].strip() in mun_ma)
                    for rec in ["total"] + (["flora_amazonia_legal"] if amz and tipo == "Flora" else []) + \
                            (["flora_amazonia_legal_tipo_original"]
                             if amz and (l["TIPO_INFRACAO"] or "").strip() == "Flora" else []):
                        ag[(rec, ano)][0] += 1
                        ag[(rec, ano)][1] += valor
    ipca = ipca_medio_anual()
    f_id = "autos_infracao_ibama"
    anos = sorted({a for _, a in ag if a < str(ANO_CORRENTE)})
    fonte = ("Ibama, dados abertos, autos de infração (SIFISC e AI-e), por ano de lavratura; sem cancelados e sem "
             "duplicatas")
    for rec, rotulo in (("total", "todos os tipos, Brasil"),
                        ("flora_amazonia_legal", "tipo Flora, Amazônia Legal (oito UFs e municípios do Maranhão "
                                                 "listados pelo IBGE); tipo vazio preenchido pelo arquivo de multas "
                                                 "por bens tutelados")):
        n = {a: float(ag[(rec, a)][0]) for a in anos}
        v = {a: ag[(rec, a)][1] for a in anos}
        arqs = [arq, arq_mb, arq_ckan] + ([arq_amz] if rec != "total" else [])
        saida.add(f_id, f"{f_id}__numero_{rec}", n, "autos por ano", f"{fonte}; {rotulo}", arqs, 0)
        saida.add(f_id, f"{f_id}__valor_{rec}_nominal", {a: x / 1e6 for a, x in v.items()}, "R$ milhões correntes",
                  f"{fonte}; {rotulo}; valor indicado na lavratura", arqs, 2)
        saida.add(f_id, f"{f_id}__valor_{rec}_real", {a: x / 1e6 for a, x in real(v, ipca).items()},
                  f"R$ milhões de {ANO_BASE_REAL} (IPCA, média do ano)",
                  f"{fonte}; {rotulo}; valor indicado na lavratura; reais de {ANO_BASE_REAL} pelo IPCA médio do ano",
                  arqs, 2)
    # conferência com os relatórios de gestão do Ibama
    linhas_conf = []
    for a, (n_rg, v_rg, ref) in RG_IBAMA.items():
        n, v = ag[("total", a)]
        linhas_conf.append(f"{a}: {br(n)} autos contra {br(n_rg)} no {ref} ({br(100 * (n / n_rg - 1), 2)}%)"
                           + (f", R$ {br(v / 1e9, 3)} bilhões contra R$ {br(v_rg / 1e9, 3)} bilhões" if v_rg else ""))
    for a, (n_rg, v_rg, ref) in RG_IBAMA_FLORA_AMZ.items():
        n, v = ag[("flora_amazonia_legal", a)]
        linhas_conf.append(f"flora na Amazônia Legal em {a}: {br(n)} autos contra {br(n_rg)} no {ref}, R$ "
                           f"{br(v / 1e9, 3)} bilhões contra R$ {br(v_rg / 1e9, 3)} bilhões")
    print("  conferência Ibama x relatórios de gestão: " + "; ".join(linhas_conf))
    saida.notas[f_id].append("Conferência com os Relatórios de Gestão do Ibama: " + "; ".join(linhas_conf) + ".")
    saida.notas[f_id].append(
        "Tipo de infração vazio no arquivo de autos, preenchido pelo arquivo de multas por bens tutelados (autos "
        "sem cancelados e sem duplicatas; sem tipo (% do total) / preenchidos / sem correspondência): "
        + "; ".join(f"{a}: {br(diag[('sem_tipo', a)])} ({br(100 * diag[('sem_tipo', a)] / ag[('total', a)][0], 1)}%) / "
                    f"{br(diag[('tipo_preenchido', a)])} / {br(diag[('tipo_ausente', a)])}"
                    for a in anos if diag[("sem_tipo", a)])
        + f". Chaves com tipos diferentes no arquivo de multas: {br(conflitos)} (vale a primeira). Autos de flora na "
        "Amazônia Legal só com o tipo original do arquivo de autos: "
        + "; ".join(f"{a}: {br(ag[('flora_amazonia_legal_tipo_original', a)][0])}" for a in anos) + ".")
    def maior_uf(a):
        ufs = {k[2]: v for k, v in diag.items() if k[0] == "uf" and k[1] == a}
        u = max(ufs, key=ufs.get)
        return f"{a}: {u} {br(ufs[u])} ({br(100 * ufs[u] / ag[('total', a)][0])}%)"
    saida.notas[f_id].append(
        "UF com mais autos em cada ano (autos e % do total do Brasil): " + "; ".join(maior_uf(a) for a in anos)
        + ". Autos em Minas Gerais: " + "; ".join(f"{a}: {br(diag[('uf', a, 'MG')])}" for a in anos) + ".")
    saida.notas[f_id].append(
        "Linhas excluídas por ano (canceladas / duplicadas): "
        + "; ".join(f"{a}: {br(diag[('cancelado', a)])} / {br(diag[('duplicado', a)])}" for a in anos)
        + ". Autos lançados no sistema (DT_LANCAMENTO) em ano posterior ao da lavratura: "
        + "; ".join(f"{a}: {br(diag[('lancado_depois', a)])}" for a in anos[-5:])
        + f". Data de atualização dos dados no arquivo (ULTIMA_ATUALIZACAO_RELATORIO): {', '.join(sorted(atualizacao - {''}))}. "
        f"Municípios da Amazônia Legal (IBGE 2024): {br(len(mun_amz))}, dos quais {br(len(mun_ma))} no Maranhão.")
    return ag, diag


# ================================================================ perda de floresta primária (GFW/UMD) e pares

FONTE_GFW = "gfw_umd"
VERSAO_GFW = "2025"  # pasta do S3 = último ano de dados (UMD Hansen v1.13, 2001-2025, publicada em 28/04/2026)
URL_GFW = f"https://gfw2-data.s3.amazonaws.com/country-pages/country_stats/download/{VERSAO_GFW}/global.xlsx"
NOME_GFW = f"global_country_stats_{VERSAO_GFW}"
ABA_GFW = "Country primary loss"
NOMES_GFW = {"Brazil": "BRA", "Bolivia": "BOL", "Colombia": "COL", "Ecuador": "ECU", "Guyana": "GUY",
             "Peru": "PER", "Suriname": "SUR", "Venezuela": "VEN"}
ANO_INICIAL_GFW = "2002"  # a base é a floresta primária de 2001; a planilha começa em 2002


def grupo_amazonico():
    with open(METODOLOGIA / "pares_amazonia.csv", encoding="utf-8") as f:
        linhas = list(csv.DictReader(f))
    grupos = {l["grupo"] for l in linhas}
    if grupos != {"paises_amazonicos"}:
        raise RuntimeError(f"grupos inesperados em pares_amazonia.csv: {grupos}")
    return [l["iso3"] for l in linhas]


def coletar_gfw(args):
    def validar(c):
        if c[:2] != b"PK":
            raise RuntimeError("GFW: resposta não é um .xlsx")
    obter(FONTE_GFW, NOME_GFW, "xlsx", URL_GFW, validar=validar, reaproveitar=not args.rebaixar)


def tratar_gfw(saida):
    arq = bruto(FONTE_GFW, NOME_GFW)
    livro = Xlsx(str(arq))
    leiame = " ".join(limpo(x) for l in livro.linhas("ReadMe") for x in l if x)
    versao = re.search(r"v\d{8}", leiame)
    linhas = livro.linhas(ABA_GFW)
    cab = [limpo(x) for x in linhas[0]]
    i_pais, i_lim, i_area = cab.index("country"), cab.index("threshold"), cab.index("area__ha")
    anos = {c: m.group(1) for c, x in enumerate(cab) if (m := re.fullmatch(r"tc_loss_ha_(\d{4})", x))}
    ha, extensao = {}, {}
    for l in linhas[1:]:
        iso = NOMES_GFW.get(limpo(l[i_pais]) if l else "")
        if not iso:
            continue
        if num(l[i_lim]) != 30:
            raise RuntimeError(f"{arq.name}: limiar de dossel {l[i_lim]} para {iso} (esperado 30)")
        if iso in ha:
            raise RuntimeError(f"{arq.name}: {iso} repetido na aba {ABA_GFW}")
        extensao[iso] = num(l[i_area])
        ha[iso] = {a: num(l[c]) for c, a in anos.items()
                   if a >= ANO_INICIAL_GFW and c < len(l) and num(l[c]) is not None}
    faltam = set(NOMES_GFW.values()) - set(ha)
    if faltam:
        raise RuntimeError(f"{arq.name}: países sem linha na aba {ABA_GFW}: {sorted(faltam)}")
    pct = {p: {a: 100 * v / extensao[p] for a, v in obs.items()} for p, obs in ha.items()}

    paises = grupo_amazonico()
    f_id = "perda_floresta_primaria"
    fonte = (f"GFW/UMD (Hansen et al.), tree cover loss {versao.group(0) if versao else '?'}, perda de floresta "
             f"primária úmida tropical (Turubanova et al. 2018), dossel > 30%")
    for sufixo, dados, unidade, unidade_dif, casas in (
            ("", ha, "ha", "ha", 0),
            ("_pct", pct, "% da floresta primária de 2001", "p.p. da floresta primária de 2001", 4)):
        brasil = dados["BRA"]
        med, n = mediana_por_ano(dados, paises)  # D-022: só com pelo menos metade do grupo
        fonte_s = fonte + ("" if not sufixo else "; % = perda do ano / extensão em 2001 (area__ha)")
        saida.add(f_id, f"{f_id}__brasil{sufixo}", brasil, unidade, fonte_s, [arq], casas)
        saida.add(f_id, f"{f_id}__mediana_paises_amazonicos{sufixo}", med, unidade,
                  f"{fonte_s}, mediana de paises_amazonicos", [arq], casas)
        dif = {a: brasil[a] - med[a] for a in brasil if a in med}
        saida.add(f_id, f"{f_id}__brasil_menos_mediana_paises_amazonicos{sufixo}", dif, unidade_dif,
                  f"{fonte_s}, Brasil menos mediana de paises_amazonicos", [arq], casas)
        for a in sorted(n):
            saida.paises.append([f"{f_id}__n_paises_amazonicos{sufixo}", "paises_amazonicos", a, n[a], "países", fonte])
        for pais in ["BRA"] + paises:
            for a, v in sorted(dados[pais].items()):
                saida.paises.append([f"{f_id}{'__pct' if sufixo else ''}", pais, a, round(v, casas) if casas else int(v),
                                     unidade, fonte])
    for pais in ["BRA"] + paises:
        saida.paises.append([f"{f_id}__extensao_2001", pais, "2001", int(extensao[pais]),
                             "ha de floresta primária úmida em 2001 (area__ha)", fonte])
    saida.notas[f_id].append(
        f"Versão dos dados: {versao.group(0) if versao else '?'} (aba ReadMe da planilha; UMD Hansen v1.13, "
        f"dados de 2001 a {max(anos.values())}). Extensão da floresta primária úmida em 2001 (area__ha), em hectares: "
        + "; ".join(f"{p}: {br(extensao[p])}" for p in ["BRA"] + paises) + ". Valores de cada país e número de "
        f"países de cada mediana (`{f_id}__n_paises_amazonicos`) em `dados/tratados/{TEMA}_pares_paises.csv`.")
    return ha, pct, extensao


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

def etapas():
    return [
        ("Despesa federal na função 18 (SIOP) e conferência com a DCA (Siconfi)", coletar_despesa, tratar_despesa),
        ("PRODES Amazônia Legal e Cerrado (INPE, TerraBrasilis)", coletar_prodes, tratar_prodes),
        ("DETER-B, alertas mensais (INPE, TerraBrasilis)", coletar_deter, tratar_deter),
        ("Focos de calor do satélite de referência (INPE, Programa Queimadas)", coletar_queimadas, tratar_queimadas),
        ("Área queimada (MapBiomas Fogo, Plataforma MapBiomas)", coletar_mapbiomas, tratar_mapbiomas),
        ("Emissões de gases de efeito estufa (MCTI e SEEG)", coletar_emissoes, tratar_emissoes),
        ("Autos de infração (Ibama) e municípios da Amazônia Legal (IBGE)", coletar_ibama, tratar_ibama),
        ("Perda de floresta primária (GFW/UMD), Brasil e países amazônicos", coletar_gfw, tratar_gfw),
    ]


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


def tratar():
    fichas = {f["id"]: f for f in ler_fichas(TEMA)}
    saida = Saida()
    print("Tratamento")
    for _, _, funcao in etapas():
        funcao(saida)
    saida.linhas.sort(key=lambda l: (l[0], l[1]))
    escrever_csv(TRATADOS / f"{TEMA}.csv", ["indicador", "data", "valor", "unidade", "fonte"], saida.linhas)
    escrever_csv(TRATADOS / f"{TEMA}_pares_paises.csv",
                 ["indicador", "pais", "data", "valor", "unidade", "fonte"], saida.paises)
    for ficha_id, itens in saida.extracao.items():
        if ficha_id not in fichas:
            raise RuntimeError(f"série sem ficha: {ficha_id}")
        atualizar_extracao(fichas[ficha_id], itens, 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:72s} {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 (GFW, MapBiomas, SEEG, MCTI)")
    args = ap.parse_args()
    if not args.so_tratar:
        coletar(args)
    tratar()


if __name__ == "__main__":
    main()
