"""
Input template: Escavador API → canonical dict

Parser stateless: recebe payload bruto da API Escavador,
sanitiza, classifica, deduplica e entrega canonical dict.

Entrypoint público:
    parse(payload: dict) -> dict   # canonical agnóstico de output
"""

import re
import unicodedata
from datetime import datetime, timezone
from itertools import groupby

CNJ_REGEX = re.compile(r"^\d{7}-\d{2}\.\d{4}\.\d\.\d{2}\.\d{4}$")

_GRAU_LABEL = {1: "Primeiro Grau", 2: "Segundo Grau", 3: "Terceiro Grau"}
_FASE_MAP = {1: "PRIMEIRO_GRAU", 2: "SEGUNDO_GRAU", 3: "TERCEIRO_GRAU"}

_NULLISH = {"", "null", "none", "n/a", "na", "undefined", "-"}

_DATE_MIN_YEAR = 1850
_DATE_MAX_YEAR = 2100

_EQUIV_CLASSE = {
    "PROCEDIMENTO COMUM": "PROCEDIMENTO COMUM CÍVEL",
    "RECURSO INOMINADO": "RECURSO INOMINADO CÍVEL",
    "APELAÇÃO": "APELAÇÃO CÍVEL",
    "RECURSO CIVEL": "RECURSO CÍVEL",
    "EMBARGOS DE DECLARAÇÃO": "EMBARGOS DE DECLARAÇÃO CÍVEL",
    "AGRAVO INTERNO": "AGRAVO INTERNO CÍVEL",
    # variantes com sufixo de tribunal
    "AGRAVO DE INSTRUMENTO - CÍVEL":    "AGRAVO DE INSTRUMENTO",
    "AGRAVO DE INSTRUMENTO-CV":         "AGRAVO DE INSTRUMENTO",
    "EMBARGOS DE DECLARAÇÃO-CV":        "EMBARGOS DE DECLARAÇÃO CÍVEL",
    "APELAÇÃO / REMESSA NECESSÁRIA":    "APELAÇÃO CÍVEL",
    "REMESSA NECESSÁRIA CÍVEL":         "APELAÇÃO CÍVEL",
    # juizados especiais — variantes
    "PROCEDIMENTO DO JUIZADO ESPECIAL CÍVEL (JEF CÍVEL)":   "PROCEDIMENTO DO JUIZADO ESPECIAL CÍVEL",
    "PROCEDIMENTO DO JUIZADO ESPECIAL CÍVEL/FAZENDÁRIO":    "PROCEDIMENTO DO JUIZADO ESPECIAL CÍVEL",
    "PROCEDIMENTO COMUM (VARA CÍVEL)":                      "PROCEDIMENTO COMUM CÍVEL",
    "PROCEDIMENTO ORDINÁRIO":                               "PROCEDIMENTO COMUM CÍVEL",
    "PROCEDIMENTO SUMÁRIO":                                 "PROCEDIMENTO COMUM CÍVEL",
    # superendividamento
    "PROCEDIMENTO DE REPACTUAÇÃO DE DÍVIDAS (SUPERENDIVIDAMENTO)": "PROCEDIMENTO DE REPACTUAÇÃO DE DÍVIDAS",
    # jurisdição voluntária / cautelares / notificações
    "OUTROS PROCEDIMENTOS DE JURISDIÇÃO VOLUNTÁRIA": "JURISDIÇÃO VOLUNTÁRIA",
    "NOTIFICAÇÃO":        "NOTIFICAÇÃO CÍVEL",
    "INTERPELAÇÃO":       "NOTIFICAÇÃO CÍVEL",
    "ARROLAMENTO COMUM":  "JURISDIÇÃO VOLUNTÁRIA",
    "PETIÇÃO":            "PETIÇÃO CÍVEL",
    # execução variantes
    "EXECUÇÃO DE TÍTULO EXTRAJUDICIAL - CPC": "EXECUÇÃO DE TÍTULO EXTRAJUDICIAL",
    "EXECUÇÃO FISCAL":                        "EXECUÇÃO DE TÍTULO EXTRAJUDICIAL",
    # cumprimento variantes
    "CUMPRIMENTO DE SENTENÇA CONTRA A FAZENDA PÚBLICA": "CUMPRIMENTO DE SENTENÇA",
    "LIQUIDAÇÃO DE SENTENÇA PELO PROCEDIMENTO COMUM":   "CUMPRIMENTO DE SENTENÇA",
    "LIQUIDAÇÃO POR ARBITRAMENTO":                      "CUMPRIMENTO DE SENTENÇA",
    # recursos superiores variantes
    "RECURSO EXTRAORDINÁRIO COM AGRAVO":            "AGRAVO EM RECURSO EXTRAORDINÁRIO",
    "AGRAVO DE INSTRUMENTO EM RECURSO ESPECIAL":    "AGRAVO EM RECURSO ESPECIAL",
    # breadcrumb com pipe "|"
    "PROCESSO CÍVEL E DO TRABALHO | PROCESSO DE CONHECIMENTO | PROCEDIMENTO DE CONHECIMENTO | PROCEDIMENTO COMUM CÍVEL":           "PROCEDIMENTO COMUM CÍVEL",
    "PROCESSO CÍVEL E DO TRABALHO | PROCESSO DE CONHECIMENTO | PROCEDIMENTO DE CONHECIMENTO | PROCEDIMENTO DO JUIZADO ESPECIAL CÍVEL": "PROCEDIMENTO DO JUIZADO ESPECIAL CÍVEL",
    "PROCESSO CÍVEL E DO TRABALHO": "PROCEDIMENTO COMUM CÍVEL",
    # cumprimento — variantes JEF e fazenda pública
    "CUMPRIMENTO DE SENTENÇA (JEF)":                            "CUMPRIMENTO DE SENTENÇA",
    "CUMPRIMENTO DE SENTENÇA CONTRA A FAZENDA PÚBLICA (JEF)":  "CUMPRIMENTO DE SENTENÇA",
    "CUMPRIMENTO DE SENTENÇA CONTRA FAZENDA PÚBLICA":          "CUMPRIMENTO DE SENTENÇA",
    "CUMPRIMENTO DE SENTENÇA CONTRA A FAZENDA PÚBLICA (VARA CÍVEL)": "CUMPRIMENTO DE SENTENÇA",
    "CUMPRIMENTO DE SENTENÇA CONTRA FAZENDA PÚBLICA (JEF)":    "CUMPRIMENTO DE SENTENÇA",
    "CUMPRIMENTO DE SENTENÇA DE OBRIGAÇÃO DE PRESTAR ALIMENTOS": "CUMPRIMENTO DE SENTENÇA",
    "EXECUÇÃO DE TÍTULO JUDICIAL":                              "CUMPRIMENTO DE SENTENÇA",
    "IMPUGNAÇÃO AO CUMPRIMENTO DE SENTENÇA":                   "CUMPRIMENTO DE SENTENÇA",
    "EMBARGOS À EXECUÇÃO":                                      "CUMPRIMENTO DE SENTENÇA",
    # superendividamento — truncado pelo banco
    "PROCEDIMENTO DE REPACTUAÇÃO DE DÍVIDAS (SUPERENDIVIDAMEN": "PROCEDIMENTO DE REPACTUAÇÃO DE DÍVIDAS",
    # recursos — mapear para classes canônicas, NUNCA para strings de tipo
    "RECURSO DE MEDIDA CAUTELAR":        "AGRAVO DE INSTRUMENTO",
    "RECURSO DE MEDIDA CAUTELAR CÍVEL":  "AGRAVO DE INSTRUMENTO",
    "AGRAVO DE PETIÇÃO":                 "AGRAVO DE PETIÇÃO CÍVEL",
    "AP CÍVEL/REM NECESSÁRIA":           "APELAÇÃO CÍVEL",
    "AÇÃO RESCISÓRIA":                   "AÇÃO RESCISÓRIA CÍVEL",
    "RECLAMAÇÃO":                        "RECLAMAÇÃO CÍVEL",
    "CONFLITO DE COMPETÊNCIA (TURMA)":   "CONFLITO DE COMPETÊNCIA CÍVEL",
    # ações originárias diversas — normalização de nome (nunca mapear para tipo)
    "MANDADO DE SEGURANÇA":                   "MANDADO DE SEGURANÇA CÍVEL",
    "MANDADO DE SEGURANÇA TR":                "MANDADO DE SEGURANÇA CÍVEL",
    "MANDADO DE SEGURANÇA - CPC":             "MANDADO DE SEGURANÇA CÍVEL",
    "EXIBIÇÃO":                               "EXIBIÇÃO DE DOCUMENTO OU COISA CÍVEL",
    "INCIDENTES - EXIBIÇÃO DE DOCUMENTO OU COISA": "EXIBIÇÃO DE DOCUMENTO OU COISA CÍVEL",
    "PETIÇÃO TR":                             "PETIÇÃO CÍVEL",
    "TUTELA CÍVEL":                           "TUTELA CAUTELAR ANTECEDENTE",
    "ALVARÁ JUDICIAL - LEI 6858/80":          "JURISDIÇÃO VOLUNTÁRIA",
    "PROCEDIMENTO COMUM INFÂNCIA E JUVENTUDE": "PROCEDIMENTO COMUM CÍVEL",
    "PROCEDIMENTO SUMÁRIO (CADASTRO OU CONVOLAÇÃO ATÉ 17.03.2016)": "PROCEDIMENTO COMUM CÍVEL",
    "PROCEDIMENTO DE CONHECIMENTO - PROCEDIMENTO ORDINÁRIO": "PROCEDIMENTO COMUM CÍVEL",
    "PRODUÇÃO ANTECIPADA DE PROVAS":          "PRODUÇÃO ANTECIPADA DA PROVA",
    "PRODUÇÃO ANTECIPADA DE PROVAS - CPC":    "PRODUÇÃO ANTECIPADA DA PROVA",
    "CAUTELAR INOMINADA":                     "TUTELA CAUTELAR ANTECEDENTE",
    "HABILITAÇÃO":                            "JURISDIÇÃO VOLUNTÁRIA",
    "REQUISIÇÃO DE PEQUENO VALOR":            "JURISDIÇÃO VOLUNTÁRIA",
    "PRECATÓRIO":                             "JURISDIÇÃO VOLUNTÁRIA",
    "CONFLITO DE COMPETÊNCIA":                "CONFLITO DE COMPETÊNCIA CÍVEL",
    "RECURSOS":                               "RECURSO INOMINADO CÍVEL",
    "PRESTAÇÃO DE CONTAS - OFERECIDAS":       "PRESTAÇÃO DE CONTAS - EXIGIDAS",
    # recursos — variantes de abreviação/sufixo de tribunal (TJRJ, TJMG)
    "EMBARGOS":                               "EMBARGOS DE DECLARAÇÃO CÍVEL",
    "AGRAVO":                                 "AGRAVO DE INSTRUMENTO",
    "AGRAVO - CÍVEL":                         "AGRAVO DE INSTRUMENTO",
    "AGRAVO INTERNO CV":                      "AGRAVO INTERNO CÍVEL",
    "AGRAVO EM RESP":                         "AGRAVO EM RECURSO ESPECIAL",
    "REXT COM AGRAVO":                        "AGRAVO EM RECURSO EXTRAORDINÁRIO",
    "AGRAVO DE INSTRUMENTO EM RECURSO EXTRAORDINÁRIO": "AGRAVO EM RECURSO EXTRAORDINÁRIO",
    "RECURSO ESPECIAL - CÍVEL":               "RECURSO ESPECIAL",
    "RECURSO ESPECIAL - CIVEL":               "RECURSO ESPECIAL",
    "RECURSO EXTRAORDINÁRIO - CÍVEL":         "RECURSO EXTRAORDINÁRIO",
    "RECURSO EXTRAORDINARIO COM AGRAVO - CÍVEL": "AGRAVO EM RECURSO EXTRAORDINÁRIO",
    "AGRAVO EM RECURSO ESPECIAL - CÍVEL":     "AGRAVO EM RECURSO ESPECIAL",
    "APELAÇÃO/REMESSA NECESSÁRIA":            "APELAÇÃO CÍVEL",
    # cumprimento — variantes com texto entre parênteses não numérico
    "CUMPRIMENTO DE SENTENÇA (JEF CÍVEL)":    "CUMPRIMENTO DE SENTENÇA",
    "EXECUÇÃO DE SENTENÇA":                   "CUMPRIMENTO DE SENTENÇA",
    "EXECUÇÃO DE SENTENÇA PROVISÓRIA":        "CUMPRIMENTO DE SENTENÇA",
    "IMPUGNAÇÃO À EXECUÇÃO DE SENTENÇA":      "CUMPRIMENTO DE SENTENÇA",
    # trabalhista (cai em DESCONHECIDO — mapeia para tipo próprio)
    "AÇÃO TRABALHISTA - RITO SUMARÍSSIMO": "ACAO_TRABALHISTA",
    "AÇÃO TRABALHISTA - RITO ORDINÁRIO":   "ACAO_TRABALHISTA",
    "INQUÉRITO POLICIAL":                  "PROCESSO_CRIMINAL",
}

_MAPA_TIPO: dict[str, str] = {
    # Ação original — grau 1
    "PROCEDIMENTO COMUM CÍVEL":                       "ACAO_ORIGINAL",
    "PROCEDIMENTO DO JUIZADO ESPECIAL CÍVEL":         "ACAO_ORIGINAL",
    "PROCEDIMENTO DO JUIZADO ESPECIAL DA FAZENDA PÚBLICA": "ACAO_ORIGINAL",
    "PROCEDIMENTO COMUM":                             "ACAO_ORIGINAL",
    "AÇÃO CIVIL PÚBLICA":                             "ACAO_ORIGINAL",
    "PRODUÇÃO ANTECIPADA DA PROVA":                   "ACAO_ORIGINAL",
    "PROCEDIMENTO DE REPACTUAÇÃO DE DÍVIDAS":         "ACAO_ORIGINAL",
    "PROCESSO ADMINISTRATIVO":                        "ACAO_ORIGINAL",
    "TUTELA CAUTELAR ANTECEDENTE":                    "ACAO_ORIGINAL",
    "TUTELA ANTECIPADA ANTECEDENTE":                  "ACAO_ORIGINAL",
    "JURISDIÇÃO VOLUNTÁRIA":                          "ACAO_ORIGINAL",
    "PETIÇÃO CÍVEL":                                  "ACAO_ORIGINAL",
    "EXIBIÇÃO DE DOCUMENTO OU COISA CÍVEL":           "ACAO_ORIGINAL",
    "EXIBIÇÃO DE DOCUMENTO OU COISA":                 "ACAO_ORIGINAL",
    "NOTIFICAÇÃO CÍVEL":                              "ACAO_ORIGINAL",
    "CONSIGNAÇÃO EM PAGAMENTO":                       "ACAO_ORIGINAL",
    "MONITÓRIA":                                      "ACAO_ORIGINAL",
    "RECLAMAÇÃO PRÉ-PROCESSUAL":                      "ACAO_ORIGINAL",
    "AÇÃO DE EXIGIR CONTAS":                          "ACAO_ORIGINAL",
    "MANDADO DE SEGURANÇA CÍVEL":                     "ACAO_ORIGINAL",
    "MANDADO DE SEGURANÇA (VARA CÍVEL)":              "ACAO_ORIGINAL",
    "CARTA PRECATÓRIA CÍVEL":                         "ACAO_ORIGINAL",
    "CARTA PRECATÓRIA - CPC":                         "ACAO_ORIGINAL",
    "CONFLITO DE COMPETÊNCIA CÍVEL":                  "ACAO_ORIGINAL",
    # Cumprimento / execução
    "CUMPRIMENTO DE SENTENÇA":                        "CUMPRIMENTO_SENTENCA",
    "CUMPRIMENTO PROVISÓRIO DE SENTENÇA":             "CUMPRIMENTO_SENTENCA",
    "CUMPRIMENTO PROVISÓRIO DE DECISÃO":              "CUMPRIMENTO_SENTENCA",
    "EXECUÇÃO":                                       "CUMPRIMENTO_SENTENCA",
    "EXECUÇÃO DE TÍTULO EXTRAJUDICIAL":               "CUMPRIMENTO_SENTENCA",
    # Recurso — grau 2
    "APELAÇÃO CÍVEL":                                 "RECURSO",
    "RECURSO INOMINADO CÍVEL":                        "RECURSO",
    "RECURSO CÍVEL":                                  "RECURSO",
    # Recurso intermediário — grau 2
    "AGRAVO DE INSTRUMENTO":                          "RECURSO_INTERMEDIARIO",
    "EMBARGOS DE DECLARAÇÃO CÍVEL":                   "RECURSO_INTERMEDIARIO",
    "AGRAVO INTERNO CÍVEL":                           "RECURSO_INTERMEDIARIO",
    # Recurso superior — grau 3
    "RECURSO ESPECIAL":                               "RECURSO_SUPERIOR",
    "AGRAVO EM RECURSO ESPECIAL":                     "RECURSO_SUPERIOR",
    "RECURSO EXTRAORDINÁRIO":                         "RECURSO_SUPERIOR",
    "AGRAVO EM RECURSO EXTRAORDINÁRIO":               "RECURSO_SUPERIOR",
    "PEDIDO DE UNIFORMIZAÇÃO DE INTERPRETAÇÃO DE LEI CÍVEL": "RECURSO_SUPERIOR",
    # tipos especiais
    "MANDADO DE SEGURANÇA CÍVEL":                          "ACAO_ORIGINAL",
    "HABEAS DATA CÍVEL":                                   "ACAO_ORIGINAL",
    "AÇÃO POPULAR":                                        "ACAO_ORIGINAL",
    "USUCAPIÃO":                                           "ACAO_ORIGINAL",
    "REINTEGRAÇÃO / MANUTENÇÃO DE POSSE":                  "ACAO_ORIGINAL",
    "BUSCA E APREENSÃO EM ALIENAÇÃO FIDUCIÁRIA":           "ACAO_ORIGINAL",
    "ALIMENTOS - LEI ESPECIAL Nº 5.478/68":                "ACAO_ORIGINAL",
    "REVISIONAL DE ALUGUEL":                               "ACAO_ORIGINAL",
    "PRESTAÇÃO DE CONTAS - EXIGIDAS":                      "ACAO_ORIGINAL",
    "EXCEÇÃO DE ILEGITIMIDADE DE PARTE":                   "ACAO_ORIGINAL",
    "HOMOLOGAÇÃO DA TRANSAÇÃO EXTRAJUDICIAL":               "ACAO_ORIGINAL",
    "INSOLVÊNCIA REQUERIDA PELO DEVEDOR OU PELO ESPÓLIO":  "ACAO_ORIGINAL",
    "IMPUGNAÇÃO DE CRÉDITO":                               "ACAO_ORIGINAL",
    "ACAO_TRABALHISTA":                  "ACAO_TRABALHISTA",
    "PROCESSO_CRIMINAL":                 "PROCESSO_CRIMINAL",
    "RECURSO_INTERMEDIARIO_CAUTELAR":    "RECURSO_INTERMEDIARIO",
    # Novas classes canônicas adicionadas
    "AÇÃO RESCISÓRIA CÍVEL":             "RECURSO",
    "RECLAMAÇÃO CÍVEL":                  "RECURSO",
    "AGRAVO DE PETIÇÃO CÍVEL":           "RECURSO_INTERMEDIARIO",
    "TUTELA CAUTELAR ANTECEDENTE":       "ACAO_ORIGINAL",
    "PRODUÇÃO ANTECIPADA DA PROVA":      "ACAO_ORIGINAL",
    "PROCESSO ADMINISTRATIVO":           "ACAO_ORIGINAL",
    "PRESTAÇÃO DE CONTAS - EXIGIDAS":    "ACAO_ORIGINAL",
}

_TRIBUNAL_SISTEMA: dict[str, str] = {
    # e-SAJ (Softplan)
    "TJSP": "e-SAJ", "TJBA": "e-SAJ", "TJSC": "e-SAJ",
    "TJAM": "e-SAJ", "TJCE": "e-SAJ", "TJMS": "e-SAJ", "TJMT": "e-SAJ",
    # eproc (TRF4 / TJRS)
    "TRF4": "eproc", "TJRS": "eproc",
    # PJe — demais TJs
    "TJRJ": "PJe", "TJMG": "PJe", "TJPE": "PJe", "TJGO": "PJe",
    "TJPR": "PJe", "TJDF": "PJe", "TJPA": "PJe", "TJPI": "PJe",
    "TJPB": "PJe", "TJRN": "PJe", "TJSE": "PJe", "TJAL": "PJe",
    "TJRR": "PJe", "TJAP": "PJe", "TJAC": "PJe", "TJRO": "PJe",
    "TJTO": "PJe", "TJES": "PJe", "TJMA": "PJe",
    # Superiores e Federais
    "STJ": "PJe", "STF": "PJe", "TST": "PJe",
    "TRF1": "PJe", "TRF2": "PJe", "TRF3": "PJe", "TRF5": "PJe", "TRF6": "PJe",
    **{f"TRT{i}": "PJe" for i in range(1, 25)},
}

# ---------------------------------------------------------------------------
# Sanitização
# ---------------------------------------------------------------------------

def _fix_double_encode(s: str) -> str:
    """Corrige double-encode UTF-8/Latin-1 (ex: 'RelaÃ§Ã£o' → 'Relação')."""
    try:
        return s.encode("latin-1").decode("utf-8")
    except (UnicodeDecodeError, UnicodeEncodeError):
        return s


def clean_string(value) -> str | None:
    """Sanitiza string: remove controles, normaliza espaços, corrige encoding."""
    if value is None:
        return None
    if not isinstance(value, str):
        value = str(value)

    value = _fix_double_encode(value)
    value = re.sub(r"[\n\r\t]", " ", value)
    value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", value)
    value = re.sub(r" {2,}", " ", value)
    value = value.strip()

    if value.lower() in _NULLISH:
        return None

    return value


def clean_date(value) -> str | None:
    """Normaliza datas para ISO 8601. Rejeita anos impossíveis."""
    if value is None:
        return None
    s = clean_string(value)
    if s is None:
        return None

    for fmt in (
        "%Y-%m-%dT%H:%M:%S%z",
        "%Y-%m-%dT%H:%M:%S.%f%z",
        "%Y-%m-%dT%H:%M:%SZ",
    ):
        try:
            dt = datetime.strptime(s, fmt)
            if not (_DATE_MIN_YEAR <= dt.year <= _DATE_MAX_YEAR):
                return None
            return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        except ValueError:
            continue

    try:
        dt = datetime.strptime(s[:10], "%Y-%m-%d")
        if not (_DATE_MIN_YEAR <= dt.year <= _DATE_MAX_YEAR):
            return None
        return dt.strftime("%Y-%m-%d")
    except ValueError:
        return None


def clean_valor_causa(value) -> dict | None:
    """Extrai valor numérico de valor_causa. Retorna {valor_causa, moeda} ou None."""
    if value is None:
        return None

    if isinstance(value, dict):
        raw = value.get("valor") or value.get("valor_formatado")
        if raw is None:
            return None
    elif isinstance(value, (int, float)):
        raw = value
    elif isinstance(value, str):
        raw = value
    else:
        return None

    if isinstance(raw, str):
        raw = re.sub(r"[R$\s]", "", raw)
        dot_pos = raw.rfind(".")
        comma_pos = raw.rfind(",")
        if comma_pos > dot_pos:
            raw = raw.replace(".", "").replace(",", ".")
        else:
            raw = raw.replace(",", "")
        try:
            raw = float(raw)
        except ValueError:
            return None

    raw = float(raw)
    if raw <= 0:
        return None

    return {"valor_causa": round(raw, 2), "moeda": "BRL"}


def clean_classe(value) -> str | None:
    """Normaliza classe processual: remove códigos CNJ, colchetes, breadcrumbs; UPPERCASE."""
    s = clean_string(value)
    if s is None:
        return None

    s = re.split(r"\s*«\s*", s)[0]
    # breadcrumb com "->" ou "|" — pega o segmento mais específico (último)
    if "->" in s:
        s = s.split("->")[-1].strip()
    elif " | " in s:
        s = s.split(" | ")[-1].strip()
    s = re.sub(r"^\[[^\]]+\]\s*", "", s)
    # Remove prefixo numérico: "7 - ", "436 - ", "03.2025.8.26.0000 - " etc.
    s = re.sub(r"^\d[\d\.]*\s*[-]\s*", "", s)
    # Remove sufixo código CNJ numérico: "(7)", "(156)", "(436)"
    s = re.sub(r"\s*\(\d+\)\s*$", "", s)
    # Remove sufixos não numéricos de parênteses conhecidos: "(JEF Cível)", "(Vara Cível)" etc.
    s = re.sub(r"\s*\((?:JEF\s*\w*|VARA\s*\w*|CPC)\)\s*$", "", s, flags=re.IGNORECASE)
    s = unicodedata.normalize("NFC", s.strip().upper())

    if not s:
        return None

    return _EQUIV_CLASSE.get(s, s)


def _extract_classe_codigo(classe_raw: str | None) -> int | None:
    """Extrai código TPU numérico da string de classe, quando presente.

    Padrões aceitos:
        "Procedimento Comum Cível (436)"  → 436
        "436 - Procedimento Comum Cível"  → 436
    """
    if not classe_raw:
        return None
    m = re.search(r"\((\d+)\)\s*$", classe_raw)
    if m:
        return int(m.group(1))
    m = re.match(r"^(\d+)\s*[-]\s*", classe_raw)
    if m:
        return int(m.group(1))
    return None

# ---------------------------------------------------------------------------
# Classificação
# ---------------------------------------------------------------------------

def classify_tipo(classe_normalizada: str | None, grau: int) -> str:
    """Classifica tipo da instância baseado na classe processual normalizada."""
    if not classe_normalizada:
        return "DESCONHECIDO"
    return _MAPA_TIPO.get(classe_normalizada, "DESCONHECIDO")


def classify_fase(instancias: list[dict]) -> str:
    """Determina fase_atual: grau mais alto com status ATIVO."""
    ativas = [i for i in instancias if i.get("status") == "ATIVO"]
    if not ativas:
        return "ENCERRADO"
    return _FASE_MAP.get(max(i["grau"] for i in ativas), "DESCONHECIDO")


def classify_status_geral(instancias: list[dict]) -> str:
    """ATIVO se qualquer instância ativa; INATIVO caso contrário."""
    return "ATIVO" if any(i.get("status") == "ATIVO" for i in instancias) else "INATIVO"

# ---------------------------------------------------------------------------
# Deduplicação
# ---------------------------------------------------------------------------

def deduplicate_fontes(fontes: list[dict]) -> tuple[list[dict], list[str]]:
    """
    Deduplica fontes por (grau, sigla).
    Retorna (fontes_deduplicadas, fenomenos).
    """
    fenomenos: list[str] = []

    def key(f):
        return (f["grau"], f["sigla"])

    resultado: list[dict] = []

    for (grau, sigla), grupo_iter in groupby(sorted(fontes, key=key), key=key):
        grupo = list(grupo_iter)

        if len(grupo) == 1:
            resultado.append(grupo[0])
            continue

        # --- Passo 1: colapsar IDs duplicados dentro do grupo ---
        # Mesmo id pode aparecer com classe diferente (snapshots distintos).
        # Prefere: com classe > sem classe; em empate, data_ultima_movimentacao mais recente.
        por_id: dict = {}
        for f in grupo:
            fid = f["id"]
            if fid not in por_id:
                por_id[fid] = f
            else:
                existente = por_id[fid]
                tem_classe_novo = bool(f.get("classe_normalizada"))
                tem_classe_ant  = bool(existente.get("classe_normalizada"))
                if tem_classe_novo and not tem_classe_ant:
                    por_id[fid] = f
                elif tem_classe_novo and tem_classe_ant:
                    # Ambos têm classe — guarda o mais recente
                    if (f.get("data_ultima_movimentacao") or "") > (existente.get("data_ultima_movimentacao") or ""):
                        por_id[fid] = f

        if len(por_id) < len(grupo):
            fenomenos.append(f"duplicata_ingestao:grau={grau}:{sigla}")
        grupo = list(por_id.values())

        if len(grupo) == 1:
            resultado.append(grupo[0])
            continue

        ids = [f["id"] for f in grupo]
        classes = [f.get("classe_normalizada") for f in grupo]
        orgaos = [f.get("orgao_julgador") for f in grupo]

        if len(set(ids)) == 1:
            fenomenos.append(f"duplicata_ingestao:grau={grau}:{sigla}")
            resultado.append(grupo[0])
        elif len({c for c in classes if c}) > 1:
            fenomenos.append(f"recursos_distintos:grau={grau}:{sigla}")
            resultado.extend(grupo)
        elif len({o for o in orgaos if o}) > 1:
            fenomenos.append(f"redistribuicao:grau={grau}:{sigla}")
            resultado.append(_merge_redistribuicao(grupo))
        else:
            fenomenos.append(f"crawl_duplicado:grau={grau}:{sigla}")
            # Prefere a fonte com classe; em empate, maior id
            com_classe = [f for f in grupo if f.get("classe_normalizada")]
            resultado.append(max(com_classe or grupo, key=lambda f: f["id"]))

    return resultado, fenomenos


def _merge_redistribuicao(grupo: list[dict]) -> dict:
    """Mescla redistribuição: partes únicas + data_mov mais recente."""
    base = dict(max(grupo, key=lambda f: f.get("data_ultima_movimentacao") or ""))
    partes_vistas: set[str] = set()
    partes_merged = []
    for fonte in grupo:
        for parte in fonte.get("partes", []):
            nome = parte.get("nome", "")
            if nome not in partes_vistas:
                partes_vistas.add(nome)
                partes_merged.append(parte)
    base["partes"] = partes_merged
    return base

# ---------------------------------------------------------------------------
# Fenômenos
# ---------------------------------------------------------------------------

def detect_polo_inversion(instancias: list[dict]) -> list[str]:
    """Detecta inversão de polo entre instâncias. Retorna ["inversao_polo:<doc>"]."""
    polo_por_doc: dict[str, dict[int, str]] = {}

    for inst in instancias:
        grau = inst.get("grau", 0)
        for parte in inst.get("partes", []):
            doc = parte.get("documento")
            polo = parte.get("polo")
            if not doc or not polo or polo == "DESCONHECIDO":
                continue
            polo_por_doc.setdefault(doc, {})[grau] = polo

    fenomenos = []
    for doc, polos in polo_por_doc.items():
        graus = sorted(polos)
        if len(graus) < 2:
            continue
        p1, p2 = polos[graus[0]], polos[graus[-1]]
        if p1 != p2 and {p1, p2} == {"ATIVO", "PASSIVO"}:
            fenomenos.append(f"inversao_polo:{doc}")

    return fenomenos


def detect_multiplas_ativas(instancias: list[dict]) -> bool:
    """True se 2+ instâncias com status ATIVO."""
    return sum(1 for i in instancias if i.get("status") == "ATIVO") >= 2

# ---------------------------------------------------------------------------
# Fallbacks
# ---------------------------------------------------------------------------

def _polo_fallback(instancias: list[dict], polo: str) -> str | None:
    """
    Reconstrói polo_ativo/polo_passivo a partir dos envolvidos quando
    titulo_polo_ativo/passivo não está disponível na raiz do payload.
    Retorna os nomes únicos concatenados por " | " ou None.
    """
    nomes: list[str] = []
    vistos: set[str] = set()
    for inst in instancias:
        for parte in inst.get("partes", []):
            if parte.get("polo") != polo:
                continue
            nome = clean_string(parte.get("nome"))
            if nome and nome not in vistos:
                vistos.add(nome)
                nomes.append(nome)
    return " | ".join(nomes) if nomes else None


# ---------------------------------------------------------------------------
# Transformer — orquestrador principal
# ---------------------------------------------------------------------------

def parse(payload: dict) -> dict:
    """
    Parseia payload bruto da API Escavador → canonical dict.
    Stateless, determinístico. Agnóstico de output_template.
    """
    fenomenos: list[str] = []

    numero_cnj = clean_string(payload.get("numero_cnj")) or ""
    if not CNJ_REGEX.match(numero_cnj):
        fenomenos.append("cnj_invalido")

    fontes_raw = payload.get("fontes") or []
    if not fontes_raw:
        fenomenos.append("sem_fontes")

    fontes_san = [_sanitize_fonte(f) for f in fontes_raw]
    fontes_san = _merge_djxx(fontes_san)
    fontes_dedup, fenomenos_dedup = deduplicate_fontes(fontes_san)
    fenomenos.extend(fenomenos_dedup)

    fenomenos.extend(detect_polo_inversion(fontes_dedup))
    tem_multiplas = detect_multiplas_ativas(fontes_dedup)
    if tem_multiplas:
        fenomenos.append("multiplas_instancias_ativas")

    instancias = sorted(
        [_build_instancia(f) for f in fontes_dedup],
        key=lambda i: i["grau"],
    )

    tipo_principal = _get_tipo_principal(instancias)
    fase_atual = classify_fase(instancias)
    status_geral = classify_status_geral(instancias)

    segredo_justica = any(f.get("segredo_justica") for f in fontes_raw)
    nivel_sigilo = 1 if segredo_justica else 0
    capa_disponivel = any(i["capa_disponivel"] for i in instancias)
    tem_audiencia = any(i.get("audiencias") for i in instancias)

    return {
        "_fonte_origem": "escavador",
        "numero_cnj": numero_cnj,
        "tipo_principal": tipo_principal,
        "fase_atual": fase_atual,
        "status_geral": status_geral,
        "total_instancias": len(instancias),
        "segredo_justica": segredo_justica,
        "nivel_sigilo": nivel_sigilo,
        "capa_disponivel": capa_disponivel,
        "tem_audiencia": tem_audiencia,
        "tem_multiplas_instancias_ativas": tem_multiplas,
        "fenomenos_detectados": fenomenos,
        "ano_inicio": payload.get("ano_inicio"),
        "data_inicio": clean_date(payload.get("data_inicio")),
        "data_ultima_movimentacao": clean_date(payload.get("data_ultima_movimentacao")),
        "data_ultima_verificacao": clean_date(payload.get("data_ultima_verificacao")),
        "polo_ativo": (
            clean_string(payload.get("titulo_polo_ativo"))
            or _polo_fallback(instancias, "ATIVO")
        ),
        "polo_passivo": (
            clean_string(payload.get("titulo_polo_passivo"))
            or _polo_fallback(instancias, "PASSIVO")
        ),
        "instancias": instancias,
    }


def _merge_djxx(fontes: list[dict]) -> list[dict]:
    """
    Remove fontes DJxx (Diários de Justiça) das instâncias e mescla seus
    envolvidos na fonte TJxx de mesmo grau. DJxx nunca têm capa/classe.
    """
    djxx = [f for f in fontes if (f.get("sigla") or "").startswith("DJ")]
    tjxx = [f for f in fontes if not (f.get("sigla") or "").startswith("DJ")]

    if not djxx:
        return fontes

    # Indexa TJxx por grau para mesclar envolvidos
    tjxx_por_grau: dict[int, dict] = {}
    for f in tjxx:
        grau = f.get("grau", 1)
        if grau not in tjxx_por_grau:
            tjxx_por_grau[grau] = f

    for dj in djxx:
        grau = dj.get("grau", 1)
        partes_dj = dj.get("partes") or []
        if not partes_dj:
            continue
        alvo = tjxx_por_grau.get(grau)
        if alvo is None:
            # Não há TJxx para esse grau — marca como DIARIO_OFICIAL e mantém
            dj["_tipo_fonte"] = "DIARIO_OFICIAL"
            tjxx.append(dj)
            continue
        # Mescla partes únicas (por nome)
        nomes_existentes = {p.get("nome") for p in alvo.get("partes", [])}
        for parte in partes_dj:
            if parte.get("nome") not in nomes_existentes:
                alvo.setdefault("partes", []).append(parte)
                nomes_existentes.add(parte.get("nome"))

    return tjxx


def _orgao_from_infos_complementares(capa: dict) -> str | None:
    """
    Extrai orgao_julgador de capa.informacoes_complementares quando o campo
    principal está ausente. Usa o tipo 'Origem' que contém o caminho completo
    da vara (ex: 'Comarca de SP / Foro Central / 1ª Vara Cível').
    Retorna apenas o último segmento (a vara específica).
    """
    infos = capa.get("informacoes_complementares") or []
    for info in infos:
        if info.get("tipo") == "Origem" and info.get("valor"):
            partes = [p.strip() for p in info["valor"].split("/") if p.strip()]
            return clean_string(partes[-1]) if partes else None
    return None


def _sanitize_fonte(fonte: dict) -> dict:
    capa = fonte.get("capa") or {}
    classe_raw = capa.get("classe")
    classe_norm = clean_classe(classe_raw)
    valor = clean_valor_causa(capa.get("valor_causa"))

    assuntos_raw = capa.get("assuntos_normalizados") or []
    assuntos = [
        {"id": a.get("id"), "nome": clean_string(a.get("nome")), "path": clean_string(a.get("path"))}
        for a in assuntos_raw if a.get("nome")
    ]

    partes = [_sanitize_envolvido(e) for e in (fonte.get("envolvidos") or [])]

    return {
        "id": fonte.get("id"),
        "sigla": clean_string(fonte.get("sigla")),
        "nome": clean_string(fonte.get("nome")),
        "grau": fonte.get("grau", 1),
        "grau_formatado": clean_string(fonte.get("grau_formatado")),
        "status": _resolve_status(fonte),
        "arquivado": fonte.get("arquivado"),
        "segredo_justica": fonte.get("segredo_justica", False),
        "capa_disponivel": bool(capa),
        "classe_normalizada": classe_norm,
        "classe_raw": clean_string(classe_raw),
        "classe_codigo": _extract_classe_codigo(classe_raw),
        "orgao_julgador": (
            clean_string(capa.get("orgao_julgador"))
            or _orgao_from_infos_complementares(capa)
        ),
        "assunto_principal": clean_string(
            (assuntos[0].get("nome") if assuntos else None) or capa.get("assunto")
        ),
        "assunto_path": clean_string(assuntos[0].get("path") if assuntos else None),
        "assuntos": assuntos,
        "valor_causa": valor.get("valor_causa") if valor else None,
        "moeda": valor.get("moeda") if valor else None,
        "data_inicio": clean_date(fonte.get("data_inicio") or capa.get("data_inicio")),
        "data_ultima_movimentacao": clean_date(fonte.get("data_ultima_movimentacao")),
        "partes": partes,
        "audiencias": [],
    }


def _resolve_status(fonte: dict) -> str:
    sp = fonte.get("status_predito")
    if sp in ("ATIVO", "INATIVO"):
        return sp
    return "INATIVO" if fonte.get("arquivado") is True else "ATIVO"


def _sanitize_envolvido(env: dict) -> dict:
    doc = clean_string(env.get("cpf")) or clean_string(env.get("cnpj"))
    polo = (env.get("polo") or "").upper()
    if polo not in ("ATIVO", "PASSIVO", "ADVOGADO"):
        polo = "DESCONHECIDO"
    return {
        "nome": clean_string(env.get("nome")),
        "polo": polo,
        "tipo": clean_string(env.get("tipo_pessoa")),
        "tipo_pessoa": clean_string(env.get("tipo_pessoa")),
        "documento": doc,
        "advogados": [],
    }


def _build_instancia(fonte: dict) -> dict:
    grau = fonte["grau"]
    classe = fonte.get("classe_normalizada")
    inst = {
        "fonte_id": fonte["id"],
        "grau": grau,
        "grau_formatado": fonte.get("grau_formatado") or _GRAU_LABEL.get(grau, str(grau)),
        "tipo": fonte.get("_tipo_fonte") or classify_tipo(classe, grau),
        "tribunal": {
            "sigla": fonte.get("sigla"),
            "nome": fonte.get("nome"),
            "sistema": _TRIBUNAL_SISTEMA.get(fonte.get("sigla") or ""),
        },
        "classe": classe,
        "classe_raw": fonte.get("classe_raw"),
        "classe_codigo": fonte.get("classe_codigo"),
        "assunto_principal": fonte.get("assunto_principal"),
        "assunto_path": fonte.get("assunto_path"),
        "assuntos": fonte.get("assuntos", []),
        "orgao_julgador": fonte.get("orgao_julgador"),
        "valor_causa": fonte.get("valor_causa"),
        "moeda": fonte.get("moeda", "BRL") if fonte.get("valor_causa") is not None else None,
        "data_inicio": fonte.get("data_inicio"),
        "data_ultima_movimentacao": fonte.get("data_ultima_movimentacao"),
        "status": fonte.get("status", "ATIVO"),
        "arquivado": fonte.get("arquivado"),
        "capa_disponivel": fonte.get("capa_disponivel", False),
        "partes": fonte.get("partes", []),
        "audiencias": fonte.get("audiencias", []),
        "movimentos": [],
    }
    return inst


def _get_tipo_principal(instancias: list[dict]) -> str:
    ativas = [i for i in instancias if i.get("status") == "ATIVO"]
    candidatos = ativas or instancias
    if not candidatos:
        return "DESCONHECIDO"
    # Prefere maior grau com tipo conhecido entre os ativos
    conhecidos = [i for i in candidatos if i.get("tipo") not in (None, "", "DESCONHECIDO")]
    if not conhecidos:
        # Expande para todas as instâncias quando nenhuma ativa tem tipo conhecido
        conhecidos = [i for i in instancias if i.get("tipo") not in (None, "", "DESCONHECIDO")]
    pool = conhecidos or candidatos
    return max(pool, key=lambda i: i["grau"])["tipo"]

