#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Agro Revolution — generador de cartas PDF personalizadas + mensajes de WhatsApp.

Lee Contactos_Expo_Agro-2026.xlsx (hoja Contactos), renderiza Plantilla_Carta.html
por contacto y produce:
  - Cartas_PDF/NNN_Nombre_Apellido.pdf (una página A4)
  - Mensajes_WhatsApp.csv
  - QA_Cartas.md

Requiere: openpyxl, PyMuPDF (fitz) y Chrome instalado.
"""

from __future__ import annotations

import base64
import csv
import html
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unicodedata
from datetime import datetime
from pathlib import Path

import fitz  # PyMuPDF
import openpyxl

# --------------------------------------------------------------------------
# Configuración
# --------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parent
PROJECT = ROOT.parents[1]                     # Brands/Agro_Revolution
XLSX = ROOT / "Contactos_Expo_Agro-2026.xlsx"
TEMPLATE = ROOT / "Plantilla_Carta.html"
LOGO = PROJECT / "02_brand_kit" / "logos" / "Agro_Revolution-logo-hires.png"
OUT_PDF = ROOT / "Cartas_PDF"
OUT_CSV = ROOT / "Mensajes_WhatsApp.csv"
OUT_QA = ROOT / "QA_Cartas.md"

CHROME_CANDIDATES = [
    r"C:\Program Files\Google\Chrome\Application\chrome.exe",
    r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
    r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
]

SITE_URL = "https://agrorevolution.com.co/"
VIDEO_URL = "https://www.youtube.com/watch?v=3ooaNCg1d0U"
WEBINAR_URL = "https://agrorevolution.com.co/webinar/"

# Dueño comercial de cada lote de contactos. El prefijo de la columna "Fuente"
# (D… / L…) indica quién captó la tarjeta y, por lo tanto, quién firma la carta.
OWNERS = {
    "D": {
        "nombre": "Diego Quintero",
        "corto": "Diego",
        "cargo": "Gerente de Ventas",
        "whatsapp": "317 255 3745",
        "email": "contacto@agrorevolution.com.co",
    },
    "L": {
        "nombre": "Luis Carlos Gutiérrez Barragán",
        "corto": "Luis Carlos",
        "cargo": "Gerente de Ventas",
        "whatsapp": "320 724 6202",
        "email": "contacto@agrorevolution.com.co",
    },
}

MESES = [
    "enero", "febrero", "marzo", "abril", "mayo", "junio",
    "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre",
]

# (cultivo, zona) por valor exacto de la columna "Zona / Cultivo"
ZONA_MAP = {
    "Ganadería": ("ganadería", ""),
    "Casanare — palma": ("palma", "Casanare"),
    "Café": ("café", ""),
    "Cauca": ("", "Cauca"),
    "Chitaraque, Boyacá — café": ("café", "Chitaraque, Boyacá"),
    "Hongos": ("hongos", ""),
    "Sorgo, maíz, soya": ("sorgo, maíz y soya", ""),
    "Palma": ("palma", ""),
    "Icononzo — naranjas": ("naranjas", "Icononzo"),
    "Arándanos": ("arándanos", ""),
    "Flores, papa, café": ("flores, papa y café", ""),
    "Saldaña, Los Cáiros": ("", "Saldaña, Los Cáiros"),
    "Palma de cera": ("palma de cera", ""),
    "Cota — arándanos": ("arándanos", "Cota"),
    "Cali — ganado": ("ganadería", "Cali"),
    "Medellín": ("", "Medellín"),
    "Pamplona — caña": ("caña", "Pamplona"),
    "Villa de Leyva": ("", "Villa de Leyva"),
    "Maíz": ("maíz", ""),
    "Garzón, Huila": ("", "Garzón, Huila"),
    "Bananos": ("bananos", ""),
    "Cocos": ("cocos", ""),
    "Pasto": ("pasto", ""),
    "Tenjo — papa": ("papa", "Tenjo"),
    "Lulo": ("lulo", ""),
    "Perú — cítricos": ("cítricos", ""),
    "Flores": ("flores", ""),
    "Ganadería lechera": ("ganadería lechera", ""),
    "Castaña/nuez": ("castaña y nuez", ""),
    "Neiva(?) — maíz": ("maíz", ""),
    "Espinal, Tolima": ("", "Espinal, Tolima"),
    "Vereda Corea, Los Córdobas": ("", "Vereda Corea, Los Córdobas"),
}

LIVESTOCK = {"ganadería", "ganadería lechera"}

# Empresas que no son empresas o que el origen marca como dudosas/ilegibles.
BAD_COMPANY_MARKERS = ("dudoso", "dudosa", "ilegible", "poco legible", "?", "rubros", "cultivo")
NOT_COMPANIES = {
    "Fresas", "Hongos", "Sorgo, maíz, soya", "Passion Fruit", "Cocos", "Lulo",
    "Flores", "España", "Payandé", "Tenjo", "Lechero", "UN Ing. Agrícola",
    "Proveedor insumos",
}


# --------------------------------------------------------------------------
# Utilidades de texto
# --------------------------------------------------------------------------
def clean_text(value) -> str:
    if value is None:
        return ""
    return re.sub(r"\s+", " ", str(value)).strip()


def strip_accents(value: str) -> str:
    normalized = unicodedata.normalize("NFKD", value)
    return "".join(c for c in normalized if not unicodedata.combining(c))


def slugify(value: str) -> str:
    value = strip_accents(value)
    value = re.sub(r"[^A-Za-z0-9]+", "_", value)
    return re.sub(r"_+", "_", value).strip("_")


def spanish_date(value: datetime) -> str:
    return f"{value.day} de {MESES[value.month - 1]} de {value.year}"


def first_name(full_name: str) -> str:
    return full_name.split(" ")[0].strip(".,") if full_name else ""


def owner_for(row) -> dict:
    """Devuelve el asesor que firma, según el prefijo D/L de la columna Fuente."""
    source = clean_text(row.get("Fuente")).upper()
    prefix = source[0] if source else "L"
    return OWNERS.get(prefix, OWNERS["L"])


def name_is_unknown(raw: str) -> bool:
    low = raw.lower()
    return (not raw) or "(sin nombre)" in low or low in {"nombre", "sin nombre"}


def clean_person_name(raw: str) -> str:
    name = clean_text(raw)
    if name_is_unknown(name):
        return ""
    name = re.sub(r"\s*\([^)]*\)\s*", " ", name).strip()
    name = re.sub(r"\s+", " ", name)
    return name


def company_is_usable(raw: str, notas: str) -> bool:
    company = clean_text(raw)
    if not company:
        return False
    low = company.lower()
    if any(marker in low for marker in BAD_COMPANY_MARKERS):
        return False
    if company in NOT_COMPANIES:
        return False
    if "organización" in notas.lower():
        return True
    return True


def clean_company(raw: str, notas: str) -> str:
    if not company_is_usable(raw, notas):
        return ""
    company = re.sub(r"\s*\([^)]*\)\s*", " ", clean_text(raw)).strip()
    return re.sub(r"\s+", " ", company)


def parse_zona(raw: str):
    value = clean_text(raw)
    if not value:
        return "", ""
    if value in ZONA_MAP:
        return ZONA_MAP[value]
    # Fallback defensivo: no inventar; dejar el texto crudo como zona.
    return "", value


def crop_clause(crop: str) -> str:
    """Fragmento para la frase 'ha mostrado ... 20 % o más {clause}'."""
    if not crop:
        return "en diferentes cultivos"
    if crop in LIVESTOCK:
        return "en diferentes sistemas productivos"
    return f"en cultivos como {crop}"


def whatsapp_crop_clause(crop: str) -> str:
    if not crop or crop in LIVESTOCK:
        return ""
    return f" en cultivos como {crop}"


def personalization_paragraph(company: str, crop: str, zone: str) -> str:
    if company and zone:
        return (
            f"Sabemos que en {company} tienen operación en {zone}; por eso quise "
            "compartirle específicamente los resultados obtenidos en esa región y "
            "cómo se comporta la tecnología en ese tipo de producción."
        )
    if company:
        return (
            f"Sabemos que en {company} trabajan en el sector agrícola; por eso quise "
            "compartirle específicamente cómo se comporta la tecnología en ese tipo "
            "de operación."
        )
    if zone:
        return (
            f"Sabemos que su operación está en {zone}; por eso quise compartirle "
            "específicamente los resultados obtenidos en esa región."
        )
    return ""


def bullet_results(zone: str, crop: str) -> str:
    if zone:
        return f"Los resultados de campo en {zone}."
    if crop in LIVESTOCK:
        return "Los resultados de campo en su tipo de operación."
    return "Los resultados de campo en su tipo de cultivo."


# --------------------------------------------------------------------------
# Teléfonos
# --------------------------------------------------------------------------
def split_phones(raw: str):
    """Devuelve la lista de números crudos de una celda."""
    value = clean_text(raw)
    if not value:
        return []
    value = re.sub(r"\(whatsapp\)", "", value, flags=re.I)
    parts = re.split(r"[;|]", value)
    return [p.strip() for p in parts if p and p.strip()]


def normalize_phone(raw: str, country: str):
    """Devuelve (e164, motivo). e164 es '' si no es utilizable."""
    value = clean_text(raw)
    if not value:
        return "", "sin teléfono"
    if "_" in value or "?" in value:
        return "", "número incompleto o ilegible"

    has_plus = value.strip().startswith("+")
    digits = re.sub(r"\D", "", value)
    if not digits:
        return "", "sin dígitos"

    country = (country or "").strip().lower()

    if has_plus:
        if 10 <= len(digits) <= 15:
            return digits, ""
        return "", "longitud internacional inválida"

    if country == "colombia":
        if len(digits) == 10 and digits.startswith("3"):
            return "57" + digits, ""
        if len(digits) == 12 and digits.startswith("57"):
            return digits, ""
    elif country == "chile":
        if len(digits) == 9 and digits.startswith("9"):
            return "56" + digits, ""
        if len(digits) == 11 and digits.startswith("56"):
            return digits, ""
    elif country == "venezuela":
        if len(digits) == 10 and digits.startswith("4"):
            return "58" + digits, ""
        if len(digits) == 12 and digits.startswith("58"):
            return digits, ""
    elif country == "perú" or country == "peru":
        if len(digits) == 9 and digits.startswith("9"):
            return "51" + digits, ""
        if len(digits) == 11 and digits.startswith("51"):
            return digits, ""
    elif country == "argentina":
        if len(digits) == 10 and digits.startswith("9"):
            return "54" + digits, ""
        if len(digits) == 11 and digits.startswith("54"):
            return digits, ""
        if len(digits) == 13 and digits.startswith("549"):
            return digits, ""

    # Último recurso: si ya trae un código de país conocido, aceptarlo.
    for prefix in ("57", "56", "58", "51", "54"):
        if digits.startswith(prefix) and 11 <= len(digits) <= 13:
            return digits, ""

    return "", "formato no reconocido"


def resolve_phone(raw_cell: str, country: str):
    """Devuelve (e164, nota, motivo_invalido, usó_segundo)."""
    parts = split_phones(raw_cell)
    if not parts:
        return "", "", "sin teléfono", False

    reasons = []
    for index, part in enumerate(parts):
        e164, reason = normalize_phone(part, country)
        if e164:
            note = ""
            if index > 0:
                note = "Se usó el segundo número; el primero no es válido."
            return e164, note, "", index > 0
        reasons.append(reason)

    note = ""
    if len(parts) > 1:
        note = "Contiene varios números; ninguno utilizable."
    return "", note, reasons[0], False


# --------------------------------------------------------------------------
# Plantilla
# --------------------------------------------------------------------------
def load_template() -> str:
    return TEMPLATE.read_text(encoding="utf-8")


def render_letter(template: str, logo_b64: str, values: dict) -> str:
    out = template
    out = out.replace("{{LOGO_B64}}", logo_b64)
    for key, value in values.items():
        out = out.replace("{{" + key + "}}", html.escape(value, quote=False))
    return out


def build_values(row, today: datetime) -> dict:
    raw_name = clean_text(row["Nombre"])
    name = clean_person_name(raw_name)
    company = clean_company(row["Empresa"], clean_text(row["Notas"]))
    crop, zone = parse_zona(row["Zona / Cultivo"])
    country = clean_text(row["Pais"]) or "Colombia"
    owner = owner_for(row)

    if name:
        saludo = f"Estimado(a) {name}:"
    elif company:
        saludo = f"Estimados señores de {company}:"
    else:
        saludo = "Estimado(a) señor(a):"

    return {
        "CIUDAD_FECHA": f"{country}, {spanish_date(today)}",
        "SALUDO": saludo,
        "CULTIVO_FRASE": crop_clause(crop),
        "PERSONALIZADO": personalization_paragraph(company, crop, zone),
        "BULLET_RESULTADOS": bullet_results(zone, crop),
        "NOMBRE_ARCHIVO": name or company or "sin nombre",
        "ASESOR_NOMBRE": owner["nombre"],
        "ASESOR_CARGO": owner["cargo"],
        "ASESOR_WHATSAPP": owner["whatsapp"],
        "ASESOR_EMAIL": owner["email"],
    }


def build_whatsapp(row) -> str:
    raw_name = clean_text(row["Nombre"])
    name = clean_person_name(raw_name)
    crop, _ = parse_zona(row["Zona / Cultivo"])
    owner = owner_for(row)

    greeting = f"Hola {first_name(name)}," if name else "Hola,"
    crop_text = crop if crop and crop not in LIVESTOCK else "diferentes cultivos"

    return (
        f"{greeting} soy {owner['nombre']} de Agro Revolution, del stand del Pabellón 6 "
        "de Expo Agrofuturo. ¡Gracias por pasar y por tu interés!\n\n"
        f"Te dejo el video de 3 min que te comenté:\n{VIDEO_URL}\n"
        f"Y la web con el proceso y la tecnología:\n{SITE_URL}\n\n"
        "También te envío la carta que te prometimos en PDF.\n\n"
        "¿Te agendo cupo para el webinar de 40 min con el equipo de USA: "
        "jueves a las 19:00 o viernes a las 18:00? Veremos casos de éxito en "
        f"{crop_text}.\n"
        f"Regístrate aquí y te enviamos el enlace:\n{WEBINAR_URL}\n\n"
        "Si prefieres una reunión 1 a 1, respóndeme y la agendamos.\n\n"
        "Gracias de nuevo."
    )


def pdf_filename(index: int, row) -> str:
    name = clean_person_name(clean_text(row["Nombre"]))
    company = clean_company(row["Empresa"], clean_text(row["Notas"]))
    if name:
        base = slugify(name)
    elif company:
        base = slugify(company)
    else:
        base = "SIN_NOMBRE"
    return f"{index:03d}_{base or 'SIN_NOMBRE'}.pdf"


# --------------------------------------------------------------------------
# PDF
# --------------------------------------------------------------------------
def find_chrome() -> str:
    for candidate in CHROME_CANDIDATES:
        if Path(candidate).exists():
            return candidate
    which = shutil.which("chrome") or shutil.which("msedge")
    if which:
        return which
    raise RuntimeError("No se encontró Chrome/Edge para generar los PDF.")


def html_to_pdf(chrome: str, html_path: Path, pdf_path: Path, profile_dir: Path):
    url = html_path.resolve().as_uri()
    cmd = [
        chrome,
        "--headless=new",
        "--disable-gpu",
        "--no-pdf-header-footer",
        "--no-first-run",
        "--no-default-browser-check",
        f"--user-data-dir={profile_dir}",
        "--virtual-time-budget=5000",
        f"--print-to-pdf={pdf_path.resolve()}",
        url,
    ]
    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
    if not pdf_path.exists():
        raise RuntimeError(
            f"Chrome no generó el PDF. stdout={proc.stdout[-500:]} stderr={proc.stderr[-500:]}"
        )


# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def main() -> int:
    messages_only = "--messages-only" in sys.argv
    today = datetime.now()
    chrome = None if messages_only else find_chrome()
    template = None if messages_only else load_template()
    logo_b64 = "" if messages_only else "data:image/png;base64," + base64.b64encode(LOGO.read_bytes()).decode("ascii")

    wb = openpyxl.load_workbook(XLSX, data_only=True)
    ws = wb["Contactos"]
    rows = list(ws.iter_rows(values_only=True))
    headers = [clean_text(h) for h in rows[0]]
    records = [dict(zip(headers, r)) for r in rows[1:] if any(v is not None for v in r)]

    if not messages_only:
        OUT_PDF.mkdir(exist_ok=True)
        for old in OUT_PDF.glob("*.pdf"):
            old.unlink()

    qa = {
        "no_name": [],
        "no_phone": [],
        "organizations": [],
        "duplicates": [],
        "verify": [],
        "invalid_phone": [],
        "dubious_company": [],
        "second_number": [],
        "name_dubious": [],
        "failed": [],
        "multi_page": [],
        "owners": {"L": 0, "D": 0},
    }
    csv_rows = []
    generated = 0
    fonts_sample = set()

    with tempfile.TemporaryDirectory(prefix="agro_letters_") as tmp:
        tmp_path = Path(tmp)
        profile = tmp_path / "chrome-profile"
        if not messages_only:
            profile.mkdir()
        for index, row in enumerate(records, 1):
            values = None if messages_only else build_values(row, today)
            rendered = None if messages_only else render_letter(template, logo_b64, values)

            name_raw = clean_text(row["Nombre"])
            name = clean_person_name(name_raw)
            company = clean_company(row["Empresa"], clean_text(row["Notas"]))
            notas = clean_text(row["Notas"])
            phone_e164, phone_note, invalid_reason, used_second = resolve_phone(
                row["Telefono/WhatsApp"], clean_text(row["Pais"])
            )
            message = build_whatsapp(row)
            pdf_name = pdf_filename(index, row)

            source_prefix = (clean_text(row["Fuente"]) or "L")[0].upper()
            qa["owners"][source_prefix if source_prefix in qa["owners"] else "L"] += 1

            if not name:
                qa["no_name"].append((index, name_raw, company))
            if "(dudoso)" in name_raw.lower() or "?" in name_raw:
                qa["name_dubious"].append((index, name_raw))
            if not phone_e164:
                qa["no_phone" if invalid_reason == "sin teléfono" else "invalid_phone"].append(
                    (index, name or company or name_raw, clean_text(row["Telefono/WhatsApp"]), invalid_reason)
                )
            if "organización" in clean_text(row["Cargo"]).lower():
                qa["organizations"].append((index, company or name_raw))
            if "duplicado" in notas.lower():
                qa["duplicates"].append((index, name or name_raw, notas))
            if clean_text(row["Verificar"]).lower().startswith("s"):
                qa["verify"].append((index, name or company or name_raw))
            if clean_text(row["Empresa"]) and not company:
                qa["dubious_company"].append((index, clean_text(row["Empresa"])))
            if used_second and phone_note:
                qa["second_number"].append((index, name or company, phone_note))

            csv_rows.append(
                {
                    "#": index,
                    "Nombre": name or ("REVISAR NOMBRE" if not company else company),
                    "Telefono_E164": ("+" + phone_e164) if phone_e164 else "",
                    "Mensaje": message,
                    "PDF": pdf_name,
                }
            )
            print(f"[{index:03d}/{len(records)}] {pdf_name}{' (solo mensaje)' if messages_only else ''}")

            if messages_only:
                continue

            html_path = tmp_path / f"{index:03d}.html"
            html_path.write_text(rendered, encoding="utf-8")
            pdf_path = OUT_PDF / pdf_name

            try:
                html_to_pdf(chrome, html_path, pdf_path, profile)
            except Exception as exc:  # noqa: BLE001
                qa["failed"].append((index, name or company or name_raw, str(exc)))
                print(f"[FAIL] {pdf_name}: {exc}")
                continue

            doc = fitz.open(pdf_path)
            page_count = doc.page_count
            if page_count != 1:
                qa["multi_page"].append((index, pdf_name, page_count))
            text = "\n".join(page.get_text() for page in doc)
            for font in doc[0].get_fonts():
                fonts_sample.add(font[2])
            doc.close()

            leaked = [token for token in ("[", "]", "POR CONFIRMAR", "dudoso", "sin nombre") if token in text]
            if leaked:
                qa.setdefault("leaked", []).append((index, pdf_name, ", ".join(leaked)))

            generated += 1

    with OUT_CSV.open("w", encoding="utf-8-sig", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=["#", "Nombre", "Telefono_E164", "Mensaje", "PDF"])
        writer.writeheader()
        writer.writerows(csv_rows)

    if messages_only:
        print(f"\nMensajes actualizados: {len(csv_rows)}/{len(records)}")
        print(f"CSV: {OUT_CSV}")
        print("PDFs y QA sin cambios (modo --messages-only).")
        return 0

    write_qa(qa, generated, len(records), fonts_sample, chrome, today)
    print(f"\nPDFs generados: {generated}/{len(records)}")
    print(f"CSV: {OUT_CSV}")
    print(f"QA:  {OUT_QA}")
    return 0 if generated else 1


def write_qa(qa, generated, total, font_types, chrome, today):
    def table(rows, headers):
        if not rows:
            return "_Ninguno._\n"
        out = ["| " + " | ".join(headers) + " |", "|" + "|".join(["---"] * len(headers)) + "|"]
        for row in rows:
            out.append("| " + " | ".join(str(c).replace("|", "\\|") for c in row) + " |")
        return "\n".join(out) + "\n"

    lines = [
        "# QA — Cartas y mensajes de WhatsApp",
        "",
        f"**Generado:** {spanish_date(today)}",
        f"**Contactos en el origen:** {total}",
        f"**PDF generados:** {generated}",
        f"**Navegador usado:** `{chrome}`",
        "",
        "## Firma por asesor",
        "",
        f"- Luis Carlos Gutiérrez Barragán (lote L): {qa['owners']['L']}",
        f"- Diego Quintero (lote D): {qa['owners']['D']}",
        "",
        "## Tipografía",
        "",
        "Chrome incrustó las fuentes web como Type3 (glifos vectorizados; el PDF no",
        "conserva el nombre de la familia, pero el render usa Baloo 2, Lexend y DM Sans):",
        "",
        ", ".join(sorted(font_types)) if font_types else "_No se pudieron leer las fuentes._",
        "",
        "> Verificar visualmente una carta de muestra antes del envío masivo.",
        "",
    ]

    lines += [
        "## Resumen de excepciones",
        "",
        f"- Sin nombre: {len(qa['no_name'])}",
        f"- Sin teléfono: {len(qa['no_phone'])}",
        f"- Teléfono inválido o ilegible: {len(qa['invalid_phone'])}",
        f"- Organizaciones: {len(qa['organizations'])}",
        f"- Posibles duplicados: {len(qa['duplicates'])}",
        f"- Marcados «Verificar = Sí»: {len(qa['verify'])}",
        f"- Empresa omitida por ser dudosa: {len(qa['dubious_company'])}",
        f"- Se usó el segundo número: {len(qa['second_number'])}",
        f"- PDF con más de una página: {len(qa['multi_page'])}",
        f"- PDF fallidos: {len(qa['failed'])}",
        "",
        "## Sin nombre (saludo genérico)",
        "",
        table(qa["no_name"], ["#", "Nombre en origen", "Empresa usada"]),
        "## Sin teléfono (carta generada, sin envío por WhatsApp)",
        "",
        table(qa["no_phone"], ["#", "Contacto", "Teléfono", "Motivo"]),
        "## Teléfono inválido o ilegible",
        "",
        table(qa["invalid_phone"], ["#", "Contacto", "Teléfono", "Motivo"]),
        "## Organizaciones",
        "",
        table(qa["organizations"], ["#", "Organización"]),
        "## Posibles duplicados (se conservaron ambos)",
        "",
        table(qa["duplicates"], ["#", "Nombre", "Nota"]),
        "## Empresa omitida de la carta por dato dudoso",
        "",
        table(qa["dubious_company"], ["#", "Empresa en origen"]),
        "## Nombre dudoso (se usó la parte legible)",
        "",
        table(qa["name_dubious"], ["#", "Nombre en origen"]),
        "## Se usó el segundo número de la celda",
        "",
        table(qa["second_number"], ["#", "Contacto", "Nota"]),
        "## PDF con más de una página",
        "",
        table(qa["multi_page"], ["#", "Archivo", "Páginas"]),
        "## PDF fallidos",
        "",
        table(qa["failed"], ["#", "Contacto", "Error"]),
        "## Texto filtrado detectado en PDF (debe estar vacío)",
        "",
        table(qa.get("leaked", []), ["#", "Archivo", "Tokens"]),
        "",
        "## Pendiente de revisión humana",
        "",
        "- Los 42 contactos marcados «Verificar = Sí» requieren confirmación antes de enviar.",
        "- Los saludos genéricos («Estimado(a) señor(a)») deben revisarse si se conoce el nombre.",
        "- Los números con nota de duda pueden pertenecer a otra persona de la misma empresa.",
        "",
    ]
    OUT_QA.write_text("\n".join(lines), encoding="utf-8")


if __name__ == "__main__":
    sys.exit(main())
