#!/usr/bin/env python3
"""Transformacao da fatura normalizada (dict JSON) -> formato textual em blocos.

Formato economico em tokens (~72% menor que o JSON indentado), pensado para enviar
a uma LLM. API publica: `to_text(data)` e a flag `USE_TEXT_FORMAT`.
"""
from __future__ import annotations

import json
import logging
import os
import re
from typing import Any

logger = logging.getLogger(__name__)

# Liga o formato textual da fatura no prompt. Default conceitual: False;
# mantido True nesta fase de rollout. Override por env, sem edicao de codigo.
USE_TEXT_FORMAT = os.getenv("TIM_INVOICE_DETAIL_AS_TEXT", "true").lower() == "true"

# ---------------------------------------------------------------------------
# secao -> (acao, classe). O cabecalho usa o proprio nome da secao (mantido como no JSON).
SEC_META = {
    "SVA Detalhe Total": ("cancelar", "avulso"),
    "Itens Eventuais": ("cancelar", "avulso"),
    "Serviços Bundle Inclusos": ("falar sobre", "bundle"),
}

# Secoes que rendem a acao por ITEM (sem "| ação=... | classe" no header).
# Item estrategico (estrategico=True) usa "falar sobre"; avulso, "cancelar".
PER_ITEM_ACTION_SECTIONS = {
    "SVA Detalhe Total",
    "Itens Eventuais",
    "Mensalidades Adicionais",
}

# Secoes cujo header recebe o rotulo "| cobrados a parte" (servicos avulsos
# contratados separadamente). A acao continua por item.
COBRADOS_A_PARTE_SECTIONS = {"SVA Detalhe Total", "Itens Eventuais"}

# secao do JSON -> rotulo no cabecalho do prompt. "SVA Detalhe Total" e "Itens
# Eventuais" sao a MESMA categoria (avulsos cobrados a parte) e saem sob um unico
# cabecalho "Itens Eventuais". "Serviços Bundle Inclusos" vira "Serviços Inclusos
# no Plano": a palavra "bundle" e tecnica/interna e nao deve chegar ao prompt (nem
# ao LLM). As chaves do JSON normalizado NAO mudam nos dois casos.
SECTION_LABELS = {
    "SVA Detalhe Total": "Itens Eventuais",
    "Serviços Bundle Inclusos": "Benefícios do Plano",
}

# classe interna -> rotulo exibido no cabecalho (ver SEC_META). Mesma logica do
# SECTION_LABELS: so a APRESENTACAO muda, a classe interna ("bundle") segue igual
# em todo o resto do backend (bill_processor, invoice_resolver, tools, etc.).
CLASSE_LABELS = {"bundle": "incluso"}

_RX_OUTROS = re.compile(r"^\s*([^:()]+?)\s*:\s*\((.+)\)\s*$")


def _fmt_money(value: Any) -> str:
    if isinstance(value, (int, float)):
        return f"{value:.2f}"
    return str(value)


def _snake(desc: Any) -> str:
    return re.sub(r"\s+", "_", str(desc).strip().lower())


def is_period_range(period: Any) -> bool:
    """True quando ``period`` é uma FAIXA (ciclo da fatura, ex.: "14/10 a 13/11"),
    não uma data única de cobrança. Compartilhado com o resolver, que usa o mesmo
    critério para decidir se uma entry expõe ``charge_date`` (data única) — assim o
    que o resolver enxerga como cobrança datada casa exatamente o ``data=`` do render
    lean do classificador."""
    text = str(period)
    return " a " in text or "~" in text


def _item_acao(item: dict[str, Any], section: str) -> str | None:
    """Verbo de acao por item: estrategico -> 'falar sobre'; senao, o default da secao."""
    if item.get("estrategico"):
        return "falar sobre"
    acao, _ = SEC_META.get(section, (None, None))
    return acao


def _render_item(item: dict[str, Any], section: str) -> str:
    """<nome solto> | campo=valor | ...  (com tratamento especial p/ Outros Valores)."""
    desc = str(item.get("desc", "")).strip()
    value = item.get("value")

    if section == "Outros Valores":
        match = _RX_OUTROS.match(desc)
        if match:
            parts = [match.group(1).strip().lower()]
            if value is not None and value != "Incluído":
                parts.append(f"valor={_fmt_money(value)}")
            parts.append(f'ref="{match.group(2).strip()}"')
            return " | ".join(parts)

    parts = [desc]
    if value is not None and value != "Incluído":
        parts.append(f"valor={_fmt_money(value)}")
    period = item.get("period")
    if period and not is_period_range(period):
        parts.append(f"data={period}")
    if item.get("franchise"):
        parts.append(f"franquia={item['franchise']}")
    if item.get("consumption"):
        parts.append(f"consumo={item['consumption']}")
    if item.get("installment"):
        parts.append(f"parcela={item['installment']}")
    return " | ".join(parts)


# Seções omitidas no modo enxuto (intent classifier): o classificador só precisa
# dos NOMES dos itens por linha/seção, não de totais, planos nem juros/multas.
_LEAN_SKIP_SECTIONS = frozenset({"Fatura Resumo", "Planos", "Outros Valores"})


def to_text(data: dict[str, Any], *, lean: bool = False) -> str:
    """Converte o JSON normalizado de uma fatura no formato textual em blocos.

    ``lean=True`` (formato do intent classifier): mantém a estrutura LINHA/seção,
    o NOME dos itens e o ``valor``/``data`` por item — descarta "Fatura Resumo",
    "Planos", "Outros Valores" e os demais campos por-item (ação, type, franquia,
    consumo). O ``valor``/``data`` distinguem cobranças duplicadas do mesmo nome na
    mesma linha (desambiguação de cobrança). O orquestrador usa ``lean=False``
    (formato completo, com ações)."""
    lines: list[str] = []
    msisdn_count = 0

    # FATURA (achatada) — omitida no modo enxuto.
    resumo = data.get("Fatura Resumo")
    if not lean and isinstance(resumo, list):
        lines.append("Fatura Resumo")
        for item in resumo:
            desc = item.get("desc", "?")
            if "period" in item:
                lines.append(f"  {_snake(desc)}={item['period']}")
            elif "emissao" in item:
                lines.append(f"  {_snake(desc)}={item['emissao']}")
            elif "value" in item:
                lines.append(f"  {_snake(desc)}={_fmt_money(item['value'])}")
        lines.append("")

    # LINHAS (MSISDN)
    for key, block in data.items():
        if key in ("Fatura Resumo", "vocalized_msisdn"):
            continue
        if not isinstance(block, dict):
            continue

        msisdn_count += 1
        lines.append(f"LINHA {key}")

        # Planos — omitido no modo enxuto.
        planos = block.get("Planos")
        if not lean and isinstance(planos, dict) and planos:
            lines.append("Planos")
            for nome, plano in planos.items():
                bits = [f"nome={nome}"]
                if plano.get("period"):
                    bits.append(f"período={plano['period']}")
                if plano.get("days") is not None:
                    bits.append(f"dias={plano['days']}")
                bits.append(f"valor_final={_fmt_money(plano.get('valor_final'))}")
                if plano.get("valor_bruto") not in (None, plano.get("valor_final")):
                    bits.append(f"valor_bruto={_fmt_money(plano.get('valor_bruto'))}")
                if plano.get("total_descontos"):
                    bits.append(f"total_desc={_fmt_money(plano.get('total_descontos'))}")
                if plano.get("is_controle"):
                    bits.append("controle=sim")
                lines.append("  " + " | ".join(bits))
                descontos = plano.get("descontos") or []
                if descontos:
                    lines.append("  descontos:")
                    for desconto in descontos:
                        dbits = [
                            f"nome={desconto.get('desc')}",
                            f"valor={_fmt_money(desconto.get('value'))}",
                        ]
                        if desconto.get("installment"):
                            dbits.append(f"parcela={desconto['installment']}")
                        lines.append("    " + " | ".join(dbits))

        # Seções agrupadas pelo RÓTULO do cabeçalho (``SECTION_LABELS``): as que
        # compartilham rótulo saem sob um único cabeçalho, na posição da primeira
        # delas. Cada item guarda a seção de ORIGEM, que é quem decide a ação/classe
        # por item — o rótulo é só apresentação.
        groups: dict[str, tuple[str, list[tuple[str, dict[str, Any]]]]] = {}
        for section, items in block.items():
            if section == "Planos" or not isinstance(items, list):
                continue
            if lean and section in _LEAN_SKIP_SECTIONS:
                continue
            label = SECTION_LABELS.get(section, section)
            _, entries = groups.setdefault(label, (section, []))
            entries.extend((section, item) for item in items)

        for label, (first_section, entries) in groups.items():
            acao, classe = SEC_META.get(first_section, (None, None))
            header = label
            # No modo enxuto o cabeçalho é só o nome da seção (sem ação/classe).
            if not lean and first_section in COBRADOS_A_PARTE_SECTIONS:
                header += " | cobrados a parte"
            elif acao and first_section not in PER_ITEM_ACTION_SECTIONS and not lean:
                header += f" | ação={acao} | {CLASSE_LABELS.get(classe, classe)}"
            lines.append(header)
            for section, item in entries:
                if lean:
                    # Nome do item + valor + data da cobrança (sem ação/type/
                    # franquia/consumo). O valor/data por item permitem o classifier
                    # DISTINGUIR N cobranças do mesmo nome na mesma linha (mesmo
                    # desc/msisdn, períodos diferentes) na desambiguação de cobrança
                    # duplicada — o msisdn vem do cabeçalho ``LINHA``. Itens
                    # ``Incluído`` (bundle) e períodos em faixa (ciclo da fatura, não
                    # data de cobrança) seguem só com o nome, como antes.
                    desc = str(item.get("desc", "")).strip()
                    if not desc:
                        continue
                    parts = [desc]
                    value = item.get("value")
                    if value is not None and value != "Incluído":
                        parts.append(f"valor={_fmt_money(value)}")
                    period = item.get("period")
                    if period and not is_period_range(period):
                        parts.append(f"data={period}")
                    lines.append("  " + " | ".join(parts))
                    continue
                line = "  " + _render_item(item, section)
                if section in PER_ITEM_ACTION_SECTIONS:
                    item_acao = _item_acao(item, section)
                    if item_acao:
                        line += f" | ação={item_acao}"
                    if item.get("estrategico"):
                        line += " | type=estrategico"
                lines.append(line)
        lines.append("")

    text = "\n".join(lines).rstrip()
    if msisdn_count > 1:
        text += "\n\nmultiplas_linhas = true"
    else:
        text += "\n\nmultiplas_linhas = false"
    return text + "\n"


# ordem pedida pelo produto: avulso -> estrategico -> bundle
# (difere da ordem informacional bundle/estrategico/avulso usada em backend.py)
_VAS_PRODUCT_ORDER = ("avulso", "estrategico", "bundle")


def get_vas_product_names(data: dict[str, Any]) -> str:
    """Nomes dos VAS (avulso, estrategico, bundle — nessa ordem) da fatura
    normalizada, deduplicados e separados por vírgula. Ex.: "Focus Mensal,
    Tamboro Mensal". Ignora plano e demais seções não-VAS. Retorna "" se não houver."""
    buckets: dict[str, list[str]] = {c: [] for c in _VAS_PRODUCT_ORDER}
    seen: set[str] = set()
    for key, block in data.items():
        if key in ("Fatura Resumo", "vocalized_msisdn") or not isinstance(block, dict):
            continue
        for section, items in block.items():
            if section == "Planos" or not isinstance(items, list):
                continue
            for item in items:
                classe = item.get("classe")
                if not classe and item.get("estrategico"):
                    classe = "estrategico"
                if classe not in buckets:
                    continue
                name = str(item.get("desc", "")).strip()
                if not name or name in seen:
                    continue
                seen.add(name)
                buckets[classe].append(name)
    ordered = [n for c in _VAS_PRODUCT_ORDER for n in buckets[c]]
    return ", ".join(ordered)


def render_for_prompt(invoice_detail: Any, *, lean: bool = False) -> str | None:
    """Renderiza ``invoice_detail`` (dict ou str JSON) no formato textual ``to_text``
    para uso no prompt. Fonte única compartilhada pelo orquestrador (system prompt)
    e pelo intent classifier — garante que ambos leiam a MESMA fatura, sem drift.

    ``lean=True`` produz o formato enxuto do intent classifier (nomes + valor/data
    por item, por linha/seção; ver :func:`to_text`); o orquestrador usa ``lean=False``.

    Retorna ``None`` quando o formato textual está desligado (``USE_TEXT_FORMAT``
    false) ou em qualquer falha de parse/render — cabe ao chamador decidir o
    fallback (JSON cru no orquestrador; renderizador compacto no classifier).
    Nunca levanta exceção: o prompt não pode quebrar por causa da fatura."""
    if not (USE_TEXT_FORMAT and invoice_detail):
        return None
    try:
        parsed = (
            json.loads(invoice_detail)
            if isinstance(invoice_detail, str)
            else invoice_detail
        )
        if isinstance(parsed, dict) and parsed:
            return to_text(parsed, lean=lean)
    except Exception:  # noqa: BLE001 — nunca quebrar o prompt
        logger.warning("invoice_to_text.render_for_prompt falhou", exc_info=True)
    return None
