"""Cliente del PAC Comercio Digital — servicio ``timbrarV5``.

El contrato está tomado del sistema legado (``CFDI.cs:19274-19346``), que es la única fuente
de verdad disponible: se transmite el XML **sellado** en crudo por POST y la respuesta trae el
comprobante timbrado en el cuerpo y los metadatos en cabeceras HTTP.

Tres defectos del legado se corrigen aquí en vez de replicarse — ver ``_read_headers``.
"""

from __future__ import annotations

from dataclasses import dataclass

import httpx

from .models import MODE_TEST, STAMPING_MODES

# Códigos de error propios del cliente, con los mismos números que usa el legado para que los
# reportes de ambos sistemas se puedan comparar.
ERR_USER = 701  # usuario con longitud inválida
ERR_PASSWORD = 702  # password vacío
ERR_EMPTY_XML = 711  # XML vacío o demasiado corto
ERR_NETWORK = 833  # excepción de red
ERR_HTTP = 998  # respuesta HTTP distinta de 200

# El usrws de Comercio Digital tiene forma de RFC.
_USER_MIN, _USER_MAX = 12, 13
# Un CFDI sellado nunca baja de este tamaño; por debajo, es que algo se truncó.
_MIN_XML_BYTES = 200


class PacConfigError(Exception):
    """Configuración inválida del PAC. Se detecta antes de tocar la red."""


@dataclass
class StampResult:
    """Respuesta del PAC ante un intento de timbrado."""

    ok: bool
    code: int | None
    error_message: str
    xml: str = ""
    uuid: str = ""
    balance: int | None = None
    email_error: str = ""


def resolve_host(mode: str, host_test: str, host_prod: str) -> str:
    """Host del PAC a partir del modo. **Es la única forma de elegirlo.**

    No hay parámetro de host, ni de URL, ni forma de pasarlos desde la capa HTTP: el modo sale
    de ``invoices.stamping_mode`` y nada más. En el legado, ``CFDIPacUrl`` es un campo mutable
    que cualquier rama del código reasigna, y un host vacío cae silenciosamente al de pruebas
    (``CFDI.cs:19288``). Aquí un modo desconocido es un error, no un valor por defecto: el
    default equivocado emite un CFDI con validez fiscal real.
    """
    if mode not in STAMPING_MODES:
        raise PacConfigError(
            f"Modo de timbrado inválido: {mode!r}. Sólo se admiten {STAMPING_MODES}."
        )
    return host_test if mode == MODE_TEST else host_prod


def stamp(
    xml_bytes: bytes,
    *,
    mode: str,
    user: str,
    password: str,
    host_test: str,
    host_prod: str,
    email: str = "",
    timeout: int = 15,
) -> StampResult:
    """Transmite el CFDI sellado al PAC y devuelve el resultado.

    Nunca lanza por causas de red o del PAC: esas se devuelven como ``StampResult`` con
    ``ok=False``, porque el llamador tiene que persistir el intento fallido. Sí lanza
    ``PacConfigError`` si el modo es inválido, que es un error de programación, no de operación.
    """
    host = resolve_host(mode, host_test, host_prod)

    # Validaciones previas: mismas condiciones y códigos que el legado, antes de salir a la red.
    if not user or not (_USER_MIN <= len(user) <= _USER_MAX):
        return StampResult(False, ERR_USER, f"{ERR_USER} Usuario del PAC inválido o no configurado")
    if not password:
        return StampResult(False, ERR_PASSWORD, f"{ERR_PASSWORD} Password del PAC no configurado")
    if not xml_bytes or len(xml_bytes) < _MIN_XML_BYTES:
        return StampResult(False, ERR_EMPTY_XML, f"{ERR_EMPTY_XML} Contenido XML vacío")

    url = f"https://{host}/timbre4/timbrarV5"
    headers = {
        "usrws": user,
        "pwdws": password,
        "tipo": "XML",
        "Content-Type": "text/plain",
    }
    if email:
        headers["email"] = email.lower()

    try:
        # El cuerpo va en crudo: ni base64 ni SOAP. httpx no reintenta por defecto, y así debe
        # ser: reintentar un timbrado puede consumir un folio y generar un CFDI duplicado.
        response = httpx.post(url, content=xml_bytes, headers=headers, timeout=timeout)
    except httpx.HTTPError as exc:
        return StampResult(
            False, ERR_NETWORK, f"{ERR_NETWORK} Error de transmisión a {host}: {exc}"
        )

    if response.status_code != 200:
        # El cuerpo se conserva aunque el estado no sea 200: cuando el PAC contesta con un
        # error de servidor, lo que explica el rechazo suele venir precisamente ahí.
        return StampResult(
            False,
            ERR_HTTP,
            f"{ERR_HTTP} El PAC respondió HTTP {response.status_code}",
            response.text,
        )

    return _read_headers(response)


def _read_headers(response: httpx.Response) -> StampResult:
    """Interpreta la respuesta del PAC.

    Aquí se corrigen tres defectos del legado (``CFDI.cs:19324-19336``):

    1. ``codigo`` **se lee y se conserva**. El legado lo asignaba a una variable local que
       descartaba, así que su parámetro de salida quedaba siempre en 999 o 991.
    2. ``saldo`` **también**. Mismo patrón: se perdía siempre, y con él la única señal de
       cuántos folios quedan.
    3. La presencia de una cabecera se comprueba de verdad. En .NET, ``GetResponseHeader``
       devuelve ``""`` y no ``null`` cuando falta, así que la rama ``== null`` del legado
       prácticamente nunca se cumplía.
    """
    headers = response.headers
    error_message = (headers.get("errmsg") or "").strip()
    uuid = (headers.get("uuid") or "").strip()
    email_error = (headers.get("erremail") or "").strip()

    def entero(nombre: str) -> int | None:
        crudo = (headers.get(nombre) or "").strip()
        if not crudo:
            return None
        try:
            return int(crudo)
        except ValueError:
            # El PAC mandó algo no numérico: se ignora el valor, pero no se rompe el timbrado
            # por ello. Queda como None, que es "no informado".
            return None

    code = entero("codigo")
    balance = entero("saldo")

    # Criterio de éxito del legado: errmsg vacío. Se le añade la exigencia de UUID, porque una
    # respuesta 200 sin errmsg y sin UUID no es un comprobante timbrado.
    if error_message:
        return StampResult(False, code, error_message, response.text, uuid, balance, email_error)
    if not uuid:
        return StampResult(
            False,
            code,
            "El PAC respondió sin error pero no devolvió UUID",
            response.text,
            "",
            balance,
            email_error,
        )

    return StampResult(True, code, "", response.text, uuid, balance, email_error)
