#!/usr/bin/env python3
# SPDX-License-Identifier: MIT  (LICENSE, item 1-A; as bases que ele produz ficam em CC BY 4.0)
"""Agregado do observatório de preços (ADR 0010), a partir da base limpa já publicada.

    python3 -I agregar-observatorio.py            # confere a paridade e reescreve src/data/observatorio.json
                                                  # e public/dados/observatorio-AAAA-MM-DD.csv
    python3 -I agregar-observatorio.py --conferir # só confere a paridade, não grava nada
    python3 -I agregar-observatorio.py --simular-ipca  # imprime a mediana nominal e a corrigida
                                                  # pelo IPCA de cada linha, sem gravar (N14)

Não coleta nada e não faz requisição de rede. Lê `faixas-preco-AAAA-MM-DD.jsonl` (a mesma base de
/dados) e produz, para cada linha de serviço e base de comparação:

- a faixa geral, com o P10 somado aos percentis que a ferramenta de faixa observada já publica;
- a faixa por perfil do comprador: região e via de contratação.

PARIDADE ANTES DE TUDO. Antes de gravar, o script recalcula a faixa geral de cada linha e compara,
número a número, com `src/data/faixas-preco.json`, que é o que está no ar. Se um percentil, um n ou
uma supressão divergir, ele para sem gravar. Assim o observatório nunca publica, para a mesma
pergunta, um número diferente do da ferramenta que já existe.

As regras, todas com teste em test_observatorio.py:

1. Faixa geral: as regras de supressão já publicadas (n mínimo 8, ao menos 3 órgãos distintos,
   nenhum órgão com mais da metade das observações).
2. Perfil: as mesmas três regras, com n mínimo 10 (decisão do titular, 29/10/2026). Perfil com
   menos que isso devolve o n e o motivo, nunca percentil.
3. Percentil por interpolação linear entre postos, o mesmo cálculo da faixa observada.
4. Valores nominais. A correção pelo IPCA entra com a coleta de 01/12/2026 (ADR 0008), e a página
   diz isso.
"""
import hashlib
import json
import sys
from collections import Counter
from pathlib import Path

AQUI = Path(__file__).resolve().parent
RAIZ = AQUI.parent.parent
FAIXAS = RAIZ / 'src' / 'data' / 'faixas-preco.json'
DESTINO = RAIZ / 'src' / 'data' / 'observatorio.json'
CAMPOS_CSV = ('tema', 'base', 'dimensao', 'valor', 'n', 'nEntes', 'periodoInicio', 'periodoFim',
              'p10', 'p25', 'p50', 'p75', 'p90', 'suprimido', 'motivo')

N_MIN_GERAL = 8
N_MIN_PERFIL = 10
ENTES_MIN = 3
CONCENTRACAO_MAX = 0.5
PERCENTIS = (('p10', 0.10), ('p25', 0.25), ('p50', 0.50), ('p75', 0.75), ('p90', 0.90))

# Regiões do IBGE. É geografia, não norma: a divisão regional oficial do país.
REGIOES = {
    'N': ('Norte', 'AC AM AP PA RO RR TO'),
    'NE': ('Nordeste', 'AL BA CE MA PB PE PI RN SE'),
    'CO': ('Centro-Oeste', 'DF GO MS MT'),
    'SE': ('Sudeste', 'ES MG RJ SP'),
    'S': ('Sul', 'PR RS SC'),
}
UF_REGIAO = {uf: rid for rid, (_, ufs) in REGIOES.items() for uf in ufs.split()}

# Via de contratação, lida do campo `modalidade` que o próprio registro declara. Não é enquadramento
# jurídico feito aqui: é o que o órgão informou ao PNCP. Credenciamento fica fora dos dois grupos,
# porque não é nem disputa por preço nem uma das duas hipóteses de contratação direta pelo nome.
VIAS = {
    'licitacao': 'Licitação (pregão ou concorrência)',
    'direta': 'Contratação direta (dispensa ou inexigibilidade)',
}


def via_de(modalidade):
    m = (modalidade or '').lower()
    if m.startswith('pregão') or m.startswith('concorrência'):
        return 'licitacao'
    if m.startswith('dispensa') or m.startswith('inexigibilidade'):
        return 'direta'
    return None


def percentil(xs, p):
    """Interpolação linear entre postos (o 'tipo 7' das planilhas e do numpy)."""
    xs = sorted(xs)
    k = (len(xs) - 1) * p
    f = int(k)
    c = min(f + 1, len(xs) - 1)
    return round(xs[f] + (xs[c] - xs[f]) * (k - f), 2)


def valor(r, base):
    return float(r['valor_mensal'] if base == 'mensal' else r['valor_total'])


def grupo(registros, base, n_min):
    """O agregado de um conjunto de registros, já com a regra de supressão aplicada."""
    n = len(registros)
    orgaos = Counter(r['orgao_nome'] for r in registros)
    datas = sorted(r['data_referencia'] for r in registros)
    g = {
        'n': n,
        'nEntes': len(orgaos),
        'periodoInicio': datas[0] if datas else None,
        'periodoFim': datas[-1] if datas else None,
    }
    motivo = None
    if n < n_min:
        motivo = f'n<{n_min}'
    elif len(orgaos) < ENTES_MIN:
        motivo = f'menos de {ENTES_MIN} entes distintos'
    elif orgaos.most_common(1)[0][1] / n > CONCENTRACAO_MAX:
        motivo = 'um órgão concentra mais da metade das observações'
    xs = [valor(r, base) for r in registros]
    for chave, p in PERCENTIS:
        g[chave] = None if motivo else percentil(xs, p)
    g['suprimido'] = motivo is not None
    g['motivo'] = motivo
    return g


def carregar():
    faixas = json.loads(FAIXAS.read_text(encoding='utf-8'))
    base = AQUI / Path(faixas['origemArquivo']).name
    bruto = base.read_bytes()
    sha = hashlib.sha256(bruto).hexdigest()
    if sha != faixas['origemSha256']:
        sys.exit(f'PARE: {base.name} tem sha256 {sha}, e faixas-preco.json declara {faixas["origemSha256"]}.')
    registros = [json.loads(l) for l in bruto.decode('utf-8').splitlines() if l.strip()]
    return faixas, registros, base.name, sha


def agregar(faixas, registros):
    temas = []
    for g in faixas['grupos']:
        if (g['tema'], g['rotuloTema']) not in temas:
            temas.append((g['tema'], g['rotuloTema']))
    grupos = []
    for tema, _ in temas:
        for b in faixas['bases']:
            base = b['id']
            sel = [r for r in registros if tema in r['temas'] and r['base_comparacao'] == base]
            if not sel:
                continue
            grupos.append({'tema': tema, 'base': base, 'dimensao': 'geral', 'valor': None,
                           **grupo(sel, base, N_MIN_GERAL)})
            for rid in REGIOES:
                sub = [r for r in sel if UF_REGIAO.get(r['uf']) == rid]
                if sub:
                    grupos.append({'tema': tema, 'base': base, 'dimensao': 'regiao', 'valor': rid,
                                   **grupo(sub, base, N_MIN_PERFIL)})
            for vid in VIAS:
                sub = [r for r in sel if via_de(r['modalidade']) == vid]
                if sub:
                    grupos.append({'tema': tema, 'base': base, 'dimensao': 'via', 'valor': vid,
                                   **grupo(sub, base, N_MIN_PERFIL)})
    return temas, grupos


def conferir_paridade(faixas, grupos):
    """Lista de divergências entre a faixa geral daqui e a publicada (vazia = paridade)."""
    daqui = {(g['tema'], g['base']): g for g in grupos if g['dimensao'] == 'geral'}
    erros = []
    for pub in faixas['grupos']:
        if pub['recorte'] != 'todos':
            continue
        nosso = daqui.get((pub['tema'], pub['base']))
        if nosso is None:
            erros.append(f'{pub["tema"]}/{pub["base"]}: publicado e ausente aqui')
            continue
        for campo in ('n', 'nEntes', 'periodoInicio', 'periodoFim', 'p25', 'p50', 'p75', 'p90', 'suprimido', 'motivo'):
            if nosso[campo] != pub[campo]:
                erros.append(f'{pub["tema"]}/{pub["base"]}.{campo}: aqui {nosso[campo]!r}, publicado {pub[campo]!r}')
    publicados = {(p['tema'], p['base']) for p in faixas['grupos'] if p['recorte'] == 'todos'}
    for chave in daqui:
        if chave not in publicados:
            erros.append(f'{chave[0]}/{chave[1]}: existe aqui e não está publicado')
    return erros


def montar(faixas, temas, grupos, arquivo, sha, registros=None):
    temas_ids = {t for t, _ in temas}
    com_linha = None if registros is None else sum(1 for r in registros if temas_ids & set(r['temas']))
    return {
        'versao': '1',
        'gerador': 'docs/pesquisa/agregar-observatorio.py',
        'origemArquivo': f'docs/pesquisa/{arquivo}',
        'origemSha256': sha,
        'proveniencia': faixas['proveniencia'],
        'nComLinha': com_linha,
        'metodo': {
            'adr': 'docs/adr/0010-observatorio-faixa-aceitavel.md',
            'referencia': 'p50',
            'faixaUsual': ['p25', 'p75'],
            'faixaComJustificativa': ['p10', 'p90'],
            'nMinGeral': N_MIN_GERAL,
            'nMinPerfil': N_MIN_PERFIL,
            'entesMin': ENTES_MIN,
            'concentracaoMax': CONCENTRACAO_MAX,
            'percentil': 'interpolação linear entre postos, o mesmo cálculo da faixa observada',
            'correcao': 'valores nominais; a correção pelo IPCA entra com a coleta de 01/12/2026',
        },
        'limites': faixas['limites'],
        'bases': faixas['bases'],
        'temas': [{'id': t, 'rotulo': r} for t, r in temas],
        'dimensoes': [
            {'id': 'regiao', 'rotulo': 'Região do comprador',
             'valores': [{'id': k, 'rotulo': v[0]} for k, v in REGIOES.items()]},
            {'id': 'via', 'rotulo': 'Via de contratação informada no registro',
             'valores': [{'id': k, 'rotulo': v} for k, v in VIAS.items()]},
        ],
        'grupos': grupos,
    }


# Correção pelo IPCA (N14), preparada em 30/10/2026 para a coleta de 01/12. NÃO é aplicada ao que o
# site publica: só a simulação usa, para a nota de método poder dizer de antemão quanto cada linha muda.
IPCA = AQUI / 'ipca-numero-indice-2026-10-30.json'


def ipca_carregar():
    return json.loads(IPCA.read_text(encoding='utf-8'))


def fator_ipca(serie, data_referencia, alvo):
    """Fator que leva um valor do mês da data de referência ao mês-alvo (AAAA-MM). Mês sem índice: None."""
    origem = str(data_referencia)[:7]
    if origem not in serie or alvo not in serie:
        return None
    return serie[alvo] / serie[origem]


def simular_ipca(faixas, registros):
    ipca = ipca_carregar()
    alvo = ipca['ultimoMes']
    linhas = []
    for tema, _ in [(g['tema'], None) for g in faixas['grupos'] if g['recorte'] == 'todos' and g['base'] == 'global' and not g['suprimido']]:
        sel = [r for r in registros if tema in r['temas'] and r['base_comparacao'] == 'global']
        nominais = [valor(r, 'global') for r in sel]
        corrigidos = [valor(r, 'global') * (fator_ipca(ipca['serie'], r['data_referencia'], alvo) or 1) for r in sel]
        sem = sum(1 for r in sel if fator_ipca(ipca['serie'], r['data_referencia'], alvo) is None)
        linhas.append((tema, percentil(nominais, 0.5), percentil(corrigidos, 0.5), sem))
    return alvo, linhas


def destino_csv(faixas):
    """O CSV publicado em /dados leva a data da coleta, como a base de que ele sai."""
    return RAIZ / 'public' / 'dados' / f'observatorio-{faixas["proveniencia"]["coletadoEm"]}.csv'


def csv_de(grupos):
    """Um grupo por linha, separador vírgula, sem aspas: nenhum campo tem vírgula (há teste)."""
    def cel(v):
        if v is None:
            return ''
        if isinstance(v, bool):
            return 'sim' if v else 'nao'
        s = str(v)
        if ',' in s or '\n' in s or '"' in s:
            raise ValueError(f'campo com vírgula, aspas ou quebra de linha não cabe no CSV simples: {s!r}')
        return s
    linhas = [','.join(CAMPOS_CSV)]
    linhas += [','.join(cel(g[c]) for c in CAMPOS_CSV) for g in grupos]
    return '\n'.join(linhas) + '\n'


def main(argv):
    faixas, registros, arquivo, sha = carregar()
    temas, grupos = agregar(faixas, registros)
    erros = conferir_paridade(faixas, grupos)
    if erros:
        print('PARE: a faixa geral diverge da publicada em faixas-preco.json:', *erros, sep='\n  ')
        return 1
    print(f'paridade: {sum(1 for g in grupos if g["dimensao"] == "geral")} faixas gerais idênticas às publicadas')
    if "--conferir" in argv:
        return 0
    if "--simular-ipca" in argv:
        alvo, linhas = simular_ipca(faixas, registros)
        print(f'mediana do valor global, nominal e corrigida pelo IPCA até {alvo} (simulação, nada é gravado):')
        for tema, nom, cor, sem in linhas:
            print(f'  {tema}: {nom:,.2f} -> {cor:,.2f} ({(cor / nom - 1) * 100:+.1f}%){f"; {sem} sem índice" if sem else ""}')
        return 0
    saida = montar(faixas, temas, grupos, arquivo, sha, registros)
    DESTINO.write_text(json.dumps(saida, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    destino_csv(faixas).write_text(csv_de(grupos), encoding='utf-8')
    abertos = sum(1 for g in grupos if g['dimensao'] != 'geral' and not g['suprimido'])
    print(f'gravado {DESTINO.relative_to(RAIZ)}: {len(grupos)} grupos, {abertos} perfis publicáveis')
    return 0


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
