"""Orquestación del timbrado: factura → XML → sello → PAC → persistencia."""

from __future__ import annotations

import logging
from datetime import datetime
from decimal import Decimal

from fastapi import HTTPException, status
from sqlalchemy.orm import Session

from core.config import settings
from core.s3_keys import (
    STAMP_XML_KINDS,
    invoice_stamp_attempt_xml_key,
    invoice_stamp_xml_key,
)

from ..catalogs.models import (
    CfdiUse,
    PaymentForm,
    PaymentMethod,
    ProductService,
    Tax,
    TaxObject,
    TaxRegime,
    UnitOfMeasure,
)
from ..invoices.models import Invoice, InvoiceItem, InvoiceItemTax
from ..issuer.models import IssuerSettings
from . import cfdi_builder as builder
from . import pac_comercio_digital as pac
from . import sealer
from .models import (
    PAC_RFC_BY_MODE,
    STAMPING_MODES,
    STATUS_ERROR,
    STATUS_STAMPED,
    InvoiceStamp,
)

logger = logging.getLogger(__name__)

CFDI_NS = "http://www.sat.gob.mx/cfd/4"
TFD_NS = "http://www.sat.gob.mx/TimbreFiscalDigital"


# --------------------------------------------------------------------------------------
# Lectura
# --------------------------------------------------------------------------------------
def get_stamp(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> InvoiceStamp | None:
    """Timbre vigente de la factura, si lo hay. Sólo cuenta el exitoso."""
    return (
        db.query(InvoiceStamp)
        .filter(
            InvoiceStamp.invoice_id == invoice_id,
            InvoiceStamp.tenant_id == tenant_id,
            InvoiceStamp.company_id == company_id,
            InvoiceStamp.status == STATUS_STAMPED,
            InvoiceStamp.deleted_at.is_(None),
        )
        .order_by(InvoiceStamp.id.desc())
        .first()
    )


def list_attempts(
    db: Session, invoice_id: int, tenant_id: int, company_id: int
) -> list[InvoiceStamp]:
    """Todos los intentos de la factura, del más reciente al más antiguo.

    A diferencia de ``get_stamp``, incluye los rechazados: son los que hay que consultar
    cuando el PAC devuelve un error y hace falta ver qué se le mandó.
    """
    return (
        db.query(InvoiceStamp)
        .filter(
            InvoiceStamp.invoice_id == invoice_id,
            InvoiceStamp.tenant_id == tenant_id,
            InvoiceStamp.company_id == company_id,
            InvoiceStamp.deleted_at.is_(None),
        )
        .order_by(InvoiceStamp.id.desc())
        .all()
    )


def get_attempt_xml_url(
    db: Session, invoice_id: int, attempt_id: int, kind: str, tenant_id: int, company_id: int
) -> str:
    """URL firmada del XML enviado o recibido en un intento concreto."""
    from core.storage_s3 import presigned_get_url  # noqa: PLC0415

    if kind not in STAMP_XML_KINDS:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=f"Tipo de XML inválido: {kind!r}. Sólo se admiten {list(STAMP_XML_KINDS)}.",
        )

    attempt = (
        db.query(InvoiceStamp)
        .filter(
            InvoiceStamp.id == attempt_id,
            # invoice_id va en el filtro, no sólo en la ruta: sin él, el id de un intento de
            # otra factura de la misma empresa devolvería su XML.
            InvoiceStamp.invoice_id == invoice_id,
            InvoiceStamp.tenant_id == tenant_id,
            InvoiceStamp.company_id == company_id,
            InvoiceStamp.deleted_at.is_(None),
        )
        .first()
    )
    if not attempt:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Intento no encontrado")

    key = getattr(attempt, f"{kind}_xml_file_key")
    if not key:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"El intento no tiene guardado el XML de {kind}",
        )
    return presigned_get_url(key)


def _get_invoice(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> Invoice:
    obj = (
        db.query(Invoice)
        .filter(
            Invoice.id == invoice_id,
            Invoice.tenant_id == tenant_id,
            Invoice.company_id == company_id,
            Invoice.deleted_at.is_(None),
        )
        .first()
    )
    if not obj:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Factura no encontrada")
    return obj


def _code(db: Session, model, pk: int | None) -> str:
    """Clave del SAT de un catálogo, o cadena vacía si no está capturado.

    Devolver "" en vez de lanzar es deliberado: la validación del builder acumula TODOS los
    faltantes y los reporta juntos, en vez de obligar a descubrirlos de uno en uno.
    """
    if not pk:
        return ""
    row = db.query(model).filter(model.id == pk).first()
    return row.code if row else ""


# --------------------------------------------------------------------------------------
# Armado de los datos fiscales
# --------------------------------------------------------------------------------------
def _build_data(db: Session, invoice: Invoice, tenant_id: int, company_id: int) -> builder.CfdiData:
    """Reúne emisor, receptor y partidas resolviendo las claves contra los catálogos."""
    from ...crm.accounts.models import Account
    from ...crm.addresses.models import Address

    issuer = (
        db.query(IssuerSettings)
        .filter(
            IssuerSettings.tenant_id == tenant_id,
            IssuerSettings.company_id == company_id,
            IssuerSettings.deleted_at.is_(None),
        )
        .first()
    )
    if not issuer:
        raise builder.CfdiBuildError(
            ["no hay datos fiscales del emisor configurados para la empresa (fin.issuer_settings)"]
        )

    account = None
    if invoice.account_id:
        account = db.query(Account).filter(Account.id == invoice.account_id).first()
    if not account:
        raise builder.CfdiBuildError(["la factura no tiene cliente asignado"])

    # CP fiscal del receptor: vive en la dirección de tipo 'fiscal' de la cuenta.
    receiver_zip = ""
    direccion = (
        db.query(Address)
        .filter(
            Address.account_id == account.id,
            Address.address_type == "fiscal",
            Address.deleted_at.is_(None),
        )
        .first()
    )
    if direccion and direccion.postal_code:
        receiver_zip = (direccion.postal_code or "").strip()[:5]

    items = (
        db.query(InvoiceItem)
        .filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
        .order_by(InvoiceItem.id)
        .all()
    )

    concepts: list[builder.ConceptLine] = []
    for it in items:
        taxes: list[builder.TaxLine] = []
        for t in (
            db.query(InvoiceItemTax)
            .filter(InvoiceItemTax.invoice_item_id == it.id, InvoiceItemTax.deleted_at.is_(None))
            .order_by(InvoiceItemTax.id)
            .all()
        ):
            taxes.append(
                builder.TaxLine(
                    code=_code(db, Tax, t.tax_id),
                    rate=Decimal(str(t.rate or 0)),
                    amount=Decimal(str(t.amount or 0)),
                    is_withholding=bool(t.is_withholding),
                )
            )
        concepts.append(
            builder.ConceptLine(
                product_service_code=_code(db, ProductService, it.product_service_id),
                unit_code=_code(db, UnitOfMeasure, it.unit_of_measure_id),
                description=(it.description or it.concept or "").strip(),
                quantity=Decimal(str(it.quantity or 0)),
                unit_price=Decimal(str(it.unit_amount or 0)),
                tax_object=_code(db, TaxObject, it.tax_object_id),
                taxes=taxes,
            )
        )

    # Fecha del comprobante: la de emisión si existe, y si no, ahora. Sin desplazamiento
    # horario, que en CFDI 4.0 no se pone (ver cfdi_builder).
    if invoice.issue_date:
        fecha = datetime.combine(invoice.issue_date, datetime.now().time())
    else:
        fecha = datetime.now()

    return builder.CfdiData(
        folio=invoice.reference or str(invoice.id),
        serie=None,
        date=fecha.strftime("%Y-%m-%dT%H:%M:%S"),
        payment_form=_code(db, PaymentForm, invoice.payment_form_id),
        payment_method=_code(db, PaymentMethod, invoice.payment_method_id),
        currency=(invoice.currency or "MXN").upper(),
        exchange_rate=None,
        expedition_zip=(invoice.expedition_zip_code or issuer.zip_code or "").strip()[:5],
        payment_conditions=None,
        issuer_rfc=(issuer.rfc or "").strip().upper(),
        issuer_name=(issuer.legal_name or "").strip(),
        issuer_tax_regime=_code(db, TaxRegime, issuer.tax_regime_id),
        receiver_rfc=(account.rfc or "").strip().upper(),
        receiver_name=(account.name or "").strip(),
        receiver_zip=receiver_zip,
        receiver_tax_regime=_code(db, TaxRegime, account.tax_regime_id),
        receiver_cfdi_use=_code(db, CfdiUse, account.cfdi_use_id),
        concepts=concepts,
    )


def _load_csd(db: Session, tenant_id: int, company_id: int) -> tuple[bytes, bytes, str]:
    """Bytes del ``.cer``, del ``.key`` y la contraseña descifrada del CSD de la empresa.

    Las rutas salen de ``fin.issuer_settings``, no de una convención fija: cada empresa carga
    su propio certificado desde la configuración fiscal.

    Import diferido de ``storage_s3``: importar arriba abre conexión y rompe los tests, que
    corren sin MinIO. Es el mismo patrón que usa ``invoices.service``.
    """
    from core.crypto import SecretDecryptionError, SecretsNotConfigured, decrypt_secret  # noqa: PLC0415

    issuer = (
        db.query(IssuerSettings)
        .filter(
            IssuerSettings.tenant_id == tenant_id,
            IssuerSettings.company_id == company_id,
            IssuerSettings.deleted_at.is_(None),
        )
        .first()
    )
    if not issuer or not issuer.csd_cer_file_key or not issuer.csd_key_file_key:
        raise builder.CfdiBuildError(
            [
                "la empresa no tiene CSD cargado: súbelo en Configuración de Facturación "
                "(certificado .cer, llave .key y su contraseña)"
            ]
        )

    # Contraseña por empresa; la global de entorno queda sólo como respaldo del esquema previo.
    if issuer.csd_password_enc:
        try:
            password = decrypt_secret(issuer.csd_password_enc)
        except (SecretDecryptionError, SecretsNotConfigured) as exc:
            raise builder.CfdiBuildError([str(exc)]) from exc
    elif settings.CSD_PASSWORD:
        password = settings.CSD_PASSWORD
    else:
        raise builder.CfdiBuildError(
            ["la empresa no tiene guardada la contraseña de su CSD: vuelve a cargarlo"]
        )

    from core.storage_s3 import get_object_bytes  # noqa: PLC0415

    try:
        cer = get_object_bytes(issuer.csd_cer_file_key)
        key = get_object_bytes(issuer.csd_key_file_key)
    except Exception as exc:  # noqa: BLE001 — cualquier fallo aquí es "no hay CSD utilizable"
        raise builder.CfdiBuildError(
            [f"no pude leer los archivos del CSD desde el almacenamiento: {exc}"]
        ) from exc
    return cer, key, password


# --------------------------------------------------------------------------------------
# Timbrado
# --------------------------------------------------------------------------------------
def stamp_invoice(
    db: Session,
    invoice_id: int,
    tenant_id: int,
    company_id: int,
    user_id: str | None = None,
) -> InvoiceStamp:
    """Genera, sella y transmite el CFDI de la factura.

    Es idempotente: si la factura ya tiene un timbre exitoso lo devuelve tal cual, **sin**
    llamar al PAC. Retimbrar cuesta un folio y genera un comprobante duplicado ante el SAT,
    que después hay que cancelar.
    """
    invoice = _get_invoice(db, invoice_id, tenant_id, company_id)

    existente = get_stamp(db, invoice_id, tenant_id, company_id)
    if existente:
        return existente

    if invoice.status == "cancelada":
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT, detail="La factura está cancelada"
        )

    mode = (invoice.stamping_mode or "").strip()
    if mode not in STAMPING_MODES:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=f"Modo de timbrado inválido en la factura: {mode!r}",
        )

    if not settings.PAC_USER or not settings.PAC_PASSWORD:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="No están configuradas las credenciales del PAC (PAC_USER / PAC_PASSWORD).",
        )

    # ----- Datos, CSD, XML y sello -----
    try:
        data = _build_data(db, invoice, tenant_id, company_id)
        cer_bytes, key_bytes, csd_password = _load_csd(db, tenant_id, company_id)
        cert_number, cert_b64 = sealer.read_certificate(cer_bytes)
        xml = builder.build_xml(data, cert_number=cert_number, cert_b64=cert_b64)
        cadena = sealer.build_original_string(xml)
        private_key = sealer.load_private_key(key_bytes, csd_password)
        sello = sealer.sign(cadena, private_key)
        xml_sellado = builder.apply_seal(xml, sello)
    except builder.CfdiBuildError as exc:
        # 422 con la lista completa: son datos que falta capturar, no un fallo del sistema.
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail={"message": "Faltan datos fiscales para timbrar", "missing": exc.missing},
        ) from exc
    except sealer.SealingError as exc:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
        ) from exc

    # ----- Transmisión -----
    resultado = pac.stamp(
        xml_sellado,
        mode=mode,
        user=settings.PAC_USER,
        password=settings.PAC_PASSWORD,
        host_test=settings.PAC_HOST_TEST,
        host_prod=settings.PAC_HOST_PROD,
        email=settings.PAC_NOTIFICATION_EMAIL,
        timeout=settings.PAC_TIMEOUT_SECONDS,
    )

    stamp = InvoiceStamp(
        tenant_id=tenant_id,
        company_id=company_id,
        invoice_id=invoice.id,
        mode=mode,
        status=STATUS_ERROR,
        pac_code=resultado.code,
        pac_balance=resultado.balance,
        error_message=resultado.error_message or None,
        created_by=user_id,
    )

    # El id se necesita para nombrar los XML del intento, y sólo existe después del flush.
    db.add(stamp)
    db.flush()
    _store_attempt_xml(stamp, xml_sellado, resultado.xml)

    if not resultado.ok:
        db.commit()
        db.refresh(stamp)
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail={
                "message": "El PAC rechazó el comprobante",
                "pac_code": resultado.code,
                "pac_error": resultado.error_message,
                "stamp_id": stamp.id,
            },
        )

    # ----- Verificación del timbre recibido -----
    try:
        tfd = _read_tfd(resultado.xml)
    except ValueError as exc:
        stamp.error_message = str(exc)
        db.commit()
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail=f"El PAC devolvió un XML que no pude interpretar: {exc}",
        ) from exc

    esperado = PAC_RFC_BY_MODE[mode]
    if tfd["pac_rfc"] != esperado:
        # Red de seguridad final: se pidió un entorno y contestó otro. Nunca se da por bueno.
        stamp.error_message = (
            f"El timbre viene del PAC {tfd['pac_rfc']!r} y para el modo {mode!r} se esperaba "
            f"{esperado!r}: se timbró contra un entorno distinto del solicitado."
        )
        db.commit()
        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=stamp.error_message)

    # ----- Persistencia -----
    stamp.status = STATUS_STAMPED
    stamp.uuid = tfd["uuid"]
    stamp.stamped_at = tfd["stamped_at"]
    stamp.pac_rfc = tfd["pac_rfc"]
    stamp.sat_cert_number = tfd["sat_cert_number"]
    stamp.sat_seal = tfd["sat_seal"]
    stamp.cfd_seal = tfd["cfd_seal"]
    stamp.error_message = None

    key = invoice_stamp_xml_key(tenant_id, company_id, invoice.id, tfd["uuid"])
    try:
        from core.storage_s3 import put_object_bytes  # noqa: PLC0415

        put_object_bytes(key, resultado.xml.encode("utf-8"), content_type="application/xml")
        stamp.xml_file_key = key
    except Exception as exc:  # noqa: BLE001
        # El comprobante YA está timbrado ante el SAT: perder el archivo no puede invalidar el
        # timbre ni provocar un retimbrado. Se guarda el registro sin la clave y se anota.
        stamp.error_message = f"Timbrado correcto, pero no se pudo guardar el XML: {exc}"

    db.commit()
    db.refresh(stamp)
    return stamp


def _store_attempt_xml(stamp: InvoiceStamp, sent: bytes, received: str) -> None:
    """Guarda el par enviado/recibido del intento y anota sus claves en ``stamp``.

    Nunca propaga una excepción. Este rastro es para diagnóstico: si el almacenamiento está
    caído no puede tumbar un timbrado que el SAT ya dio por bueno, ni convertir el rechazo del
    PAC —que es lo que hay que contarle a quien factura— en un error de almacenamiento. Lo que
    no se pudo subir queda con la clave en NULL y en el log.
    """
    from core.storage_s3 import put_object_bytes  # noqa: PLC0415

    # El de respuesta puede venir vacío: un fallo de red corta antes de que el PAC conteste.
    partes = [("request", sent), ("response", received.encode("utf-8") if received else b"")]
    for kind, cuerpo in partes:
        if not cuerpo:
            continue
        key = invoice_stamp_attempt_xml_key(
            stamp.tenant_id, stamp.company_id, stamp.invoice_id, stamp.id, kind
        )
        try:
            put_object_bytes(key, cuerpo, content_type="application/xml")
        except Exception:  # noqa: BLE001
            logger.exception("No se pudo guardar el XML de %s del intento %s", kind, stamp.id)
            continue
        setattr(stamp, f"{kind}_xml_file_key", key)


def _read_tfd(xml_text: str) -> dict:
    """Extrae el Timbre Fiscal Digital del XML que devolvió el PAC."""
    from lxml import etree  # noqa: PLC0415

    try:
        root = etree.fromstring(xml_text.encode("utf-8") if isinstance(xml_text, str) else xml_text)
    except etree.XMLSyntaxError as exc:
        raise ValueError(f"XML mal formado: {exc}") from exc

    nodo = root.find(f".//{{{TFD_NS}}}TimbreFiscalDigital")
    if nodo is None:
        raise ValueError("no trae el nodo TimbreFiscalDigital")

    uuid = (nodo.get("UUID") or "").strip()
    if not uuid:
        raise ValueError("el TimbreFiscalDigital no trae UUID")

    crudo = (nodo.get("FechaTimbrado") or "").strip()
    try:
        stamped_at = datetime.fromisoformat(crudo) if crudo else None
    except ValueError:
        # Fecha ilegible: no invalida el timbre, que ya existe ante el SAT. Se deja en NULL.
        stamped_at = None

    return {
        "uuid": uuid,
        "stamped_at": stamped_at,
        "pac_rfc": (nodo.get("RfcProvCertif") or "").strip(),
        "sat_cert_number": (nodo.get("NoCertificadoSAT") or "").strip() or None,
        "sat_seal": (nodo.get("SelloSAT") or "").strip() or None,
        "cfd_seal": (nodo.get("SelloCFD") or "").strip() or None,
    }


def get_stamp_xml_url(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> str:
    """URL firmada del XML timbrado. Las presignadas caducan, así que se genera al vuelo."""
    from core.storage_s3 import presigned_get_url  # noqa: PLC0415

    stamp = get_stamp(db, invoice_id, tenant_id, company_id)
    if not stamp or not stamp.xml_file_key:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="La factura no tiene XML timbrado almacenado",
        )
    return presigned_get_url(stamp.xml_file_key)
