"""
Generación de PDF de Acuse de COVE (Jinja2 + pdfkit / wkhtmltopdf).

El contexto se construye preferentemente a partir del XML de COVE
almacenado en S3 (cuando está disponible y coincide el e-document),
y en su defecto directamente desde la base de datos reutilizando
la lógica ya existente en FacturaCoveDomainService (_build_personas,
_build_mercancias).

Secciones del reporte (derivadas del Report REPORT legacy en Clarion):
  - Cabecera:    tipo_operacion, numero_factura, tipo_figura, fecha_expedicion
  - Observaciones
  - RFC de consulta / patente aduanal
  - Datos del proveedor/emisor (nombre, identificación, domicilio completo)
  - Datos del destinatario (nombre, identificación, domicilio completo)
  - Por cada partida: descripción genérica de la mercancía, clave UMC,
                      cantidad, tipo moneda, valor unitario, valor total,
                      valor en dólares
  - Por cada partida: marca, modelo, serie, submodelo (DescripcionEspecifica)
  - Footer
"""

from __future__ import annotations

import base64
import logging
import os
import shutil
import tempfile
from dataclasses import dataclass, field
from decimal import Decimal
from pathlib import Path
from typing import Any, List, Optional
from xml.etree import ElementTree as ET

from core import storage_s3
from core.s3_keys import cove_xml_key
import pdfkit
from jinja2 import Environment, FileSystemLoader, select_autoescape
from sqlalchemy.orm import Session

from core.exceptions import ValidationException

from .schemas import MercanciaCove, PersonaCove
from .service import FacturaCoveDomainService

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Modelos de vista para el template (uno por sección del reporte legacy)
# ---------------------------------------------------------------------------

@dataclass
class CoveItemView:
    """Corresponde a DetallePartidasFactura + DetallePartidaMarcaModelo del legacy."""
    # DetallePartidasFactura
    descripcion_generica: str
    clave_unidad_medida: str
    cantidad: str
    tipo_moneda: str
    valor_unitario: str
    valor_total: str
    valor_dolares: str
    # DetallePartidaMarcaModelo (DescripcionEspecifica)
    descripciones_especificas: List["CoveDescEspecificaView"] = field(default_factory=list)


@dataclass
class CoveDescEspecificaView:
    marca: str = ""
    modelo: str = ""
    submodelo: str = ""
    numero_serie: str = ""


@dataclass
class CovePersonaView:
    """Corresponde al bloque Emisor/Destinatario del reporte legacy."""
    # Loc:TipoIdentificador / Loc:Des_TipoIdentificador
    tipo_identificador: str = ""
    # Loc:identificacion / Loc:Des_identificacion
    identificacion: str = ""
    # Loc:Emisor_Nombre / Loc:Des_Nombre
    nombre: str = ""
    apellido_paterno: str = ""
    apellido_materno: str = ""
    # Domicilio
    calle: str = ""
    numero_exterior: str = ""
    numero_interior: str = ""
    colonia: str = ""
    codigo_postal: str = ""
    localidad: str = ""
    municipio: str = ""
    entidad_federativa: str = ""
    pais: str = ""


@dataclass
class CoveAcuseContext:
    """
    Contexto completo para el template acuse_cove.html.

    Mapeo de campos Clarion → atributos de este dataclass:
      Loc:tipoOperacion              → tipo_operacion
      Loc:numeroFacturaOriginal      → numero_factura_relacionada
      Loc:numeroFactura              → numero_factura
      Loc:tipoFigura                 → tipo_figura
      Loc:fechaExpedicion            → fecha_expedicion
      Loc:ObservacionesCove          → observaciones
      Loc:rfcConsulta                → rfc_consulta
      Loc:NombreRazonSocial_Consulta → nombre_razon_social_consulta
      Loc:patenteAduanal             → patente_aduanal
      Loc:subdivision                → subdivision
      Loc:certificadoOrigen          → certificado_origen
      Loc:exportadorAutorizado       → exportador_autorizado
      Emisor.*                       → emisor (CovePersonaView)
      Destinatario.*                 → destinatario (CovePersonaView)
      DetallePartidasFactura.*       → items (lista de CoveItemView)
    """
    # --- Sección "Datos" (cabecera) ---
    tipo_operacion: str = ""
    numero_factura_relacionada: str = ""
    numero_factura: str = ""
    tipo_figura: str = ""
    fecha_expedicion: str = ""
    observaciones: str = ""
    rfc_consulta: str = ""
    nombre_razon_social_consulta: str = ""
    patente_aduanal: str = ""
    subdivision: str = ""
    certificado_origen: str = ""
    exportador_autorizado: str = ""
    edocument: str = ""
    vucem_operation_num: str = ""
    # --- Personas ---
    emisor: CovePersonaView = field(default_factory=CovePersonaView)
    destinatario: CovePersonaView = field(default_factory=CovePersonaView)
    # --- Partidas ---
    items: List[CoveItemView] = field(default_factory=list)


# ---------------------------------------------------------------------------
# Helpers de conversión
# ---------------------------------------------------------------------------

def _fmt_decimal(value: Decimal | None) -> str:
    if value is None:
        return "0.00"
    return f"{value:,.2f}"


def _persona_to_view(p: PersonaCove) -> CovePersonaView:
    return CovePersonaView(
        tipo_identificador=p.tipo_identificador or "",
        identificacion=p.identificacion or "",
        nombre=p.nombre or "",
        apellido_paterno=p.apellido_paterno or "",
        apellido_materno=p.apellido_materno or "",
        calle=p.calle or "",
        numero_exterior=p.numero_exterior or "",
        numero_interior=p.numero_interior or "",
        colonia=p.colonia or "",
        codigo_postal=p.codigo_postal or "",
        localidad=p.localidad or "",
        municipio=p.municipio or "",
        entidad_federativa=p.entidad_federativa or "",
        pais=p.pais or "",
    )


def _mercancia_to_view(m: MercanciaCove) -> CoveItemView:
    descripciones = [
        CoveDescEspecificaView(
            marca=d.marca or "",
            modelo=d.modelo or "",
            submodelo=d.submodelo or "",
            numero_serie=d.numero_serie or "",
        )
        for d in (m.descripcion_especifica or [])
    ]
    return CoveItemView(
        descripcion_generica=m.descripcion_generica or "",
        clave_unidad_medida=m.clave_unidad_medida or "",
        cantidad=_fmt_decimal(m.cantidad),
        tipo_moneda=m.tipo_moneda or "",
        valor_unitario=_fmt_decimal(m.valor_unitario),
        valor_total=_fmt_decimal(m.valor_total),
        valor_dolares=_fmt_decimal(m.valor_dolares),
        descripciones_especificas=descripciones,
    )


def _strip_tag(tag: str) -> str:
    """
    Normaliza un tag XML removiendo el namespace ({...}) y llevándolo a minúsculas.
    """
    if "}" in tag:
        tag = tag.split("}", 1)[1]
    return tag.strip().lower()


def _find_first_text(elem: ET.Element, candidates: list[str]) -> str:
    """
    Busca recursivamente el primer elemento cuyo nombre coincida con alguno
    de los candidatos (ignorando namespace y mayúsculas) y devuelve su texto.
    """
    wanted = {c.lower() for c in candidates}
    for node in elem.iter():
        if _strip_tag(node.tag) in wanted:
            return (node.text or "").strip()
    return ""


def _find_child_text(elem: ET.Element, candidates: list[str]) -> str:
    """
    Busca solo entre los hijos directos de elem.
    """
    wanted = {c.lower() for c in candidates}
    for node in list(elem):
        if _strip_tag(node.tag) in wanted:
            return (node.text or "").strip()
    return ""


def _build_persona_from_xml(root: ET.Element, role_candidates: list[str]) -> CovePersonaView:
    """
    Intenta construir una CovePersonaView desde un bloque <emisor> o <destinatario>
    (o etiquetas equivalentes) en el XML de COVE.

    Estrategia de búsqueda:
    - Identidad/nombre: _find_child_text (hijos directos) para evitar que campos
      de otros bloques hermanos contaminen el resultado.
    - Domicilio: _find_first_text dentro del elemento de persona, porque el XML
      de VUCEM puede anidar las direcciones en un sub-bloque <domicilio>.
    """
    wanted_roles = {r.lower() for r in role_candidates}
    persona_elem: Optional[ET.Element] = None
    for node in root.iter():
        if _strip_tag(node.tag) in wanted_roles:
            persona_elem = node
            break

    if persona_elem is None:
        return CovePersonaView()

    e = persona_elem
    return CovePersonaView(
        # Campos de identificación — busca solo hijos directos para no mezclar
        # datos entre emisor y destinatario cuando ambos están en el mismo árbol.
        tipo_identificador=_find_child_text(e, [
            "tipoIdentificador", "tipo_identificador",
        ]),
        identificacion=_find_child_text(e, [
            # El campo del schema Pydantic se serializa como "identificacion"
            "identificacion",
            # Variantes legacy / VUCEM camelCase
            "identificador", "taxId", "tax_id", "rfc", "sinTaxId", "sin_tax_id",
        ]),
        nombre=_find_child_text(e, [
            "nombre", "razonSocial", "razon_social", "name",
        ]),
        apellido_paterno=_find_child_text(e, ["apellidoPaterno", "apellido_paterno"]),
        apellido_materno=_find_child_text(e, ["apellidoMaterno", "apellido_materno"]),
        # Campos de domicilio — búsqueda recursiva dentro de la persona para
        # manejar el wrapper <domicilio> que usa el esquema VUCEM.
        calle=_find_first_text(e, ["calle", "street"]),
        numero_exterior=_find_first_text(e, ["numeroExterior", "numero_exterior", "numExterior"]),
        numero_interior=_find_first_text(e, ["numeroInterior", "numero_interior", "numInterior"]),
        colonia=_find_first_text(e, ["colonia", "neighborhood"]),
        codigo_postal=_find_first_text(e, ["codigoPostal", "codigo_postal", "cp", "zipCode", "zip_code"]),
        localidad=_find_first_text(e, ["localidad", "ciudad", "city"]),
        municipio=_find_first_text(e, ["municipio", "delegacion"]),
        entidad_federativa=_find_first_text(e, [
            "entidadFederativa", "entidad_federativa", "estado", "state",
        ]),
        pais=_find_first_text(e, ["pais", "country", "pais_code", "paisCode"]),
    )


def _build_items_from_xml(root: ET.Element) -> list[CoveItemView]:
    """
    Construye una lista de CoveItemView a partir de los nodos <mercancia>
    (o equivalentes) del XML COVE.
    """
    items: list[CoveItemView] = []

    # Encontrar todos los nodos tipo "mercancia"
    for node in root.iter():
        if _strip_tag(node.tag) not in {"mercancia", "mercancias", "item", "partida"}:
            continue

        # Evitar interpretar el contenedor <mercancias> como ítem
        # (si tiene hijos <mercancia> lo tratamos como contenedor).
        child_tags = {_strip_tag(c.tag) for c in list(node)}
        if "mercancia" in child_tags:
            continue

        desc_generica = _find_child_text(node, ["descripcionGenerica", "descripcion_generica", "descripcion"])
        clave_uom = _find_child_text(node, ["claveUnidadMedida", "clave_unidad_medida", "claveUmed"])
        cantidad_txt = _find_child_text(node, ["cantidad"])
        tipo_moneda = _find_child_text(node, ["tipoMoneda", "tipo_moneda", "moneda"])
        valor_unit_txt = _find_child_text(node, ["valorUnitario", "valor_unitario"])
        valor_total_txt = _find_child_text(node, ["valorTotal", "valor_total"])
        valor_usd_txt = _find_child_text(node, ["valorDolares", "valor_dolares", "valorUsd", "valor_usd"])

        def _to_decimal(text: str) -> Decimal:
            try:
                return Decimal(text.replace(",", "")) if text else Decimal("0")
            except Exception:
                return Decimal("0")

        cantidad = _to_decimal(cantidad_txt).quantize(Decimal("0.01"))
        valor_unitario = _to_decimal(valor_unit_txt).quantize(Decimal("0.01"))
        valor_total = _to_decimal(valor_total_txt).quantize(Decimal("0.01"))
        valor_dolares = _to_decimal(valor_usd_txt).quantize(Decimal("0.01"))

        # Descripciones específicas (marca/modelo/serie/submodelo)
        desc_especificas: list[CoveDescEspecificaView] = []
        for de in node.iter():
            # _strip_tag ya devuelve minúsculas, así que los candidatos deben
            # estar también en minúsculas para que el `in` funcione.
            if _strip_tag(de.tag) not in {"descripcionespecifica", "descripcion_especifica"}:
                continue
            desc_especificas.append(
                CoveDescEspecificaView(
                    marca=_find_child_text(de, ["marca"]),
                    modelo=_find_child_text(de, ["modelo"]),
                    submodelo=_find_child_text(de, ["submodelo"]),
                    numero_serie=_find_child_text(de, ["numeroSerie", "numero_serie", "serie"]),
                )
            )

        items.append(
            CoveItemView(
                descripcion_generica=desc_generica,
                clave_unidad_medida=clave_uom,
                cantidad=_fmt_decimal(cantidad),
                tipo_moneda=tipo_moneda,
                valor_unitario=_fmt_decimal(valor_unitario),
                valor_total=_fmt_decimal(valor_total),
                valor_dolares=_fmt_decimal(valor_dolares),
                descripciones_especificas=desc_especificas,
            )
        )

    return items


# ---------------------------------------------------------------------------
# Servicio principal
# ---------------------------------------------------------------------------

class CoveAcuseReportService:
    """
    Genera el PDF de Acuse de COVE a partir de los datos de la factura en BD.

    Patrón idéntico al de DodaReportPdfService:
      1. build_context()   → CoveAcuseContext  (desde BD vía FacturaCoveDomainService)
      2. render_pdf_bytes() → bytes             (Jinja2 → HTML → pdfkit/wkhtmltopdf)
    """

    def __init__(self) -> None:
        self.template_dir = Path(__file__).parent / "templates"
        self.jinja_env = Environment(
            loader=FileSystemLoader(str(self.template_dir)),
            autoescape=select_autoescape(["html"]),
        )
        self._tpl = self.jinja_env.get_template("acuse_cove.html")

    # ------------------------------------------------------------------
    # Helpers de imagen
    # ------------------------------------------------------------------

    def _image_data_uri(self, filename: str) -> str:
        """Carga una imagen del directorio de templates y la devuelve como data URI base64.

        Detecta el tipo real desde magic bytes en lugar de confiar en la extensión,
        porque las imágenes del template son JPEGs renombrados como .png.
        """
        img_path = self.template_dir / filename
        try:
            with open(img_path, "rb") as fh:
                raw = fh.read()
            data = base64.b64encode(raw).decode("ascii")
            if raw[:2] == b"\xff\xd8":
                mime = "image/jpeg"
            elif raw[:8] == b"\x89PNG\r\n\x1a\n":
                mime = "image/png"
            elif raw[:6] in (b"GIF87a", b"GIF89a"):
                mime = "image/gif"
            else:
                ext = img_path.suffix.lstrip(".").lower()
                mime = f"image/{ext}"
            return f"data:{mime};base64,{data}"
        except Exception:
            logger.warning("No se pudo cargar imagen del template: %s", img_path)
            return ""

    # ------------------------------------------------------------------
    # wkhtmltopdf config (mismo helper que DodaReportPdfService)
    # ------------------------------------------------------------------

    def _get_wkhtmltopdf_config(self):
        for path in (
            shutil.which("wkhtmltopdf"),
            "/usr/local/bin/wkhtmltopdf",
            "/usr/bin/wkhtmltopdf",
        ):
            if path:
                return pdfkit.configuration(wkhtmltopdf=path)
        raise RuntimeError("wkhtmltopdf binary not found.")

    # ------------------------------------------------------------------
    # Construcción del contexto desde BD
    # ------------------------------------------------------------------

    def _load_cove_xml_bytes(
        self,
        domain: FacturaCoveDomainService,
        ctx: Any,
    ) -> Optional[bytes]:
        """
        Intenta obtener el XML de COVE asociado a la factura.

        Orden de búsqueda:
          1. Clave S3 específica por factura (guardada al recibir el XML de VU):
             ``.../invoices/{invoice_id}/cove/cove.xml``
          2. CustomsBrokerVU.xml_files_path (ruta compartida legacy).
          3. Company.ventanilla_unica.xml_files_path (ruta compartida legacy).

        Devuelve None si no encuentra o no puede leer ningún XML.
        """
        # 1. Clave S3 específica por factura (prioridad máxima)
        invoice = getattr(ctx, "invoice", None)
        if invoice is not None:
            try:
                invoice_xml_key = cove_xml_key(invoice.tenant_id, invoice.company_id, invoice.id)
                xml_bytes = storage_s3.get_object_bytes(invoice_xml_key)
                if xml_bytes:
                    logger.info("XML de COVE cargado desde S3 por factura: %s", invoice_xml_key)
                    return xml_bytes
            except Exception:
                logger.debug("XML de COVE no encontrado en S3 por factura %s", getattr(invoice, "id", None))

        # 2 & 3. Rutas configuradas en VU / empresa (modo legacy)
        candidates: list[str] = []

        vu = getattr(ctx, "vu", None)
        if vu is not None:
            ref = getattr(vu, "xml_files_path", None)
            if isinstance(ref, str) and ref.strip():
                candidates.append(ref.strip())

        company = domain._get_company(ctx)
        company_vu = getattr(company, "ventanilla_unica", None) if company else None
        if company_vu is not None:
            ref = getattr(company_vu, "xml_files_path", None)
            if isinstance(ref, str) and ref.strip():
                candidates.append(ref.strip())

        for ref in candidates:
            try:
                if ref.startswith("tenants/"):
                    return storage_s3.get_object_bytes(ref)
                if os.path.isfile(ref):
                    with open(ref, "rb") as fh:
                        return fh.read()
            except Exception:
                logger.warning("No se pudo leer XML de COVE desde %s", ref, exc_info=True)

        return None

    def _build_context_from_xml_if_possible(
        self,
        xml_bytes: bytes,
        invoice: Any,
        compliance: Any,
        invoice_specific: bool = False,
    ) -> Optional[CoveAcuseContext]:
        """
        Intenta construir un CoveAcuseContext en base al XML de COVE.

        - Si ``invoice_specific=True`` el XML fue cargado desde la clave S3
          específica de la factura; en ese caso se omite la validación cruzada
          por e-document (ya sabemos que pertenece a esta factura) y se usa
          el primer nodo COVE válido.
        - Si ``invoice_specific=False`` (modo legacy / ruta compartida) se
          requiere que el e-document de la factura coincida con el XML.
        """
        try:
            root = ET.fromstring(xml_bytes)
        except Exception:
            logger.warning("No se pudo parsear XML de COVE para factura %s", getattr(invoice, "id", None))
            return None

        edoc_invoice = (getattr(compliance, "edocument", None) or "").strip() if compliance else ""

        cove_node: Optional[ET.Element] = None

        if invoice_specific:
            # Modo sin validación cruzada: tomamos el primer nodo con datos de factura.
            for node in root.iter():
                if _find_first_text(node, ["numeroFactura", "numero_factura", "factura"]):
                    cove_node = node
                    break
            # Si no encontramos nodo con numero_factura, usamos root directamente.
            if cove_node is None:
                cove_node = root
        else:
            if not edoc_invoice:
                # Sin e-document en la factura no podemos ligar el XML de ruta compartida.
                return None

            # Buscar el bloque cuyo e-document coincida exactamente con el de la factura.
            for node in root.iter():
                edoc_xml = _find_first_text(node, ["e-document", "edocument", "edocumento"])
                if edoc_xml and edoc_xml.strip().upper() == edoc_invoice.upper():
                    cove_node = node
                    break

            if cove_node is None:
                logger.info(
                    "XML de COVE no contiene bloque con e-document=%s para factura %s",
                    edoc_invoice,
                    getattr(invoice, "id", None),
                )
                return None

        # Cabecera / datos generales
        # Cada lista incluye: camelCase VUCEM, snake_case del schema Pydantic,
        # y variantes alternativas observadas en XMLs de VUCEM.
        tipo_operacion = _find_first_text(cove_node, [
            "tipoOperacion", "tipo_operacion",
        ])
        numero_factura_relacionada = _find_first_text(cove_node, [
            "numeroFacturaOriginal", "numero_factura_original",
            "relacionFactura", "relacionFacturas", "numero_factura_relacionada",
            "facturaRelacionada",
        ])
        numero_factura = _find_first_text(cove_node, [
            "numeroFactura", "numero_factura", "factura", "invoice_number",
        ])
        tipo_figura = _find_first_text(cove_node, [
            "tipoFigura", "tipo_figura",
        ])
        fecha_expedicion = _find_first_text(cove_node, [
            "fechaExpedicion", "fecha_expedicion", "fechaEmision", "fecha_emision",
        ])
        observaciones = _find_first_text(cove_node, [
            "observaciones", "observacionesCove", "observaciones_cove",
        ])
        rfc_consulta = _find_first_text(cove_node, [
            "rfcConsulta", "rfc_consulta", "rfcDeConsulta",
        ])
        nombre_razon_social_consulta = _find_first_text(cove_node, [
            "nombreRazonSocialConsulta", "nombre_razon_social_consulta",
            "nombreConsulta", "razonSocialConsulta",
        ])
        patente_aduanal = _find_first_text(cove_node, [
            "patenteAduanal", "patente_aduanal", "patente",
        ])
        subdivision = _find_first_text(cove_node, [
            "subdivision", "tieneSubdivision", "tiene_subdivision",
        ])
        certificado_origen = _find_first_text(cove_node, [
            "certificadoOrigen", "certificado_origen",
        ])
        exportador_autorizado = _find_first_text(cove_node, [
            "exportadorAutorizado", "exportador_autorizado",
            "numExportadorAutorizado", "numero_exportador_autorizado",
        ])

        if not invoice_specific and not numero_factura:
            logger.info(
                "Bloque XML de COVE con e-document=%s no tiene numeroFactura legible; se descarta.",
                edoc_invoice,
            )
            return None

        # Si no hay e-document en la factura, intentamos extraerlo del XML.
        edoc_final = edoc_invoice or _find_first_text(cove_node, ["e-document", "edocument", "edocumento"])

        emisor_view = _build_persona_from_xml(cove_node, ["emisor", "exportador"])
        destinatario_view = _build_persona_from_xml(cove_node, ["destinatario", "importador"])
        items = _build_items_from_xml(cove_node)

        return CoveAcuseContext(
            tipo_operacion=tipo_operacion or "",
            numero_factura_relacionada=numero_factura_relacionada or "",
            numero_factura=numero_factura or "",
            tipo_figura=tipo_figura or "",
            fecha_expedicion=fecha_expedicion or "",
            observaciones=observaciones or "",
            rfc_consulta=rfc_consulta or "",
            nombre_razon_social_consulta=nombre_razon_social_consulta or "",
            patente_aduanal=patente_aduanal or "",
            subdivision=subdivision or "",
            certificado_origen=certificado_origen or "",
            exportador_autorizado=exportador_autorizado or "",
            edocument=edoc_final,
            vucem_operation_num=(getattr(compliance, "vucem_operation_num", None) or "") if compliance else "",
            emisor=emisor_view,
            destinatario=destinatario_view,
            items=items,
        )

    def build_context(
        self,
        db: Session,
        invoice_id: int,
        tenant_id: int,
        company_id: int,
    ) -> CoveAcuseContext:
        """
        Construye el CoveAcuseContext reutilizando FacturaCoveDomainService.

        Flujo:
          1. Intenta leer el XML de COVE específico de la factura desde S3
             (cargado al recibir la respuesta de VU).
          2. Si no existe, intenta desde xml_files_path configurado en VU / empresa.
          3. Si no hay XML disponible, lanza ValidationException.
        """
        domain = FacturaCoveDomainService(db)
        ctx = domain._load_context(invoice_id, tenant_id, company_id)
        invoice = ctx.invoice
        compliance = invoice.compliance_mx

        # Intentar primero la clave S3 específica por factura.
        invoice_specific = False
        xml_bytes: Optional[bytes] = None
        try:
            invoice_xml_key = cove_xml_key(tenant_id, company_id, invoice_id)
            xml_bytes = storage_s3.get_object_bytes(invoice_xml_key)
            if xml_bytes:
                invoice_specific = True
        except Exception:
            logger.debug("XML de COVE no encontrado en S3 por factura %s, intentando ruta configurada.", invoice_id)

        # Fallback: rutas configuradas en VU / empresa.
        if not xml_bytes:
            try:
                xml_bytes = self._load_cove_xml_bytes(domain, ctx)
            except Exception:
                logger.warning("Error leyendo XML de COVE para factura %s", invoice.id, exc_info=True)
                xml_bytes = None

        if not xml_bytes:
            raise ValidationException(
                "La factura aún no cuenta con XML de COVE consultado en Ventanilla Única.",
                errors=[
                    {
                        "field": "vu.xml_files_path",
                        "message": "No se encontró ningún archivo XML de COVE asociado a la configuración VU.",
                    }
                ],
            )

        xml_ctx = self._build_context_from_xml_if_possible(
            xml_bytes, invoice, compliance, invoice_specific=invoice_specific
        )
        if not xml_ctx:
            raise ValidationException(
                "No se pudo interpretar el XML de COVE asociado a la factura.",
                errors=[
                    {
                        "field": "cove_xml",
                        "message": "El XML de COVE no coincide con el e-document de la factura o no tiene la estructura esperada.",
                    }
                ],
            )

        return xml_ctx

    # ------------------------------------------------------------------
    # Renderizado PDF
    # ------------------------------------------------------------------

    # Dimensiones de las imágenes de cabecera/pie (JPEG 1024px de ancho):
    #   HeaderCOVE: 149px tall → ~31mm a ancho Letter (215.9mm)
    #   FooterCOVE: 126px tall → ~27mm a ancho Letter
    _HEADER_MARGIN_MM = "34"   # 31mm imagen + 3mm buffer
    _FOOTER_MARGIN_MM = "29"   # 27mm imagen + 2mm buffer

    @staticmethod
    def _make_page_frame_html(img_data_uri: str) -> str:
        """Genera el HTML mínimo para usar como header/footer de wkhtmltopdf.

        Usa resets explícitos en html/body y overflow:hidden para evitar que
        wkhtmltopdf añada scrollbars al viewport del header/footer, lo que
        reduciría el ancho efectivo y cortaría la imagen.
        """
        return (
            "<!DOCTYPE html><html><head><meta charset=\"UTF-8\">"
            "<style>"
            "*{margin:0;padding:0;box-sizing:border-box;}"
            "html,body{width:100%;overflow:hidden;}"
            "body{padding:0 8mm;}"
            "img{width:100%;display:block;}"
            "</style>"
            "</head><body>"
            f"<img src=\"{img_data_uri}\">"
            "</body></html>"
        )

    def render_pdf_bytes(self, context: CoveAcuseContext) -> bytes:
        header_uri = self._image_data_uri("HeaderCOVE.png")
        footer_uri = self._image_data_uri("FooterCOVE.png")

        html = self._tpl.render(
            header_img=header_uri,
            footer_img=footer_uri,
            tipo_operacion=context.tipo_operacion,
            numero_factura_relacionada=context.numero_factura_relacionada,
            numero_factura=context.numero_factura,
            tipo_figura=context.tipo_figura,
            fecha_expedicion=context.fecha_expedicion,
            observaciones=context.observaciones,
            rfc_consulta=context.rfc_consulta,
            nombre_razon_social_consulta=context.nombre_razon_social_consulta,
            patente_aduanal=context.patente_aduanal,
            subdivision=context.subdivision,
            certificado_origen=context.certificado_origen,
            exportador_autorizado=context.exportador_autorizado,
            edocument=context.edocument,
            vucem_operation_num=context.vucem_operation_num,
            emisor=context.emisor,
            destinatario=context.destinatario,
            items=context.items,
        )

        # Escribir los HTML de cabecera y pie en archivos temporales.
        # wkhtmltopdf los renderiza en el área de margen de CADA página.
        header_tmp = tempfile.NamedTemporaryFile(
            suffix=".html", delete=False, mode="w", encoding="utf-8"
        )
        header_tmp.write(self._make_page_frame_html(header_uri))
        header_tmp.close()

        footer_tmp = tempfile.NamedTemporaryFile(
            suffix=".html", delete=False, mode="w", encoding="utf-8"
        )
        footer_tmp.write(self._make_page_frame_html(footer_uri))
        footer_tmp.close()

        try:
            options = {
                "page-size": "Letter",
                "encoding": "UTF-8",
                "margin-top": self._HEADER_MARGIN_MM,
                "margin-bottom": self._FOOTER_MARGIN_MM,
                "margin-left": "0",
                "margin-right": "0",
                "header-html": header_tmp.name,
                "footer-html": footer_tmp.name,
                "header-spacing": "0",
                "footer-spacing": "0",
                "print-media-type": "",
                "enable-local-file-access": "",
            }
            return pdfkit.from_string(
                html,
                False,
                options=options,
                configuration=self._get_wkhtmltopdf_config(),
            )
        finally:
            os.unlink(header_tmp.name)
            os.unlink(footer_tmp.name)

    def build_pdf(
        self,
        db: Session,
        invoice_id: int,
        tenant_id: int,
        company_id: int,
    ) -> bytes:
        ctx = self.build_context(db, invoice_id, tenant_id, company_id)
        return self.render_pdf_bytes(ctx)
