"""vas_variation: quais VAS variaram entre a fatura passada e a atual.

Responde, deterministicamente: *que cobranças de VAS entraram (ou subiram de valor)
na fatura atual em relação à passada?* Dois consumidores dependem dessa resposta, e é
de propósito que ela viva num lugar só — duas implementações dariam respostas
diferentes para a mesma fatura. Eles pedem RECORTES DE CLASSE diferentes, porque a
pergunta de negócio de cada um é diferente:

- **Retenção de VAS no pedido de humano** (SPEC §9) → :func:`varied_avulso_items`: só
  **avulso**, porque o agente promete *cancelar* o que ofereceu, e só avulso é
  cancelável (SPEC §11). Estratégico/bundle nunca disparam retenção.
- **Ramo NÃO do ``invoice_explanation``** (SPEC §9 "Explicação da variação recusada")
  → :func:`varied_vas_charges`: **avulso ou estratégico**, lidos direto dos grupos da
  análise. Aqui a pergunta não é "o que eu cancelo?", é "existe algo que eu resolva?" —
  e o estratégico o agente resolve pela tool ``vas_estrategico``. Sem nenhum dos dois
  por trás da variação não há o que o bot resolva, e o atendimento finaliza
  ``nao_resolvido`` em vez de virar improviso do orquestrador.

**Só a retenção cruza com o PDF.** O ``type`` do grupo de análise já é a classe, então
o ramo NÃO não precisa do ``build_snapshot``: ele só conta o que variou nos dois grupos
de VAS. A retenção precisa, porque promete cancelar e tem de saber QUAL linha — e paga
por isso o preço de conciliar nome e valor entre dois pipelines.

``varied_current_charges`` responde só pela variação (o grupo de análise lido é
parâmetro); :func:`varied_avulso_items` cruza com a classificação da fatura.

**Por que ler o payload CRU e não o snapshot do resolver.**
``InvoiceResolver._iter_msisdn_buckets`` passa por ``_billing_analysis_sections``,
que FUNDE ``currentInvoice`` e ``invoiceVariation`` num único bucket por ``desc``.
Isso destrói a partição passada/atual — a informação de que este módulo depende — e
faz o snapshot conter itens que existiam SÓ na fatura passada. Por isso aqui as duas
listas são lidas cruas do ``invoice_detail``.

**A regra.** Dentro dos grupos de variação pedidos — ``servicos_contratados_de_parceiros``
é o VAS AVULSO e o bucket irmão ``streaming`` é o ESTRATÉGICO (SPEC §12.A) —, cada item
carrega o campo ``invoice`` = vencimento da fatura de onde veio. Particiona-se por esse
vencimento (o menor é a fatura passada, o maior a atual) e tira-se a diferença de
**multiset** por ``(nome normalizado, valor)`` — a multiplicidade importa, porque o mesmo
serviço cobrado 2× no ciclo são duas cobranças reais. Só a direção *entrou/subiu* é
devolvida: variação causada por REMOÇÃO não tem o que cancelar (decisão de produto).

Medida (no recorte avulso) contra 88 pares de fatura reais com as faturas normalizadas
como verdade: 88/88 exatas, precisão e recall 1.000. O desempate de data única (abaixo)
é essencial — sem ele a direção erra e a precisão cai para 0.377.

**Duas formas de payload.** O runtime entrega ``value`` como string (``'19.9'``) e
``invoice`` em ISO-8601 (``'2026-04-07T00:00:00.000Z'``); outra serialização do
mesmo pipeline usa float e ``dd/mm/yyyy``. As duas são aceitas.

**Falha fechada.** Qualquer ausência ou inconsistência que impeça estabelecer a
partição devolve tupla vazia — e sem cobrança variada a Policy não oferece retenção
(o pedido de humano volta ao single-strike). Nunca promete cancelar o que não sabe
que variou.
"""
from __future__ import annotations

import logging
import os
import re
from collections import Counter
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Iterable, Iterator, Mapping, Sequence

from .invoice_resolver import STRATEGIC_NAMES, InvoiceResolver
from .invoice_models import ResolvedInvoiceItem

logger = logging.getLogger(__name__)

# Segunda passada por PREFIXO DE TOKENS (``_match_qualified_names``, incidente
# e081812886a: variação "FIT ME App" × fatura "FIT ME App Premium Mensal").
# DESLIGADA por padrão: o corpus real prova que o mesmo prefixo tanto qualifica o
# mesmo produto quanto separa produtos DIFERENTES — ``Fluid Light`` × ``Fluid
# Premium`` na mesma fatura com preços distintos, e ``VOD + Canais Abertos`` ×
# ``VOD +Canais`` com valor IDÊNTICO (19.90). Como a forma é a mesma nos dois
# casos, nenhuma regra sintática os separa, e casar significaria cancelar o
# serviço errado sem confirmação (esta rota autoriza a fila direto). Religar só
# quando houver identificador de produto compartilhado entre as duas fontes — a
# interseção de campos hoje é apenas ``{desc, value}``.
# ``TIM_VAS_QUALIFIED_NAME_MATCH=true`` reativa (só para experimento/shadow).
_VAS_QUALIFIED_NAME_MATCH = (
    os.getenv("TIM_VAS_QUALIFIED_NAME_MATCH", "false").lower() == "true"
)

# Buckets de VAS na análise de variação/fatura atual: ``servicos_contratados_de_parceiros``
# é o AVULSO e ``streaming`` o ESTRATÉGICO (SPEC §12.A). A retenção lê só o avulso (é o
# único cancelável); o ramo NÃO do invoice_explanation lê os dois.
_AVULSO_ANALYSIS_TYPE = "servicos_contratados_de_parceiros"
_STRATEGIC_ANALYSIS_TYPE = "streaming"
_VAS_ANALYSIS_TYPES = (_AVULSO_ANALYSIS_TYPE, _STRATEGIC_ANALYSIS_TYPE)

# Chaves de análise no ``invoice_detail``, como o backend as entrega (camelCase).
_VARIATION_KEY = "invoiceVariation"
_CURRENT_KEY = "currentInvoice"

_ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")
_BR_DATE = re.compile(r"^(\d{2})/(\d{2})/(\d{4})")

# ----- data da COBRANÇA (≠ vencimento da fatura) ------------------------------
# ``_due_date_key``/``_BR_DATE`` acima leem o campo ``invoice`` (VENCIMENTO) e
# particionam fatura-passada × atual — são load-bearing e ficam intocados. O par
# abaixo resolve outra pergunta: QUAL cobrança, dentro da fatura, é esta. Precisa
# de duas formas que o ``_BR_DATE`` rejeita (ano de 2 dígitos), porque a
# explicação diz "no dia 29/06/26" e o PDF grava ``period`` como "01/11/25".
_CHARGE_DATE_ISO = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")
_CHARGE_DATE_BR = re.compile(r"^(\d{2})/(\d{2})/(\d{2,4})\b")
# Sentinela que o backend emite quando não sabe a data. Parseia como data válida
# e viraria desempate FALSO se passasse adiante.
_CHARGE_DATE_SENTINEL = ("0000", "12", "31")


def _charge_date_key(raw: Any) -> tuple[str, str, str] | None:
    """Chave canônica ``(ano, mês, dia)`` de uma data de COBRANÇA.

    Aceita ISO (``2025-11-01T00:00:00.000Z``), ``dd/mm/aa`` (``25/06/26``) e
    ``dd/mm/yyyy``. Ano de 2 dígitos vira ``20xx`` — as faturas em questão são
    todas deste século e o campo não carrega o século.

    Devolve ``None`` para ausente, ilegível, data impossível e para o SENTINELA
    ``0000-12-31``: aqui a igualdade de data é EVIDÊNCIA para desempatar
    cancelamento, então "não sei" tem de ser indistinguível de "não tem" — nunca
    tolerância por prefixo (isso é similaridade, e similaridade não autoriza
    cancelar)."""
    text = str(raw or "").strip()
    if not text:
        return None
    iso = _CHARGE_DATE_ISO.match(text)
    if iso:
        key = (iso.group(1), iso.group(2), iso.group(3))
    else:
        br = _CHARGE_DATE_BR.match(text)
        if not br:
            return None
        year = br.group(3)
        key = (year if len(year) == 4 else f"20{year}", br.group(2), br.group(1))
    if key == _CHARGE_DATE_SENTINEL:
        return None
    try:
        if not (1 <= int(key[1]) <= 12 and 1 <= int(key[2]) <= 31):
            return None
    except ValueError:
        return None
    return key


@dataclass(frozen=True, slots=True)
class _ExplainedCharge:
    """Uma cobrança DATADA extraída do texto cru do ``invoiceExplanation``.

    É a única fonte de data de cobrança que serve: o campo ``date`` estruturado
    do ``invoiceVariation`` vem sentinela/ausente em produção."""

    desc: str
    value: Decimal
    date_key: tuple[str, str, str]


# Só a forma COM data explícita. "Foram cobrados os seguintes serviços de
# terceiros: X" e "* X variou em R$ Y" não têm dia e, por desenho, não casam:
# sem data não há evidência para desempatar, e o fluxo falha FECHADO.
_EXPLAINED_CHARGE_RE = re.compile(
    r"^(?:Cobran[çc]a\s+)?(?P<desc>.+?)\s+no\s+valor\s+de\s+R\$\s*"
    r"(?P<value>[\d.,]+)\s+no\s+dia\s+(?P<date>\d{2}/\d{2}/\d{2,4})",
    re.IGNORECASE,
)


def _explained_charges(text: Any) -> list[_ExplainedCharge]:
    """Extrai as cobranças datadas do ``invoiceExplanation`` CRU.

    ``text`` tem de ser o ``invoice_explanation_base`` (resposta original do
    backend), NUNCA a reescrita do LLM: a reescrita mexe em pontuação e formato
    monetário, e identidade determinística não pode depender disso.

    Devolve LISTA, não conjunto — dois bullets podem ter o mesmo (valor, data), e
    a multiplicidade é o que impede um hint de ser reusado por duas cobranças
    independentes."""
    out: list[_ExplainedCharge] = []
    for line in str(text or "").splitlines():
        stripped = line.strip().lstrip("*-•").strip()
        if not stripped:
            continue
        match = _EXPLAINED_CHARGE_RE.match(stripped)
        if match is None:
            continue
        date_key = _charge_date_key(match.group("date"))
        if date_key is None:
            continue
        raw_value = match.group("value")
        # "16,99" e "1.234,56": vírgula é decimal, ponto é milhar.
        if "," in raw_value:
            raw_value = raw_value.replace(".", "").replace(",", ".")
        value = InvoiceResolver._parse_money(raw_value)
        if value is None:
            continue
        out.append(
            _ExplainedCharge(
                desc=match.group("desc").strip(),
                value=value,
                date_key=date_key,
            )
        )
    return out


@dataclass(frozen=True, slots=True)
class VariedCharge:
    """Uma cobrança de VAS avulso que entrou ou subiu na fatura atual.

    ``desc`` é o texto CRU da fatura (nunca normalizado) — serve a telemetria e a
    qualquer fala futura. O casamento com itens do resolver é sempre por
    :func:`charge_match_key`, nunca por ``desc`` direto."""

    desc: str
    value: Decimal


def charge_match_key(desc: Any, value: Any) -> tuple[str, Decimal] | None:
    """Chave de identidade de uma cobrança: ``(nome normalizado, valor)``.

    Definição ÚNICA, usada dos dois lados do casamento (as cobranças variadas
    daqui e os ``ResolvedInvoiceItem`` do snapshot). Reusa os helpers canônicos do
    :class:`InvoiceResolver` de propósito: um normalizador próprio poderia divergir
    do que casa ``canonical_name``, e aí a fila de cancelamento sairia errada.

    ``None`` quando falta nome ou o valor não é parseável — cobrança sem identidade
    não entra no diff."""
    name = str(desc or "").strip()
    if not name:
        return None
    parsed = InvoiceResolver._parse_money(value)
    if parsed is None:
        return None
    return (InvoiceResolver._normalize_match_text(name), parsed)


def varied_current_charges(
    invoice_detail: Mapping[str, Any] | None,
    *,
    analysis_types: Sequence[str] = (_AVULSO_ANALYSIS_TYPE,),
) -> tuple[VariedCharge, ...]:
    """Cobranças de VAS que ENTRARAM ou SUBIRAM na fatura atual.

    ``analysis_types`` escolhe os grupos de análise lidos — só o avulso (default, o
    recorte da retenção) ou ``_VAS_ANALYSIS_TYPES`` (avulso + estratégico). A partição
    passada × atual é feita sobre a UNIÃO dos grupos pedidos: os itens dos dois carregam
    o vencimento das MESMAS duas faturas, e olhar a união é o que salva o caso em que um
    dos grupos existe só na fatura atual (sozinho ele cairia no desempate de data única).

    Tupla vazia quando não há dados de variação utilizáveis (sem ``invoiceVariation``,
    sem os grupos pedidos, vencimento ilegível, ou variação causada apenas por remoção)
    — falha fechada, ver docstring do módulo."""
    try:
        items = list(_iter_vas_items(invoice_detail, _VARIATION_KEY, analysis_types))
    except Exception:  # pragma: no cover - defensivo, payload arbitrário
        logger.debug("vas_variation.read_failed", exc_info=True)
        return ()
    if not items:
        return ()

    by_due_date: dict[tuple[str, str, str], list[Mapping[str, Any]]] = {}
    for item in items:
        due = _due_date_key(item.get("invoice"))
        if due is None:
            # Item sem vencimento legível não pode ser atribuído a nenhuma das duas
            # faturas. Incluí-lo em qualquer um dos lados corromperia o diff — e um
            # diff errado cancela o serviço errado. Aborta.
            logger.debug("vas_variation.unattributable_item")
            return ()
        by_due_date.setdefault(due, []).append(item)

    due_dates = sorted(by_due_date)
    if len(due_dates) >= 2:
        past_items = by_due_date[due_dates[0]]
        current_items = by_due_date[due_dates[-1]]
    else:
        current_items, past_items = _split_single_due_date(
            by_due_date[due_dates[0]], invoice_detail
        )

    current = _charge_counter(current_items)
    past = _charge_counter(past_items)
    added = current - past
    if not added:
        return ()

    raw_descs = _first_raw_descs(current_items)
    return tuple(
        VariedCharge(desc=raw_descs.get(key, key[0]), value=key[1])
        for key in added.elements()
    )


def variation_source(
    invoice_variation: Mapping[str, Any] | None,
    invoice_detail: Mapping[str, Any] | None,
) -> Mapping[str, Any]:
    """Qual dos dois payloads carrega a ANÁLISE de variação.

    Primária: ``invoice_variation`` — gravada pelo prefetch a partir de
    ``InvoiceExplanationOutput.detalhes`` (corpo do ``billingAnalysis``). NÃO vem do
    ``invoice_detail``, que é o PDF parseado e não carrega a partição fatura-passada ×
    atual.

    Fallback para ``invoice_detail``: em entradas onde ele JÁ é o payload de análise (o
    resolver suporta esse formato — ver ``_billing_analysis_sections``), a variação
    está lá. Definição ÚNICA para os dois consumidores (runtime e workflow), para os
    dois lerem a mesma coisa."""
    if isinstance(invoice_variation, Mapping) and (
        invoice_variation.get(_VARIATION_KEY) or invoice_variation.get(_CURRENT_KEY)
    ):
        return invoice_variation
    return invoice_detail if isinstance(invoice_detail, Mapping) else {}


def varied_avulso_items(
    *,
    invoice_variation: Mapping[str, Any] | None,
    invoice_detail: Mapping[str, Any] | None,
    resolver: InvoiceResolver,
    by_charge: bool,
    explanation_text: str | None = None,
) -> list[ResolvedInvoiceItem] | None:
    """Itens ``avulso`` da fatura cujas cobranças VARIARAM na atual.

    Responde a pergunta "a variação foi causada por VAS avulso?" e, quando sim, com
    QUAIS cobranças — o escopo da retenção no pedido de humano (SPEC §9), que promete
    cancelar o que ofereceu. Para o recorte mais largo (avulso **ou** estratégico), do
    ramo NÃO do ``invoice_explanation``, ver :func:`varied_vas_charges`.

    Cruza duas fontes, cada uma com o que só ela tem:

    - ``invoice_variation`` → a ANÁLISE (``invoiceVariation``), única com a partição
      fatura-passada × atual, logo a única que sabe o que VARIOU;
    - ``invoice_detail`` → o PDF parseado, única com a classificação canônica
      (``classe``/``estrategico`` carimbados pelo parser e honrados por ``_classify``),
      logo a única que sabe o que é CANCELÁVEL.

    O cruzamento é por :func:`charge_match_key` (nome normalizado + valor), a mesma
    chave dos dois lados. Se os valores divergirem entre as fontes o casamento falha e
    a lista sai vazia → falha fechada.

    **Só AVULSO conta** (SPEC §9/§11): bundle/estratégico nunca entram. O ``item_type``
    do resolver não basta como único filtro — na seção "Serviços de valor adicionado"
    ele decide por ``contestable``, então um serviço que a API bucketou como avulso sai
    ``avulso`` mesmo sendo estratégico. Visto em fatura real: ``YouTube Premium Mensal``
    no bucket avulso da variação vira ``avulso``/``cancelar_vas_avulso`` e, pelo grupo
    ``Streamings`` da mesma fatura, ``estrategico`` — duas classes para o MESMO serviço.
    Por isso dois guards, e a NOSSA classificação vence a da API:

    1. nome na lista fixa ``STRATEGIC_NAMES`` (SPEC §12.A) → nunca avulso;
    2. o mesmo nome classificado ``estrategico``/``bundle`` em qualquer seção desta
       fatura → o "avulso" é mis-bucket da API; descarta.

    ``None`` em falha de leitura/resolução; lista (possivelmente vazia) caso
    contrário — o chamador decide o que "não sei" significa no fluxo dele."""
    if not invoice_detail:
        return None
    try:
        varied = varied_current_charges(
            variation_source(invoice_variation, invoice_detail)
        )
        if not varied:
            return []
        snapshot = resolver.build_snapshot(invoice_detail, by_charge=by_charge)
    except Exception:
        logger.debug("vas_variation.avulso_lookup_failed", exc_info=True)
        return None
    wanted = Counter(
        key
        for key in (charge_match_key(c.desc, c.value) for c in varied)
        if key is not None
    )
    if not wanted:
        return []
    # Guard 2: nomes que ESTA fatura classifica como não-cancelável em alguma seção.
    # Normalizado pela mesma chave para casar grafias divergentes entre os dois campos
    # de análise.
    blocked_names = {
        charge_match_key(it.canonical_name, 0)
        for it in snapshot
        if it.item_type in {"estrategico", "bundle"}
    }
    items: list[ResolvedInvoiceItem] = []
    # Datas de cobrança do ``invoiceExplanation`` cru. Parse LAZY: sem explicação,
    # ou sem nenhuma ambiguidade a desempatar, nada é parseado e o comportamento é
    # IDÊNTICO ao de antes desta mudança.
    hints = _DateHints(explanation_text)
    # Iteração CHARGE-driven (não item-driven): para cada cobrança que variou,
    # coleta TODOS os itens do snapshot que a atendem e só então decide. O loop
    # item-driven anterior consumia a cobrança no PRIMEIRO item que casava, então
    # o vencedor era quem ``build_snapshot`` emitisse antes — com o mesmo
    # ``(nome, valor)`` em duas LINHAS, o cancelamento saía na linha decidida pela
    # ordem de iteração do dict, não por evidência.
    for key, missing in list(wanted.items()):
        if missing <= 0:
            continue
        eligible = [
            item
            for item in snapshot
            if item.item_type == "avulso"
            and not is_strategic_name(item.canonical_name)  # guard 1
            and charge_match_key(item.canonical_name, 0) not in blocked_names  # guard 2
            and charge_match_key(item.canonical_name, item.value) == key
        ]
        if not eligible:
            continue
        # ORDEM DOS GUARDS — IDENTIDADE ANTES DE EXECUTABILIDADE.
        # Multiplicidade (SPEC §9): o mesmo serviço cobrado N× no ciclo são N
        # cobranças e cada uma entra na fila — casar todas é correto quando a
        # contagem BATE. Sobrando candidato, não se sabe QUAL variou: falha
        # FECHADA, nunca "escolhe o primeiro".
        #
        # Este teste vem ANTES do filtro de ``msisdn`` de propósito. Invertido,
        # um candidato descartado por falta de linha COROARIA o outro: com uma
        # cobrança variada e dois itens de mesma identidade — um com linha, outro
        # sem — a ambiguidade de IDENTIDADE continua de pé, e a inexecutabilidade
        # de um deles não é evidência de que o outro seja o que variou.
        if len(eligible) > missing:
            # Antes de descartar, tenta ESTREITAR pela data da cobrança (a única
            # evidência determinística que distingue linhas com o mesmo nome e
            # valor — titular × dependente). Só reduz o conjunto: o resultado é
            # sempre subconjunto de ``eligible``, e este caminho só é alcançado
            # onde hoje se descarta tudo. Nenhum casamento existente muda.
            narrowed, hint = _narrow_by_charge_date(eligible, missing, hints, key[1])
            if narrowed is None:
                logger.info(
                    "vas_variation.varied_charge_unmatched reason=multiple_candidates"
                    " date_hint=%s service=%s count=%s",
                    hint,
                    key[0],
                    len(eligible),
                )
                # Já reportada com a razão CERTA: consome para não reaparecer no
                # resíduo de ``_log_unmatched_varied_charges`` como
                # ``name_absent``/``value_mismatch``. Uma cobrança descartada emite
                # exatamente um evento — telemetria dupla corrompe a taxa.
                wanted[key] = 0
                continue
            logger.info(
                "vas_variation.varied_charge_matched reason=charge_date"
                " service=%s count=%s candidates=%s",
                key[0],
                len(narrowed),
                len(eligible),
            )
            eligible = narrowed
        # Guard 3: resolvida a identidade, sobra a executabilidade. Sem ``msisdn``
        # não há linha de destino, e o campo vai CRU para os args de
        # ``cancelar_vas_avulso`` (``action_queue._to_tool_args``). Razão própria —
        # sem ela a cobrança sairia adiante como ``name_absent``/``value_mismatch``,
        # diagnóstico errado (o nome estava lá; faltava a linha).
        candidates = [item for item in eligible if item.msisdn]
        if len(candidates) < len(eligible):
            logger.info(
                "vas_variation.varied_charge_unmatched reason=missing_msisdn"
                " service=%s count=%s",
                key[0],
                len(eligible) - len(candidates),
            )
        # Consome as identidades RESOLVIDAS (não só as executáveis): a cobrança
        # descartada por falta de linha já foi reportada com a razão certa e não
        # pode reaparecer no resíduo como não-casada.
        wanted[key] -= len(eligible)
        items.extend(candidates)
    # Ordem do snapshot preservada na saída: a decisão é por cobrança, mas a fila
    # sai na ordem da fatura (determinismo de saída, independente da ordem em que
    # as cobranças variadas foram iteradas).
    order = {id(item): idx for idx, item in enumerate(snapshot)}
    items.sort(key=lambda it: order.get(id(it), 0))
    # Passe residual por (valor, data): resolve o caso em que os NOMES divergem
    # entre as fontes ("FIT ME App" na análise × "FIT ME App Premium Mensal" no
    # PDF). A ponte é determinística — valor ao centavo mais data da cobrança —,
    # sem afirmar que os nomes são equivalentes. Só atua sobre chaves que o loop
    # principal deixou por resolver.
    items.extend(
        _match_by_charge_date(wanted, snapshot, items, blocked_names, hints)
    )
    items.sort(key=lambda it: order.get(id(it), 0))
    if _VAS_QUALIFIED_NAME_MATCH:
        items.extend(_match_qualified_names(wanted, snapshot, items))
    _log_unmatched_varied_charges(wanted, snapshot)
    return items


def varied_vas_charges(
    *,
    invoice_variation: Mapping[str, Any] | None,
    invoice_detail: Mapping[str, Any] | None,
) -> tuple[VariedCharge, ...]:
    """Cobranças de VAS que VARIARAM — a pergunta do ramo NÃO do
    ``invoice_explanation`` (SPEC §9 "Explicação da variação recusada").

    "A variação foi causada por VAS que o agente resolve?" Recorte mais largo que
    :func:`varied_avulso_items` porque o desfecho aqui não é cancelar, é *ter assunto*:
    o estratégico o agente trata pela tool ``vas_estrategico``, então uma variação
    causada por streaming volta ao orquestrador em vez de encerrar o atendimento.

    **Não cruza com o PDF da fatura, de propósito.** O ``type`` do grupo de análise JÁ
    É a classe: ``servicos_contratados_de_parceiros`` é o avulso e ``streaming`` o
    estratégico (confirmado contra a API, 2026-08-10). O ``build_snapshot`` só
    reafirmaria uma classificação que já veio carimbada, ao custo de casar nome e valor
    entre dois pipelines que divergem em grafia e centavo — e cada divergência
    ENCERRAVA a ligação de quem tinha o serviço na fatura (incidentes e081812886a,
    "FIT ME App" × "FIT ME App Premium Mensal", e Neymar Jr.Experience, PDF ilegível).
    Aqui nada é cancelado, então identidade não precisa ser provada: basta existir
    assunto. ``bundle`` nunca chega nestes grupos, e cobrança de outra jornada
    mis-bucketada pela API (roaming visto no grupo de parceiros) é risco ACEITO — o
    preço é devolver o turno ao orquestrador, que é o comportamento histórico.

    A retenção (:func:`varied_avulso_items`) segue cruzando com o PDF: lá se promete
    cancelar, e é preciso saber QUAL linha.

    Tupla vazia quando nada variou nos dois grupos — inclui análise ausente, vencimento
    ilegível e variação causada apenas por REMOÇÃO (falha fechada de
    :func:`varied_current_charges`), e o ramo NÃO finaliza ``nao_resolvido``."""
    return varied_current_charges(
        variation_source(invoice_variation, invoice_detail),
        analysis_types=_VAS_ANALYSIS_TYPES,
    )


class _DateHints:
    """Cobranças datadas da explicação, com CARDINALIDADE preservada.

    Não é ``set[(valor, data)]`` de propósito: dois bullets reais podem ter o
    mesmo valor e a mesma data, e um hint não pode ser reusado por duas cobranças
    independentes sem evidência de multiplicidade. Parse lazy — só na primeira
    consulta, que só acontece se algum caminho precisar desempatar."""

    __slots__ = ("_text", "_charges", "_consumed")

    def __init__(self, text: str | None) -> None:
        self._text = text
        self._charges: list[_ExplainedCharge] | None = None
        self._consumed: set[int] = set()

    def _all(self) -> list[_ExplainedCharge]:
        if self._charges is None:
            self._charges = _explained_charges(self._text) if self._text else []
        return self._charges

    def available(self, value: Decimal) -> list[int]:
        """Índices dos hints deste valor ainda NÃO consumidos."""
        return [
            idx
            for idx, charge in enumerate(self._all())
            if idx not in self._consumed and charge.value == value
        ]

    def date_keys(self, idxs: Sequence[int]) -> set[tuple[str, str, str]]:
        charges = self._all()
        return {charges[idx].date_key for idx in idxs}

    def take(
        self, idxs: Sequence[int], items: Sequence[ResolvedInvoiceItem]
    ) -> bool:
        """Consome UM hint por item, casando data 1:1. False se não fechar."""
        charges = self._all()
        remaining = list(idxs)
        used: list[int] = []
        for item in items:
            key = _charge_date_key(item.charge_date)
            for idx in remaining:
                if charges[idx].date_key == key:
                    used.append(idx)
                    remaining.remove(idx)
                    break
            else:
                return False
        self._consumed.update(used)
        return True


def _narrow_by_charge_date(
    eligible: "list[ResolvedInvoiceItem]",
    missing: int,
    hints: "_DateHints",
    value: Decimal,
) -> "tuple[list[ResolvedInvoiceItem] | None, str]":
    """Estreita candidatos ambíguos pela data da cobrança. Nunca amplia.

    Devolve ``(None, motivo)`` quando a evidência não fecha — e aí o chamador
    falha FECHADA, exatamente como antes desta mudança."""
    idxs = hints.available(value)
    if not idxs:
        return None, "absent"
    # Candidato SEM data legível não pode ser eliminado: ausência de data não é
    # evidência negativa. Com um datado e um sem data, escolher o datado seria
    # decidir por eliminação — proibido.
    if any(_charge_date_key(item.charge_date) is None for item in eligible):
        return None, "undated"
    keys = hints.date_keys(idxs)
    narrowed = [
        item for item in eligible if _charge_date_key(item.charge_date) in keys
    ]
    if len(narrowed) != missing:
        return None, "ambiguous"
    if not hints.take(idxs, narrowed):
        return None, "ambiguous"
    return narrowed, "matched"


def _match_by_charge_date(
    wanted: "Counter[tuple[str, Decimal]]",
    snapshot: "list[ResolvedInvoiceItem]",
    already: "list[ResolvedInvoiceItem]",
    blocked_names: "set[tuple[str, Decimal] | None]",
    hints: "_DateHints",
) -> "list[ResolvedInvoiceItem]":
    """Passe residual: casa por (VALOR, DATA) quando o NOME diverge entre fontes.

    A análise traz o nome curto e o PDF o comercial completo — a igualdade de
    nome falha, mas valor ao centavo + data da cobrança identificam a mesma
    cobrança sem nenhuma inferência sobre os nomes. Todos os guards do loop
    principal valem aqui, na mesma ordem: identidade primeiro, executabilidade
    (``msisdn``) depois, cardinalidade EXATA, e falha fechada em qualquer dúvida."""
    consumed = {id(item) for item in already}
    extra: list[ResolvedInvoiceItem] = []
    for key, missing in list(wanted.items()):
        if missing <= 0:
            continue
        idxs = hints.available(key[1])
        if not idxs:
            continue
        keys = hints.date_keys(idxs)
        eligible = [
            item
            for item in snapshot
            if item.item_type == "avulso"
            and id(item) not in consumed
            and not is_strategic_name(item.canonical_name)  # guard 1
            and charge_match_key(item.canonical_name, 0) not in blocked_names  # guard 2
            and item.value == key[1]
            and _charge_date_key(item.charge_date) in keys
        ]
        if len(eligible) != missing:
            continue
        if not hints.take(idxs, eligible):
            continue
        candidates = [item for item in eligible if item.msisdn]  # guard 3
        if len(candidates) < len(eligible):
            logger.info(
                "vas_variation.varied_charge_unmatched reason=missing_msisdn"
                " service=%s count=%s",
                key[0],
                len(eligible) - len(candidates),
            )
        wanted[key] = 0
        for item in eligible:
            consumed.add(id(item))
        extra.extend(candidates)
        if candidates:
            logger.info(
                "vas_variation.varied_charge_matched reason=charge_date"
                " service=%s count=%s candidates=%s",
                key[0],
                len(candidates),
                len(eligible),
            )
    return extra


def _match_qualified_names(
    wanted: "Counter[tuple[str, Decimal]]",
    snapshot: "list[ResolvedInvoiceItem]",
    already: "list[ResolvedInvoiceItem]",
) -> "list[ResolvedInvoiceItem]":
    """Segunda passada: a análise de variação traz o nome CURTO do serviço e a
    fatura traz o nome COMPLETO (incidente e081812886a: ``FIT ME App`` na variação
    × ``FIT ME App Premium Mensal`` em ``Itens Eventuais``). A igualdade estrita de
    nome descartava o item e o pedido de atendente encerrava sem oferecer retenção.

    Casa apenas por **prefixo de TOKENS** — os tokens da variação têm de ser o
    início exato dos tokens do item, nessa ordem. Não é substring solta ("me app"
    não casa) nem fuzzy. E só consome a cobrança com todas as travas:

    * mesmo valor, ao centavo (a chave já carrega o valor parseado);
    * item ``avulso`` que passou pelos guards de estratégico/classe mista;
    * **exatamente um** candidato — ambiguidade falha FECHADA (SPEC §retenção:
      nunca prometer cancelamento do que não se sabe ter variado).
    """
    if not wanted:
        return []
    consumed = {id(item) for item in already}
    extra: list[ResolvedInvoiceItem] = []
    for (wanted_name, wanted_value), missing in list(wanted.items()):
        if missing <= 0:
            continue
        wanted_tokens = wanted_name.split()
        if not wanted_tokens:
            continue
        candidates = [
            item
            for item in snapshot
            if item.item_type == "avulso"
            and item.msisdn  # guard 3: mesma exigência da primeira passada
            and id(item) not in consumed
            and not is_strategic_name(item.canonical_name)
            and _is_token_prefix(
                wanted_tokens, charge_match_key(item.canonical_name, item.value)
            )
            and (charge_match_key(item.canonical_name, item.value) or (None, None))[1]
            == wanted_value
        ]
        if len(candidates) != 1:
            if len(candidates) > 1:
                logger.info(
                    "vas_variation.varied_charge_unmatched reason=multiple_candidates"
                    " service=%s count=%s",
                    wanted_name,
                    len(candidates),
                )
            continue
        item = candidates[0]
        consumed.add(id(item))
        wanted[(wanted_name, wanted_value)] -= 1
        extra.append(item)
        logger.info(
            "vas_variation.varied_charge_matched reason=qualified_name service=%s",
            wanted_name,
        )
    return extra


def _is_token_prefix(
    wanted_tokens: "list[str]", item_key: "tuple[str, Decimal] | None"
) -> bool:
    """Os tokens da variação são o PREFIXO exato dos tokens do item da fatura."""
    if item_key is None:
        return False
    item_tokens = item_key[0].split()
    if len(item_tokens) <= len(wanted_tokens):
        return False
    return item_tokens[: len(wanted_tokens)] == wanted_tokens


def is_strategic_name(name: str) -> bool:
    """True se o nome do serviço está na lista fixa de SVA Estratégico/Terceiros
    (:data:`STRATEGIC_NAMES`, SPEC §12.A).

    Guard 1 de :func:`varied_avulso_items`: estratégico NÃO é cancelável, e a
    classificação por seção sozinha deixa passar estratégico que a API bucketou como
    avulso. Substring sobre o nome em caixa baixa — a lista guarda a marca ("netflix",
    "youtube"), não o nome comercial completo ("YouTube Premium Mensal")."""
    lowered = str(name or "").casefold()
    return any(brand in lowered for brand in STRATEGIC_NAMES)


def _log_unmatched_varied_charges(
    wanted: Counter[tuple[str, Decimal]],
    snapshot: Sequence[ResolvedInvoiceItem],
) -> None:
    """Registra as cobranças que a API diz ter variado mas NÃO casaram na fatura
    atual. Elas ficam fora de propósito (só contamos o que casa nome E valor), mas o
    descarte silencioso esconderia a taxa real.

    As duas fontes são pipelines distintos — a variação vem do ``billingAnalysis`` e a
    fatura atual do PDF parseado —, então grafia e arredondamento podem divergir.
    Separar ``value_mismatch`` (o serviço está na fatura, o valor não bate) de
    ``name_absent`` (não achamos o serviço) é o que diria se vale afrouxar o casamento:
    afrouxar por nome faria a cobrança variada casar a cobrança ERRADA do mesmo serviço
    (ex.: 2 cobranças, só uma variou), que é a super-cancelação que este filtro existe
    para evitar."""
    unmatched = +wanted  # descarta chaves já consumidas (contagem <= 0)
    if not unmatched:
        return
    snapshot_names = {charge_match_key(it.canonical_name, 0) for it in snapshot}
    for (name, _value), count in unmatched.items():
        reason = (
            "value_mismatch"
            if charge_match_key(name, 0) in snapshot_names
            else "name_absent"
        )
        logger.info(
            "vas_variation.varied_charge_unmatched reason=%s service=%s count=%s",
            reason,
            name,
            count,
        )


def _split_single_due_date(
    items: Sequence[Mapping[str, Any]],
    invoice_detail: Mapping[str, Any] | None,
) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]]]:
    """Bloco com um ÚNICO vencimento: não há partição para comparar.

    Acontece quando uma das duas faturas não tinha nenhum VAS avulso (27 de 88 casos
    na base medida) — então o bloco é inteiramente de um lado, e o que falta saber é
    QUAL. Desempata contra ``currentInvoice``: se a maioria do bloco aparece lá, é o
    lado ATUAL (tudo entrou); senão é o PASSADO (tudo saiu, nada a cancelar). Devolve
    ``(atuais, passadas)``.

    Olha TODOS os grupos de ``currentInvoice``, não só o avulso: a pergunta aqui é
    "esta cobrança está na fatura atual?", e a CLASSE é problema do resolver. Os dois
    campos vêm de geradores distintos e discordam da classe do mesmo item (visto na
    base: um serviço no bucket avulso da variação e em ``streaming`` da fatura atual)
    — restringir ao bucket avulso perderia a cobrança e inverteria a direção."""
    current_charges = _charge_counter(
        _iter_all_items(invoice_detail, _CURRENT_KEY)
    )
    matched = 0
    for item in items:
        key = charge_match_key(item.get("desc"), item.get("value"))
        if key is not None and current_charges[key] > 0:
            current_charges[key] -= 1
            matched += 1
    if matched * 2 >= len(items):
        return list(items), []
    return [], list(items)


def _iter_vas_items(
    invoice_detail: Mapping[str, Any] | None,
    analysis_key: str,
    analysis_types: Sequence[str],
) -> Iterator[Mapping[str, Any]]:
    """Itens dos grupos de VAS pedidos em ``analysis_key`` (``invoiceVariation`` ou
    ``currentInvoice``), lidos crus."""
    yield from _iter_all_items(invoice_detail, analysis_key, only_types=analysis_types)


def _iter_all_items(
    invoice_detail: Mapping[str, Any] | None,
    analysis_key: str,
    *,
    only_types: Sequence[str] | None = None,
) -> Iterator[Mapping[str, Any]]:
    """Itens de ``analysis_key``, opcionalmente restritos a certos ``type`` de grupo.
    Silencioso em qualquer forma inesperada."""
    for groups in _iter_analysis_lists(invoice_detail, analysis_key):
        for group in groups:
            if not isinstance(group, Mapping):
                continue
            if (
                only_types is not None
                and str(group.get("type") or "").strip() not in only_types
            ):
                continue
            items = group.get("items")
            if not isinstance(items, list):
                continue
            for item in items:
                if isinstance(item, Mapping):
                    yield item


def _iter_analysis_lists(
    invoice_detail: Mapping[str, Any] | None, analysis_key: str
) -> Iterator[list[Any]]:
    """Rende cada lista ``analysis_key`` do payload: no top-level e dentro de cada
    bucket por msisdn — espelha os dois pontos de leitura de
    ``InvoiceResolver._iter_msisdn_buckets``."""
    if not isinstance(invoice_detail, Mapping):
        return
    top = invoice_detail.get(analysis_key)
    if isinstance(top, list):
        yield top
    for value in invoice_detail.values():
        if not isinstance(value, Mapping):
            continue
        nested = value.get(analysis_key)
        if isinstance(nested, list):
            yield nested


def _charge_counter(
    items: Iterable[Mapping[str, Any]],
) -> Counter[tuple[str, Decimal]]:
    """Multiset das cobranças por ``(nome normalizado, valor)``. Cobranças sem
    identidade são descartadas."""
    counter: Counter[tuple[str, Decimal]] = Counter()
    for item in items:
        key = charge_match_key(item.get("desc"), item.get("value"))
        if key is not None:
            counter[key] += 1
    return counter


def _first_raw_descs(
    items: Iterable[Mapping[str, Any]],
) -> dict[tuple[str, Decimal], str]:
    """Mapa chave → primeiro ``desc`` cru visto, para devolver o nome como está na
    fatura em vez do normalizado."""
    raw: dict[tuple[str, Decimal], str] = {}
    for item in items:
        key = charge_match_key(item.get("desc"), item.get("value"))
        if key is not None and key not in raw:
            raw[key] = str(item.get("desc") or "").strip()
    return raw


def _due_date_key(raw: Any) -> tuple[str, str, str] | None:
    """Chave ordenável ``(ano, mês, dia)`` do vencimento (campo ``invoice`` do
    item). Aceita ISO-8601 (payload do runtime) e ``dd/mm/yyyy`` (serialização
    alternativa do mesmo pipeline). ``None`` quando não parseável."""
    text = str(raw or "").strip()
    if not text:
        return None
    iso = _ISO_DATE.match(text)
    if iso is not None:
        return (iso.group(1), iso.group(2), iso.group(3))
    br = _BR_DATE.match(text)
    if br is not None:
        return (br.group(3), br.group(2), br.group(1))
    return None


__all__ = [
    "VariedCharge",
    "charge_match_key",
    "is_strategic_name",
    "variation_source",
    "varied_avulso_items",
    "varied_current_charges",
    "varied_vas_charges",
]
