"""Cadena original y sello del CFDI.

El sello es la firma del emisor sobre el comprobante. Se obtiene en dos pasos:

1. **Cadena original**: transformación XSLT oficial del SAT sobre el XML *sin* sello.
2. **Sello**: firma RSA con SHA-256 de esa cadena, en base64.

Ambos pasos son exactos: un carácter de diferencia en la cadena produce un sello que el SAT
rechaza. Por eso la cadena no se construye a mano ni se "normaliza" — sale tal cual del XSLT.
"""

from __future__ import annotations

import base64
import threading
from pathlib import Path

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
from lxml import etree

_XSLT_DIR = Path(__file__).parent / "xslt"
_XSLT_CADENA = _XSLT_DIR / "cadenaoriginal_4_0.xslt"

# La compilación del XSLT es cara (33 includes) y el resultado es inmutable: se hace una vez.
# El lock evita que dos peticiones concurrentes la compilen a la vez en el arranque.
_transform: etree.XSLT | None = None
_transform_lock = threading.Lock()


class SealingError(Exception):
    """Falla al calcular la cadena original o el sello."""


def _get_transform() -> etree.XSLT:
    global _transform
    if _transform is None:
        with _transform_lock:
            if _transform is None:  # otro hilo pudo compilarla mientras esperábamos
                if not _XSLT_CADENA.is_file():
                    raise SealingError(
                        f"No encuentro el XSLT de la cadena original en {_XSLT_CADENA}"
                    )
                # Los includes del XSLT apuntan a rutas RELATIVAS locales (ver xslt/README.md):
                # no hay resolución por red, ni aquí ni dentro de libxslt.
                _transform = etree.XSLT(etree.parse(str(_XSLT_CADENA)))
    return _transform


def build_original_string(xml_bytes: bytes) -> str:
    """Cadena original del comprobante, vía el XSLT oficial del SAT.

    ``xml_bytes`` es el CFDI **sin** los atributos ``Sello``, ``NoCertificado`` ni
    ``Certificado``: son justamente los que se calculan a partir de esta cadena.
    """
    try:
        doc = etree.fromstring(xml_bytes)
    except etree.XMLSyntaxError as exc:
        raise SealingError(f"El XML del comprobante no es válido: {exc}") from exc

    cadena = str(_get_transform()(doc))
    # Una cadena vacía o sin los delimitadores significa que la transformación no aplicó ninguna
    # plantilla — pasa si el XSLT perdió sus includes. Firmar eso daría un sello con pinta de
    # correcto sobre nada, así que se corta aquí.
    if not cadena.startswith("||") or not cadena.endswith("||"):
        raise SealingError(
            "La cadena original no tiene la forma esperada (debe abrir y cerrar con '||'). "
            "Revisa los includes de xslt/cadenaoriginal_4_0.xslt."
        )
    return cadena


def load_private_key(key_der: bytes, password: str) -> rsa.RSAPrivateKey:
    """Carga la llave privada del CSD.

    El ``.key`` que entrega el SAT es PKCS#8 **DER** cifrado con contraseña. Se acepta también
    PEM para no obligar a convertir llaves que ya estén en ese formato.
    """
    if not password:
        raise SealingError(
            "No se configuró la contraseña de la llave privada del CSD (CSD_PASSWORD)."
        )
    clave = password.encode("utf-8")
    try:
        key = serialization.load_der_private_key(key_der, password=clave)
    except ValueError:
        try:
            key = serialization.load_pem_private_key(key_der, password=clave)
        except ValueError as exc:
            # Mismo error para "archivo corrupto" y "contraseña incorrecta" porque la librería no
            # los distingue; el mensaje nombra las dos causas para no mandar a nadie al lugar
            # equivocado.
            raise SealingError(
                "No pude abrir la llave privada del CSD: el archivo no es una llave válida "
                "o la contraseña es incorrecta."
            ) from exc
    if not isinstance(key, rsa.RSAPrivateKey):
        raise SealingError("La llave privada del CSD no es RSA.")
    return key


def read_certificate(cer_bytes: bytes) -> tuple[str, str]:
    """Devuelve ``(numero_de_certificado, certificado_base64)`` a partir del ``.cer``.

    - El ``.cer`` del SAT es X.509 **DER**.
    - El atributo ``Certificado`` del comprobante es el base64 de ese DER.
    - El atributo ``NoCertificado`` son los 20 dígitos del número de serie. El SAT lo codifica
      de forma que los bytes del serial son directamente sus caracteres ASCII, así que se
      decodifica en vez de imprimirse en hexadecimal.
    """
    try:
        cert = load_der_x509_certificate(cer_bytes)
        der = cer_bytes
    except ValueError:
        try:
            cert = load_pem_x509_certificate(cer_bytes)
            der = cert.public_bytes(serialization.Encoding.DER)
        except ValueError as exc:
            raise SealingError("El archivo del certificado no es un X.509 válido (.cer).") from exc

    serial_bytes = cert.serial_number.to_bytes((cert.serial_number.bit_length() + 7) // 8, "big")
    try:
        numero = serial_bytes.decode("ascii")
    except UnicodeDecodeError as exc:
        raise SealingError(
            "El número de serie del certificado no tiene el formato del SAT "
            "(sus bytes deben ser los 20 dígitos en ASCII)."
        ) from exc
    if len(numero) != 20 or not numero.isdigit():
        raise SealingError(f"El número de certificado debe ser 20 dígitos; se obtuvo {numero!r}.")

    return numero, base64.b64encode(der).decode("ascii")


def sign(original_string: str, private_key: rsa.RSAPrivateKey) -> str:
    """Sello del comprobante: RSA sobre SHA-256 de la cadena original, en base64.

    SHA-256 y no SHA-1: SHA-1 sólo aplica al CFDI de retenciones con otros PAC (ver
    ``CFDI.cs:8157`` del sistema legado). Para CFDI de ingreso con Comercio Digital es SHA-256.
    """
    firma = private_key.sign(
        original_string.encode("utf-8"),
        padding.PKCS1v15(),
        hashes.SHA256(),
    )
    return base64.b64encode(firma).decode("ascii")
