"""Testes do InvoiceResolver: matching estruturado entre fala do cliente e
``invoice_detail``. Cobre as seções suportadas, dedupe, classificação por
seção, match bidirecional, parse monetário e os
casos de borda do shape do payload."""
from __future__ import annotations

from decimal import Decimal
from typing import Any

import pytest

from app.domain.contas.invoice_resolver import (
    SECTION_DEFAULTS,
    STRATEGIC_NAMES,
    InvoiceResolver,
    ItemMatcherError,
)
from app.domain.contas.invoice_models import (
    MentionedItem,
    ResolvedInvoiceItem,
)


# ----- fixtures ---------------------------------------------------------------


def _entry(desc: str, value: Any, msisdn: str = "11999999999") -> dict[str, Any]:
    return {"desc": desc, "value": value, "msisdn": msisdn}


@pytest.fixture
def invoice_detail() -> dict[str, Any]:
    """``invoice_detail`` realista cobrindo as 4 seções suportadas e duas linhas
    (titular + dependente). Inclui chaves de metadado para garantir que são
    ignoradas pelo iterador."""
    return {
        "Fatura Resumo": {"total": 199.90},
        "vocalized_msisdn": {"11999999999": "nove nove nove nove"},
        "DANFE-COM": [],
        "11999999999": {
            "Plano": [_entry("TIM Black 80GB", 79.90)],
            "SVA Detalhe Total": [
                _entry("TIM Fashion Mensal", 10.00),
                _entry("Aluguel de Filme 1", 9.90),
                _entry("Aluguel de Filme 2", 9.90),
                _entry("Galinha Pintadinha", 7.99),
            ],
            "Serviços Contratados de Terceiros": [
                _entry("Netflix Premium", 39.90),
            ],
            "Serviços Bundle Inclusos": [
                _entry("HBO Max", 0.00),
            ],
            "Itens Eventuais": [
                _entry("Pacote Internacional", 25.00),
            ],
        },
        "11988888888": {
            "SVA Detalhe Total": [
                _entry("Tamboro Mensal", 5.99, msisdn="11988888888"),
            ],
        },
    }


# ----- resolve_items (caminho feliz) -----------------------------------------


def test_resolve_avulso_em_sva_detalhe_total(invoice_detail) -> None:
    items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
    assert len(items) == 1
    item = items[0]
    assert item.canonical_name == "TIM Fashion Mensal"
    assert item.tool_category == "cancelar_vas_avulso"
    assert item.item_type == "avulso"
    assert item.msisdn == "11999999999"
    assert item.section == "SVA Detalhe Total"
    assert item.value == Decimal("10.00")


def test_resolve_estrategico_via_secao_terceiros(invoice_detail) -> None:
    """Item em ``Serviços Contratados de Terceiros`` segue para VAS estratégico
    pela seção da fatura."""
    items = InvoiceResolver().resolve_items(["netflix"], invoice_detail)
    assert len(items) == 1
    item = items[0]
    assert item.canonical_name == "Netflix Premium"
    assert item.tool_category == "vas_estrategico"
    assert item.item_type == "estrategico"
    assert item.section == "Serviços Contratados de Terceiros"


def test_resolve_bundle_sempre_classifica_como_bundle(invoice_detail) -> None:
    """Bundle Inclusos sempre classifica como ``bundle`` por estar nessa seção."""
    items = InvoiceResolver().resolve_items(["HBO"], invoice_detail)
    assert len(items) == 1
    item = items[0]
    assert item.canonical_name == "HBO Max"
    assert item.tool_category == "vas_estrategico"
    assert item.item_type == "bundle"  # NÃO "estrategico"
    assert item.section == "Serviços Bundle Inclusos"


def test_resolve_avulso_em_itens_eventuais(invoice_detail) -> None:
    items = InvoiceResolver().resolve_items(["Pacote Internacional"], invoice_detail)
    assert len(items) == 1
    assert items[0].tool_category == "cancelar_vas_avulso"
    assert items[0].item_type == "avulso"
    assert items[0].section == "Itens Eventuais"


# ----- múltiplos itens / múltiplos MSISDNs -----------------------------------


def test_resolve_multiplos_itens_em_categorias_diferentes(invoice_detail) -> None:
    items = InvoiceResolver().resolve_items(
        ["tim fashion", "netflix", "HBO"], invoice_detail
    )
    by_name = {i.canonical_name: i for i in items}
    assert set(by_name) == {"TIM Fashion Mensal", "Netflix Premium", "HBO Max"}
    assert by_name["TIM Fashion Mensal"].tool_category == "cancelar_vas_avulso"
    assert by_name["Netflix Premium"].tool_category == "vas_estrategico"
    assert by_name["HBO Max"].tool_category == "vas_estrategico"


def test_resolve_itens_parecidos_nao_sao_deduplicados(invoice_detail) -> None:
    """'Aluguel de Filme' casa com '1', '2'. Como os ``canonical_name`` são
    diferentes, ambos devem ficar — dedupe é apenas para colisão exata."""
    items = InvoiceResolver().resolve_items(["Aluguel de Filme"], invoice_detail)
    names = sorted(i.canonical_name for i in items)
    assert names == ["Aluguel de Filme 1", "Aluguel de Filme 2"]


def test_match_exato_tem_precedencia_sobre_substring_de_outro_item() -> None:
    """Regressão CY0004: quando a fatura tem 'VOD + Canais' e 'VOD + Canais
    Abertos' (um é substring do outro), o cliente que cita ambos por nome
    canônico exato deve resolver cada um individualmente, sem ambiguidade.
    Sem essa precedência, ambos virariam ambíguos e a fila perderia avulsos.
    """
    detail = {
        "11964200059": {
            "SVA Detalhe Total": [
                {"desc": "VOD + Canais Abertos", "value": 19.90, "msisdn": "11964200059"},
                {"desc": "VOD + Canais", "value": 19.90, "msisdn": "11964200059"},
            ],
        }
    }
    outcome = InvoiceResolver().resolve(
        ["VOD + Canais Abertos", "VOD + Canais"], detail
    )
    # Cada menção bate em EXATAMENTE um item, sem ambiguidade
    assert [i.canonical_name for i in outcome.resolved] == [
        "VOD + Canais Abertos",
        "VOD + Canais",
    ]
    assert outcome.ambiguous == []
    assert outcome.not_found == []


def test_match_substring_continua_funcionando_quando_nao_ha_exato() -> None:
    """Quando o cliente cita só "VOD" (sem o nome canônico completo), o
    matching cai em substring bidirecional e ambos os "VOD + Canais ..."
    casam. Comportamento esperado de ambiguidade legítima — preserva o caso
    "dois ayas" e similar."""
    detail = {
        "11964200059": {
            "SVA Detalhe Total": [
                {"desc": "VOD + Canais Abertos", "value": 19.90, "msisdn": "11964200059"},
                {"desc": "VOD + Canais", "value": 19.90, "msisdn": "11964200059"},
            ],
        }
    }
    outcome = InvoiceResolver().resolve(["VOD"], detail)
    # Sem match exato, fallback substring acha os dois → ambíguo
    assert outcome.resolved == []
    assert len(outcome.ambiguous) == 1
    assert {m.canonical_name for m in outcome.ambiguous[0].matches} == {
        "VOD + Canais Abertos",
        "VOD + Canais",
    }


def test_match_exato_case_insensitive_e_trim() -> None:
    """Match exato ignora case e espaços nas pontas. Tolera variações
    inofensivas do classifier sem inflar ambiguidade."""
    detail = {
        "11964200059": {
            "SVA Detalhe Total": [
                {"desc": "VOD + Canais Abertos", "value": 19.90, "msisdn": "11964200059"},
                {"desc": "VOD + Canais", "value": 19.90, "msisdn": "11964200059"},
            ],
        }
    }
    outcome = InvoiceResolver().resolve(
        ["  vod + canais abertos  ", "VOD + CANAIS"], detail
    )
    assert [i.canonical_name for i in outcome.resolved] == [
        "VOD + Canais Abertos",
        "VOD + Canais",
    ]
    assert outcome.ambiguous == []


def test_msisdn_vem_da_entrada_nao_da_linha_titular(invoice_detail) -> None:
    """'Tamboro' aparece só no MSISDN dependente — o item resolvido carrega o
    MSISDN da entrada, NUNCA o do titular."""
    items = InvoiceResolver().resolve_items(["Tamboro"], invoice_detail)
    assert len(items) == 1
    assert items[0].msisdn == "11988888888"


def test_msisdn_cai_no_bucket_quando_entrada_nao_traz(invoice_detail) -> None:
    """Se o ``msisdn`` da entrada vier vazio, usamos o bucket pai."""
    invoice_detail["11999999999"]["SVA Detalhe Total"][0]["msisdn"] = ""
    items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
    assert items[0].msisdn == "11999999999"


# ----- dedupe -----------------------------------------------------------------


def test_dedupe_remove_entry_duplicada_na_mesma_secao(invoice_detail) -> None:
    """Mesmo ``desc`` + mesmo ``msisdn`` na mesma seção (duplicata real):
    fica uma só."""
    invoice_detail["11999999999"]["SVA Detalhe Total"].append(
        _entry("TIM Fashion Mensal", 10.00)
    )
    items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
    assert len(items) == 1


def test_dedupe_nao_colapsa_mesma_desc_em_msisdns_diferentes(invoice_detail) -> None:
    """Mesmo nome em linhas diferentes são itens distintos — não colapsar."""
    invoice_detail["11988888888"]["SVA Detalhe Total"].append(
        _entry("TIM Fashion Mensal", 10.00, msisdn="11988888888")
    )
    items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
    assert len(items) == 2
    assert {i.msisdn for i in items} == {"11999999999", "11988888888"}


# ----- defesa em profundidade -----------------------------------------------


def test_nome_estrategico_em_secao_avulso_permanece_avulso() -> None:
    detail = {
        "11999999999": {
            "SVA Detalhe Total": [_entry("Netflix Standard", 25.00)],
        }
    }
    items = InvoiceResolver().resolve_items(["netflix"], detail)
    assert len(items) == 1
    assert items[0].item_type == "avulso"
    assert items[0].tool_category == "cancelar_vas_avulso"


def test_sva_detalhe_total_com_classe_estrategico_vai_para_vas_estrategico() -> None:
    detail = {
        "11999999999": {
            "SVA Detalhe Total": [
                {
                    "desc": "Netflix Standard",
                    "value": 25.00,
                    "classe": "estrategico",
                    "msisdn": "11999999999",
                }
            ],
        }
    }

    items = InvoiceResolver().resolve_items(["netflix"], detail)

    assert len(items) == 1
    assert items[0].item_type == "estrategico"
    assert items[0].tool_category == "vas_estrategico"
    assert items[0].section == "SVA Detalhe Total"


def test_youtube_em_itens_eventuais_permanece_avulso() -> None:
    detail = {
        "11999999999": {
            "Itens Eventuais": [_entry("YouTube Premium Mensal", 24.90)],
        }
    }
    items = InvoiceResolver().resolve_items(["youtube premium"], detail)
    assert len(items) == 1
    assert items[0].canonical_name == "YouTube Premium Mensal"
    assert items[0].item_type == "avulso"
    assert items[0].tool_category == "cancelar_vas_avulso"


def test_classify_diretamente_para_todos_os_paths() -> None:
    r = InvoiceResolver()
    assert r._classify("HBO Max", "Serviços Bundle Inclusos", "bundle") == "bundle"
    assert r._classify("Netflix Standard", "SVA Detalhe Total", "avulso") == "avulso"
    assert (
        r._classify(
            "Netflix Standard",
            "SVA Detalhe Total",
            "avulso",
            {"classe": "estrategico"},
        )
        == "estrategico"
    )
    assert r._classify("Galinha Pintadinha", "SVA Detalhe Total", "avulso") == "avulso"
    assert r._classify("YouTube Premium", "Itens Eventuais", "avulso") == "avulso"
    assert (
        r._classify(
            "YouTube Premium",
            "Serviços Contratados de Terceiros",
            "estrategico",
        )
        == "estrategico"
    )


# ----- _was_mentioned --------------------------------------------------------


def test_was_mentioned_bidirecional() -> None:
    fn = InvoiceResolver._was_mentioned
    assert fn("TIM Fashion Mensal", ["tim fashion"]) is True   # desc contém mention
    assert fn("Netflix", ["Netflix Premium"]) is True          # mention contém desc
    assert fn("Disney+", ["DISNEY+"]) is True                  # case-insensitive
    assert fn("  TIM Fashion  ", ["tim fashion"]) is True      # trim
    assert fn("Galinha Pintadinha", ["netflix"]) is False
    assert fn("", ["x"]) is False
    assert fn("desc", []) is False
    assert fn("desc", ["", None]) is False  # type: ignore[list-item]


# ----- _parse_money ----------------------------------------------------------


@pytest.mark.parametrize(
    "raw,expected",
    [
        (10.00, Decimal("10.00")),
        ("9.99", Decimal("9.99")),
        (0, Decimal("0.00")),
        (Decimal("39.9"), Decimal("39.90")),
        (None, None),
        ("", None),
        ("não-numérico", None),
        (object(), None),
    ],
)
def test_parse_money(raw: Any, expected: Decimal | None) -> None:
    assert InvoiceResolver._parse_money(raw) == expected


# ----- shape do payload / robustez -----------------------------------------


def test_invoice_detail_vazio() -> None:
    assert InvoiceResolver().resolve_items(["netflix"], {}) == []


def test_mentioned_items_vazio(invoice_detail) -> None:
    assert InvoiceResolver().resolve_items([], invoice_detail) == []


def test_invoice_detail_nao_dict() -> None:
    assert InvoiceResolver().resolve_items(["x"], None) == []  # type: ignore[arg-type]


def test_secoes_denylist_sao_ignoradas() -> None:
    """Seções de ``_NON_SERVICE_SECTIONS`` (plano/desconto/consumo) não são
    serviços acionáveis — entradas sem flag tratável são ignoradas."""
    detail = {
        "11999999999": {
            "Roaming Internacional": [_entry("Pacote Roaming", 100.00)],
            "Outros Valores": [_entry("MULTAS", 5.00)],
        }
    }
    assert InvoiceResolver().resolve_items(["Pacote", "MULTAS"], detail) == []


def test_secao_de_servico_fora_das_classes_vira_out_of_scope() -> None:
    """``Cobranças de Terceiros`` (fora de SECTION_DEFAULTS, não é denylist):
    item encontrado mas não tratável → bucket ``out_of_scope``, não ``not_found``."""
    detail = {
        "11999999999": {
            "Cobranças de Terceiros": [_entry("Seguro Celular Proteção Total", 53.34)],
        }
    }
    outcome = InvoiceResolver().resolve(["Seguro Celular Proteção Total"], detail)
    assert outcome.resolved == []
    assert outcome.not_found == []
    assert [m.canonical_name for r in outcome.out_of_scope for m in r.matches] == [
        "Seguro Celular Proteção Total"
    ]
    oos_item = outcome.out_of_scope[0].matches[0]
    assert oos_item.item_type == "out_of_scope"
    assert oos_item.tool_category is None


def test_linha_de_credito_nao_e_avulso() -> None:
    """"CRÉDITO: PAGAMENTO" (ajuste financeiro, valor negativo) em "Itens Eventuais"
    é carimbado ``classe=avulso`` pela seção, mas NÃO é serviço → o resolver o ignora
    (não vira candidato/avulso). Um avulso real na mesma seção segue normal."""
    detail = {
        "11999999999": {
            "Itens Eventuais": [
                {
                    "desc": "CRÉDITO: PAGAMENTO",
                    "value": -49.77,
                    "msisdn": "11999999999",
                    "classe": "avulso",
                },
                {
                    "desc": "Tamboro Mensal",
                    "value": 14.99,
                    "msisdn": "11999999999",
                    "classe": "avulso",
                },
            ]
        }
    }
    outcome = InvoiceResolver().resolve(
        ["CRÉDITO: PAGAMENTO", "Tamboro Mensal"], detail
    )
    resolved = [(i.canonical_name, i.item_type) for i in outcome.resolved]
    assert ("Tamboro Mensal", "avulso") in resolved
    assert all("CRÉDITO" not in name for name, _ in resolved)  # crédito não roteado
    # crédito não casa nenhum candidato → not_found (nem avulso, nem out_of_scope)
    assert "CRÉDITO: PAGAMENTO" in outcome.not_found


def test_mensalidades_adicionais_e_secao_mista() -> None:
    """"Mensalidades Adicionais" é seção MISTA: estratégico vem da FLAG do parser
    (lista fixa por nome), não da seção. Item flageado → ``estrategico``; item sem
    flag (ex.: "Plugin 5G Plus") → ``out_of_scope`` — não é estratégico só por estar
    na seção (SPEC §5)."""
    detail = {
        "11999999999": {
            "Mensalidades Adicionais": [
                {
                    "desc": "Apple Music SVA Mes",
                    "value": 5.0,
                    "msisdn": "11999999999",
                    "estrategico": True,
                    "classe": "estrategico",
                },
                {"desc": "Plugin 5G Plus", "value": 5.0, "msisdn": "11999999999"},
            ]
        }
    }
    resolver = InvoiceResolver()

    flagged = resolver.resolve(["Apple Music SVA Mes"], detail)
    assert [(i.canonical_name, i.item_type) for i in flagged.resolved] == [
        ("Apple Music SVA Mes", "estrategico")
    ]

    plugin = resolver.resolve(["Plugin 5G Plus"], detail)
    assert plugin.resolved == []
    assert plugin.not_found == []
    assert [m.item_type for r in plugin.out_of_scope for m in r.matches] == [
        "out_of_scope"
    ]


def test_metadata_keys_nao_explodem() -> None:
    """``Fatura Resumo`` é dict, ``DANFE-COM`` é lista, ``vocalized_msisdn`` é
    dict — nenhum deles deve causar erro no iterador."""
    detail = {
        "Fatura Resumo": {"x": 1},
        "DANFE-COM": [{"y": 2}],
        "vocalized_msisdn": {"z": 3},
    }
    assert InvoiceResolver().resolve_items(["qualquer"], detail) == []


def test_entries_nao_list_ignorada() -> None:
    detail = {"11999999999": {"SVA Detalhe Total": "não é lista"}}
    assert InvoiceResolver().resolve_items(["x"], detail) == []


def test_entry_sem_desc_ignorada() -> None:
    detail = {"11999999999": {"SVA Detalhe Total": [{"value": 10.00}, "string"]}}
    assert InvoiceResolver().resolve_items(["x"], detail) == []


def test_sections_nao_dict_ignorada() -> None:
    detail = {"11999999999": "não é dict de seções"}
    assert InvoiceResolver().resolve_items(["x"], detail) == []


# ----- estruturais ----------------------------------------------------------


def test_strategic_names_alinhado_com_apendice_a() -> None:
    """A constante carrega exatamente os nomes validados do Apêndice A do
    orquestrador (Apple Music, Deezer, Disney, Fuze, Forge, Globoplay, HBO,
    Looke, Netflix, Paramount, TIM Cloud Gaming, YouTube). Mudança aqui é
    decisão de produto — exige PR explícito."""
    assert STRATEGIC_NAMES == frozenset(
        {
            "apple music",
            "deezer",
            "disney",
            "fuze",
            "forge",
            "globoplay",
            "hbo",
            "looke",
            "netflix",
            "paramount",
            "tim cloud gaming",
            "youtube",
            "amazon prime",
        }
    )


def test_section_defaults_cobre_as_secoes_suportadas() -> None:
    # "Mensalidades Adicionais" (formato PDF) é seção MISTA e NÃO entra aqui:
    # estratégico vem da flag do parser; item sem flag → out_of_scope.
    assert set(SECTION_DEFAULTS) == {
        "SVA Detalhe Total",
        "Itens Eventuais",
        "Serviços Contratados de Terceiros",
        "Serviços Bundle Inclusos",
        "Serviços de valor adicionado",
        "Streamings",
    }


def test_resolve_formato_billing_analysis_com_streaming_e_sva_avulso() -> None:
    detail = {
        "currentInvoice": [
            {
                "desc": "Serviços de valor adicionado",
                "items": [
                    {
                        "contestable": True,
                        "desc": "Aluguel de Filme 3",
                        "type": "servicos_contratados_de_parceiros",
                        "value": "19.9",
                    }
                ],
            },
            {
                "desc": "Streamings",
                "items": [
                    {
                        "contestable": False,
                        "desc": "YouTube Premium Mensal",
                        "type": "streaming",
                        "value": "24.9",
                    }
                ],
            },
        ]
    }

    items = InvoiceResolver().resolve_items(
        ["YouTube Premium Mensal", "Aluguel de Filme 3"],
        detail,
    )

    assert [(item.canonical_name, item.item_type, item.tool_category) for item in items] == [
        ("YouTube Premium Mensal", "estrategico", "vas_estrategico"),
        ("Aluguel de Filme 3", "avulso", "cancelar_vas_avulso"),
    ]


def test_resolve_eh_idempotente(invoice_detail) -> None:
    """Função pura: mesma entrada → mesma saída em chamadas repetidas."""
    r = InvoiceResolver()
    a = r.resolve_items(["netflix", "tim fashion"], invoice_detail)
    b = r.resolve_items(["netflix", "tim fashion"], invoice_detail)
    assert [(i.canonical_name, i.msisdn) for i in a] == [
        (i.canonical_name, i.msisdn) for i in b
    ]


# ----- ordem da menção (regra do produto 2026-06) ---------------------------


def test_preserva_ordem_da_mencao_categorias_diferentes(invoice_detail) -> None:
    """Cliente menciona ``tim fashion`` (avulso) ANTES de ``netflix``
    (estratégico). O output deve refletir essa ordem — o builder vai usar isso
    para ordenar os steps da ActionQueue."""
    items = InvoiceResolver().resolve_items(
        ["tim fashion", "netflix"], invoice_detail
    )
    names = [i.canonical_name for i in items]
    assert names == ["TIM Fashion Mensal", "Netflix Premium"]


def test_inverter_a_mencao_inverte_o_output(invoice_detail) -> None:
    """Mesma combinação de itens, ordem invertida na fala → output invertido."""
    items = InvoiceResolver().resolve_items(
        ["netflix", "tim fashion"], invoice_detail
    )
    names = [i.canonical_name for i in items]
    assert names == ["Netflix Premium", "TIM Fashion Mensal"]


def test_mencao_invalida_eh_ignorada_sem_quebrar_ordem(invoice_detail) -> None:
    """Itens vazios/None na lista de menções não devem deslocar a ordem dos
    matches válidos."""
    items = InvoiceResolver().resolve_items(
        ["", "tim fashion", None, "netflix"],  # type: ignore[list-item]
        invoice_detail,
    )
    names = [i.canonical_name for i in items]
    assert names == ["TIM Fashion Mensal", "Netflix Premium"]


# ----- resolve(): ambiguidade + matcher LLM (híbrido) ------------------------


class _FakeMatcher:
    """``ItemMatcherLLM`` fake: mapeia ``mention.lower()`` → lista de descs;
    casa UMA menção por chamada (o resolver dispara várias em paralelo);
    registra chamadas e pode levantar para exercitar o caminho de erro.
    ``calls.append`` é thread-safe sob o GIL — seguro com o ThreadPoolExecutor."""

    def __init__(
        self,
        mapping: dict[str, list[str]] | None = None,
        *,
        raises: bool = False,
    ) -> None:
        self.mapping = mapping or {}
        self.raises = raises
        self.calls: list[tuple[str, list[str]]] = []

    def match(
        self, mention: str, candidates: list[str], *, callbacks: Any = None
    ) -> list[str]:
        self.calls.append((mention, list(candidates)))
        if self.raises:
            raise ItemMatcherError("boom")
        return list(self.mapping.get(mention.lower().strip(), []))


def test_resolve_mencao_unica_vai_para_resolved(invoice_detail) -> None:
    outcome = InvoiceResolver().resolve(["netflix"], invoice_detail)
    assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
    assert outcome.ambiguous == []
    assert outcome.not_found == []


def test_resolve_nomes_distintos_que_casam_sao_ambiguos(invoice_detail) -> None:
    """'Aluguel de Filme' casa com '1' e '2' (nomes distintos) → ambíguo: vai
    para ``ambiguous``, não para ``resolved``."""
    outcome = InvoiceResolver().resolve(["Aluguel de Filme"], invoice_detail)
    assert outcome.resolved == []
    assert len(outcome.ambiguous) == 1
    res = outcome.ambiguous[0]
    assert res.mention == "Aluguel de Filme"
    assert res.is_ambiguous is True
    assert {m.canonical_name for m in res.matches} == {
        "Aluguel de Filme 1",
        "Aluguel de Filme 2",
    }


def test_resolve_mesmo_nome_em_linhas_diferentes_eh_ambiguo(invoice_detail) -> None:
    """Mesma desc em msisdns diferentes (os 'dois ayas') → ambíguo por linha."""
    invoice_detail["11988888888"]["SVA Detalhe Total"].append(
        _entry("TIM Fashion Mensal", 10.00, msisdn="11988888888")
    )
    outcome = InvoiceResolver().resolve(["Tim Fashion"], invoice_detail)
    assert outcome.resolved == []
    assert len(outcome.ambiguous) == 1
    assert {m.msisdn for m in outcome.ambiguous[0].matches} == {
        "11999999999",
        "11988888888",
    }


# ----- resolve(): menção COM msisdn (MentionedItem) desambigua a linha --------


def _tim_fashion_em_duas_linhas(invoice_detail) -> dict[str, Any]:
    """Coloca 'TIM Fashion Mensal' nas DUAS linhas (11999999999 e 11988888888) — o
    mesmo cenário nome-em-2-linhas do teste anterior, reusado pelos casos de msisdn."""
    invoice_detail["11988888888"]["SVA Detalhe Total"].append(
        _entry("TIM Fashion Mensal", 10.00, msisdn="11988888888")
    )
    return invoice_detail


def test_resolve_com_msisdn_desambigua_para_uma_linha(invoice_detail) -> None:
    """Menção COM msisdn (MentionedItem) restringe à linha → 1 resolved, não ambíguo
    (é o conserto do bug do trace: '8119' escolhe a linha, resolve p/ 1 item)."""
    detail = _tim_fashion_em_duas_linhas(invoice_detail)
    outcome = InvoiceResolver().resolve(
        [MentionedItem(desc="Tim Fashion", msisdn="11988888888")], detail
    )
    assert outcome.ambiguous == []
    assert len(outcome.resolved) == 1
    assert outcome.resolved[0].msisdn == "11988888888"


def test_resolve_com_msisdn_sufixo_desambigua(invoice_detail) -> None:
    """O msisdn-dica pode vir só com os últimos dígitos ('8888') → casa por sufixo."""
    detail = _tim_fashion_em_duas_linhas(invoice_detail)
    outcome = InvoiceResolver().resolve(
        [MentionedItem(desc="Tim Fashion", msisdn="8888")], detail
    )
    assert outcome.ambiguous == []
    assert len(outcome.resolved) == 1
    assert outcome.resolved[0].msisdn == "11988888888"


def test_resolve_msisdn_que_nao_casa_linha_nao_filtra(invoice_detail) -> None:
    """msisdn-dica inválido (não casa NENHUMA linha real) → NÃO filtra: volta ao
    comportamento sem linha (ambíguo). A dica nunca zera o resultado."""
    detail = _tim_fashion_em_duas_linhas(invoice_detail)
    outcome = InvoiceResolver().resolve(
        [MentionedItem(desc="Tim Fashion", msisdn="0000")], detail
    )
    assert outcome.resolved == []
    assert len(outcome.ambiguous) == 1
    assert {m.msisdn for m in outcome.ambiguous[0].matches} == {
        "11999999999",
        "11988888888",
    }


def test_resolve_mentioneditem_sem_msisdn_segue_como_string(invoice_detail) -> None:
    """Back-compat: MentionedItem SEM msisdn (e a str pura) seguem ambíguos como antes."""
    detail = _tim_fashion_em_duas_linhas(invoice_detail)
    outcome = InvoiceResolver().resolve(
        [MentionedItem(desc="Tim Fashion")], detail
    )
    assert outcome.resolved == []
    assert len(outcome.ambiguous) == 1


# ----- resolve(): cobrança duplicada na MESMA linha (Fase 3) ------------------


def _dup_charges_detail(
    v1: float = 14.99, v2: float = 19.99, p1: str = "01/11/25", p2: str = "02/11/25"
) -> dict[str, Any]:
    """Duas cobranças de "Tamboro Mensal" na MESMA linha/seção, distintas só pela
    ``period`` (data única). Espelha o fixture invoice_pdf_include_danfe_false."""
    return {
        "1199999999": {
            "SVA Detalhe Total": [
                {"desc": "Tamboro Mensal", "value": v1, "msisdn": "1199999999", "period": p1},
                {"desc": "Tamboro Mensal", "value": v2, "msisdn": "1199999999", "period": p2},
            ],
        },
    }


def test_build_item_charge_date_apenas_para_data_unica() -> None:
    """``charge_date`` = ``period`` só quando é data única; faixa (ciclo) ou ausente
    → ``None``."""
    detail = {
        "1199999999": {
            "SVA Detalhe Total": [
                {"desc": "A", "value": 1.0, "msisdn": "1199999999", "period": "01/11/25"},
                {"desc": "B", "value": 1.0, "msisdn": "1199999999", "period": "14/10 a 13/11"},
                {"desc": "C", "value": 1.0, "msisdn": "1199999999"},
            ]
        }
    }
    by_name = {i.canonical_name: i.charge_date for i in InvoiceResolver().build_snapshot(detail)}
    assert by_name["A"] == "01/11/25"
    assert by_name["B"] is None
    assert by_name["C"] is None


def test_resolve_cobranca_duplicada_mesma_linha_eh_ambiguo() -> None:
    outcome = InvoiceResolver().resolve(
        [MentionedItem(desc="Tamboro Mensal")], _dup_charges_detail()
    )
    assert outcome.resolved == []
    assert len(outcome.ambiguous) == 1
    res = outcome.ambiguous[0]
    assert res.is_ambiguous is True
    assert {m.charge_date for m in res.matches} == {"01/11/25", "02/11/25"}
    assert {m.msisdn for m in res.matches} == {"1199999999"}


def test_resolve_com_date_desambigua_para_uma_cobranca() -> None:
    outcome = InvoiceResolver().resolve(
        [MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="01/11/25")],
        _dup_charges_detail(),
    )
    assert outcome.ambiguous == []
    assert len(outcome.resolved) == 1
    assert outcome.resolved[0].charge_date == "01/11/25"


def test_resolve_date_prefixo_sem_ano_desambigua() -> None:
    """A dica de data pode vir sem o ano ("01/11") → casa por prefixo."""
    outcome = InvoiceResolver().resolve(
        [MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="01/11")],
        _dup_charges_detail(),
    )
    assert len(outcome.resolved) == 1
    assert outcome.resolved[0].charge_date == "01/11/25"


def test_resolve_date_que_nao_casa_nao_filtra() -> None:
    """Dica de data inválida (nenhuma cobrança real) → NÃO filtra: segue ambíguo
    (a dica nunca zera o resultado; espelha o msisdn-dica inválido)."""
    outcome = InvoiceResolver().resolve(
        [MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="31/12/25")],
        _dup_charges_detail(),
    )
    assert outcome.resolved == []
    assert len(outcome.ambiguous) == 1


def test_resolve_ambas_cobrancas_dois_objetos_mantem_duas() -> None:
    """"As duas" = 2 objetos datados → 2 cobranças distintas no resolved (o dedupe
    final por cobrança NÃO as colapsa)."""
    outcome = InvoiceResolver().resolve(
        [
            MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="01/11/25"),
            MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="02/11/25"),
        ],
        _dup_charges_detail(),
    )
    assert outcome.ambiguous == []
    assert len(outcome.resolved) == 2
    assert {i.charge_date for i in outcome.resolved} == {"01/11/25", "02/11/25"}


def test_dedupe_by_charge_flag() -> None:
    """``_dedupe_exact_matches``: default colapsa 2 cobranças de mesmo
    (name,msisdn,section); ``by_charge=True`` mantém as datas distintas."""
    r = InvoiceResolver()
    a = ResolvedInvoiceItem(
        "X", "cancelar_vas_avulso", "avulso", "111", None, "SVA Detalhe Total", "01/11/25"
    )
    b = ResolvedInvoiceItem(
        "X", "cancelar_vas_avulso", "avulso", "111", None, "SVA Detalhe Total", "02/11/25"
    )
    assert len(r._dedupe_exact_matches([a, b])) == 1
    assert len(r._dedupe_exact_matches([a, b], by_charge=True)) == 2


def test_build_snapshot_colapsa_cobrancas_duplicadas() -> None:
    """Regressão: build_snapshot mantém a chave de 3 (name,msisdn,section) → 2
    cobranças do mesmo serviço/linha colapsam em 1 (anáfora plural não infla)."""
    snap = InvoiceResolver().build_snapshot(_dup_charges_detail())
    tamboros = [i for i in snap if i.canonical_name == "Tamboro Mensal"]
    assert len(tamboros) == 1


def test_build_snapshot_by_charge_mantem_cobrancas_duplicadas() -> None:
    """``by_charge=True``: as 2 cobranças datadas do mesmo serviço/linha SOBREVIVEM
    (usado ao cancelar TODOS os avulsos — cada cobrança vira um item)."""
    snap = InvoiceResolver().build_snapshot(_dup_charges_detail(), by_charge=True)
    tamboros = [i for i in snap if i.canonical_name == "Tamboro Mensal"]
    assert len(tamboros) == 2
    assert {i.charge_date for i in tamboros} == {"01/11/25", "02/11/25"}


def test_same_charge_tolerancia_de_prefixo() -> None:
    sc = InvoiceResolver._same_charge
    assert sc("01/11/25", "01/11") is True
    assert sc("01/11/25", "01/11/25") is True
    assert sc("02/11/25", "01/11/25") is False
    assert sc(None, "01/11") is False   # item sem data nunca casa dica de data
    assert sc("01/11/25", "") is True   # sem dica → não filtra


def test_resolve_sem_matcher_mencao_nao_encontrada_vai_para_not_found(
    invoice_detail,
) -> None:
    outcome = InvoiceResolver().resolve(["Spotify"], invoice_detail)
    assert outcome.resolved == []
    assert outcome.ambiguous == []
    assert outcome.not_found == ["Spotify"]


def test_resolve_matcher_nao_acionado_quando_deterministico_casa(
    invoice_detail,
) -> None:
    """Happy-path: substring já casa → o matcher LLM NÃO é chamado."""
    matcher = _FakeMatcher()
    outcome = InvoiceResolver(matcher=matcher).resolve(["netflix"], invoice_detail)
    assert matcher.calls == []
    assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]


def test_resolve_matcher_casa_no_miss_de_grafia(invoice_detail) -> None:
    """'Netflics' não casa por substring → o matcher é acionado e resolve para
    'Netflix Premium'."""
    matcher = _FakeMatcher(mapping={"netflics": ["Netflix Premium"]})
    outcome = InvoiceResolver(matcher=matcher).resolve(["Netflics"], invoice_detail)
    assert len(matcher.calls) == 1
    assert "Netflix Premium" in matcher.calls[0][1]  # desc candidato presente
    assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
    assert outcome.not_found == []


def test_resolve_multiplas_mencoes_grafia_em_paralelo(invoice_detail) -> None:
    """Várias menções com grafia errada (todas furam o substring) → uma chamada
    ao matcher LLM POR menção, disparadas em paralelo por threads (cobre o trace
    2253d235…). Cada uma resolve para seu item."""
    matcher = _FakeMatcher(
        mapping={
            "netflics": ["Netflix Premium"],
            "spotfy": ["Spotify Família"],
        }
    )
    invoice_detail["11999999999"]["SVA Detalhe Total"].append(
        _entry("Spotify Família", 19.90, msisdn="11999999999")
    )
    outcome = InvoiceResolver(matcher=matcher).resolve(
        ["Netflics", "Spotfy"], invoice_detail
    )
    assert len(matcher.calls) == 2
    assert {c[0] for c in matcher.calls} == {"Netflics", "Spotfy"}
    assert {i.canonical_name for i in outcome.resolved} == {
        "Netflix Premium",
        "Spotify Família",
    }
    assert outcome.not_found == []


def test_resolve_matcher_multiplos_vira_ambiguo(invoice_detail) -> None:
    matcher = _FakeMatcher(
        mapping={"filmes": ["Aluguel de Filme 1", "Aluguel de Filme 2"]}
    )
    outcome = InvoiceResolver(matcher=matcher).resolve(["filmes"], invoice_detail)
    assert outcome.resolved == []
    assert len(outcome.ambiguous) == 1
    assert {m.canonical_name for m in outcome.ambiguous[0].matches} == {
        "Aluguel de Filme 1",
        "Aluguel de Filme 2",
    }


def test_resolve_matcher_desc_fora_da_lista_eh_ignorado(invoice_detail) -> None:
    """Defensivo: o matcher devolve um desc que não está nos candidatos →
    ignorado (não inventa item) → menção fica em not_found."""
    matcher = _FakeMatcher(mapping={"xpto": ["Serviço Inexistente"]})
    outcome = InvoiceResolver(matcher=matcher).resolve(["xpto"], invoice_detail)
    assert outcome.resolved == []
    assert outcome.not_found == ["xpto"]


def test_resolve_matcher_erro_degrada_para_not_found(invoice_detail) -> None:
    matcher = _FakeMatcher(raises=True)
    outcome = InvoiceResolver(matcher=matcher).resolve(["Netflics"], invoice_detail)
    assert outcome.resolved == []
    assert outcome.not_found == ["Netflics"]


def test_resolve_invoice_vazio_e_mencoes_vazias() -> None:
    assert InvoiceResolver().resolve(["x"], {}).resolved == []
    assert InvoiceResolver().resolve([], {"11999999999": {}}).not_found == []


# ----- gate de reconhecimento (best_similarity antes do matcher LLM) ----------


class _GatingMatcher(_FakeMatcher):
    """``_FakeMatcher`` + ``best_similarity`` controlável, para exercitar o gate
    de reconhecimento (o ``_FakeMatcher`` base NÃO expõe ``best_similarity`` →
    gate degrada para legado e o LLM é sempre chamado)."""

    def __init__(self, *, similarity: float, mapping=None, raises: bool = False) -> None:
        super().__init__(mapping=mapping, raises=raises)
        self._similarity = similarity

    def best_similarity(self, mention: str, candidates: list[str]) -> float:
        return self._similarity


def test_gate_bloqueia_mencao_ruido_e_nao_chama_o_llm(invoice_detail) -> None:
    """best_similarity <= 0.6 (transcrição/ruído): o matcher LLM NÃO é chamado e
    a menção fica em not_found (o runtime pede para repetir)."""
    matcher = _GatingMatcher(similarity=0.5, mapping={"youtube": ["Netflix Premium"]})
    outcome = InvoiceResolver(matcher=matcher).resolve(["youtube"], invoice_detail)
    assert matcher.calls == []           # LLM poupado
    assert outcome.resolved == []
    assert outcome.not_found == ["youtube"]


def test_gate_piso_estrito_reprova_em_06(invoice_detail) -> None:
    """Estrito: exatamente 0.6 reprova (ruído de Jaro-Winkler bate 0.600)."""
    matcher = _GatingMatcher(similarity=0.6, mapping={"youtube": ["Netflix Premium"]})
    outcome = InvoiceResolver(matcher=matcher).resolve(["youtube"], invoice_detail)
    assert matcher.calls == []
    assert outcome.not_found == ["youtube"]


def test_gate_libera_mencao_reconhecida_e_chama_o_llm(invoice_detail) -> None:
    """best_similarity > 0.6 (typo/pronúncia plausível): o LLM é acionado e o
    match resolve normalmente."""
    matcher = _GatingMatcher(similarity=0.92, mapping={"netflics": ["Netflix Premium"]})
    outcome = InvoiceResolver(matcher=matcher).resolve(["Netflics"], invoice_detail)
    assert len(matcher.calls) == 1       # LLM chamado
    assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]


def test_gate_ausente_degrada_para_legado(invoice_detail) -> None:
    """Matcher sem best_similarity (fake legado): gate não bloqueia, LLM é
    chamado mesmo para menção de baixa similaridade."""
    matcher = _FakeMatcher(mapping={"qualquer": ["Netflix Premium"]})
    outcome = InvoiceResolver(matcher=matcher).resolve(["qualquer"], invoice_detail)
    assert len(matcher.calls) == 1
    assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]


# ----- resolve_each (motor por menção, reusado pela expansão de RAG) ----------


def test_resolve_each_devolve_uma_resolucao_por_mencao(invoice_detail) -> None:
    """resolve_each devolve UMA ItemResolution por menção válida, 1:1 e na ordem,
    sem classificar/dedupar entre menções: 1 match, N matches (distintos) e 0
    match coexistem na lista de saída."""
    resolutions = InvoiceResolver().resolve_each(
        ["Tim Fashion", "Aluguel de Filme", "Spotify"], invoice_detail
    )
    assert [r.mention for r in resolutions] == [
        "Tim Fashion",
        "Aluguel de Filme",
        "Spotify",
    ]
    # 1 match → não ambíguo
    assert [m.canonical_name for m in resolutions[0].matches] == ["TIM Fashion Mensal"]
    assert resolutions[0].is_ambiguous is False
    # N matches distintos → ambíguo
    assert sorted(m.canonical_name for m in resolutions[1].matches) == [
        "Aluguel de Filme 1",
        "Aluguel de Filme 2",
    ]
    assert resolutions[1].is_ambiguous is True
    # 0 match
    assert resolutions[2].matches == []


def test_resolve_each_pula_mencoes_vazias_e_invalidas(invoice_detail) -> None:
    """Menções em branco/não-string são descartadas; a saída fica 1:1 com as
    válidas, na ordem."""
    resolutions = InvoiceResolver().resolve_each(
        ["", "  ", "netflix"], invoice_detail
    )
    assert [r.mention for r in resolutions] == ["netflix"]


# ----- build_snapshot (snapshot completo da fatura) --------------------------


def test_build_snapshot_enumera_todos_os_itens_classificados(invoice_detail) -> None:
    """Cobre a fixture realista: 4 avulsos no SVA Detalhe Total do titular + 1
    estratégico + 1 bundle + 1 avulso em Itens Eventuais + 1 avulso no
    dependente = 8 itens. O ``Plano`` é ignorado por não estar em
    SECTION_DEFAULTS."""
    snapshot = InvoiceResolver().build_snapshot(invoice_detail)
    assert len(snapshot) == 8
    by_name = {item.canonical_name: item for item in snapshot}
    assert by_name["TIM Fashion Mensal"].tool_category == "cancelar_vas_avulso"
    assert by_name["Netflix Premium"].item_type == "estrategico"
    assert by_name["HBO Max"].item_type == "bundle"
    assert by_name["Tamboro Mensal"].msisdn == "11988888888"


def test_build_snapshot_invoice_vazio_retorna_tupla_vazia() -> None:
    assert InvoiceResolver().build_snapshot({}) == ()


def test_build_snapshot_payload_invalido_retorna_tupla_vazia() -> None:
    """Tipo errado entra silenciosamente como vazio (mesmo padrão de
    ``resolve``)."""
    assert InvoiceResolver().build_snapshot(None) == ()  # type: ignore[arg-type]
    assert InvoiceResolver().build_snapshot("string") == ()  # type: ignore[arg-type]


def test_build_snapshot_classificacao_canonica_cobre_familia_multi_msisdn(
    invoice_detail,
) -> None:
    """A fixture tem titular + 1 dependente. O snapshot agrega ambos, sem
    distinguir por bucket. O consumer (classificador) filtra por msisdn ativo
    no render."""
    snapshot = InvoiceResolver().build_snapshot(invoice_detail)
    msisdns = {item.msisdn for item in snapshot}
    assert msisdns == {"11999999999", "11988888888"}


# ----- Bug #Gamedom: recognition_score (piso do resgate de funil) ------------


def _invoice_gamedom() -> dict:
    return {
        "11986811082": {
            "Serviços de valor adicionado": [
                {"desc": "Gamedom Mensal", "value": 14.99, "msisdn": "11986811082"},
                {"desc": "Aluguel de Filme 2", "value": 9.90, "msisdn": "11986811082"},
            ]
        }
    }


def test_recognition_score_pontua_fala_mal_transcrita_do_caso_real() -> None:
    """"Game is done" (URA) contra "Gamedom Mensal": cruza o piso de resgate com
    folga e a margem para o 2º candidato afasta ambiguidade."""
    from app.domain.contas.invoice_resolver import (
        _FUNNEL_RESCUE_MARGIN,
        _FUNNEL_RESCUE_THRESHOLD,
    )
    from app.domain.contas.item_matcher import SimilarityItemMatcher

    resolver = InvoiceResolver(matcher=SimilarityItemMatcher())

    score = resolver.recognition_score("Game is done", _invoice_gamedom())

    assert score is not None
    assert score.desc == "Gamedom Mensal"
    assert score.best >= _FUNNEL_RESCUE_THRESHOLD
    assert score.margin >= _FUNNEL_RESCUE_MARGIN


def test_recognition_score_reprova_ruido_e_fala_curta() -> None:
    from app.domain.contas.invoice_resolver import (
        _FUNNEL_RESCUE_THRESHOLD,
    )
    from app.domain.contas.item_matcher import SimilarityItemMatcher

    resolver = InvoiceResolver(matcher=SimilarityItemMatcher())

    for fala in ("Deine", "blarg zorp flim flam ploft", "tem jogo hoje?"):
        score = resolver.recognition_score(fala, _invoice_gamedom())
        assert score is not None
        assert score.best < _FUNNEL_RESCUE_THRESHOLD, fala


def test_recognition_score_fail_closed_sem_matcher_ou_sem_candidatos() -> None:
    """Assimetria deliberada vs ``_passes_recognition_gate`` (que degrada para
    True): o resgate CRIA autorização, então sem sinal devolve None."""
    from app.domain.contas.item_matcher import SimilarityItemMatcher

    assert InvoiceResolver().recognition_score("Game is done", _invoice_gamedom()) is None

    resolver = InvoiceResolver(matcher=SimilarityItemMatcher())
    assert resolver.recognition_score("", _invoice_gamedom()) is None
    assert resolver.recognition_score("Game is done", {}) is None
