import pdfplumber
from pypdf import PdfReader, PdfWriter
import os
import re
import io
import shutil
from datetime import datetime
from typing import Optional, List
from collections import defaultdict
import zipfile
from ftplib import FTP
import base64
import mimetypes
import gc

import barcode
from barcode.writer import ImageWriter
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from reportlab.lib.colors import white
from PIL import Image
import fitz  # PyMuPDF
from mailjet_rest import Client


# --- Configuración FTP ---
def load_env():
    """Carga variables del archivo .env"""
    env_path = os.path.join(os.path.dirname(__file__), '.env')
    if os.path.isfile(env_path):
        with open(env_path) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith('#') and '=' in line:
                    key, value = line.split('=', 1)
                    os.environ[key.strip()] = value.strip()


load_env()

FTP_HOST = os.environ.get("FTP_HOST")
FTP_USER = os.environ.get("FTP_USER")
FTP_PASS = os.environ.get("FTP_PASS")
FTP_REMOTE_DIR = "DAIMLER_TRUCKS"

MAILJET_API_KEY = os.environ.get("MAILJET_API_KEY")
MAILJET_SECRET_KEY = os.environ.get("MAILJET_SECRET_KEY")
MAILJET_SENDER_EMAIL = os.environ.get("MAILJET_SENDER_EMAIL")
MAILJET_SENDER_NAME = os.environ.get("MAILJET_SENDER_NAME", "Sistema Daimler")
MAILJET_RECIPIENT_EMAIL = os.environ.get("MAILJET_RECIPIENT_EMAIL")


def ftp_upload(file_path: str) -> bool:
    """
    Sube un archivo al servidor FTP en la carpeta DAIMLER_TRUCKS.

    Args:
        file_path: Ruta al archivo local

    Returns:
        True si la subida fue exitosa, False en caso contrario
    """
    if not all([FTP_HOST, FTP_USER, FTP_PASS]):
        print("Error: Faltan credenciales FTP. Verifica el archivo .env")
        return False

    if not os.path.isfile(file_path):
        print(f"Error: El archivo no existe: {file_path}")
        return False

    filename = os.path.basename(file_path)

    try:
        print(f"Conectando a {FTP_HOST}...")
        ftp = FTP(FTP_HOST)
        ftp.login(FTP_USER, FTP_PASS)
        print(f"Conectado como {FTP_USER}")

        # Crear carpeta DAIMLER_TRUCKS si no existe
        try:
            ftp.cwd(FTP_REMOTE_DIR)
        except:
            print(f"Creando carpeta {FTP_REMOTE_DIR}...")
            ftp.mkd(FTP_REMOTE_DIR)
            ftp.cwd(FTP_REMOTE_DIR)

        print(f"Directorio remoto: {FTP_REMOTE_DIR}")
        print(f"Subiendo {filename}...")

        with open(file_path, 'rb') as f:
            ftp.storbinary(f'STOR {filename}', f)

        print(f"Archivo subido correctamente: {filename}")
        ftp.quit()

        # Crear archivo de confirmación
        result_file = os.path.join(os.path.dirname(file_path), "subida_ftp.txt")
        with open(result_file, 'w', encoding='utf-8') as f:
            f.write(f"Subida FTP exitosa\n")
            f.write(f"Fecha: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
            f.write(f"Archivo: {filename}\n")
            f.write(f"Servidor: {FTP_HOST}\n")
            f.write(f"Directorio: {FTP_REMOTE_DIR}\n")
        print(f"Registro creado: {result_file}")

        return True

    except Exception as e:
        print(f"Error FTP: {e}")
        return False


def send_notification_email(execution_id: str, ftp_success: bool, stats_file: str, ftp_file: str = None) -> bool:
    """
    Envía un email de notificación con los archivos de estadísticas y FTP adjuntos.

    Args:
        execution_id: ID de la ejecución (ej: 20260508_201030)
        ftp_success: True si la subida FTP fue exitosa
        stats_file: Ruta al archivo estadisticas_correos.txt
        ftp_file: Ruta al archivo subida_ftp.txt (solo si FTP exitoso)

    Returns:
        True si el envío fue exitoso, False en caso contrario
    """
    if not all([MAILJET_API_KEY, MAILJET_SECRET_KEY, MAILJET_SENDER_EMAIL, MAILJET_RECIPIENT_EMAIL]):
        print("Error: Faltan credenciales de Mailjet. Verifica el archivo .env")
        return False

    if not os.path.isfile(stats_file):
        print(f"Error: El archivo no existe: {stats_file}")
        return False

    try:
        mailjet = Client(auth=(MAILJET_API_KEY, MAILJET_SECRET_KEY), version='v3.1')

        # Adjuntar estadísticas siempre
        attachments = []
        with open(stats_file, "rb") as f:
            content = base64.b64encode(f.read()).decode("utf-8")
        attachments.append({
            "ContentType": "text/plain",
            "Filename": os.path.basename(stats_file),
            "Base64Content": content
        })

        # Adjuntar archivo FTP si existe
        if ftp_file and os.path.isfile(ftp_file):
            with open(ftp_file, "rb") as f:
                content = base64.b64encode(f.read()).decode("utf-8")
            attachments.append({
                "ContentType": "text/plain",
                "Filename": os.path.basename(ftp_file),
                "Base64Content": content
            })

        subject = f"[DAIMLER] {execution_id}"

        if ftp_success:
            text_content = "La ejecución del sistema Daimler Trucks ha sido exitosa."
            status_color = "green"
            status_text = "EXITOSA"
        else:
            text_content = "La ejecución del sistema Daimler Trucks ha fallado."
            status_color = "red"
            status_text = "FALLIDA"

        html_content = f"""
        <h1>{subject}</h1>
        <p style="color: {status_color}; font-weight: bold;">Estado: {status_text}</p>
        <p>{text_content}</p>
        """

        message = {
            "From": {
                "Email": MAILJET_SENDER_EMAIL,
                "Name": MAILJET_SENDER_NAME
            },
            "To": [
                {"Email": MAILJET_RECIPIENT_EMAIL}
            ],
            "Subject": subject,
            "TextPart": text_content,
            "HTMLPart": html_content,
            "Attachments": attachments
        }

        result = mailjet.send.create(data={"Messages": [message]})

        if result.status_code == 200:
            print(f"Email enviado correctamente a {MAILJET_RECIPIENT_EMAIL}")
            return True
        else:
            print(f"Error al enviar email: {result.json()}")
            return False

    except Exception as e:
        print(f"Error Mailjet: {e}")
        return False


class CorreosHelper:
    """Helper para calcular zonas de Correos basándose en códigos postales."""

    CORREOS_ZONES = [
        '03200', '03201', '03202', '03203', '03204', '03205', '03206', '03207', '03208', '06800', '07700', '07701',
        '07702', '07703', '07714', '07800', '07819', '08100', '08104', '08161', '08191', '08193', '08200', '08201',
        '08202', '08203', '08204', '08205', '08206', '08207', '08208', '08220', '08221', '08222', '08223', '08224',
        '08225', '08226', '08227', '08228', '08240', '08241', '08242', '08243', '08248', '08290', '08300', '08301',
        '08302', '08303', '08304', '08401', '08402', '08403', '08404', '08802', '08805', '08820', '08830', '08840',
        '08900', '08901', '08902', '08903', '08904', '08905', '08906', '08907', '08908', '08910', '08911', '08912',
        '08913', '08914', '08915', '08916', '08917', '08918', '08920', '08921', '08922', '08923', '08924', '08940',
        '11100', '11110', '11120', '11130', '11138', '11139', '11200', '11201', '11202', '11203', '11204', '11205',
        '11206', '11207', '11270', '11271', '11280', '11300', '11401', '11402', '11403', '11404', '11405', '11406',
        '11407', '11408', '11471', '11480', '11500', '13500', '15401', '15402', '15403', '15404', '15405', '15406',
        '15470', '15471', '15480', '15482', '15490', '15701', '15702', '15703', '15704', '15705', '15706', '15707',
        '15770', '15771', '15780', '15781', '15782', '20300', '20301', '20302', '20303', '20304', '20305', '23700',
        '28100', '28108', '28109', '28400', '28520', '28521', '28522', '28523', '28524', '28800', '28801', '28802',
        '28803', '28804', '28805', '28806', '28807', '28820', '28821', '28822', '28823', '28850', '28870', '28900',
        '28901', '28902', '28903', '28904', '28905', '28906', '28907', '28909', '28910', '28911', '28912', '28913',
        '28914', '28915', '28916', '28917', '28918', '28919', '28920', '28921', '28922', '28923', '28924', '28925',
        '28930', '28931', '28932', '28933', '28934', '28935', '28936', '28937', '28938', '28940', '28941', '28942',
        '28943', '28944', '28945', '28946', '28947', '28980', '28981', '28982', '28983', '28984', '29620', '29640',
        '30200', '30201', '30202', '30203', '30204', '30205', '31001', '31002', '31003', '31004', '31005', '31006',
        '31007', '31008', '31009', '31010', '31011', '31012', '31013', '31014', '31015', '31016', '31070', '31071',
        '31080', '33200', '33201', '33202', '33203', '33204', '33205', '33206', '33207', '33208', '33209', '33210',
        '33211', '33212', '33213', '33290', '33299', '36200', '36201', '36202', '36203', '36204', '36205', '36206',
        '36207', '36208', '36209', '36210', '36211', '36212', '36213', '36214', '36215', '36216', '38200', '38201',
        '38202', '38203', '38204', '38205', '38206', '38207', '38208', '38291', '38293', '38295', '38296', '38297',
        '39300', '39315', '41500', '41700', '41701', '41702', '41703', '41704', '43200', '43201', '43202', '43203',
        '43204', '43205', '43206', '45600', '45613', '45614', '45694', '48900', '48901', '48902', '48903', '48930',
        '48990', '48991', '48992', '48993'
    ]

    ZONE_NAMES = {
        1: 'NACIONAL LOCAL',
        2: 'DESTINO 1 CANARIAS',
        3: 'DESTINO 1 CEUTA',
        4: 'DESTINO 1 MELILLA',
        5: 'DESTINO 2 CANARIAS',
        6: 'DESTINO 2 ANDORRA',
        7: 'DESTINO 1',
        8: 'DESTINO 2',
        9: 'DESTINO INTERNACIONAL',
    }

    @staticmethod
    def compute_zone(zip_code: Optional[str]) -> int:
        """Calcula la zona de Correos basándose en el código postal."""
        correos_zones = CorreosHelper.CORREOS_ZONES

        rules = [
            {
                'id': 9,
                'name': 'DESTINO INTERNACIONAL',
                'checker': lambda value: len(value) > 5 if value else True
            },
            {
                'id': 1,
                'name': 'NACIONAL LOCAL',
                'checker': lambda value: value[:3] == '280' if value else False
            },
            {
                'id': 2,
                'name': 'DESTINO 1 CANARIAS',
                'checker': lambda value: (
                    bool(re.match(r'^3[58]0', value)) or
                    (bool(re.match(r'^3[58]', value)) and value in correos_zones)
                ) if value else False
            },
            {
                'id': 5,
                'name': 'DESTINO 2 CANARIAS',
                'checker': lambda value: bool(re.match(r'^3[58]', value)) if value else False
            },
            {
                'id': 3,
                'name': 'DESTINO 1 CEUTA',
                'checker': lambda value: value[:2] == '51' if value else False
            },
            {
                'id': 4,
                'name': 'DESTINO 1 MELILLA',
                'checker': lambda value: value[:2] == '52' if value else False
            },
            {
                'id': 6,
                'name': 'DESTINO 2 ANDORRA',
                'checker': lambda value: value[:2] == 'AD' if value else False
            },
            {
                'id': 7,
                'name': 'DESTINO 1',
                'checker': lambda value: (
                    value in correos_zones or
                    (len(value) > 2 and value[2] == '0')
                ) if value else False
            },
            {
                'id': 8,
                'name': 'DESTINO 2',
                'checker': lambda value: True
            }
        ]

        for rule in rules:
            if rule['checker'](zip_code):
                return rule['id']

        return rules[-1]['id']

    @staticmethod
    def get_zone_name(zone_id: int) -> str:
        """Obtiene el nombre de la zona dado su ID."""
        return CorreosHelper.ZONE_NAMES.get(zone_id, 'DESCONOCIDO')

    @staticmethod
    def analyze_step2(step2_dir: str) -> dict:
        """Analiza los archivos de step2 y calcula estadísticas por zona de Correos."""
        if not os.path.isdir(step2_dir):
            raise ValueError(f"El directorio no existe: {step2_dir}")

        stats_by_zone = defaultdict(lambda: {
            'facturas': [],
            'count': 0,
            'total_pages': 0,
        })
        errors = []

        for filename in sorted(os.listdir(step2_dir)):
            if not filename.endswith('.pdf'):
                continue

            parts = filename.replace('.pdf', '').split('_')

            if len(parts) < 3:
                errors.append(f"Formato inválido: {filename}")
                continue

            cp = parts[0]
            try:
                num_pages = int(parts[1])
            except ValueError:
                errors.append(f"Número de páginas inválido en: {filename}")
                continue

            factura_id = '_'.join(parts[2:])

            zone_id = CorreosHelper.compute_zone(cp)

            stats_by_zone[zone_id]['facturas'].append({
                'filename': filename,
                'cp': cp,
                'num_pages': num_pages,
                'factura_id': factura_id,
            })
            stats_by_zone[zone_id]['count'] += 1
            stats_by_zone[zone_id]['total_pages'] += num_pages

        total_facturas = sum(z['count'] for z in stats_by_zone.values())
        total_pages = sum(z['total_pages'] for z in stats_by_zone.values())

        return {
            'by_zone': dict(stats_by_zone),
            'total_facturas': total_facturas,
            'total_pages': total_pages,
            'errors': errors,
        }

    @staticmethod
    def print_stats(step2_dir: str, save_to_file: bool = True):
        """Imprime un reporte de estadísticas desglosado por zona y número de páginas."""
        stats = CorreosHelper.analyze_step2(step2_dir)

        all_page_counts = set()
        for zone_data in stats['by_zone'].values():
            for factura in zone_data['facturas']:
                all_page_counts.add(factura['num_pages'])
        page_counts = sorted(all_page_counts)

        lines = []
        lines.append("=" * 90)
        lines.append("ESTADÍSTICAS DE ZONAS DE CORREOS - STEP 2")
        lines.append("=" * 90)
        lines.append(f"Directorio analizado: {step2_dir}")
        lines.append("")

        header_pages = "".join([f"{p}p".rjust(6) for p in page_counts])
        lines.append("-" * 90)
        lines.append(f"{'ID':<4} {'ZONA':<22} {header_pages} {'TOTAL':>8} {'%':>7}")
        lines.append("-" * 90)

        total_facturas = stats['total_facturas']
        totals_by_pages = {p: 0 for p in page_counts}

        for zone_id in sorted(stats['by_zone'].keys()):
            zone_data = stats['by_zone'][zone_id]
            zone_name = CorreosHelper.get_zone_name(zone_id)

            by_pages = defaultdict(int)
            for factura in zone_data['facturas']:
                by_pages[factura['num_pages']] += 1
                totals_by_pages[factura['num_pages']] += 1

            pages_str = "".join([str(by_pages.get(p, 0)).rjust(6) for p in page_counts])
            count = zone_data['count']
            pct = (count / total_facturas * 100) if total_facturas > 0 else 0

            lines.append(f"{zone_id:<4} {zone_name:<22} {pages_str} {count:>8} {pct:>6.1f}%")

        lines.append("-" * 90)
        totals_str = "".join([str(totals_by_pages[p]).rjust(6) for p in page_counts])
        lines.append(f"{'TOTAL':<26} {totals_str} {stats['total_facturas']:>8} {'100.0%':>7}")
        lines.append("=" * 90)

        if stats['errors']:
            lines.append("")
            lines.append("ERRORES:")
            for error in stats['errors']:
                lines.append(f"  - {error}")

        output = "\n".join(lines)
        print(output)

        if save_to_file:
            parent_dir = os.path.dirname(step2_dir.rstrip('/'))
            output_dir = os.path.join(parent_dir, "output")
            os.makedirs(output_dir, exist_ok=True)

            output_file = os.path.join(output_dir, "estadisticas_correos.txt")
            with open(output_file, 'w', encoding='utf-8') as f:
                f.write(output)

            print()
            print(f"Archivo guardado: {output_file}")

        return stats


# Coordenadas de los campos en las facturas Mercedes-Benz
COORDS = {
    "numero_factura": (460, 215, 520, 235),
    "pagina": (464, 252, 478, 263),
    "codigo_postal": (50, 155, 95, 200),  # Región más amplia para capturar toda la dirección
}

# Configuración de movimiento de región en Step 1
# El contenido en REGION_ORIGEN se mueve a REGION_DESTINO
REGION_MOVER = {
    "origen": (402.5, 122.0, 575.5, 266.0),  # x0, y0, x1, y1
    "destino": (384.5, 130.0, 560.5, 272.0),  # x0, y0, x1, y1
}


def extract_number_from_region(page, x0: float, y0: float, x1: float, y1: float) -> Optional[str]:
    """Extrae un número de una región específica de una página."""
    cropped = page.crop((x0, y0, x1, y1))
    text = cropped.extract_text()

    if text:
        text = text.strip()
        numbers = re.findall(r'-?[\d.]+,\d+|-?\d+[.,]?\d*', text)
        if numbers:
            return numbers[0]
    return None


def extract_codigo_postal(page, x0: float, y0: float, x1: float, y1: float) -> Optional[str]:
    """
    Extrae el código postal (5 dígitos) de una región.
    Busca específicamente un número de exactamente 5 dígitos que es el formato de CP español.
    """
    cropped = page.crop((x0, y0, x1, y1))
    text = cropped.extract_text()

    if text:
        # Buscar códigos postales: exactamente 5 dígitos (pueden empezar con 0)
        codigos_postales = re.findall(r'\b(\d{5})\b', text)
        if codigos_postales:
            return codigos_postales[0]
    return None


def extract_text_from_region(page, x0: float, y0: float, x1: float, y1: float) -> Optional[str]:
    """Extrae texto de una región específica de una página."""
    cropped = page.crop((x0, y0, x1, y1))
    text = cropped.extract_text()
    return text.strip() if text else None


def extract_page_data(page) -> dict:
    """Extrae los datos de una página: número de factura, página, código postal."""
    numero = extract_number_from_region(page, *COORDS["numero_factura"])
    pagina = extract_text_from_region(page, *COORDS["pagina"])
    codigo_postal = extract_codigo_postal(page, *COORDS["codigo_postal"])

    return {
        "numero_factura": numero,
        "pagina": pagina,
        "codigo_postal": codigo_postal,
    }


def sanitize_filename(pagina: str) -> str:
    """Reemplaza / por _ en el nombre de la página."""
    if pagina:
        return pagina.replace("/", "_")
    return "unknown"


def create_region_move_pdf(fitz_doc: "fitz.Document", page_num: int, page_width: float, page_height: float) -> io.BytesIO:
    """
    Crea un PDF que tapa la región original con blanco y dibuja el contenido en la nueva posición.

    Las coordenadas usan sistema visual (origen arriba-izquierda, Y aumenta hacia abajo),
    como las genera la herramienta web PDF Extractor.

    Args:
        fitz_doc: Documento PyMuPDF ya abierto (reutilizado entre páginas)
        page_num: Número de página (0-indexed)
        page_width: Ancho de la página en puntos
        page_height: Alto de la página en puntos

    Returns:
        BytesIO con el PDF de overlay
    """
    origen = REGION_MOVER["origen"]  # (x0, y0, x1, y1) - coordenadas visuales (origen arriba-izquierda)
    destino = REGION_MOVER["destino"]

    page = fitz_doc[page_num]

    # PyMuPDF usa coordenadas con origen arriba-izquierda (igual que nuestras coordenadas)
    clip_rect = fitz.Rect(
        origen[0],    # x0
        origen[1],    # y0
        origen[2],    # x1
        origen[3]     # y1
    )

    # Extraer la región como imagen con alta resolución
    mat = fitz.Matrix(3, 3)  # Máxima calidad - 3x
    pix = page.get_pixmap(matrix=mat, clip=clip_rect)

    # Convertir a imagen PIL
    img_data = pix.tobytes("png")
    img = Image.open(io.BytesIO(img_data))

    del page, pix  # Liberar referencias explícitamente (el documento se cierra fuera de esta función)
    gc.collect()

    # Guardar imagen en buffer
    img_buffer = io.BytesIO()
    img.save(img_buffer, format='PNG')
    img_buffer.seek(0)

    # Crear PDF con reportlab (usa coordenadas PDF: origen abajo-izquierda)
    pdf_buffer = io.BytesIO()
    c = canvas.Canvas(pdf_buffer, pagesize=(page_width, page_height))

    # Convertir coordenadas visuales a coordenadas PDF para reportlab
    # En PDF: y_pdf = page_height - y_visual - altura
    origen_altura = origen[3] - origen[1]
    destino_altura = destino[3] - destino[1]

    # 1. Tapar la región original con un rectángulo blanco
    c.setFillColor(white)
    c.rect(
        origen[0],                           # x
        page_height - origen[3],             # y (convertido a coords PDF)
        origen[2] - origen[0],               # ancho
        origen_altura,                       # alto
        fill=True,
        stroke=False
    )

    # 2. Dibujar la imagen extraída en la nueva posición
    img_reader = ImageReader(img_buffer)
    c.drawImage(
        img_reader,
        destino[0],                          # x destino
        page_height - destino[3],            # y destino (convertido a coords PDF)
        width=destino[2] - destino[0],       # ancho destino
        height=destino_altura,               # alto destino
        preserveAspectRatio=False,
        mask='auto'
    )

    c.save()
    pdf_buffer.seek(0)

    del img, img_buffer, img_reader  # Liberar referencias
    gc.collect()

    return pdf_buffer


def create_barcode_pdf(numero_factura: str, page_width: float, page_height: float) -> io.BytesIO:
    """
    Crea un PDF con un código de barras Code128 vertical en el margen derecho.
    Replica las dimensiones y posición del código de barras original de Mercedes-Benz.

    Args:
        numero_factura: Número de factura a codificar
        page_width: Ancho de la página en puntos
        page_height: Alto de la página en puntos

    Returns:
        BytesIO con el PDF del código de barras
    """
    # Generar código de barras Code128 como imagen
    code128 = barcode.get_barcode_class('code128')

    # Configurar writer para imagen sin texto
    writer = ImageWriter()

    # Generar código de barras
    barcode_instance = code128(numero_factura, writer=writer)

    # Guardar en memoria como imagen con opciones para ocultar texto
    barcode_buffer = io.BytesIO()
    barcode_instance.write(barcode_buffer, options={
        'module_width': 0.4,
        'module_height': 15,
        'quiet_zone': 1,
        'write_text': False,
        'text_distance': 0,
        'font_size': 0,
    })
    barcode_buffer.seek(0)

    # Rotar la imagen 90 grados para que sea vertical
    img = Image.open(barcode_buffer)
    img_rotated = img.rotate(90, expand=True)

    # Guardar imagen rotada
    rotated_buffer = io.BytesIO()
    img_rotated.save(rotated_buffer, format='PNG')
    rotated_buffer.seek(0)

    # Crear PDF con el código de barras
    pdf_buffer = io.BytesIO()
    c = canvas.Canvas(pdf_buffer, pagesize=(page_width, page_height))

    # Dimensiones del código de barras en el PDF
    barcode_height_target = 70   # Alto deseado en puntos
    barcode_width_target = 26    # Ancho deseado en puntos

    # Posición: margen derecho superior (replicando el original)
    x = page_width - barcode_width_target - 12  # Un poco más a la izquierda
    y = page_height - barcode_height_target - 150

    # Dibujar el código de barras usando ImageReader
    img_reader = ImageReader(rotated_buffer)
    c.drawImage(
        img_reader,
        x, y,
        width=barcode_width_target,
        height=barcode_height_target,
        preserveAspectRatio=False,  # Forzar dimensiones exactas
        mask='auto'
    )

    c.save()
    pdf_buffer.seek(0)

    del img, img_rotated, barcode_buffer, rotated_buffer, img_reader
    gc.collect()

    return pdf_buffer


def extract_single_page_pdf(reader: PdfReader, fitz_doc: "fitz.Document", page_num: int, output_path: str, numero_factura: str = None):
    """
    Extrae una página individual del PDF y la guarda como nuevo PDF.
    Aplica las siguientes transformaciones:
    1. Mueve el contenido de REGION_MOVER["origen"] a REGION_MOVER["destino"]
    2. Si se proporciona numero_factura, añade un código de barras en el margen derecho

    Args:
        reader: PdfReader ya abierto sobre el PDF de origen (reutilizado entre páginas)
        fitz_doc: Documento PyMuPDF ya abierto sobre el mismo PDF (reutilizado entre páginas)
    """
    writer = PdfWriter()

    page = reader.pages[page_num]

    # Obtener dimensiones de la página
    media_box = page.mediabox
    page_width = float(media_box.width)
    page_height = float(media_box.height)

    # 1. Aplicar movimiento de región
    region_move_buffer = create_region_move_pdf(fitz_doc, page_num, page_width, page_height)
    region_move_reader = PdfReader(region_move_buffer)
    region_move_page = region_move_reader.pages[0]
    page.merge_page(region_move_page)

    # 2. Si hay número de factura, añadir código de barras
    if numero_factura and numero_factura != "unknown":
        # Crear PDF con el código de barras
        barcode_pdf_buffer = create_barcode_pdf(numero_factura, page_width, page_height)
        barcode_reader = PdfReader(barcode_pdf_buffer)
        barcode_page = barcode_reader.pages[0]

        # Superponer el código de barras sobre la página original
        page.merge_page(barcode_page)

    writer.add_page(page)

    with open(output_path, "wb") as output_file:
        writer.write(output_file)

    del writer, region_move_reader, region_move_buffer  # Liberar referencias
    gc.collect()


def merge_pdfs(pdf_paths: list, output_path: str):
    """Combina múltiples PDFs en uno solo - optimizado para PDFs grandes."""
    writer = PdfWriter()

    for idx, pdf_path in enumerate(pdf_paths):
        reader = PdfReader(pdf_path)
        pages_count = len(reader.pages)
        
        for page_idx, page in enumerate(reader.pages):
            writer.add_page(page)
            
            # Limpieza cada 50 páginas
            if (page_idx + 1) % 50 == 0:
                gc.collect()
        
        del reader  # Liberar reader después de cada PDF
        
        # Limpieza agresiva: cada PDF procesado
        gc.collect()
        
        if (idx + 1) % 10 == 0:
            print(f"    [{idx+1}/{len(pdf_paths)}] Procesados, memoria liberada")

    # Escribir resultado final
    with open(output_path, "wb") as output_file:
        writer.write(output_file)

    del writer
    gc.collect()


def step1_extract_pages(input_pdf_path: str, output_dir: str) -> dict:
    """
    STEP 1: Extrae cada página del PDF como archivo individual.

    Returns:
        Dict con información de cada página extraída, agrupada por número de factura
    """
    print("=" * 60)
    print("STEP 1: Extrayendo páginas individuales")
    print("=" * 60)

    os.makedirs(output_dir, exist_ok=True)

    # Diccionario para agrupar páginas por factura
    facturas = defaultdict(list)

    # Abrimos el PDF de origen UNA SOLA VEZ y reutilizamos los handles en todas
    # las páginas. Reabrirlo por página (como se hacía antes) multiplicaba el
    # coste de parseo por el número de páginas y dejaba el proceso cada vez
    # más lento hasta parecer colgado en PDFs grandes.
    reader = PdfReader(input_pdf_path)
    fitz_doc = fitz.open(input_pdf_path)

    try:
        with pdfplumber.open(input_pdf_path) as pdf:
            total_pages = len(pdf.pages)
            print(f"Total de páginas: {total_pages}")
            print()

            for page_num, page in enumerate(pdf.pages):
                # Extraer datos de la página
                data = extract_page_data(page)

                cp = data["codigo_postal"] or "00000"
                pagina = sanitize_filename(data["pagina"]) or "unknown"
                numero = data["numero_factura"] or "unknown"

                # Crear nombre del archivo: cp_pagina_factura.pdf
                filename = f"{cp}_{pagina}_{numero}.pdf"
                output_path = os.path.join(output_dir, filename)

                # Extraer la página como PDF individual (con código de barras)
                extract_single_page_pdf(reader, fitz_doc, page_num, output_path, numero_factura=numero)

                # Guardar información para step2
                facturas[numero].append({
                    "path": output_path,
                    "filename": filename,
                    "cp": cp,
                    "pagina": data["pagina"],
                    "pagina_sanitized": pagina,
                    "page_num": page_num,
                })

                print(f"Página {page_num + 1}/{total_pages}: {filename}")

                # Liberar la caché interna de pdfplumber para esta página
                # (si no, pdfplumber acumula objetos parseados de cada página en memoria)
                page.flush_cache()

                # Limpieza de memoria cada 10 páginas
                if (page_num + 1) % 10 == 0:
                    gc.collect()
    finally:
        fitz_doc.close()
        del reader, fitz_doc
        gc.collect()

    print()
    print(f"Step 1 completado: {total_pages} páginas en {output_dir}")

    return dict(facturas)


def step2_merge_invoices(facturas: dict, output_dir: str):
    """
    STEP 2: Agrupa las páginas que pertenecen a la misma factura.
    """
    print()
    print("=" * 60)
    print("STEP 2: Agrupando páginas por factura")
    print("=" * 60)

    os.makedirs(output_dir, exist_ok=True)

    total_facturas = len(facturas)
    print(f"Total de facturas: {total_facturas}")
    print()

    for idx, (numero_factura, paginas) in enumerate(facturas.items(), 1):
        # Ordenar páginas por el número de página (1/2, 2/2, etc.)
        paginas_ordenadas = sorted(paginas, key=lambda x: x["pagina"] or "")

        # Usar el CP de la primera página para el nombre
        cp = paginas_ordenadas[0]["cp"]

        # Obtener el número máximo de páginas (de "1/2" extraer el 2)
        num_paginas = len(paginas_ordenadas)

        # Nombre del archivo final: cp_numpaginas_factura.pdf
        filename = f"{cp}_{num_paginas}_{numero_factura}.pdf"
        output_path = os.path.join(output_dir, filename)

        # Obtener las rutas de los PDFs a combinar
        pdf_paths = [p["path"] for p in paginas_ordenadas]

        if len(pdf_paths) == 1:
            # Si solo hay una página, copiar directamente
            shutil.copy(pdf_paths[0], output_path)
        else:
            # Combinar múltiples páginas
            merge_pdfs(pdf_paths, output_path)

        paginas_str = ", ".join([p["pagina"] or "?" for p in paginas_ordenadas])
        print(f"[{idx}/{total_facturas}] {filename} ({len(paginas)} páginas: {paginas_str})")

        if idx % 50 == 0:
            gc.collect()

    print()
    print(f"Step 2 completado: {total_facturas} facturas en {output_dir}")


def step3_unify_by_page_count(step2_dir: str, output_dir: str):
    """
    STEP 3: Unifica facturas por número de páginas.
    Crea un PDF con todas las facturas de 1 página, otro con las de 2, etc.
    """
    print()
    print("=" * 60)
    print("STEP 3: Unificando por número de páginas")
    print("=" * 60)

    os.makedirs(output_dir, exist_ok=True)

    # Leer todos los PDFs de step2 y agrupar por número de páginas
    facturas_por_paginas = defaultdict(list)

    for filename in os.listdir(step2_dir):
        if filename.endswith(".pdf"):
            # Formato: cp_numpaginas_idfactura.pdf
            parts = filename.replace(".pdf", "").split("_")
            if len(parts) >= 3:
                cp = parts[0]
                num_paginas = int(parts[1])
                filepath = os.path.join(step2_dir, filename)
                facturas_por_paginas[num_paginas].append({
                    "path": filepath,
                    "filename": filename,
                    "cp": cp,
                })

    print(f"Grupos encontrados: {sorted(facturas_por_paginas.keys())}")
    print()

    # Crear un PDF unificado por cada grupo de páginas
    for num_paginas in sorted(facturas_por_paginas.keys()):
        facturas = facturas_por_paginas[num_paginas]

        # Ordenar por código postal
        facturas_ordenadas = sorted(facturas, key=lambda x: x["cp"])

        # Nombre del archivo: facturas_Xpaginas.pdf
        filename = f"facturas_{num_paginas}paginas.pdf"
        output_path = os.path.join(output_dir, filename)

        # Combinar todos los PDFs
        pdf_paths = [f["path"] for f in facturas_ordenadas]
        merge_pdfs(pdf_paths, output_path)

        print(f"{filename}: {len(facturas)} facturas")

    print()
    print(f"Step 3 completado: {len(facturas_por_paginas)} archivos en {output_dir}")
    gc.collect()


def process_pdfs(input_pdf_paths: List[str], base_output_dir: str = "data", move_originals: bool = False, upload_ftp: bool = False, send_email: bool = False) -> str:
    """
    Procesa uno o varios PDFs de facturas:
    - Step 1: Extrae páginas individuales
    - Step 2: Agrupa páginas por factura
    - Step 3: Unifica por número de páginas
    - Step 4: Estadísticas de zonas de Correos
    - Step 5: Crea ZIP final
    - Step 6: (opcional) Sube ZIP por FTP
    - Step 7: (opcional) Envía email de notificación

    Args:
        input_pdf_paths: Lista de rutas a los PDFs de entrada
        base_output_dir: Directorio base para la salida (default: "data")
        move_originals: Si True, mueve los PDFs originales a la carpeta origin
        upload_ftp: Si True, sube el ZIP al servidor FTP
        send_email: Si True, envía email de notificación

    Returns:
        Ruta al directorio de salida creado
    """
    # Crear ID de ejecución basado en timestamp
    execution_id = datetime.now().strftime("%Y%m%d_%H%M%S")

    # Crear directorios de salida
    output_dir = os.path.join(base_output_dir, execution_id)
    step1_dir = os.path.join(output_dir, "step1")
    step2_dir = os.path.join(output_dir, "step2")
    step3_dir = os.path.join(output_dir, "step3")
    origin_dir = os.path.join(output_dir, "origin")

    print()
    print(f"ID de ejecución: {execution_id}")
    print(f"Directorio base: {output_dir}")
    print(f"PDFs a procesar: {len(input_pdf_paths)}")
    print()

    # Step 1: Extraer páginas individuales de todos los PDFs
    all_facturas = defaultdict(list)
    for pdf_path in input_pdf_paths:
        print(f"Procesando: {os.path.basename(pdf_path)}")
        facturas = step1_extract_pages(pdf_path, step1_dir)
        # Combinar facturas de todos los PDFs
        for numero, paginas in facturas.items():
            all_facturas[numero].extend(paginas)
        gc.collect()

    # Step 2: Agrupar por factura
    step2_merge_invoices(dict(all_facturas), step2_dir)

    # Step 3: Unificar por número de páginas
    step3_unify_by_page_count(step2_dir, step3_dir)

    # Step 4: Estadísticas de zonas de Correos
    print()
    print("=" * 60)
    print("STEP 4: Estadísticas de zonas de Correos")
    print("=" * 60)
    output_stats_dir = os.path.join(output_dir, "output")
    CorreosHelper.print_stats(step2_dir)

    # Step 5: Crear ZIP con los archivos de step3
    print()
    print("=" * 60)
    print("STEP 5: Creando archivo ZIP")
    print("=" * 60)
    zip_filename = f"DAIMLERTRUCKS_{execution_id}.zip"
    zip_path = os.path.join(output_stats_dir, zip_filename)

    # OPTIMIZACIÓN: Procesar archivos en chunks para no cargar todos en memoria
    files_to_zip = sorted([f for f in os.listdir(step3_dir) if f.endswith('.pdf')])
    
    with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
        for idx, filename in enumerate(files_to_zip):
            filepath = os.path.join(step3_dir, filename)
            zipf.write(filepath, filename)
            print(f"  [{idx+1}/{len(files_to_zip)}] {filename}")
            
            # Limpieza cada 20 archivos para evitar acumulación de descriptores
            if (idx + 1) % 20 == 0:
                gc.collect()

    print()
    print(f"ZIP creado: {zip_path}")

    # Mover archivos originales a la carpeta origin si se solicita
    if move_originals:
        print()
        print("=" * 60)
        print("Moviendo archivos originales a origin/")
        print("=" * 60)
        os.makedirs(origin_dir, exist_ok=True)
        for pdf_path in input_pdf_paths:
            filename = os.path.basename(pdf_path)
            dest_path = os.path.join(origin_dir, filename)
            shutil.move(pdf_path, dest_path)
            print(f"  Movido: {filename}")

    # Step 6: Subir ZIP por FTP si se solicita
    ftp_success = False
    if upload_ftp:
        print()
        print("=" * 60)
        print("STEP 6: Subiendo ZIP por FTP")
        print("=" * 60)
        ftp_success = ftp_upload(zip_path)

    # Step 7: Enviar email de notificación si se solicita
    email_success = False
    if send_email:
        print()
        print("=" * 60)
        print("STEP 7: Enviando email de notificación")
        print("=" * 60)
        stats_file = os.path.join(output_stats_dir, "estadisticas_correos.txt")
        ftp_file = os.path.join(output_stats_dir, "subida_ftp.txt") if ftp_success else None
        email_success = send_notification_email(execution_id, ftp_success, stats_file, ftp_file)

    print()
    print("=" * 60)
    print("PROCESO COMPLETADO")
    print("=" * 60)
    print(f"  Step 1 (páginas individuales): {step1_dir}")
    print(f"  Step 2 (facturas completas): {step2_dir}")
    print(f"  Step 3 (unificados por nº páginas): {step3_dir}")
    if move_originals:
        print(f"  Originales: {origin_dir}")
    print(f"  Estadísticas: {os.path.join(output_stats_dir, 'estadisticas_correos.txt')}")
    print(f"  ZIP: {zip_path}")
    if upload_ftp:
        print(f"  FTP: {'OK' if ftp_success else 'ERROR'}")
    if send_email:
        print(f"  Email: {'OK' if email_success else 'ERROR'}")

    return output_dir


if __name__ == "__main__":
    import sys

    INPUT_DIR = "input"
    BASE_OUTPUT_DIR = "data"

    # Detectar flags
    upload_ftp = "--ftp" in sys.argv
    send_email = "--email" in sys.argv
    args = [a for a in sys.argv[1:] if a not in ("--ftp", "--email")]

    if len(args) >= 1:
        # Modo explícito: se pasa un PDF específico
        pdf_path = args[0]
        output_dir = args[1] if len(args) > 1 else BASE_OUTPUT_DIR
        process_pdfs([pdf_path], output_dir, move_originals=False, upload_ftp=upload_ftp, send_email=send_email)
    else:
        # Modo automático: buscar PDFs en carpeta input
        if not os.path.isdir(INPUT_DIR):
            print(f"Error: No existe la carpeta '{INPUT_DIR}'")
            print()
            print("Uso:")
            print("  python process_invoices.py                          # Procesa PDFs de carpeta 'input'")
            print("  python process_invoices.py --ftp                    # Procesa y sube por FTP")
            print("  python process_invoices.py --ftp --email            # Procesa, sube por FTP y envía email")
            print("  python process_invoices.py <pdf_path>               # Procesa un PDF específico")
            print("  python process_invoices.py <pdf_path> --ftp --email # Procesa, sube por FTP y envía email")
            sys.exit(1)

        pdf_files = sorted([
            os.path.join(INPUT_DIR, f)
            for f in os.listdir(INPUT_DIR)
            if f.lower().endswith('.pdf')
        ])

        if not pdf_files:
            print(f"No se encontraron archivos PDF en '{INPUT_DIR}'")
            sys.exit(1)

        print(f"Encontrados {len(pdf_files)} PDF(s) en '{INPUT_DIR}':")
        for f in pdf_files:
            print(f"  - {os.path.basename(f)}")

        process_pdfs(pdf_files, BASE_OUTPUT_DIR, move_originals=True, upload_ftp=upload_ftp, send_email=send_email)
