#!/usr/bin/env python3
"""
Genera un PDF de informe Dossier usando WeasyPrint + pypdf.

Flujo:
  1. Lee el JSON de entrada con metadata + caratula_html + contenido + secciones
  2. Genera carátula como PDF separado sin header/footer (página completa)
  3. Genera índice como PDF separado sin header/footer
  4. Genera el contenido como PDF con header/footer via @page CSS Paged Media
  5. Genera contraportada como PDF separado sin header/footer
  6. Une todos los PDFs: carátula + índice + contenido + contraportada

Input JSON esperado:
{
  "nombre":          "EMPRESA SA",
  "cuit":            "30641368977",
  "cliente":         "CLIENTE SA",
  "tipo":            "Dossier YPF Test",
  "mes_anio":        "JULIO 2026",
  "pais":            "ARGENTINA",
  "disclaimer":      "Esta información es confidencial...",
  "contenido":       "<html del cuerpo exportable>",
  "secciones": [
    {"id": "introduccion",   "titulo": "INTRODUCCIÓN",             "nivel": 1},
    {"id": "informe-det",    "titulo": "INFORME DETALLADO",        "nivel": 1},
    ...
  ]
}

Uso:
  python3 generar_pdf.py <json_input> <pdf_output>
"""

import sys
import os
import json
import tempfile
import warnings

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from bs4 import BeautifulSoup, Tag

warnings.filterwarnings("ignore", category=UserWarning)

from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from pypdf import PdfWriter, PdfReader
from pdf_metadata import aplicar_meta_limpia


# Public real del backend Laravel.
# Se deja fijo a propósito para evitar cargar CSS/assets de otro proyecto.
PUBLIC_DIR = '/var/www/html/produccion/public'
BASE_URL   = f'file://{PUBLIC_DIR}/'

CSS_FILES = [
    f'{PUBLIC_DIR}/Libreria_CA/bootstrap-5.3.2/css/bootstrap.css',
    f'{PUBLIC_DIR}/Libreria_CA/bootstrap-icons-1.11.3/font/bootstrap-icons.css',
    f'{PUBLIC_DIR}/css/estilos_fidelitasV2.css',
    f'{PUBLIC_DIR}/css/estilos_fidelitas.css',
    f'{PUBLIC_DIR}/Libreria_CA/css/backend_inquiro.css',
    f'{PUBLIC_DIR}/Libreria_CA/css/prod.css',
    f'{PUBLIC_DIR}/Libreria_CA/css/plantillas_fidelitas.css',
    f'{PUBLIC_DIR}/Libreria_CA/css/fidecheck.css',
    f'{PUBLIC_DIR}/Libreria_CA/css/plantillas_pdf.css',
]

# ─────────────────────────────────────────────
# CSS base compartido por todas las secciones
# ─────────────────────────────────────────────
CSS_COMUN = """
:root {
    --bs-fidelitas:       #1c3a5e;
    --bs-fidelitas-rgb:   28, 58, 94;
    --fid-pri:            #1c3a5e;
    --fid-sec:            #7b9fc6;
    --fid-medio:          #2d5b8b;
    --fid-claro:          #e8f0f8;
    --fid-oscuro:         #0d1f35;
    --bs-fidelitasOR:     #e07b00;
}

*, *::before, *::after { box-sizing: border-box; }

body {
    font-family: Arial, sans-serif;
    font-size: 9pt;
    color: #222;
    margin: 0;
    padding: 0;
}

h1 { font-size: 16pt !important; margin-top: 0.4em !important; margin-bottom: 0.3em !important; }
h2 { font-size: 14pt !important; margin-top: 0.4em !important; margin-bottom: 0.3em !important; }
h3 { font-size: 12pt !important; margin-top: 0.4em !important; margin-bottom: 0.3em !important; }
h4, h5, h6 { margin-top: 0.4em !important; margin-bottom: 0.3em !important; }
p   { margin-top: 0.2em !important; margin-bottom: 0.3em !important; }
ul, ol { margin-top: 0.2em !important; margin-bottom: 0.3em !important; padding-left: 1.2em !important; }
li  { margin-bottom: 0.1em !important; }

table { width: 100%; border-collapse: collapse; }
th, td { padding: 3px 6px; font-size: 8.5pt; }

p, li                   { orphans: 2; widows: 2; }
h1, h2, h3, h4, h5, h6 { orphans: 2; widows: 2; }
h5                      { page-break-after: auto !important; break-after: auto !important; }

.subtitulo-seccion,
.subtitulo-seccion + *,
h4.subtitulo-seccion    { break-after: avoid; page-break-after: avoid; }

tr, img, figure         { page-break-inside: avoid; break-inside: avoid; }
table                   { page-break-inside: auto; }
thead                   { display: table-header-group; }

.tablas_bancos,
[style*="float:left"],
[style*="float: left"]  { float: none !important; width: 100% !important; display: block !important; }

/* Tablas de central de deudores — anular nowrap y font-size inline para que entren en la página */
#contenedor_bancos table,
#contenedor_bancos th,
#contenedor_bancos td,
.tablas_bancos table,
.tablas_bancos th,
.tablas_bancos td        { white-space: normal !important; font-size: 7pt !important; padding: 2px 3px !important; }
#contenedor_bancos th.text-start,
#contenedor_bancos td.text-start { max-width: 3.5cm !important; word-break: break-word !important; }
#contenedor_bancos th.bancos_th,
#contenedor_bancos td.bancos_td  { min-width: 0 !important; width: auto !important; }

.card-brief,
.card-brief .card-body,
.card-brief .card-body.overflow-auto,
.card-brief .card-body.overflow-hidden,
.contenido              { height: auto !important; max-height: none !important; min-height: 0 !important; overflow: visible !important; }

/* Cards del brief: rectangulares y compactas — 2 filas de 3 = 1 director, 2 directores por página */
.card-brief             { break-inside: avoid !important; page-break-inside: avoid !important;
                          margin-bottom: 0 !important; margin-top: 0 !important; height: 5.5cm !important;
                          overflow: hidden !important; font-size: 6pt !important; }
.card-brief .card-header { padding: 2px 5px !important; }
.card-brief .card-body  { padding: 3px 5px !important; overflow: hidden !important; height: auto !important; font-size: 6pt !important; }
.card-brief .card-header h5 { font-size: 7pt !important; margin: 0 !important; }
.card-brief .fbrief,
.card-brief .fs-7,
.card-brief .fs-8       { font-size: 6pt !important; }
.card-brief strong      { font-size: 6pt !important; }
.card-brief li          { font-size: 6pt !important; }
.card-brief .score100,
.card-brief [style*="font-size: 48px"],
.card-brief [style*="font-size:48px"] { font-size: 38px !important; line-height: 1 !important; text-align: center !important; display: block !important; width: 100% !important; }
.card-brief h6          { font-size: 6.5pt !important; margin: 0 !important; }

/* Score — dos columnas, sin espaciado vertical */
.card-brief .card-score .card-body .row      { margin: 0 !important; padding: 0 !important; }
.card-brief .card-score .card-body .row.my-1 { margin-top: 0 !important; margin-bottom: 0 !important; }
.card-brief .card-score .card-body .col-6    { width: 50% !important; max-width: 50% !important; padding: 0 1px !important; line-height: 1.2 !important; overflow: visible !important; }

/* Financiero — achicar espaciado entre filas */
.card-brief #card_bancos tr td { padding: 1px 2px !important; font-size: 6pt !important; }
.card-brief .text-center.pt-2  { padding-top: 2px !important; }

/* Fiscales — etiqueta y valor apilados verticalmente */
.card-brief .card-body:not(.card-score) .col-6 { width: 100% !important; max-width: 100% !important; padding: 0 !important; }
.card-brief .card-body .col-6 ul      { margin: 0 !important; padding-left: 0.8em !important; }
.card-brief .card-body .col-6 li      { font-size: 5.5pt !important; line-height: 1.2 !important; }
.card-brief .card-body .col-6 strong  { font-size: 5.5pt !important; }

/* Quitar márgenes negativos de Bootstrap en rows dentro de cards brief */
.card-brief .card-body > .row { margin-left: 0 !important; margin-right: 0 !important; }

/* 3 cards por fila */
.row-cols-print-3,
.row-cols-lg-2,
.row-cols-xl-3          { display: flex !important; flex-wrap: wrap !important; margin-top: 0.2rem !important; }
.row-cols-print-3 > .col,
.row-cols-lg-2 > .col,
.row-cols-xl-3 > .col  { width: 33.333% !important; max-width: 33.333% !important; box-sizing: border-box !important; padding: 0.1rem !important; }

/* Quitar margin-top del row de cards */
#contenido-64 .row-cols-print-3,
#contenido-64 .row-cols-lg-2,
#contenido-64 .row-cols-xl-3  { margin-top: 0 !important; margin-bottom: 0 !important; }

/* Nombre del director */
#contenido-64 .pt-2.pb-0 h5 { font-size: 11px !important; padding: 2px 0 !important; margin-bottom: 0.2em !important; }

/* Cada director se envuelve como una unidad indivisible. WeasyPrint decide
   dinámicamente si entra 1 o 2 por página según el espacio real disponible. */
.pdf-director-block {
    break-inside: avoid !important;
    page-break-inside: avoid !important;
    margin: 0 !important;
    padding: 0 !important;
}

/* Desde el segundo director se agrupan de a dos. No fuerza salto: si la pareja
   entra, queda; si no entra completa, WeasyPrint mueve ambas unidades juntas. */
.pdf-director-pair {
    break-inside: avoid !important;
    page-break-inside: avoid !important;
    margin: 0 !important;
    padding: 0 !important;
}

* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }

/* ── Colores hardcodeados (WeasyPrint resuelve mal CSS variables en algunos contextos) ── */
.bg-fidelitas, .bg-fid-pri, .bg-fidelitas-default,
.fondo-fid-pri                           { background-color: #1c3a5e !important; color: #fff !important; }
table tr.bg-fidelitas-default td,
table thead.bg-fidelitas-default th      { background-color: #1c3a5e !important; color: #fff !important; }
.bg-fid-medio,  .fondo-fid-medio        { background-color: #2d5b8b !important; color: #fff !important; }
.bg-fid-sec,    .fondo-fid-sec          { background-color: #7b9fc6 !important; color: #fff !important; }
.bg-fid-claro,  .fondo-fid-claro        { background-color: #e8f0f8 !important; }
.bg-fid-oscuro, .fondo-fid-oscuro       { background-color: #0d1f35 !important; color: #fff !important; }
table thead.bg-fid-medio tr th          { background-color: #2d5b8b !important; color: #fff !important; }
table thead.bg-fid-pri   tr th          { background-color: #1c3a5e !important; color: #fff !important; }

.text-fidelitas, .text-fid-pri          { color: #1c3a5e !important; }
h4                                      { font-size: 13pt !important; }
h4.text-fid-pri                         { color: #1c3a5e; font-size: 13pt !important; font-weight: bold !important; }
h4.titulo-fidelitas[style*="color: #fff"],
h4.titulo-fidelitas[style*="color:#fff"]  { color: #fff !important; }
.text-fid-medio                         { color: #2d5b8b !important; }
.text-fid-sec                           { color: #7b9fc6 !important; }
.text-fidelitasOR                       { color: #1c3a5e !important; }
.titulo-fidelitas                       { color: #1c3a5e !important; }
.border-top-fidleitasOR,
.border-top-fidleitasOR-pdf             { border-top: 2px solid #1c3a5e !important; }
.border-bottom-fidleitasOR,
.border-bottom-fidleitasOR-pdf          { border-bottom: 2px solid #1c3a5e !important; }
.border-fidelitas                       { border-color: #1c3a5e !important; }

/* Semáforos */
.situacion1, .situacion11               { background-color: #96CAB0 !important; }
.situacion2                             { background-color: #FFFFBA !important; }
.situacion3                             { background-color: #fdc689 !important; }
.situacion4                             { background-color: #FD676E !important; }
.situacion5, .situacion6               { background-color: #c55b5b !important; }
.situacion7                             { background-color: #cccccc !important; }

/* Ocultar elementos interactivos y de pantalla */
button, .btn, input, select,
textarea, label[for]                    { display: none !important; }
/* Ocultar forms EXCEPTO el resumen ejecutivo del dossier */
form:not(#frmDossierPreview)            { display: none !important; }
#frmDossierPreview                      { display: block !important; }
/* Formularios en headers de grupo y celdas de entidades — mostrar como texto */
.ic-group-header form,
.socio-group-header form,
.ic-col-text form                       { display: inline !important; }
.ic-entity-link,
.ic-group-header button,
.socio-group-header button,
.ic-col-text button                     { display: inline !important; background: none !important;
                                          border: none !important; padding: 0 !important;
                                          color: inherit !important; font-weight: inherit !important;
                                          font-size: inherit !important; text-decoration: none !important; }
.fa-plus-square,
.seccion-boton-editar-indicadores,
.seccion-boton-editar-conclusion,
.seccion-boton-guardar-conclusion,
.seccion-cerrar-editor-indicadores,
.seccion-cerrar-editor-ubicacion,
.seccion-editar-ubicacion,
.ver_menos_domi, .ver_mas_domi,
.ver_menos_telefonos, .ver_mas_telefonos,
.seccion-consulta-constancia-cuit       { display: none !important; }

.gap-3 { gap: 8px !important; }
.bg-gris-200 { background-color: #d9d9d9 !important; padding: 4px 8px !important; }

/* Subtítulos internos de sección — no forzar salto de página */
.ic-subsection-title {
    break-before: auto !important;
    page-break-before: auto !important;
    break-after: avoid !important;
    page-break-after: avoid !important;
    orphans: 2 !important;
    widows: 2 !important;
    padding-top: 0.2cm !important;
    color: #fff !important;
    -webkit-print-color-adjust: exact !important;
    print-color-adjust: exact !important;
}
.ic-section-header {
    padding-top: 0.2cm !important;
    color: #fff !important;
}
/* Cualquier div que contiene un ic-subsection-title al inicio de página */
.padding10 {
    padding-top: 0.3cm !important;
}

/* Cards de sociedades — solo evitar corte interno, sin restyling */
.ic-entity-card, .card.sociedades {
    break-inside: avoid !important;
    page-break-inside: avoid !important;
    font-size: 8pt !important;
}

/* Mapa de relaciones — 70% del tamaño original */
.vis-network, #graf_sociedades, #graf_sociedades img,
[id^="graf_sociedades"] img {
    max-width: 70% !important;
    max-height: 10cm !important;
    width: auto !important;
    height: auto !important;
    display: block !important;
    margin: 0 auto !important;
}

/* Cards de seguros — altura fija causa superposición con MARCAS */
.prensa[style*="height"] {
    height: auto !important;
    min-height: 0 !important;
    padding: 4px 0 !important;
    display: flex !important;
    align-items: center !important;
    gap: 6px !important;
}
.prensa img {
    max-width: 60px !important;
    max-height: 30px !important;
    width: auto !important;
    height: auto !important;
    object-fit: contain !important;
}

/* Título MARCAS — asegurar que no se superponga con seguros */
p.titulo-fidelitas.text-fidelitasOR {
    clear: both !important;
    margin-top: 12px !important;
    margin-bottom: 6px !important;
    padding: 4px 0 !important;
    display: block !important;
}

/* Saltos de página — todas las variantes de nomenclatura.
   NO se usa display:block!important para que .d-none (Bootstrap) pueda
   ocultar los .page-breaker que el JS marca antes de serializar.
   Un elemento display:none no genera caja y sus break-before son ignorados. */
.pdf-page-break,
.page-break {
    break-before: page !important;
    page-break-before: always !important;
    height: 0 !important;
    margin: 0 !important;
    padding: 0 !important;
}
.page-breaker:not(.d-none) {
    break-before: page !important;
    page-break-before: always !important;
    height: 0 !important;
    margin: 0 !important;
    padding: 0 !important;
}

/* Salto normalizado: se aplica al siguiente bloque REAL, nunca a un div vacío. */
.pdf-break-before {
    break-before: page !important;
    page-break-before: always !important;
}

/* Edictos — evitar cortes, padding interno */
/* Edictos — el card completo evita corte si entra en la página,
   pero el texto interno sí puede cortarse si es muy largo */
.ic-press-card,
[class*="edicto"] {
    break-inside: avoid !important;
    page-break-inside: avoid !important;
    font-size: 8pt !important;
    line-height: 1.5 !important;
    padding: 8px 10px !important;
    border: 1px solid #dee2e6 !important;
    border-radius: 4px !important;
    margin-bottom: 6px !important;
    background: #fafafa !important;
}

/* Cards de edictos — cada card permanece íntegra, Python inserta breaks entre ellas */
.row.prensa.card {
    break-inside: avoid !important;
    page-break-inside: avoid !important;
    border: 1px solid #dee2e6 !important;
    border-radius: 4px !important;
    margin-bottom: 8px !important;
    padding: 8px !important;
    background: #fafafa !important;
}
.row.prensa.card .col-md-12 {
    font-size: 8pt !important;
    line-height: 1.5 !important;
    orphans: 3 !important;
    widows: 3 !important;
}
.col-md-12[style*="font-size: 14px"],
.col-md-12[style*="font-size:14px"] {
    font-size: 8pt !important;
    line-height: 1.5 !important;
}

/* WeasyPrint trata flex containers como bloques atómicos y no los fragmenta.
   El contenedor de noticias de prensa (div.row.mostrar-prensa) debe ser un
   bloque normal para que los párrafos internos se corten entre páginas. */
.mostrar-prensa,
.previsualizar-contenido-prensa {
    display: block !important;
    width: 100% !important;
    margin-left: 0 !important;
    margin-right: 0 !important;
}

/* Imágenes de página web (img-fluid).
   WeasyPrint ignora object-fit:contain. Con width:100% explícito no reduce
   el ancho proporcionalmente cuando max-height limita la altura. Con
   width:auto + max-width:100% ambas restricciones actúan proporcionalmente. */
img.img-fluid.w-100.rounded.shadow-sm {
    display: block !important;
    width: auto !important;
    max-width: 100% !important;
    max-height: 8.5cm !important;
    object-fit: contain !important;
    break-inside: avoid !important;
    margin-top: 0.4cm !important;
    margin-bottom: 0.4cm !important;
}
/* Imágenes de sanciones/PEPs y similares (sin clases img-fluid).
   WeasyPrint ignora object-fit:contain; con width:100% inline la imagen
   toma el ancho del contenedor y la altura es la natural (puede ser enorme).
   width:auto + max-width:100% deja que max-height controle el tamaño real.
   La regla img.img-fluid... tiene mayor especificidad y sigue ganando. */
[data-pdf-layout="none"] img {
    width: auto !important;
    max-width: 100% !important;
    height: auto !important;
}

/* FideCheck y PNGs subidos manualmente (card-img-top).
   Bootstrap fuerza width:100% que WeasyPrint respeta, estirando la imagen
   más allá de su resolución real. width:auto + max-width:100% la mantiene
   en su tamaño natural y la escala solo si no entra en el contenedor.
   background-color:white evita que los píxeles transparentes del PNG
   se rendericen como negro (comportamiento por defecto de WeasyPrint). */
img.card-img-top {
    background-color: white !important;
    width: auto !important;
    max-width: 100% !important;
    height: auto !important;
    display: block !important;
    margin: 0 auto !important;
    break-inside: avoid !important;
    page-break-inside: avoid !important;
}

/* El contenedor de imágenes no tiene break-inside avoid */
.row.justify-content-center.my-2 {
    break-inside: auto !important;
    page-break-inside: auto !important;
}
/* El título del componente pegado a la primera imagen */
.componente-seccion-primero .subtitulo-seccion,
.componente-seccion .subtitulo-seccion {
    break-after: avoid !important;
    page-break-after: avoid !important;
}

/* Cards de marcas INPI — inline-block para flujo natural entre páginas */
#div_marcas {
    display: block !important;
    font-size: 0 !important; /* eliminar espacio entre inline-block */
}
#div_marcas .col-md-4 {
    display: inline-block !important;
    vertical-align: top !important;
    break-inside: avoid !important;
    page-break-inside: avoid !important;
    width: 31% !important;
    max-width: 31% !important;
    box-sizing: border-box !important;
    padding: 5px !important;
    margin: 0 1% 6px 0 !important;
    border: 1px solid #dde !important;
    border-radius: 4px !important;
    font-size: 8pt !important; /* restaurar */
}
/* Imagen sin fondo */
#div_marcas .div_marcas_img {
    text-align: center !important;
    margin-bottom: 3px !important;
    background: none !important;
}
#div_marcas .div_marcas_img img {
    max-width: 80px !important;
    max-height: 45px !important;
    width: auto !important;
    height: auto !important;
    object-fit: contain !important;
    background: none !important;
}
/* Datos compactos */
#div_marcas .div_marcas_datos span {
    font-size: 7.5pt !important;
    line-height: 1.3 !important;
    display: block !important;
    margin-bottom: 0 !important;
}
#div_marcas .div_marcas_datos br {
    display: none !important;
}

/* Espacio debajo del header en cada página */
body { padding-top: 0cm !important; }

[style*="display: none"],
[style*="display:none"]                 { display: none !important; }
.d-none                                 { display: none !important; }

/* Títulos de capítulo principal (nivel 1) */
.pdf-capitulo {
    background-color: #1c3a5e;
    color: #fff;
    padding: 10px 16px;
    font-size: 13pt;
    font-weight: bold;
    margin: 0 0 6px 0;
    break-before: page;
    page-break-before: always;
}
.pdf-capitulo:first-child {
    break-before: avoid;
    page-break-before: avoid;
}

/* Títulos de subsección (nivel 2) */
.pdf-subseccion {
    background-color: #e8f0f8;
    color: #1c3a5e;
    padding: 7px 16px;
    font-size: 10.5pt;
    font-weight: bold;
    margin: 10px 0 5px 0;
}

/* ── Elementos web-only — ocultar en PDF ──────────────── */
.modal, .modal-backdrop,
.toast, .toast-container,
.oculto,
.spinner-border, .spinner-grow   { display: none !important; }

/* ── Convenciones PDF declarativas ────────────────────── */
.pdf-hide,
[data-pdf-hide]                  { display: none !important; }

.pdf-keep-together,
[data-pdf-keep-together]         { break-inside: avoid !important;
                                   page-break-inside: avoid !important; }
"""

# ─────────────────────────────────────────────
# CSS @page para el contenido con header/footer
# ─────────────────────────────────────────────
CSS_PAGINA = """
@page {{
    size: A4;
    /* Un poco más de aire arriba y, sobre todo, debajo de la línea azul. */
    margin: 1.60cm 0 1cm 0;

    @top-left {{
        content: element(header-pdf);
        vertical-align: top;
        width: 21cm;
    }}
    @top-right  {{ content: ""; width: 0; }}
    @bottom-left {{
        content: element(footer-pdf);
        vertical-align: top;
        width: 21cm;
    }}
    @bottom-right {{ content: ""; width: 0; }}
}}

body {{ padding: 0 1.5cm; margin: 0; }}

#header-pdf {{
    position: running(header-pdf);
    width: 21cm;
    box-sizing: border-box;
    /* Más respiración interna sin volver al header alto original. */
    padding: 3px 1.5cm 6px 1.5cm;
    border-bottom: 2px solid #1c3a5e;
    display: flex;
    align-items: center;
    justify-content: space-between;
    background: #fff;
}}

#footer-pdf {{
    position: running(footer-pdf);
    width: 21cm;
    box-sizing: border-box;
    padding: 3px 1.5cm 0 1.5cm;
    border-top: 1.5px solid #1c3a5e;
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-family: Arial, sans-serif;
    background: #fff;
}}

.contador-pagina::before {{
    content: "Pág. " counter(page) " / TOTAL_PAGINAS_PLACEHOLDER";
    font-size: 7.5pt;
    font-weight: bold;
    color: #1c3a5e;
}}
"""

# ─────────────────────────────────────────────
# CSS para páginas sin header/footer (carátula, índice, contraportada)
# ─────────────────────────────────────────────
CSS_PAGINA_SIMPLE = """
@page {
    size: A4;
    margin: 0;
}
html, body {
    margin: 0;
    padding: 0;
    font-family: Arial, sans-serif;
    height: 29.7cm;
    max-height: 29.7cm;
    overflow: hidden;
}
"""


# ─────────────────────────────────────────────────────────────────
# Selectores de elementos web-only que se eliminan del DOM antes
# de renderizar. CSS_COMUN también los oculta como doble seguro.
# Para agregar un nuevo tipo de elemento web-only: solo editar aquí.
# ─────────────────────────────────────────────────────────────────
DOM_REMOVE_SELECTORS = [
    "script", "noscript",
    ".modal", ".modal-backdrop",
    ".toast", ".toast-container",
    ".spinner-border", ".spinner-grow",
    ".seccion-boton-editar-conclusion",
    ".seccion-boton-guardar-conclusion",
    ".edicion-conclusion",
    ".seccion-boton-editar-indicadores",
    ".seccion-cerrar-editor-indicadores",
    ".seccion-cerrar-editor-ubicacion",
    ".seccion-editar-ubicacion",
    ".seccion-consulta-constancia-cuit",
    ".ver_menos_domi", ".ver_mas_domi",
    ".ver_menos_telefonos", ".ver_mas_telefonos",
    ".fa-plus-square", ".removerDiv",
    ".pdf-hide", "[data-pdf-hide]",
    ".noGenerar", ".oculto",
    "[id^='cargando-']",
    ".acciones-prensa",
    ".seccion-boton-editar-prensa",
    ".seccion-boton-guardar-prensa",
    ".edicion-prensa",
]

# Forms que se mantienen visibles aunque el CSS oculte todos los forms
FORMS_TO_KEEP = {"frmDossierPreview"}

# Links de CSS de dominios externos que no aplican en el PDF
EXTERNAL_CSS_SKIP_DOMAINS = [
    "produccion.fidelitas.dev",
    "www.fidelitas.dev",
    "fidelitas.dev",
]


def css_links():
    return '\n'.join(
        f'<link rel="stylesheet" href="file://{f}">'
        for f in CSS_FILES if os.path.exists(f)
    )


def procesar_srt_layout(html):
    """Convierte bloques de afiliación SRT a tabla HTML para WeasyPrint.

    Estructura del blade:
      div.rounded.mb-2
        div.d-flex  → fila 1: [div.bg-gris "Aseguradora"] [div valor]
        div.d-flex  → fila 2: [div.bg-gris "N° contrato"] [div.d-flex.gap-3
                                  span nro | div.bg-gris "Desde" | span fecha
                                  | div.bg-gris "Hasta" | span estado]

    Genera una tabla de 2 columnas: label gris | valor.
    La celda valor aplana todos los hijos inline en una fila legible.
    """
    if 'REGISTRO DE CONTRATOS CON LAS ART' not in html and 'afiliacion' not in html.lower():
        return html

    soup = BeautifulSoup(html, 'html.parser')

    def es_bg_gris(tag):
        if not hasattr(tag, 'get'):
            return False
        clases = ' '.join(tag.get('class', []))
        return 'bg-gris' in clases

    def aplanar_inline(contenedor):
        """Convierte hijos mixtos (span/div inline) a texto compacto en una línea."""
        partes = []
        for child in contenedor.children:
            if not hasattr(child, 'get_text'):
                txt = str(child).strip()
                if txt:
                    partes.append(txt)
                continue
            txt = child.get_text(strip=True)
            if not txt:
                continue
            clases = ' '.join(child.get('class', []))
            if 'bg-gris' in clases:
                partes.append(
                    f'<span style="background:#d9d9d9;display:inline-block;'
                    f'padding:5px 8px;font-weight:bold;vertical-align:middle;">{txt}</span>'
                )
            else:
                bold = 'font-weight:bold;' if 'fw-bold' in clases else ''
                partes.append(f'<span style="{bold}">{txt}</span>')
        return ' '.join(partes)

    bloques = soup.find_all(
        'div', class_=lambda c: c and 'rounded' in c and 'mb-2' in c
    )
    for bloque in bloques:
        # Solo las filas d-flex directas del bloque (no las anidadas)
        filas_flex = [
            tag for tag in bloque.find_all('div', class_=lambda c: c and 'd-flex' in c)
            if hasattr(tag, 'parent') and tag.parent == bloque
        ]
        if not filas_flex:
            continue

        tabla_rows = []
        for fila in filas_flex:
            hijos = [h for h in fila.children if hasattr(h, 'get') and hasattr(h, 'get_text')]
            if not hijos:
                continue

            # Primer hijo con bg-gris → label
            label_tag = next((h for h in hijos if es_bg_gris(h)), None)
            if label_tag is None:
                texto = fila.get_text(separator=' ', strip=True)
                tabla_rows.append(
                    f'<tr><td colspan="2" style="padding:5px 8px;'
                    f'border:1px solid #d9d9d9;">{texto}</td></tr>'
                )
                continue

            label_txt = label_tag.get_text(strip=True)
            td_label = (
                f'<td style="background:#d9d9d9;padding:5px 8px;'
                f'border:1px solid #d9d9d9;white-space:nowrap;'
                f'font-weight:bold;width:130px;">{label_txt}</td>'
            )

            # Resto de hijos → valor
            resto = [h for h in hijos if h != label_tag]
            if len(resto) == 1 and 'd-flex' in ' '.join(resto[0].get('class', [])):
                # Fila 2: div.d-flex anidado — todo inline en una línea, font reducido
                contenido = f'<span style="font-size:7pt;">{aplanar_inline(resto[0])}</span>'
            elif len(resto) == 1:
                clases = ' '.join(resto[0].get('class', []))
                color  = 'color:#1c3a5e;' if 'text-fid-pri' in clases else ''
                bold   = 'font-weight:bold;' if 'fw-bold' in clases else ''
                contenido = (
                    f'<span style="{color}{bold}">'
                    f'{resto[0].get_text(strip=True)}</span>'
                )
            else:
                contenido = aplanar_inline(fila)

            es_inline = len(resto) == 1 and 'd-flex' in ' '.join(resto[0].get('class', []) if hasattr(resto[0], 'get') else [])
            td_estilo = 'padding:0;border:1px solid #d9d9d9;white-space:nowrap;overflow:hidden;vertical-align:middle;' if es_inline else 'padding:5px 8px;border:1px solid #d9d9d9;'
            td_valor = f'<td style="{td_estilo}">{contenido}</td>'
            tabla_rows.append(f'<tr>{td_label}{td_valor}</tr>')

        if tabla_rows:
            tabla = soup.new_tag(
                'table',
                style='width:100%;border-collapse:collapse;font-size:8.5pt;margin-bottom:8px;'
            )
            tabla.append(BeautifulSoup(''.join(tabla_rows), 'html.parser'))
            bloque.replace_with(tabla)

    body = soup.find('body')
    return str(body.decode_contents()) if body else str(soup)


def procesar_imagenes_web_breaks(html):
    """Deja que WeasyPrint distribuya las capturas según el espacio real.

    Antes se agrupaban obligatoriamente de a 2 por página y se inyectaban
    saltos manuales. Eso podía dejar media hoja vacía cuando una captura era
    más alta o más baja que otra. El CSS ya limita la altura de las imágenes y
    evita cortarlas, así que el motor puede hacer un packing más natural.
    """
    return html


def procesar_edictos_breaks(html):
    """Deja que WeasyPrint pagine los edictos de forma natural.

    La versión anterior estimaba alturas por cantidad de caracteres y agregaba
    page-breaks manuales. Eso era frágil: la altura real depende del ancho,
    tipografía, palabras y contenido. El CSS de `.row.prensa.card` ya evita
    cortar una card cuando entra completa en la página, por lo que no hace
    falta predecir la geometría desde Python.
    """
    return html


def procesar_directores_2_por_pagina(html):
    """Mantiene cada director entero y agrupa de a dos desde el segundo.

    La primera página de Directores suele compartir espacio con el título y la
    tabla resumen, por eso el primer director queda independiente. A partir del
    segundo, los directores se agrupan de a dos (B+C, D+E, ...), sin insertar
    saltos de página artificiales. El bloque de la pareja es indivisible, de
    modo que WeasyPrint lo deja donde entra completo o lo mueve a la página
    siguiente. Así se evita el patrón de un director solo seguido de dos juntos.
    """
    soup = BeautifulSoup(html, 'html.parser')

    # Encontrar todos los bloques de nombre de director.
    bloques_nombre = list(
        soup.find_all(class_=lambda c: c and 'pt-2' in c and 'pb-0' in c)
    )

    for bloque in bloques_nombre:
        # El brief correspondiente es el siguiente hermano con id brief_director_N.
        brief_div = bloque.find_next_sibling(
            id=lambda x: x and x.startswith('brief_director_')
        )
        if not brief_div:
            continue

        # Si no tiene brief, conservar la misma altura visual con placeholders.
        texto_brief = brief_div.get_text(strip=True)
        if 'No se encontró la sección brief' in texto_brief:
            placeholder = """<div class="row row-cols-print-3 row-cols-lg-2 row-cols-xl-3 g-4" style="margin-top:0">
                <div class="col"><div class="card card-brief border-2" style="background:#f8f9fa;"></div></div>
                <div class="col"><div class="card card-brief border-2" style="background:#f8f9fa;"></div></div>
                <div class="col"><div class="card card-brief border-2" style="background:#f8f9fa;"></div></div>
                <div class="col"><div class="card card-brief border-2" style="background:#f8f9fa;"></div></div>
                <div class="col"><div class="card card-brief border-2" style="background:#f8f9fa;"></div></div>
                <div class="col"><div class="card card-brief border-2" style="background:#f8f9fa;"></div></div>
            </div>"""
            brief_div.clear()
            brief_div.append(BeautifulSoup(placeholder, 'html.parser'))

        # Nombre + sus 6 cards forman una unidad indivisible.
        wrapper = soup.new_tag('div')
        wrapper['class'] = ['pdf-director-block']
        bloque.insert_before(wrapper)
        wrapper.append(bloque.extract())
        wrapper.append(brief_div.extract())

    # La primera unidad queda sola porque comparte página con tabla/títulos.
    # Desde la segunda, formar parejas B+C, D+E... SIN forzar break-before.
    director_blocks = list(soup.select('.pdf-director-block'))
    for i in range(1, len(director_blocks), 2):
        primero = director_blocks[i]
        if primero.parent is None:
            continue

        pair = soup.new_tag('div')
        pair['class'] = ['pdf-director-pair']
        primero.insert_before(pair)
        pair.append(primero.extract())

        if i + 1 < len(director_blocks):
            segundo = director_blocks[i + 1]
            if segundo.parent is not None:
                pair.append(segundo.extract())

    body = soup.find('body')
    return str(body.decode_contents()) if body else str(soup)


# ─────────────────────────────────────────────────────────────────
# Detectores heurísticos y mapa de transformaciones de layout.
#
# Para agregar soporte a un nuevo tipo de componente desde Laravel:
#   1. Agregar una tupla a HEURISTIC_DETECTORS con (test_fn, 'clave')
#   2. Agregar 'clave': mi_funcion en LAYOUT_TRANSFORMS
#   NO hace falta tocar main() ni el loop de componentes.
#
# Alternativamente, el blade puede declarar data-pdf-layout="clave"
# en el elemento raíz del componente para bypasear la detección automática.
# ─────────────────────────────────────────────────────────────────
HEURISTIC_DETECTORS = [
    # Brief de directores: agrupa de a 2 por página
    (lambda cid, h: 'brief_director_' in h,                                'group-2-per-page'),
    # Imágenes base64 de página web: 2 por página
    (lambda cid, h: 'data:image' in h,                                     'images-break'),
    # SRT / ART: layout flex → tabla (WeasyPrint no renderiza flex bien)
    (lambda cid, h: 'REGISTRO DE CONTRATOS CON LAS ART' in h,             'srt-table'),
    # Edictos de prensa: breaks por altura estimada
    (lambda cid, h: 'class="row prensa card' in h
                    or "class='row prensa card" in h,                       'edictos-break'),
]

LAYOUT_TRANSFORMS = {
    'group-2-per-page': procesar_directores_2_por_pagina,
    'images-break':     procesar_imagenes_web_breaks,
    'srt-table':        procesar_srt_layout,
    'edictos-break':    procesar_edictos_breaks,
}


def obtener_paginas_por_id(pdf_path):
    """Lee los named destinations de un PDF generado por WeasyPrint.
    Devuelve dict {id: numero_pagina_1indexed}."""
    reader = PdfReader(pdf_path)
    # O(P) precalcular mapa referencia→índice para luego hacer O(1) por destino
    page_ref_map = {
        page.indirect_reference: i + 1
        for i, page in enumerate(reader.pages)
    }
    resultado = {}
    for nombre, dest in reader.named_destinations.items():
        try:
            page_ref = dest['/Page'].indirect_reference
            if page_ref in page_ref_map:
                resultado[nombre] = page_ref_map[page_ref]
        except Exception:
            pass
    return resultado


# ─────────────────────────────────────────────────────────────────
# Pipeline de procesamiento de componentes
# ─────────────────────────────────────────────────────────────────

def clean_web_elements(soup: BeautifulSoup) -> None:
    """Elimina del DOM los elementos solo-web definidos en DOM_REMOVE_SELECTORS."""
    for selector in DOM_REMOVE_SELECTORS:
        for el in soup.select(selector):
            el.decompose()
    for form in soup.find_all('form'):
        if form.get('id', '') not in FORMS_TO_KEEP:
            form.decompose()


def normalize_assets(soup: BeautifulSoup) -> None:
    """Elimina links de CSS externos de dominios de producción web."""
    for link in soup.find_all('link', rel=lambda r: r and 'stylesheet' in r):
        href = link.get('href', '')
        if any(domain in href for domain in EXTERNAL_CSS_SKIP_DOMAINS):
            link.decompose()


def apply_layout_transforms(component_id: str, html: str) -> str:
    """Aplica transformaciones de layout al HTML de un componente.

    Orden de prioridad:
    1. data-pdf-layout="clave" en el elemento raíz → aplica esa transformación exclusivamente.
    2. HEURISTIC_DETECTORS → todos los detectores que coincidan se aplican en orden.
    """
    # 1. Transformación declarativa (exclusiva — primera ocurrencia gana)
    # El parse se hace sólo si el atributo aparece en el string (evita un parse
    # completo en la gran mayoría de componentes que no lo tienen).
    if 'data-pdf-layout' in html:
        try:
            root = BeautifulSoup(html, 'html.parser')
            for el in root.find_all(attrs={'data-pdf-layout': True}):
                layout = el.get('data-pdf-layout', '')
                if layout == 'none':
                    return html          # bypasear todos los transforms y heurísticos
                if layout in LAYOUT_TRANSFORMS:
                    return LAYOUT_TRANSFORMS[layout](html)
                break
        except Exception:
            pass

    # 2. Heurísticas de fallback: el primer match gana.
    # Evita que un mismo componente pase por transforms incompatibles
    # (por ejemplo, una imagen base64 dentro de otro layout especial).
    for test_fn, key in HEURISTIC_DETECTORS:
        try:
            if test_fn(component_id, html):
                return LAYOUT_TRANSFORMS[key](html)
        except Exception as exc:
            print(
                f"WARN: transform {key!r} falló para componente {component_id!r}: {exc}",
                file=sys.stderr,
            )
    return html


_PAGE_BREAK_CLASSES = {'pdf-page-break', 'page-break', 'page-breaker'}


_HEADER_CLASSES = {
    'subtitulo-seccion', 'fondo-card-fidelitas',
    'bg-fid-pri', 'bg-fid-claro', 'bg-fid-medio', 'bg-fid-oscuro',
    'ic-section-header', 'ic-subsection-title',
}


def _is_break_tag(tag) -> bool:
    return isinstance(tag, Tag) and bool(_PAGE_BREAK_CLASSES & set(tag.get('class', [])))


def _is_hidden_tag(tag) -> bool:
    if not isinstance(tag, Tag):
        return False
    classes = set(tag.get('class', []))
    if 'd-none' in classes or 'pdf-hide' in classes or 'oculto' in classes:
        return True
    style = (tag.get('style', '') or '').replace(' ', '').lower()
    return 'display:none' in style


def _is_meaningful_block(tag) -> bool:
    """True si el tag representa una caja real con contenido visible."""
    if not isinstance(tag, Tag) or _is_break_tag(tag) or _is_hidden_tag(tag):
        return False
    if tag.name in {'script', 'style', 'link', 'meta', 'noscript'}:
        return False
    if tag.name in {'img', 'svg', 'table', 'figure'}:
        return True
    return bool(tag.get_text(strip=True) or tag.find(['img', 'svg', 'table', 'figure']))


def _is_header_block(tag) -> bool:
    if not isinstance(tag, Tag):
        return False
    classes = set(tag.get('class', []))
    if classes & _HEADER_CLASSES:
        return True
    if tag.name in {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}:
        return True
    return bool(tag.find(lambda t: isinstance(t, Tag) and (
        t.name in {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'} or
        bool(set(t.get('class', [])) & _HEADER_CLASSES)
    )))


def _neighbor_meaningful_block(node, direction):
    """Busca el bloque con contenido real más cercano en la dirección dada."""
    current = node
    while isinstance(current, Tag):
        sibling = (
            current.find_previous_sibling() if direction == 'previous'
            else current.find_next_sibling()
        )
        while sibling is not None:
            if isinstance(sibling, Tag) and _is_meaningful_block(sibling):
                return sibling
            sibling = (
                sibling.find_previous_sibling() if direction == 'previous'
                else sibling.find_next_sibling()
            )
        parent = current.parent
        if not isinstance(parent, Tag) or parent.name in {'body', 'html', '[document]'}:
            break
        current = parent
    return None


def _mover_titulo_al_contenido(soup: BeautifulSoup) -> None:
    """Mueve el div.row del subtítulo dentro del div#contenido-X correspondiente.

    WeasyPrint ignora break-after/before:avoid cuando el div.row del título es un
    flex container (Bootstrap), porque la propagación del avoid no cruza el BFC
    del flex. Al mover el título como primer hijo del bloque de contenido, ambos
    quedan en el mismo fragmento y WeasyPrint los mantiene juntos de forma fiable.
    """
    for contenido in soup.find_all(
        lambda t: isinstance(t, Tag) and t.get('id', '').startswith('contenido-')
    ):
        title_row = contenido.find_previous_sibling(
            lambda t: isinstance(t, Tag) and bool(t.find(class_='subtitulo-seccion'))
        )
        if title_row is None:
            continue
        title_row.extract()
        contenido.insert(0, title_row)


def normalize_page_breaks(soup: BeautifulSoup) -> None:
    """Normaliza todos los divs sentinel de salto de página.

    Reglas (en orden):
      - Break oculto → se elimina sin efecto.
      - Break al inicio o al final del componente → se elimina (redundante con
        el break-before del wrapper externo o con el inicio de página).
      - Break justo después de un título/subtítulo → se elimina para que el
        título no quede huérfano al final de página.
      - Break interno válido → se mueve como clase `pdf-break-before` al
        siguiente bloque real, y el div sentinel desaparece.

    El div sentinel nunca sobrevive, por lo que nunca puede ocupar una página
    vacía por sí solo — que era la causa raíz de las páginas en blanco.
    """
    for pb in list(soup.find_all(_is_break_tag)):
        if pb.parent is None:
            continue

        if _is_hidden_tag(pb):
            pb.decompose()
            continue

        prev = _neighbor_meaningful_block(pb, 'previous')
        nxt  = _neighbor_meaningful_block(pb, 'next')

        # Break al inicio o al final: el wrapper ya gestiona ese límite.
        if prev is None or nxt is None:
            pb.decompose()
            continue

        # Título seguido de break: eliminarlo para no dejar el título solo.
        if _is_header_block(prev):
            pb.decompose()
            continue

        # Break interno válido: mover la semántica al siguiente bloque real.
        classes = list(nxt.get('class', []))
        if 'pdf-break-before' not in classes:
            classes.append('pdf-break-before')
            nxt['class'] = classes
        pb.decompose()


def process_component(component_id: str, html: str) -> str:
    """Pipeline de procesamiento de un componente.

    1. clean_web_elements  — elimina elementos web-only del DOM
    2. normalize_assets    — descarta <link> de CSS externos
    3. apply_layout_transforms — transforms específicos por tipo de componente
    4. Segunda pasada clean + normalize — sólo si el transform cambió el HTML
    5. normalize_page_breaks — convierte sentinels en clases sobre bloques reales

    Devuelve '' si el componente no contiene contenido visible tras la limpieza.
    """
    try:
        soup = BeautifulSoup(html or '', 'html.parser')
        clean_web_elements(soup)
        normalize_assets(soup)

        body = soup.find('body')
        clean_html = str(body.decode_contents()) if body else str(soup)

        transformed = apply_layout_transforms(component_id, clean_html)

        if transformed != clean_html:
            # El transform inyectó HTML nuevo — re-parsear y re-limpiar
            soup = BeautifulSoup(transformed, 'html.parser')
            clean_web_elements(soup)
            normalize_assets(soup)
        # else: transform no-op — reutilizar el soup ya limpio sin volver a parsear

        _mover_titulo_al_contenido(soup)
        normalize_page_breaks(soup)

        # Verificar contenido usando el soup existente (evita re-parse en el llamador)
        if not _is_meaningful_block(soup):
            return ''

        body = soup.find('body')
        return str(body.decode_contents()) if body else str(soup)
    except Exception as exc:
        raise RuntimeError(
            f"Error procesando componente PDF {component_id!r}"
        ) from exc


def build_html(body_html, css_extra=''):
    return f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
{css_links()}
<style>{CSS_COMUN}{css_extra}</style>
</head>
<body>{body_html}</body>
</html>"""


def html_to_pdf(html_str, output_path, font_config):
    HTML(string=html_str, base_url=BASE_URL).write_pdf(
        output_path,
        font_config=font_config,
        presentational_hints=True,
    )


def build_caratula(nombre, cliente, tipo, mes_anio, pais, disclaimer, logo_url, fecha=''):
    """Página de carátula idéntica al ejemplo del PDF objetivo."""
    return f"""
<div style="width:21cm; height:29.7cm; display:flex; flex-direction:column;
            font-family:Arial,sans-serif; margin:0; padding:0; position:relative;">

    <!-- Franja superior -->
    <div style="display:flex; justify-content:space-between; align-items:flex-start;
                padding:1cm 1.5cm 0 1.5cm;">
        <div style="font-size:9pt; font-weight:bold; color:#1c3a5e; letter-spacing:0.5px;">
            {mes_anio}
        </div>
        <div style="font-size:9pt; font-weight:bold; color:#1c3a5e; letter-spacing:0.5px;">
            {pais}
        </div>
    </div>

    <!-- Espacio central con logo -->
    <div style="flex:1; display:flex; flex-direction:column;
                justify-content:center; align-items:center; padding:0 2cm;">
        <img src="{logo_url}" alt="Fidelitas"
             style="width:70%; max-width:380px; display:block; margin-bottom:2.5cm;">

        <div style="text-align:center; color:#1c3a5e;">
            <div style="font-size:18pt; font-weight:bold; letter-spacing:1px; margin-bottom:0.4cm;">
                {nombre.upper()}
            </div>
            <div style="font-size:9pt; font-weight:bold; letter-spacing:0.5px; color:#444; margin-bottom:0.2cm;">
                INFORME SOLICITADO POR:&nbsp;&nbsp;{cliente.upper()}
            </div>
            <div style="font-size:8pt; color:#666; letter-spacing:0.3px;">
                FECHA DE EMISIÓN:&nbsp;&nbsp;{fecha}
            </div>
        </div>
    </div>

    <!-- Botón tipo badge -->
    <div style="display:flex; justify-content:center; padding-bottom:2cm;">
        <div style="background-color:#2d5b8b; color:#fff; padding:10px 32px;
                    font-size:10pt; font-weight:bold; letter-spacing:1px;
                    border-radius:4px; text-align:center;">
            {tipo.upper()}
        </div>
    </div>

    <!-- Footer disclaimer -->
    <div style="border-top:1px solid #ccc; padding:0.4cm 1.5cm;
                font-size:7pt; color:#555; font-style:italic; line-height:1.5;">
        {disclaimer}
    </div>
</div>
"""


def build_indice(secciones, nombre, cuit_fmt, logo_url):
    """
    Índice con target-counter() CSS Paged Media.
    WeasyPrint resuelve el número de página de cada ancla automáticamente.
    """
    css_indice = """
    .indice-fila-1 {
        display: table-row;
    }
    .indice-titulo-1 {
        font-weight: bold;
        color: #1c3a5e;
        font-size: 10pt;
        padding: 5px 0 3px 0;
        border: none;
    }
    .indice-titulo-2 {
        font-size: 8.5pt;
        color: #333;
        padding: 2px 0 2px 16px;
        border: none;
    }
    .indice-pag {
        text-align: right;
        white-space: nowrap;
        border: none;
        vertical-align: middle;
    }
    .indice-pag-2 {
        text-align: right;
        white-space: nowrap;
        border: none;
        vertical-align: middle;
    }
    .indice-pag-num {
        text-decoration: none;
        color: #1c3a5e;
        font-weight: bold;
        font-size: 8.5pt;
    }
    .indice-pag-num::after {
        content: target-counter(attr(href), page);
    }
    .indice-linea {
        color: #aaa;
        font-size: 7pt;
        letter-spacing: 2px;
        padding: 0 6px;
    }
    """

    # Palabras que no se capitalizan en Title Case (preposiciones/artículos en español)
    MINUSCULAS = {'de', 'del', 'la', 'las', 'el', 'los', 'y', 'e', 'o', 'u', 'a',
                  'en', 'con', 'por', 'para', 'al', 'un', 'una', 'unos', 'unas'}

    def title_case_es(texto):
        palabras = texto.strip().split()
        resultado = []
        for i, p in enumerate(palabras):
            if i == 0 or p.lower() not in MINUSCULAS:
                resultado.append(p.capitalize())
            else:
                resultado.append(p.lower())
        return ' '.join(resultado)

    filas = []
    for s in secciones:
        titulo = s.get('titulo', '')
        sid    = s.get('id', '')
        nivel  = s.get('nivel', 1)
        if not sid or not titulo:
            continue

        titulo_norm = title_case_es(titulo)
        clase = 'indice-titulo-1' if nivel == 1 else 'indice-titulo-2'

        filas.append(f"""
            <tr>
                <td class="{clase}">{titulo_norm}</td>
                <td class="indice-pag">
                    <span class="indice-linea">─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─</span>
                    <a href="#{sid}" class="indice-pag-num">Pág. </a>
                </td>
            </tr>""")

    filas_html = '\n'.join(filas)

    return f"""
<style>{css_indice}</style>
<div style="font-family:Arial,sans-serif; padding:1.5cm;">

    <!-- Header -->
    <div style="display:flex; justify-content:space-between; align-items:center;
                border-bottom:2px solid #1c3a5e; padding-bottom:8px; margin-bottom:1cm;">
        <img src="{logo_url}" alt="Fidelitas" style="height:28px;">
        <div style="text-align:right;">
            <div style="font-size:8.5pt; font-weight:bold; color:#1c3a5e;">{nombre}</div>
            <div style="font-size:7.5pt; color:#555;">{cuit_fmt}</div>
        </div>
    </div>

    <!-- Título -->
    <div style="text-align:center; border-top:2px solid #1c3a5e;
                border-bottom:2px solid #1c3a5e; padding:8px 0; margin-bottom:1cm;">
        <span style="font-size:15pt; font-weight:bold; color:#1c3a5e; letter-spacing:2px;">
            ÍNDICE
        </span>
    </div>

    <!-- Tabla de contenidos -->
    <table style="width:100%; border-collapse:collapse;">
        {filas_html}
        <tr>
            <td colspan="2" style="border:none; padding:16px 0 0 0;
                    text-align:center; font-size:9pt; color:#1c3a5e; font-weight:bold;">
                FIN
            </td>
        </tr>
    </table>
</div>
"""


def build_indice_nums(secciones, nombre, cuit_fmt, logo_url):
    """Índice de dos niveles con números de página reales (calculados post-render).

    Niveles en cada entrada:
      nivel 0 — ítem plano (sin grupo, pre-índice): bold, tamaño normal, sin sangría
      nivel 1 — encabezado de grupo: bold, mayor tamaño, línea separadora arriba
      nivel 2 — subtítulo de componente dentro de grupo: normal, sangría izquierda
    """
    MINUSCULAS = {'de', 'del', 'la', 'las', 'el', 'los', 'y', 'e', 'o', 'u', 'a',
                  'en', 'con', 'por', 'para', 'al', 'un', 'una'}

    def title_case_es(texto):
        palabras = texto.strip().split()
        resultado = []
        for i, p in enumerate(palabras):
            if i == 0 or p.lower() not in MINUSCULAS:
                resultado.append(p.capitalize())
            else:
                resultado.append(p.lower())
        return ' '.join(resultado)

    LINEA = '─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─'

    def celda_pagina(pag, size='8.5pt'):
        return f"""<td style="text-align:right;white-space:nowrap;border:none;vertical-align:middle;">
                    <span style="color:#aaa;font-size:7pt;letter-spacing:2px;padding:0 6px;">{LINEA}</span>
                    <span style="font-weight:bold;color:#1c3a5e;font-size:{size};">Pág. {pag}</span>
                </td>"""

    filas = []
    for s in secciones:
        titulo = s.get('titulo', '')
        pag    = s.get('pagina', '–')
        nivel  = s.get('nivel', 0)
        if not titulo or pag == '–':
            continue

        if nivel == 1:
            # Encabezado de grupo — separador superior, bold mayor
            filas.append(f"""
        <tr><td colspan="2" style="border:none;padding:10px 0 0 0;
                border-top:1.5px solid #1c3a5e;"></td></tr>
        <tr>
            <td style="font-weight:bold;color:#1c3a5e;font-size:11pt;
                       padding:3px 0 2px 0;border:none;">{titulo}</td>
            {celda_pagina(pag)}
        </tr>""")
        elif nivel == 2:
            # Subtítulo dentro de grupo — sangría izquierda
            filas.append(f"""
        <tr>
            <td style="color:#1c3a5e;font-size:9pt;
                       padding:2px 0 2px 20px;border:none;">{title_case_es(titulo)}</td>
            {celda_pagina(pag, '8pt')}
        </tr>""")
        else:
            # nivel 0: ítem plano sin grupo (pre-índice) — bold estilo actual
            filas.append(f"""
        <tr>
            <td style="font-weight:bold;color:#1c3a5e;font-size:10pt;
                       padding:5px 0 3px 0;border:none;">{title_case_es(titulo)}</td>
            {celda_pagina(pag)}
        </tr>""")

    filas_html = '\n'.join(filas)
    return f"""
<div style="font-family:Arial,sans-serif;padding:1.5cm;">
    <div style="display:flex;justify-content:space-between;align-items:center;
                border-bottom:2px solid #1c3a5e;padding-bottom:8px;margin-bottom:1cm;">
        <img src="{logo_url}" alt="Fidelitas" style="height:28px;">
        <div style="text-align:right;">
            <div style="font-size:8.5pt;font-weight:bold;color:#1c3a5e;">{nombre}</div>
            <div style="font-size:7.5pt;color:#555;">{cuit_fmt}</div>
        </div>
    </div>
    <div style="text-align:center;border-top:2px solid #1c3a5e;
                border-bottom:2px solid #1c3a5e;padding:8px 0;margin-bottom:1cm;">
        <span style="font-size:15pt;font-weight:bold;color:#1c3a5e;letter-spacing:2px;">
            ÍNDICE
        </span>
    </div>
    <table style="width:100%;border-collapse:collapse;">
        {filas_html}
        <tr><td colspan="2" style="border:none;padding:16px 0 0 0;
            text-align:center;font-size:9pt;color:#1c3a5e;font-weight:bold;">
            FIN
        </td></tr>
    </table>
</div>
"""


def build_contraportada(logo_url):
    """Contraportada institucional, limpia y con acentos geométricos sutiles."""
    return f"""
<div style="width:21cm; height:29.7cm; background-color:#1c3a5e;
            display:flex; flex-direction:column; justify-content:center;
            align-items:center; position:relative; overflow:hidden;
            font-family:Arial,sans-serif;">

    <!-- Acento geométrico superior derecho: más liviano, sin bloque interior macizo -->
    <div style="position:absolute; top:0; right:0; width:3.25cm; height:3.25cm;
                background-color:rgba(123,159,198,0.38);"></div>
    <div style="position:absolute; top:-0.12cm; right:-0.12cm;
                width:1.95cm; height:1.95cm;
                border-left:0.14cm solid #0d3f70;
                border-bottom:0.14cm solid #0d3f70;
                background:transparent;"></div>

    <!-- Logo blanco -->
    <img src="{logo_url}" alt="Fidelitas"
         style="width:55%; max-width:300px; display:block; margin-bottom:1.5cm;">

    <!-- Línea separadora -->
    <div style="width:60%; border-top:1px solid rgba(255,255,255,0.72);
                margin-bottom:1cm;"></div>

    <!-- Tagline -->
    <div style="color:#fff; font-size:13pt; font-weight:bold;
                letter-spacing:2px; text-align:center; text-transform:uppercase;">
        TU ALIADO EN CONTROL DE RIESGOS
    </div>

    <!-- Acento geométrico inferior izquierdo: simétrico y por encima del disclaimer -->
    <div style="position:absolute; left:0; bottom:1.65cm;
                width:3.25cm; height:3.25cm;
                background-color:rgba(123,159,198,0.38);"></div>
    <div style="position:absolute; left:-0.12cm; bottom:1.53cm;
                width:1.95cm; height:1.95cm;
                border-top:0.14cm solid #0d3f70;
                border-right:0.14cm solid #0d3f70;
                background:transparent;"></div>

    <!-- Disclaimer -->
    <div style="position:absolute; bottom:0; left:0; right:0; z-index:2;
                border-top:1px solid rgba(255,255,255,0.35);
                padding:0.4cm 1.5cm; font-size:7pt;
                color:rgba(255,255,255,0.78); font-style:italic; line-height:1.5;
                background-color:#1c3a5e;">
        Esta información es confidencial y de uso exclusivo de quien la solicita.<br>
        Su finalidad es meramente orientativa en decisiones crediticias. No implica juicio de valor
        ni de solvencia de los informados.<br>
        Su evaluación y las decisiones que el cliente tome son ajenas a Fidelitas.
    </div>
</div>
"""



def _normalizar_id_componente(component_id: str) -> str:
    """Normaliza `54` y `componente-54` al mismo identificador (`54`)."""
    cid = str(component_id or '').strip()
    if cid.startswith('componente-') and not cid.startswith('componente-salto-'):
        return cid[len('componente-'):]
    return cid


def _es_salto_manual(component_id: str, html: str = '') -> bool:
    """Detecta un componente de control `componente-salto-N` / `.pdf-salto-pagina`.

    Estos nodos NO se renderizan: únicamente indican que el siguiente componente
    real debe comenzar obligatoriamente en una página nueva.
    """
    cid = str(component_id or '').strip()
    if cid.startswith('componente-salto-'):
        return True

    if not html:
        return False

    try:
        soup = BeautifulSoup(html, 'html.parser')
        return bool(soup.select_one('.pdf-salto-pagina, [id^="componente-salto-"]'))
    except Exception:
        return 'pdf-salto-pagina' in html or 'componente-salto-' in html


def _saltos_manuales_desde_contenido(contenido_html: str) -> set:
    """Obtiene los IDs de componentes que deben empezar en página nueva.

    Es una segunda vía de detección para cuando Laravel no envía el salto como
    elemento dentro de `componentes`, pero sí viene en `contenido-exportable`.
    Busca cada `componente-salto-N` y marca el siguiente `componente-X` real.
    """
    if not contenido_html:
        return set()

    try:
        soup = BeautifulSoup(contenido_html, 'html.parser')
    except Exception:
        return set()

    resultado = set()
    saltos = soup.select('.pdf-salto-pagina, [id^="componente-salto-"]')

    for salto in saltos:
        siguiente = salto.find_next(
            lambda t: isinstance(t, Tag)
            and str(t.get('id', '')).startswith('componente-')
            and not str(t.get('id', '')).startswith('componente-salto-')
        )
        if siguiente is None:
            continue

        cid = _normalizar_id_componente(siguiente.get('id', ''))
        if cid:
            resultado.add(cid)

    return resultado

def main():
    if len(sys.argv) != 3:
        print("Uso: generar_pdf.py <json_input> <pdf_output>", file=sys.stderr)
        sys.exit(1)

    json_path  = sys.argv[1]
    pdf_output = sys.argv[2]

    if not os.path.exists(json_path):
        print(f"Error: no se encontró {json_path}", file=sys.stderr)
        sys.exit(1)

    with open(json_path, 'r', encoding='utf-8') as f:
        data = json.load(f)

    nombre     = data.get('nombre', '')
    cliente    = data.get('cliente', '')
    tipo       = data.get('tipo', 'Dossier Due Diligence')
    cuit_raw   = data.get('cuit', '')
    mes_anio   = data.get('mes_anio', '')
    pais       = data.get('pais', 'ARGENTINA')
    fecha      = data.get('fecha', '')
    disclaimer = data.get('disclaimer', 'Esta información es confidencial y de uso exclusivo de quien la solicita.')

    # Normalizar componentes a lista de dicts {id, html}
    componentes_raw = data.get('componentes', [])
    indice_raw      = data.get('indice', [])

    componentes = []
    for c in componentes_raw:
        if isinstance(c, dict):
            componentes.append({
                'id':         c.get('id', ''),
                'html':       c.get('html', ''),
                'pagina_nueva': c.get('pagina_nueva', False),
            })
        elif isinstance(c, str):
            componentes.append({'id': '', 'html': c, 'pagina_nueva': False})

    # Formatear CUIT: 30641368977 → 30-64136897-7
    cuit_fmt = cuit_raw
    if len(cuit_raw) == 11 and cuit_raw.isdigit():
        cuit_fmt = f"{cuit_raw[:2]}-{cuit_raw[2:10]}-{cuit_raw[10]}"

    logo_url       = f'file://{PUBLIC_DIR}/img/fidelitas.png'
    logo_url_white = f'file://{PUBLIC_DIR}/img/Logo_Fidelitas_invertido.png'
    iso_url     = f'file://{PUBLIC_DIR}/img/ISO_Fidelitas.svg'

    font_config = FontConfiguration()
    tmp_pdfs    = []
    tmp_dir     = tempfile.mkdtemp()

    try:
        # ── 1. Carátula ─────────────────────────────────────────────────────────
        caratula_html = build_caratula(nombre, cliente, tipo, mes_anio, pais, disclaimer, logo_url, fecha)
        caratula_pdf  = os.path.join(tmp_dir, '00_caratula.pdf')
        html_to_pdf(build_html(caratula_html, CSS_PAGINA_SIMPLE), caratula_pdf, font_config)
        tmp_pdfs.append(caratula_pdf)

        # ── 2. Índice + Contenido en UN SOLO HTML (target-counter requiere mismo doc) ──
        footer_iso = f'<img src="{iso_url}" alt="ISO" style="height:20px; display:block;">' \
                     if os.path.exists(f'{PUBLIC_DIR}/img/ISO_Fidelitas.svg') \
                     else f'<img src="{logo_url}" alt="F" style="height:16px; display:block; filter:grayscale(1) opacity(0.5);">'

        header_html = f"""
        <div id="header-pdf">
            <img src="{logo_url}" alt="Fidelitas" style="height:26px; display:block;">
            <div style="text-align:right;">
                <div style="font-size:8.5pt; font-weight:bold; color:#1c3a5e; line-height:1.3;">{nombre}</div>
                <div style="font-size:7.5pt; color:#555; line-height:1.3;">{cuit_fmt}</div>
            </div>
        </div>"""

        cliente_footer = (cliente or '').strip().upper()
        footer_html = f"""
        <div id="footer-pdf">
            {footer_iso}
            <div style="font-size:7pt; text-align:center; flex:1; padding:0 0.5cm; line-height:1.5; color:#666;">
                ESTA INFORMACIÓN ES CONFIDENCIAL Y DE USO EXCLUSIVO DE {cliente_footer}
            </div>
            <span class="contador-pagina"></span>
        </div>"""

        # Generamos primero con total provisional para contar páginas reales
        # luego regeneramos con el total correcto
        def build_css_pagina(pag_inicio, total_pags):
            return CSS_PAGINA.format(nombre=nombre, cuit_fmt=cuit_fmt).replace(
                'TOTAL_PAGINAS_PLACEHOLDER', str(total_pags)
            ) + f"""
/* El PDF de contenido comienza físicamente en la página {pag_inicio}.
   @page:first resetea el contador solo en la primera hoja y luego WeasyPrint
   continúa 4, 5, 6... de forma natural. */
@page:first {{
    counter-reset: page {pag_inicio};
}}
""" + """
/* ── Flujo entre componentes ────────────────────────
   Por defecto un componente continúa en el espacio disponible.
   Solo `pagina_nueva=true` agrega `.componente-pagina-nueva`. */
.componente-seccion {
    break-before: auto;
    page-break-before: auto;
}
.componente-pagina-nueva,
.forzar-pagina {
    break-before: page;
    page-break-before: always;
}

/* ── Cortes de texto: mantener legibilidad sin desperdiciar página ── */
p, li {
    orphans: 2;
    widows: 2;
}

/* ── Subtítulos pegados a su contenido ───────────── */
.subtitulo-seccion,
h3.subtitulo-seccion,
h4.subtitulo-seccion,
[class*="subtitulo"] {
    break-after: avoid !important;
    page-break-after: avoid !important;
}

/* El subtítulo + el primer elemento que le sigue no se separan */
.subtitulo-seccion + *,
[class*="subtitulo"] + *,
.fondo-card-fidelitas + *,
[class*="bg-fid-claro"] + * {
    break-before: avoid !important;
    page-break-before: avoid !important;
}

/* El bloque de contenido de cada componente nunca se separa de su título.
   El selector CSS + no puede cruzar el div.row wrapper, por eso lo forzamos
   directamente sobre todos los div#contenido-N. */
[id^="contenido-"] {
    break-before: avoid !important;
    page-break-before: avoid !important;
}

/* Un subtítulo nunca queda solo al final de página */
.subtitulo-seccion,
[class*="subtitulo"],
.fondo-card-fidelitas,
[class*="bg-fid-claro"] {
    break-inside: avoid !important;
    page-break-inside: avoid !important;
    break-after: avoid !important;
    page-break-after: avoid !important;
}

/* ── Headers de sección (barras grises/azules) ───── */
.subtitulo-seccion,
div[class*="fondo-card-fidelitas"],
div[class*="bg-fid-claro"] {
    break-after: avoid !important;
    page-break-after: avoid !important;
}

/* ── Evitar que un h2/h3/h4 quede solo al fondo ─── */
h1, h2, h3, h4, h5, h6 {
    break-after: avoid !important;
    page-break-after: avoid !important;
    orphans: 2;
    widows: 2;
}

/* ── Mantener unidas las secciones de semáforo ───── */
h4 + p, h4 + ul, h4 + ol,
h4 + div, h4 + table,
h3 + p, h3 + ul, h3 + div {
    break-before: avoid !important;
    page-break-before: avoid !important;
}

/* ── Bloque semáforo completo no se corta ────────── */
/* WeasyPrint necesita que el contenedor directo tenga avoid */
.componente-seccion > div > h4,
.componente-seccion > h4 {
    break-after: avoid !important;
    page-break-after: avoid !important;
}

/* Forzar que párrafos cortos no queden solos ──────── */
p:last-child {
    break-after: auto;
}

/* ── Evitar cortes dentro de elementos comunes ───── */
/* Evitar cortes solo en piezas realmente indivisibles.
   No se aplica `avoid` a todas las `.card`, porque una card grande podía
   empujar el contenido entero a la página siguiente y dejar huecos enormes. */
.ic-entity-card, .ic-kpi-card, .card-brief,
tr, img, figure, blockquote {
    break-inside: avoid;
    page-break-inside: avoid;
}

/* ── Canvas sin JS → ocultar sin dejar espacio ───── */
canvas {
    display: none !important;
    height: 0 !important;
    margin: 0 !important;
    padding: 0 !important;
}

/* ── Resumen ejecutivo ───────────────────────────── */
.caja-analisis {
    background-color: #d0d1d3 !important;
    padding: 12px !important;
    font-size: 9pt !important;
    border: 1px solid #c0c1c7 !important;
    border-radius: 4px !important;
}
.titulo-caja {
    text-align: center !important;
    background-color: #0f335a !important;
    color: #fff !important;
    font-size: 10pt !important;
    padding: 4px !important;
}
.caja-dossier table { font-size: 8.5pt !important; }
.caja-dossier table td { padding: 3px 6px !important; vertical-align: middle !important; }
.bg-gris-100 { background-color: #ececec !important; }
.fid-oscuro  { color: #0d1f35 !important; }
#nrLow  { background-color: #63b68c !important; color: #fff !important; }
#nrStd  { background-color: #c0c0c0 !important; color: #000 !important; }
#nrMed  { background-color: #FFB81C !important; color: #fff !important; }
#nrHigh { background-color: #A72B2A !important; color: #fff !important; }

/* ── Colapsar secciones vacías ───────────────────── */
.seccion-vacia {
    display: none !important;
}
"""

        # ── PASO 1: Armar bloques de componentes ───────────────────────────────
        # El flujo normal NO fuerza hoja nueva. Hay dos formas explícitas de corte:
        #   1) `pagina_nueva=true` en el componente.
        #   2) un nodo `componente-salto-N` / `.pdf-salto-pagina`, que no se
        #      renderiza y obliga al SIGUIENTE componente real a empezar hoja.
        contenido_exportable = data.get('contenido', '')
        saltos_antes_de = _saltos_manuales_desde_contenido(contenido_exportable)

        grupos = []
        for c in componentes:
            cid  = c.get('id', '')
            html = c.get('html', '')
            grupos.append({
                'ids': [cid] if cid else [],
                'html': html,
                'pagina_nueva': bool(c.get('pagina_nueva', False)),
                'es_salto_manual': _es_salto_manual(cid, html),
            })

        bloques_componentes = []
        forzar_salto_siguiente = False

        for g in grupos:
            ids = g.get('ids', [])
            bid = ids[0] if ids else ''

            # `componente-salto-N` es un control de paginación, nunca contenido.
            # Múltiples saltos consecutivos siguen equivalendo a un único corte.
            if g.get('es_salto_manual'):
                forzar_salto_siguiente = True
                continue

            inner = process_component(bid, g['html'])

            # Guard: si el componente quedó vacío tras limpiar elementos web-only,
            # no generar wrapper. El salto manual pendiente se conserva hasta el
            # próximo componente que realmente tenga contenido.
            # process_component devuelve '' para componentes sin contenido visible.
            if not inner:
                continue

            classes = ['componente-seccion']

            # Debe marcarse el primer componente RENDERIZADO, no el primero del JSON.
            if not bloques_componentes:
                classes.append('componente-seccion-primero')

            bid_normalizado = _normalizar_id_componente(bid)
            tiene_salto_manual = (
                forzar_salto_siguiente
                or (bid_normalizado and bid_normalizado in saltos_antes_de)
            )

            if bloques_componentes and (g.get('pagina_nueva') or tiene_salto_manual):
                classes.append('componente-pagina-nueva')

            # El corte ya fue consumido por este componente real.
            forzar_salto_siguiente = False

            cls = ' '.join(classes)
            if bid:
                bloques_componentes.append(f'<div id="{bid}" class="{cls}">{inner}</div>')
            else:
                bloques_componentes.append(f'<div class="{cls}">{inner}</div>')

        if not bloques_componentes:
            bloques_componentes = [data.get('contenido', '')]

        body_cuerpo   = header_html + footer_html + '\n'.join(bloques_componentes)
        contenido_pdf = os.path.join(tmp_dir, '02_contenido.pdf')
        # Paso 1: generar con total provisional para contar páginas
        # carátula=1, índice=1 (si hay), contenido=?, contraportada=1
        # carátula siempre=1, índice=1 si existe, contraportada=1 (no cuenta en numeración)
        # el contenido empieza en pág 3 (con índice) o pág 2 (sin índice)
        n_pags_antes_contenido = 1 + (1 if indice_raw else 0)  # carátula + índice
        pag_inicio_contenido = n_pags_antes_contenido + 1
        css_provisional = build_css_pagina(pag_inicio_contenido, 999)
        html_to_pdf(build_html(body_cuerpo, css_provisional), contenido_pdf, font_config)

        # Contar páginas reales y regenerar con total correcto
        pags_contenido = len(PdfReader(contenido_pdf).pages)
        total_real = 1 + (1 if indice_raw else 0) + pags_contenido + 1  # car+ind+cont+contra
        css_final = build_css_pagina(pag_inicio_contenido, total_real)
        html_to_pdf(build_html(body_cuerpo, css_final), contenido_pdf, font_config)
        tmp_pdfs.append(contenido_pdf)

        # ── PASO 2: Calcular en qué página está cada componente ──────────────────
        # WeasyPrint genera named_destinations para cada div con id.
        # Los leemos del PDF de contenido ANTES del merge para obtener páginas exactas.
        if indice_raw:
            n_pags_indice = n_pags_antes_contenido  # carátula + índice

            # Leer páginas exactas desde los named destinations del PDF
            pag_nums_raw = obtener_paginas_por_id(contenido_pdf)

            pag_nums = {}
            for entrada in indice_raw:
                cid = entrada.get('id', '')
                if cid in pag_nums_raw:
                    pag_nums[cid] = pag_nums_raw[cid] + n_pags_indice

            # ── PASO 3: Generar índice con números reales ─────────────────────────
            indice_con_nums = []
            for entrada in indice_raw:
                cid = entrada.get('id', '')
                pag = pag_nums.get(cid, '–')
                indice_con_nums.append({**entrada, 'pagina': pag})

            indice_html_final = build_indice_nums(indice_con_nums, nombre, cuit_fmt, logo_url)
            indice_pdf = os.path.join(tmp_dir, '01_indice.pdf')
            html_to_pdf(build_html(indice_html_final, CSS_PAGINA_SIMPLE), indice_pdf, font_config)
            # Insertar el índice entre carátula y contenido
            tmp_pdfs.insert(1, indice_pdf)

        # ── 4. Contraportada ─────────────────────────────────────────────────────
        contra_html = build_contraportada(logo_url_white)
        contra_pdf  = os.path.join(tmp_dir, '03_contraportada.pdf')
        html_to_pdf(build_html(contra_html, CSS_PAGINA_SIMPLE), contra_pdf, font_config)
        tmp_pdfs.append(contra_pdf)

        # ── 5. Unir todos los PDFs ────────────────────────────────────────────────
        writer = PdfWriter()
        for path in tmp_pdfs:
            reader = PdfReader(path)
            for page in reader.pages:
                writer.add_page(page)

        # Sanitizar metadata — ISO 27001 A.8.12 / ISO 27018 Art. 11
        aplicar_meta_limpia(writer)

        with open(pdf_output, 'wb') as out:
            writer.write(out)

        print(f"OK:{pdf_output}")
        sys.exit(0)

    except Exception as e:
        import traceback
        traceback.print_exc()
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)

    finally:
        for path in tmp_pdfs:
            try:
                os.unlink(path)
            except Exception:
                pass
        try:
            os.rmdir(tmp_dir)
        except Exception:
            pass


if __name__ == '__main__':
    main()
