"""Construcción del XML CFDI 4.0 de tipo ingreso.

El orden de los atributos **no es libre**: la cadena original se calcula recorriendo el
comprobante en el orden del XSD, y el sello se hace sobre esa cadena. Aquí se respeta el
mismo orden que usa el sistema legado (``CFDI.cs:14340-14394``), verificado contra el XSD.

Todo el dinero se maneja con ``Decimal``. Con ``float``, 0.1 + 0.2 no da 0.30 y el total del
comprobante no cuadra con la suma de las partidas: el PAC lo rechaza.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal

from lxml import etree

CFDI_NS = "http://www.sat.gob.mx/cfd/4"
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
SCHEMA_LOCATION = (
    "http://www.sat.gob.mx/cfd/4 http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd"
)

VERSION = "4.0"
TIPO_INGRESO = "I"

# Declaración XML escrita a mano, con comillas DOBLES. lxml emite la suya con comillas simples
# (<?xml version='1.0' encoding='UTF-8'?>), que es XML válido —la especificación admite ambas—
# pero Comercio Digital lo rechaza con el código 642 "la versión del XML no es 1.0" porque
# compara la cadena literal version="1.0" en vez de parsear el prólogo. Por eso el comprobante
# se serializa sin declaración y ésta se antepone.
XML_DECLARATION = b'<?xml version="1.0" encoding="UTF-8"?>\n'
# c_Exportacion: "01" = No aplica. Es obligatorio en 4.0 y este módulo no emite exportaciones.
EXPORTACION_NO_APLICA = "01"

# Decimales por moneda (c_Moneda). El SAT admite hasta ese número en importes.
_DECIMALES = {"MXN": 2, "USD": 2, "EUR": 2}
_DECIMALES_DEFECTO = 2


class CfdiBuildError(Exception):
    """El comprobante no se puede construir con los datos disponibles."""

    def __init__(self, missing: list[str]):
        # Se acumulan TODOS los faltantes en vez de fallar en el primero: quien captura la
        # factura necesita la lista completa, no descubrirlos de uno en uno.
        self.missing = missing
        super().__init__("Faltan datos fiscales: " + "; ".join(missing))


def _serialize(root: etree._Element) -> bytes:
    """Serializa el comprobante en UTF-8 con la declaración que acepta el PAC."""
    # xml_declaration=False es obligatorio: con encoding distinto de ASCII, lxml la añade sola.
    return XML_DECLARATION + etree.tostring(root, xml_declaration=False, encoding="UTF-8")


def _money(value: Decimal | float | int | None, currency: str) -> str:
    """Importe con los decimales de la moneda, sin separador de miles."""
    dec = _DECIMALES.get(currency.upper(), _DECIMALES_DEFECTO)
    q = Decimal(1).scaleb(-dec)
    return str(Decimal(str(value or 0)).quantize(q, rounding=ROUND_HALF_UP))


def _qty(value: Decimal | float | int | None) -> str:
    """Cantidad: hasta 6 decimales, sin ceros finales innecesarios."""
    d = Decimal(str(value or 0)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
    return format(d.normalize(), "f")


def _rate(value: Decimal | float | int) -> str:
    """Tasa o cuota: el SAT la exige con 6 decimales (p. ej. 0.160000)."""
    return str(Decimal(str(value)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP))


@dataclass
class TaxLine:
    """Impuesto trasladado o retenido de una partida."""

    code: str  # c_Impuesto: "002" = IVA
    rate: Decimal
    amount: Decimal
    is_withholding: bool = False
    factor: str = "Tasa"  # c_TipoFactor: Tasa | Cuota | Exento


@dataclass
class ConceptLine:
    """Partida del comprobante."""

    product_service_code: str  # ClaveProdServ
    unit_code: str  # ClaveUnidad
    description: str
    quantity: Decimal
    unit_price: Decimal
    tax_object: str  # c_ObjetoImp: "01" no objeto, "02" sí objeto
    taxes: list[TaxLine] = field(default_factory=list)
    identification: str | None = None  # NoIdentificacion

    @property
    def amount(self) -> Decimal:
        return (self.quantity * self.unit_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


@dataclass
class CfdiData:
    """Todo lo que necesita un CFDI 4.0 de ingreso, ya resuelto contra los catálogos."""

    # Comprobante
    folio: str | None
    serie: str | None
    date: str  # YYYY-MM-DDTHH:MM:SS, hora local del lugar de expedición
    payment_form: str  # c_FormaPago
    payment_method: str  # c_MetodoPago: PUE | PPD
    currency: str
    exchange_rate: Decimal | None
    expedition_zip: str  # LugarExpedicion
    payment_conditions: str | None
    # Emisor
    issuer_rfc: str
    issuer_name: str
    issuer_tax_regime: str  # c_RegimenFiscal
    # Receptor
    receiver_rfc: str
    receiver_name: str
    receiver_zip: str  # DomicilioFiscalReceptor
    receiver_tax_regime: str  # c_RegimenFiscal del receptor
    receiver_cfdi_use: str  # c_UsoCFDI
    # Partidas
    concepts: list[ConceptLine]

    def validate(self) -> None:
        """Acumula los faltantes obligatorios del CFDI 4.0 y los reporta juntos."""
        faltantes: list[str] = []
        obligatorios = {
            "fecha de emisión": self.date,
            "forma de pago (c_FormaPago)": self.payment_form,
            "método de pago (c_MetodoPago)": self.payment_method,
            "moneda": self.currency,
            "lugar de expedición (CP)": self.expedition_zip,
            "RFC del emisor": self.issuer_rfc,
            "razón social del emisor": self.issuer_name,
            "régimen fiscal del emisor": self.issuer_tax_regime,
            "RFC del receptor": self.receiver_rfc,
            "razón social del receptor": self.receiver_name,
            "domicilio fiscal del receptor (CP)": self.receiver_zip,
            "régimen fiscal del receptor": self.receiver_tax_regime,
            "uso de CFDI del receptor": self.receiver_cfdi_use,
        }
        for etiqueta, valor in obligatorios.items():
            if not valor:
                faltantes.append(f"falta {etiqueta}")

        if not self.concepts:
            faltantes.append("la factura no tiene partidas")

        for i, c in enumerate(self.concepts, start=1):
            if not c.product_service_code:
                faltantes.append(f"partida {i}: falta la clave de producto/servicio")
            if not c.unit_code:
                faltantes.append(f"partida {i}: falta la clave de unidad")
            if not c.tax_object:
                faltantes.append(f"partida {i}: falta el objeto de impuesto")
            if not c.description:
                faltantes.append(f"partida {i}: falta la descripción")
            if c.quantity is None or c.quantity <= 0:
                faltantes.append(f"partida {i}: la cantidad debe ser mayor que cero")
            # ObjetoImp "02" significa "sí objeto de impuesto": el SAT exige entonces el
            # desglose. Sin él, el comprobante se rechaza; no se inventa una tasa por defecto.
            if c.tax_object == "02" and not c.taxes:
                faltantes.append(
                    f"partida {i}: es objeto de impuesto (02) pero no tiene impuestos capturados"
                )

        if self.currency.upper() != "MXN" and not self.exchange_rate:
            faltantes.append("falta el tipo de cambio (moneda distinta de MXN)")

        if faltantes:
            raise CfdiBuildError(faltantes)

    # ----- Totales -----
    @property
    def subtotal(self) -> Decimal:
        return sum((c.amount for c in self.concepts), Decimal("0"))

    @property
    def transferred(self) -> Decimal:
        return sum(
            (t.amount for c in self.concepts for t in c.taxes if not t.is_withholding),
            Decimal("0"),
        )

    @property
    def withheld(self) -> Decimal:
        return sum(
            (t.amount for c in self.concepts for t in c.taxes if t.is_withholding),
            Decimal("0"),
        )

    @property
    def total(self) -> Decimal:
        return self.subtotal + self.transferred - self.withheld


def build_xml(data: CfdiData, cert_number: str = "", cert_b64: str = "") -> bytes:
    """XML CFDI 4.0 de ingreso, **sin** el atributo ``Sello``.

    ``cert_number`` y ``cert_b64`` salen del ``.cer`` y **tienen que venir puestos aquí**, no
    después: la cadena original incluye ``NoCertificado``. Si se calculara la cadena con el
    atributo vacío y se rellenara luego, el sello firmaría un texto distinto del que verifica
    el SAT, y el comprobante se rechazaría con un error que no menciona el certificado.

    ``Sello`` sí se deja vacío —la cadena original no lo incluye, es su resultado— y lo inserta
    ``apply_seal`` en su posición.
    """
    data.validate()
    cur = data.currency.upper()

    comprobante = etree.Element(
        f"{{{CFDI_NS}}}Comprobante",
        nsmap={"cfdi": CFDI_NS, "xsi": XSI_NS},
    )
    comprobante.set(f"{{{XSI_NS}}}schemaLocation", SCHEMA_LOCATION)

    # ORDEN DEL XSD. No reordenar: la cadena original -y por tanto el sello- depende de él.
    comprobante.set("Version", VERSION)
    if data.serie:
        comprobante.set("Serie", data.serie)
    if data.folio:
        comprobante.set("Folio", str(data.folio))
    # Sin desplazamiento horario: el "-06:00" es de CFDI 3.3 (ver CFDI.cs:12654). En 4.0 la
    # fecha va en hora local del lugar de expedición, a secas.
    comprobante.set("Fecha", data.date)
    # Sello vacío: reserva su posición en el orden del XSD para que apply_seal lo rellene sin
    # mover nada. lxml conserva el orden de inserción de los atributos.
    comprobante.set("Sello", "")
    comprobante.set("FormaPago", data.payment_form)
    comprobante.set("NoCertificado", cert_number)
    comprobante.set("Certificado", cert_b64)
    if data.payment_conditions:
        comprobante.set("CondicionesDePago", data.payment_conditions)
    comprobante.set("SubTotal", _money(data.subtotal, cur))
    comprobante.set("Moneda", cur)
    if cur != "MXN" and data.exchange_rate:
        comprobante.set(
            "TipoCambio", str(Decimal(str(data.exchange_rate)).quantize(Decimal("0.0001")))
        )
    comprobante.set("Total", _money(data.total, cur))
    comprobante.set("TipoDeComprobante", TIPO_INGRESO)
    comprobante.set("Exportacion", EXPORTACION_NO_APLICA)
    comprobante.set("MetodoPago", data.payment_method)
    comprobante.set("LugarExpedicion", data.expedition_zip)

    emisor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Emisor")
    emisor.set("Rfc", data.issuer_rfc)
    emisor.set("Nombre", data.issuer_name)
    emisor.set("RegimenFiscal", data.issuer_tax_regime)

    receptor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Receptor")
    receptor.set("Rfc", data.receiver_rfc)
    receptor.set("Nombre", data.receiver_name)
    receptor.set("DomicilioFiscalReceptor", data.receiver_zip)
    receptor.set("RegimenFiscalReceptor", data.receiver_tax_regime)
    receptor.set("UsoCFDI", data.receiver_cfdi_use)

    conceptos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Conceptos")
    for c in data.concepts:
        nodo = etree.SubElement(conceptos, f"{{{CFDI_NS}}}Concepto")
        nodo.set("ClaveProdServ", c.product_service_code)
        if c.identification:
            nodo.set("NoIdentificacion", c.identification)
        nodo.set("Cantidad", _qty(c.quantity))
        nodo.set("ClaveUnidad", c.unit_code)
        nodo.set("Descripcion", c.description)
        nodo.set("ValorUnitario", _money(c.unit_price, cur))
        nodo.set("Importe", _money(c.amount, cur))
        nodo.set("ObjetoImp", c.tax_object)
        if c.taxes:
            _add_concept_taxes(nodo, c, cur)

    if any(c.taxes for c in data.concepts):
        _add_totals(comprobante, data, cur)

    return _serialize(comprobante)


def _add_concept_taxes(nodo: etree._Element, c: ConceptLine, cur: str) -> None:
    """Nodo ``Impuestos`` de una partida: primero Traslados, después Retenciones."""
    impuestos = etree.SubElement(nodo, f"{{{CFDI_NS}}}Impuestos")

    traslados = [t for t in c.taxes if not t.is_withholding]
    if traslados:
        cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
        for t in traslados:
            el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
            el.set("Base", _money(c.amount, cur))
            el.set("Impuesto", t.code)
            el.set("TipoFactor", t.factor)
            # Un impuesto exento no lleva TasaOCuota ni Importe: ponerlos es motivo de rechazo.
            if t.factor != "Exento":
                el.set("TasaOCuota", _rate(t.rate))
                el.set("Importe", _money(t.amount, cur))

    retenciones = [t for t in c.taxes if t.is_withholding]
    if retenciones:
        cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
        for t in retenciones:
            el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
            el.set("Base", _money(c.amount, cur))
            el.set("Impuesto", t.code)
            el.set("TipoFactor", t.factor)
            el.set("TasaOCuota", _rate(t.rate))
            el.set("Importe", _money(t.amount, cur))


def _add_totals(comprobante: etree._Element, data: CfdiData, cur: str) -> None:
    """Nodo ``Impuestos`` del comprobante: totales agrupados por impuesto, factor y tasa."""
    impuestos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Impuestos")

    def agrupa(withholding: bool) -> dict[tuple[str, str, str], Decimal]:
        acc: dict[tuple[str, str, str], Decimal] = {}
        for c in data.concepts:
            for t in c.taxes:
                if t.is_withholding != withholding or t.factor == "Exento":
                    continue
                clave = (t.code, t.factor, _rate(t.rate))
                acc[clave] = acc.get(clave, Decimal("0")) + t.amount
        return acc

    # En el XSD, Retenciones va ANTES que Traslados dentro del nodo Impuestos del comprobante
    # —al revés que dentro del concepto—. Es una asimetría real del esquema, no un descuido.
    retenidos = agrupa(True)
    if retenidos:
        impuestos.set("TotalImpuestosRetenidos", _money(data.withheld, cur))
        cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
        for (code, _factor, _tasa), monto in sorted(retenidos.items()):
            el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
            el.set("Impuesto", code)
            el.set("Importe", _money(monto, cur))

    trasladados = agrupa(False)
    if trasladados:
        impuestos.set("TotalImpuestosTrasladados", _money(data.transferred, cur))
        cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
        for (code, factor, tasa), monto in sorted(trasladados.items()):
            el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
            el.set(
                "Base",
                _money(
                    sum(
                        (
                            c.amount
                            for c in data.concepts
                            for t in c.taxes
                            if not t.is_withholding
                            and t.code == code
                            and t.factor == factor
                            and _rate(t.rate) == tasa
                        ),
                        Decimal("0"),
                    ),
                    cur,
                ),
            )
            el.set("Impuesto", code)
            el.set("TipoFactor", factor)
            el.set("TasaOCuota", tasa)
            el.set("Importe", _money(monto, cur))


def apply_seal(xml_bytes: bytes, seal: str) -> bytes:
    """Inserta el ``Sello`` en el comprobante ya construido.

    Sólo el sello: ``NoCertificado`` y ``Certificado`` ya venían de ``build_xml`` porque el
    primero entra en la cadena original que se acaba de firmar.
    """
    root = etree.fromstring(xml_bytes)
    if root.get("NoCertificado") is None or not root.get("NoCertificado"):
        raise CfdiBuildError(
            ["el comprobante llegó a sellarse sin NoCertificado: la cadena original sería inválida"]
        )
    root.set("Sello", seal)
    return _serialize(root)
