import argparse
import hashlib
import shutil
import subprocess
import sys
import tempfile
import threading
import zipfile
from dataclasses import dataclass
from pathlib import Path
from tkinter import filedialog, messagebox

import customtkinter as ctk
import fitz
from PIL import Image, ImageOps, ImageTk


DEFAULT_MAX_ZIP_SIZE_MB = 15
PDF_OPTIMIZE_PROFILES = (
    ("ottimizzazione_senza_perdita", None, None),
    ("ghostscript_ebook", "ebook", None),
    ("ghostscript_screen", "screen", None),
    ("qualita_email", 150, 68),
    ("piu_leggero", 120, 60),
    ("minimo", 100, 54),
)
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"}
OFFICE_SUFFIXES = {".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"}
IMAGE_OPTIMIZE_PROFILES = (
    ("qualita_alta", 2500, 85),
    ("qualita_email", 1800, 75),
    ("massima_compressione", 1200, 60),
)


@dataclass(frozen=True)
class BundleFile:
    path: Path
    arcname: Path
    original_path: Path | None = None

    @property
    def display_name(self):
        return self.arcname.name

    def stat(self):
        return self.path.stat()


@dataclass(frozen=True)
class OptimizedPdf:
    original: BundleFile
    optimized: BundleFile
    original_size: int
    optimized_size: int
    kind: str
    profile: str
    dpi: int | str | None
    quality: int | None


def mb_to_bytes(size_mb):
    return int(float(size_mb) * 1024 * 1024)


def format_mb(size):
    return f"{size / 1024 / 1024:.2f} MB"


def optimization_label(report):
    if report.kind == "office":
        return "conversione PDF"
    if report.kind == "image":
        labels = {
            "qualita_alta": "qualita alta",
            "qualita_email": "qualita email",
            "massima_compressione": "massima compressione",
        }
        return labels.get(report.profile, report.profile)
    if report.profile.startswith("ghostscript_"):
        return f"Ghostscript {report.dpi}"
    if report.dpi is None:
        return "senza perdita"
    return f"{report.dpi} DPI"


def collect_files(folder, exclude_folder=None):
    files = []
    folder = Path(folder).resolve()
    exclude_folder = Path(exclude_folder).resolve() if exclude_folder else None

    for path in folder.rglob("*"):
        if not path.is_file():
            continue

        resolved = path.resolve()
        if exclude_folder and resolved.is_relative_to(exclude_folder):
            continue

        files.append(BundleFile(path=resolved, arcname=resolved.relative_to(folder)))

    return files


def create_zip(zip_path, files):
    with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        for bundle_file in files:
            zf.write(bundle_file.path, bundle_file.arcname)


def compressed_zip_size(files, temp_folder):
    temp_zip = Path(temp_folder) / "test.zip"
    create_zip(temp_zip, files)
    size = temp_zip.stat().st_size
    temp_zip.unlink()
    return size


def reduced_pdf_arcname(arcname):
    safe_stem = shortened_stem(arcname.stem)
    return arcname.with_name(f"{safe_stem}_ridotto_email{arcname.suffix}")


def reduced_image_arcname(arcname, target_suffix=None):
    safe_stem = shortened_stem(arcname.stem)
    suffix = target_suffix if target_suffix else arcname.suffix.lower()
    return arcname.with_name(f"{safe_stem}_ridotta_email{suffix}")


def reduced_office_arcname(arcname):
    safe_stem = shortened_stem(arcname.stem)
    return arcname.with_name(f"{safe_stem}_convertito_email.pdf")


def shortened_stem(stem, max_length=80):
    if len(stem) <= max_length:
        return stem

    digest = hashlib.sha1(stem.encode("utf-8")).hexdigest()[:8]
    return f"{stem[:max_length]}_{digest}"


def find_ghostscript():
    bundled_root = getattr(sys, "_MEIPASS", None)
    if bundled_root:
        bundled_ghostscript = Path(bundled_root) / "ghostscript"
        for executable in ("gswin64c.exe", "gswin32c.exe", "gs.exe"):
            matches = list(bundled_ghostscript.rglob(executable))
            if matches:
                return str(matches[0])

    for executable in ("gswin64c", "gswin32c", "gs"):
        path = shutil.which(executable)
        if path:
            return path
    return None


def pdf_has_significant_text(pdf_path):
    doc = fitz.open(pdf_path)
    try:
        if doc.page_count == 0:
            return False

        sample_indexes = sorted({0, doc.page_count // 2, doc.page_count - 1})
        text_length = 0
        for page_index in sample_indexes:
            text_length += len(doc.load_page(page_index).get_text("text").strip())

        return text_length >= 500
    finally:
        doc.close()


def optimized_pdf_result(source_file, optimized_path, profile, dpi, quality):
    optimized_file = BundleFile(
        path=optimized_path.resolve(),
        arcname=reduced_pdf_arcname(source_file.arcname),
        original_path=source_file.path,
    )

    return OptimizedPdf(
        original=source_file,
        optimized=optimized_file,
        original_size=source_file.stat().st_size,
        optimized_size=optimized_file.stat().st_size,
        kind="pdf",
        profile=profile,
        dpi=dpi,
        quality=quality,
    )


def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None):
    ghostscript = find_ghostscript()
    if not ghostscript:
        if log:
            log("Ghostscript non trovato: salto ottimizzazione PDF avanzata.")
        return None

    optimized_root = Path(output_folder) / "_pdf_ridotti_email"
    optimized_path = optimized_root / reduced_pdf_arcname(source_file.arcname)
    optimized_path.parent.mkdir(parents=True, exist_ok=True)

    if log:
        log(
            f"Provo Ghostscript /{profile}: "
            f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})"
        )

    command = [
        ghostscript,
        "-sDEVICE=pdfwrite",
        "-dCompatibilityLevel=1.6",
        f"-dPDFSETTINGS=/{profile}",
        "-dNOPAUSE",
        "-dQUIET",
        "-dBATCH",
        "-dDetectDuplicateImages=true",
        "-dCompressFonts=true",
        "-dSubsetFonts=true",
        f"-sOutputFile={optimized_path}",
        str(source_file.path),
    ]

    completed = subprocess.run(
        command,
        capture_output=True,
        text=True,
        timeout=300,
    )

    if completed.returncode != 0 or not optimized_path.exists():
        if log:
            error_text = (completed.stderr or completed.stdout or "errore sconosciuto").strip()
            log(f"Ghostscript non ha prodotto un PDF valido: {error_text}")
        optimized_path.unlink(missing_ok=True)
        return None

    return optimized_pdf_result(
        source_file,
        optimized_path,
        profile=f"ghostscript_{profile}",
        dpi=profile,
        quality=None,
    )


def optimize_pdf(source_file, output_folder, profile, dpi, quality, log=None):
    optimized_root = Path(output_folder) / "_pdf_ridotti_email"
    optimized_path = optimized_root / reduced_pdf_arcname(source_file.arcname)
    optimized_path.parent.mkdir(parents=True, exist_ok=True)

    if log:
        if dpi:
            log(
                f"Ottimizzo PDF a {dpi} DPI: "
                f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})"
            )
        else:
            log(
                "Provo ottimizzazione PDF senza perdita: "
                f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})"
            )

    source_doc = fitz.open(source_file.path)
    try:
        if dpi is None:
            source_doc.save(
                optimized_path,
                garbage=4,
                clean=True,
                deflate=True,
                deflate_images=True,
                deflate_fonts=True,
                use_objstms=True,
                compression_effort=9,
            )
            optimized_file = BundleFile(
                path=optimized_path.resolve(),
                arcname=reduced_pdf_arcname(source_file.arcname),
                original_path=source_file.path,
            )
            return OptimizedPdf(
                original=source_file,
                optimized=optimized_file,
                original_size=source_file.stat().st_size,
                optimized_size=optimized_file.stat().st_size,
                kind="pdf",
                profile=profile,
                dpi=dpi,
                quality=quality,
            )

        optimized_doc = fitz.open()
        matrix = fitz.Matrix(dpi / 72, dpi / 72)

        try:
            for page_index, source_page in enumerate(source_doc, start=1):
                if log:
                    log(f"  pagina {page_index}/{source_doc.page_count}")

                pixmap = source_page.get_pixmap(matrix=matrix, alpha=False)
                image_bytes = pixmap.tobytes("jpeg", jpg_quality=quality)
                page = optimized_doc.new_page(width=pixmap.width, height=pixmap.height)
                page.insert_image(page.rect, stream=image_bytes)

            optimized_doc.save(
                optimized_path,
                garbage=4,
                deflate=True,
                clean=True,
            )
        finally:
            optimized_doc.close()
    finally:
        source_doc.close()

    optimized_file = BundleFile(
        path=optimized_path.resolve(),
        arcname=reduced_pdf_arcname(source_file.arcname),
        original_path=source_file.path,
    )

    return OptimizedPdf(
        original=source_file,
        optimized=optimized_file,
        original_size=source_file.stat().st_size,
        optimized_size=optimized_file.stat().st_size,
        kind="pdf",
        profile=profile,
        dpi=dpi,
        quality=quality,
    )


def optimized_image_result(source_file, optimized_path, arcname, profile, max_side, quality):
    optimized_file = BundleFile(
        path=optimized_path.resolve(),
        arcname=arcname,
        original_path=source_file.path,
    )

    return OptimizedPdf(
        original=source_file,
        optimized=optimized_file,
        original_size=source_file.stat().st_size,
        optimized_size=optimized_file.stat().st_size,
        kind="image",
        profile=profile,
        dpi=max_side,
        quality=quality,
    )


def normalize_image_for_format(image, target_format):
    if image.mode in ("I;16", "I;16B", "I;16L"):
        image = image.point(lambda value: value / 256).convert("L")

    if target_format in {"JPEG", "BMP"} and (
        image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
    ):
        background = Image.new("RGB", image.size, "white")
        alpha = image.convert("RGBA").getchannel("A")
        background.paste(image.convert("RGBA"), mask=alpha)
        return background

    if target_format == "JPEG" and image.mode == "CMYK":
        return image

    if target_format in {"JPEG", "BMP", "WEBP"} and image.mode not in {"RGB", "L", "CMYK"}:
        return image.convert("RGB")

    if target_format == "PNG" and image.mode not in {"RGB", "RGBA", "L", "P"}:
        return image.convert("RGB")

    if target_format == "TIFF" and image.mode == "P":
        return image.convert("RGB")

    return image


def image_save_options(target_format, quality):
    if target_format == "JPEG":
        return {
            "quality": quality,
            "optimize": True,
            "progressive": True,
        }

    if target_format == "PNG":
        return {
            "optimize": True,
            "compress_level": 9,
        }

    if target_format == "TIFF":
        return {
            "compression": "tiff_adobe_deflate",
        }

    if target_format == "WEBP":
        return {
            "quality": quality,
            "method": 6,
        }

    return {}


def image_target_format(source_path, convert_uncompressed=False):
    suffix = source_path.suffix.lower()
    if convert_uncompressed and suffix in {".bmp", ".tif", ".tiff"}:
        return "JPEG"
    if suffix in {".jpg", ".jpeg"}:
        return "JPEG"
    if suffix == ".png":
        return "PNG"
    if suffix in {".tif", ".tiff"}:
        return "TIFF"
    if suffix == ".webp":
        return "WEBP"
    if suffix == ".bmp":
        return "BMP"
    return None


def optimize_image(source_file, output_folder, profile, max_side, quality, log=None, convert_uncompressed=False):
    optimized_root = Path(output_folder) / "_immagini_ridotte_email"
    target_format = image_target_format(source_file.path, convert_uncompressed=convert_uncompressed)
    if target_format is None:
        raise ValueError(f"Formato immagine non supportato: {source_file.path.suffix}")

    target_suffix = ".jpg" if target_format == "JPEG" else source_file.path.suffix.lower()
    optimized_arcname = reduced_image_arcname(source_file.arcname, target_suffix=target_suffix)
    optimized_path = optimized_root / optimized_arcname
    optimized_path.parent.mkdir(parents=True, exist_ok=True)

    if log:
        conversion_text = ""
        if source_file.path.suffix.lower() != target_suffix:
            conversion_text = f" -> {target_suffix}"
        log(
            f"Ottimizzo immagine {profile}{conversion_text}: {source_file.arcname} "
            f"({format_mb(source_file.stat().st_size)})"
        )

    with Image.open(source_file.path) as image:
        image = ImageOps.exif_transpose(image)

        if getattr(image, "is_animated", False) and log:
            log("  immagine animata: uso il primo frame per la copia email.")
            image.seek(0)

        image = normalize_image_for_format(image, target_format)
        image.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)

        image.save(
            optimized_path,
            target_format,
            **image_save_options(target_format, quality),
        )

    return optimized_image_result(source_file, optimized_path, optimized_arcname, profile, max_side, quality)


def try_optimize_image_for_limit(source_file, output_folder, max_zip_size, log=None, convert_uncompressed=False):
    original_size = source_file.stat().st_size

    for profile_name, max_side, quality in IMAGE_OPTIMIZE_PROFILES:
        optimized = optimize_image(
            source_file,
            output_folder,
            profile_name,
            max_side,
            quality,
            log=log,
            convert_uncompressed=convert_uncompressed,
        )

        if optimized.optimized_size >= original_size:
            if log:
                log(
                    "Scarto questa copia: non riduce l'immagine "
                    f"({format_mb(original_size)} -> {format_mb(optimized.optimized_size)})."
                )
            optimized.optimized.path.unlink(missing_ok=True)
            continue

        with tempfile.TemporaryDirectory() as temp_folder:
            zip_size = compressed_zip_size([optimized.optimized], temp_folder)

        if zip_size <= max_zip_size:
            return optimized

        if log:
            log(
                f"La copia immagine e ancora troppo grande: "
                f"{format_mb(zip_size)} nello ZIP."
            )
        optimized.optimized.path.unlink(missing_ok=True)

    return None


def optimized_office_result(source_file, optimized_path):
    optimized_file = BundleFile(
        path=optimized_path.resolve(),
        arcname=reduced_office_arcname(source_file.arcname),
        original_path=source_file.path,
    )

    return OptimizedPdf(
        original=source_file,
        optimized=optimized_file,
        original_size=source_file.stat().st_size,
        optimized_size=optimized_file.stat().st_size,
        kind="office",
        profile="office_pdf",
        dpi=None,
        quality=None,
    )


def convert_office_to_pdf(source_file, output_folder, log=None):
    suffix = source_file.path.suffix.lower()
    output_root = Path(output_folder) / "_office_convertiti_pdf"
    pdf_path = output_root / reduced_office_arcname(source_file.arcname)
    pdf_path.parent.mkdir(parents=True, exist_ok=True)

    if log:
        log(f"Converto Office in PDF: {source_file.arcname} ({format_mb(source_file.stat().st_size)})")

    try:
        import pythoncom
        import win32com.client
    except Exception as exc:
        if log:
            log(f"Conversione Office non disponibile: {exc}")
        return None

    pythoncom.CoInitialize()
    try:
        if suffix in {".doc", ".docx"}:
            word = None
            document = None
            try:
                word = win32com.client.DispatchEx("Word.Application")
                word.Visible = False
                word.DisplayAlerts = 0
                document = word.Documents.Open(str(source_file.path), ReadOnly=True)
                document.SaveAs(str(pdf_path), FileFormat=17)
            finally:
                if document is not None:
                    document.Close(False)
                if word is not None:
                    word.Quit()

        elif suffix in {".xls", ".xlsx"}:
            excel = None
            workbook = None
            try:
                excel = win32com.client.DispatchEx("Excel.Application")
                excel.Visible = False
                excel.DisplayAlerts = False
                workbook = excel.Workbooks.Open(str(source_file.path), ReadOnly=True)
                workbook.ExportAsFixedFormat(0, str(pdf_path))
            finally:
                if workbook is not None:
                    workbook.Close(False)
                if excel is not None:
                    excel.Quit()

        elif suffix in {".ppt", ".pptx"}:
            powerpoint = None
            presentation = None
            try:
                powerpoint = win32com.client.DispatchEx("PowerPoint.Application")
                presentation = powerpoint.Presentations.Open(
                    str(source_file.path),
                    WithWindow=False,
                )
                presentation.SaveAs(str(pdf_path), 32)
            finally:
                if presentation is not None:
                    presentation.Close()
                if powerpoint is not None:
                    powerpoint.Quit()
        else:
            return None
    except Exception as exc:
        if log:
            log(f"Conversione Office fallita: {exc}")
        pdf_path.unlink(missing_ok=True)
        return None
    finally:
        pythoncom.CoUninitialize()

    if not pdf_path.exists():
        if log:
            log("Conversione Office fallita: PDF non creato.")
        return None

    return optimized_office_result(source_file, pdf_path)


def try_convert_office_for_limit(source_file, output_folder, max_zip_size, log=None):
    converted = convert_office_to_pdf(source_file, output_folder, log=log)
    if converted is None:
        return None

    with tempfile.TemporaryDirectory() as temp_folder:
        zip_size = compressed_zip_size([converted.optimized], temp_folder)

    if zip_size <= max_zip_size:
        return converted

    if log:
        log(f"Il PDF convertito e ancora troppo grande: {format_mb(zip_size)} nello ZIP.")

    converted.optimized.path.unlink(missing_ok=True)
    return None


def try_optimize_pdf_for_limit(source_file, output_folder, max_zip_size, log=None):
    original_size = source_file.stat().st_size
    skip_raster_profiles = pdf_has_significant_text(source_file.path)

    for profile_name, dpi, quality in PDF_OPTIMIZE_PROFILES:
        if isinstance(dpi, int) and skip_raster_profiles:
            if log:
                log(
                    "Il PDF contiene testo selezionabile: salto la ricodifica a immagini "
                    "per evitare file piu grandi e perdita di qualita."
                )
            break

        if log:
            log(f"Provo profilo PDF '{profile_name}'.")

        if isinstance(dpi, str):
            optimized = optimize_pdf_with_ghostscript(source_file, output_folder, dpi, log=log)
            if optimized is None:
                continue
        else:
            optimized = optimize_pdf(source_file, output_folder, profile_name, dpi, quality, log=log)

        if optimized.optimized_size >= original_size:
            if log:
                log(
                    "Scarto questa copia: non riduce il PDF "
                    f"({format_mb(original_size)} -> {format_mb(optimized.optimized_size)})."
                )
            optimized.optimized.path.unlink(missing_ok=True)
            continue

        with tempfile.TemporaryDirectory() as temp_folder:
            zip_size = compressed_zip_size([optimized.optimized], temp_folder)

        if zip_size <= max_zip_size:
            return optimized

        if log:
            log(
                f"La copia ridotta e ancora troppo grande: "
                f"{format_mb(zip_size)} nello ZIP."
            )

    return None


def split_into_groups(
    files,
    output_folder,
    max_zip_size,
    log=None,
    confirm_optimization=None,
    convert_uncompressed_images=False,
    convert_office_to_pdf_enabled=False,
):
    groups = []
    current_group = []
    optimized_reports = []

    # Larger files first usually reduces the number of packages.
    files = sorted(files, key=lambda f: f.stat().st_size, reverse=True)

    with tempfile.TemporaryDirectory() as temp_folder:
        for index, file_path in enumerate(files, start=1):
            if log:
                log(f"Analisi file {index}/{len(files)}: {file_path.display_name}")

            candidate_group = current_group + [file_path]
            candidate_size = compressed_zip_size(candidate_group, temp_folder)

            if candidate_size <= max_zip_size:
                current_group = candidate_group
                continue

            single_size = compressed_zip_size([file_path], temp_folder)
            if single_size <= max_zip_size:
                if current_group:
                    groups.append(current_group)
                current_group = [file_path]
                continue

            replacement = None
            if file_path.path.suffix.lower() == ".pdf":
                replacement = try_optimize_pdf_for_limit(
                    file_path,
                    output_folder,
                    max_zip_size,
                    log=log,
                )

            elif file_path.path.suffix.lower() in IMAGE_SUFFIXES:
                replacement = try_optimize_image_for_limit(
                    file_path,
                    output_folder,
                    max_zip_size,
                    log=log,
                    convert_uncompressed=convert_uncompressed_images,
                )
            elif convert_office_to_pdf_enabled and file_path.path.suffix.lower() in OFFICE_SUFFIXES:
                replacement = try_convert_office_for_limit(
                    file_path,
                    output_folder,
                    max_zip_size,
                    log=log,
                )

            if replacement and replacement.kind != "office" and confirm_optimization:
                accepted = confirm_optimization(replacement)
                if not accepted:
                    if log:
                        log(f"Ottimizzazione rifiutata: {file_path.arcname}")
                    replacement = None

            if replacement:
                optimized_reports.append(replacement)
                replacement_group = current_group + [replacement.optimized]
                replacement_group_size = compressed_zip_size(replacement_group, temp_folder)

                if replacement_group_size <= max_zip_size:
                    current_group = replacement_group
                else:
                    if current_group:
                        groups.append(current_group)
                    current_group = [replacement.optimized]

                if log:
                    labels = {
                        "pdf": "PDF alleggerito accettato",
                        "image": "Immagine alleggerita accettata",
                        "office": "File Office convertito accettato",
                    }
                    item_label = labels.get(replacement.kind, "File ottimizzato accettato")
                    log(
                        f"{item_label}: {file_path.arcname} -> "
                        f"{replacement.optimized.arcname} "
                        f"({format_mb(replacement.original_size)} -> "
                        f"{format_mb(replacement.optimized_size)})"
                    )
            else:
                if current_group:
                    groups.append(current_group)
                    current_group = []
                if log:
                    log(
                        "ATTENZIONE: il file supera il limite anche da solo "
                        f"e verra inserito in un pacchetto fuori limite: {file_path.arcname} "
                        f"({format_mb(single_size)} nello ZIP)"
                    )
                groups.append([file_path])

    if current_group:
        groups.append(current_group)

    return groups, optimized_reports


def bundle_folder(
    source_folder,
    output_folder,
    max_size_mb,
    log=None,
    confirm_optimization=None,
    convert_uncompressed_images=False,
    convert_office_to_pdf_enabled=False,
):
    source_folder = Path(source_folder).resolve()
    output_folder = Path(output_folder).resolve()
    max_zip_size = mb_to_bytes(max_size_mb)

    if not source_folder.exists() or not source_folder.is_dir():
        raise ValueError("La cartella sorgente indicata non esiste.")

    if max_zip_size <= 0:
        raise ValueError("La dimensione massima deve essere maggiore di zero.")

    if output_folder == source_folder:
        raise ValueError("La cartella di destinazione deve essere diversa dalla sorgente.")

    output_folder.mkdir(parents=True, exist_ok=True)

    for old_zip in output_folder.glob("pacchetto_*.zip"):
        old_zip.unlink()

    optimized_root = output_folder / "_pdf_ridotti_email"
    if optimized_root.exists():
        for old_pdf in optimized_root.rglob("*.pdf"):
            old_pdf.unlink()

    optimized_image_root = output_folder / "_immagini_ridotte_email"
    if optimized_image_root.exists():
        for old_image in optimized_image_root.rglob("*"):
            if old_image.is_file():
                old_image.unlink()

    office_pdf_root = output_folder / "_office_convertiti_pdf"
    if office_pdf_root.exists():
        for old_pdf in office_pdf_root.rglob("*.pdf"):
            old_pdf.unlink()

    if log:
        log(f"Sorgente: {source_folder}")
        log(f"Destinazione: {output_folder}")
        log(f"Limite pacchetto: {float(max_size_mb):.2f} MB")
        log(
            "Conversione BMP/TIFF in JPEG: "
            + ("attiva" if convert_uncompressed_images else "disattiva")
        )
        log(
            "Conversione Office in PDF: "
            + ("attiva" if convert_office_to_pdf_enabled else "disattiva")
        )

    files = collect_files(source_folder, exclude_folder=output_folder)

    if not files:
        raise ValueError("Nessun file trovato nella cartella sorgente.")

    if log:
        total_size = sum(f.stat().st_size for f in files)
        log(f"Trovati {len(files)} file ({format_mb(total_size)} originali).")

    groups, optimized_reports = split_into_groups(
        files,
        output_folder,
        max_zip_size,
        log=log,
        confirm_optimization=confirm_optimization,
        convert_uncompressed_images=convert_uncompressed_images,
        convert_office_to_pdf_enabled=convert_office_to_pdf_enabled,
    )
    results = []

    for i, group in enumerate(groups, start=1):
        zip_name = f"pacchetto_{i:03d}.zip"
        zip_path = output_folder / zip_name
        create_zip(zip_path, group)

        original_size = sum(f.stat().st_size for f in group)
        zip_size = zip_path.stat().st_size
        results.append((zip_path, len(group), original_size, zip_size))

        if log:
            log(
                f"Creato {zip_name} - "
                f"{len(group)} file - "
                f"originale {format_mb(original_size)} - "
                f"zip {format_mb(zip_size)}"
            )

    if optimized_reports and log:
        log("")
        log("File ottimizzati:")
        for report in optimized_reports:
            profile_text = optimization_label(report)
            item_label = "PDF" if report.kind == "pdf" else "immagine"
            log(
                f"- {item_label}: {report.original.arcname} -> {report.optimized.arcname} "
                f"({format_mb(report.original_size)} -> "
                f"{format_mb(report.optimized_size)}, {profile_text})"
            )

    if log:
        log("")
        log(f"Finito. ZIP creati in: {output_folder}")

    return results


def pdf_page_count(pdf_path):
    doc = fitz.open(pdf_path)
    try:
        return doc.page_count
    finally:
        doc.close()


def render_pdf_preview(pdf_path, page_index=0, max_size=(360, 480)):
    doc = fitz.open(pdf_path)
    try:
        page_index = max(0, min(page_index, doc.page_count - 1))
        page = doc.load_page(page_index)
        pixmap = page.get_pixmap(matrix=fitz.Matrix(1.2, 1.2), alpha=False)
        image = Image.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
        image.thumbnail(max_size, Image.Resampling.LANCZOS)
        return image
    finally:
        doc.close()


def render_image_preview(image_path, max_size=(360, 480)):
    with Image.open(image_path) as image:
        image = ImageOps.exif_transpose(image)
        if getattr(image, "is_animated", False):
            image.seek(0)
        image = normalize_image_for_format(image, "JPEG")
        image.thumbnail(max_size, Image.Resampling.LANCZOS)
        return image.copy()


def render_preview(report, path, page_index=0):
    if report.kind == "pdf":
        return render_pdf_preview(path, page_index)
    return render_image_preview(path)


class OptimizationDialog(ctk.CTkToplevel):
    def __init__(self, parent, report):
        super().__init__(parent)
        self.report = report
        self.result = False
        self.page_index = 0
        self.page_count = 1
        if report.kind == "pdf":
            self.page_count = min(
                pdf_page_count(report.original.path),
                pdf_page_count(report.optimized.path),
            )
        self.title("File troppo grande")
        self.geometry("900x720")
        self.minsize(780, 620)
        self.transient(parent)
        self.grab_set()

        self.grid_columnconfigure((0, 1), weight=1)
        self.grid_rowconfigure(2, weight=1)

        item_label = "PDF" if report.kind == "pdf" else "immagine"
        title = (
            f"{report.original.arcname.name} supera il limite. "
            "Ho creato una copia alleggerita per email."
        )
        ctk.CTkLabel(self, text=title, wraplength=780, font=ctk.CTkFont(size=16, weight="bold")).grid(
            row=0, column=0, columnspan=2, padx=18, pady=(18, 10), sticky="w"
        )

        pager = ctk.CTkFrame(self, fg_color="transparent")
        pager.grid(row=1, column=0, columnspan=2, padx=18, pady=(0, 8), sticky="ew")
        pager.grid_columnconfigure(1, weight=1)

        self.prev_button = ctk.CTkButton(pager, text="<", width=44, command=self.previous_page)
        self.prev_button.grid(row=0, column=0, padx=(0, 10))

        self.page_label = ctk.CTkLabel(pager, text="")
        self.page_label.grid(row=0, column=1)

        self.next_button = ctk.CTkButton(pager, text=">", width=44, command=self.next_page)
        self.next_button.grid(row=0, column=2, padx=(10, 0))
        if report.kind != "pdf":
            self.prev_button.grid_remove()
            self.next_button.grid_remove()

        self.original_image = None
        self.optimized_image = None

        self._build_preview_column(
            0,
            "Originale",
            format_mb(report.original_size),
        )
        self._build_preview_column(
            1,
            f"Ridotto ({optimization_label(report)})",
            format_mb(report.optimized_size),
        )
        self.update_previews()

        buttons = ctk.CTkFrame(self, fg_color="transparent")
        buttons.grid(row=3, column=0, columnspan=2, padx=18, pady=(10, 18), sticky="e")

        ctk.CTkButton(buttons, text="Rifiuta", width=110, command=self.reject).grid(
            row=0, column=0, padx=(0, 10)
        )
        ctk.CTkButton(buttons, text="Accetta copia ridotta", width=180, command=self.accept).grid(
            row=0, column=1
        )

        self.protocol("WM_DELETE_WINDOW", self.reject)

    def _build_preview_column(self, column, label, size_text):
        frame = ctk.CTkFrame(self, corner_radius=8)
        frame.grid(row=2, column=column, padx=(18 if column == 0 else 9, 18 if column == 1 else 9), pady=8, sticky="nsew")
        frame.grid_columnconfigure(0, weight=1)
        frame.grid_rowconfigure(1, weight=1)

        ctk.CTkLabel(frame, text=f"{label} - {size_text}", font=ctk.CTkFont(weight="bold")).grid(
            row=0, column=0, padx=12, pady=(12, 8), sticky="w"
        )
        image_label = ctk.CTkLabel(frame, text="")
        image_label.grid(row=1, column=0, padx=12, pady=(0, 12), sticky="n")

        if column == 0:
            self.original_image_label = image_label
        else:
            self.optimized_image_label = image_label

    def update_previews(self):
        self.original_image = ImageTk.PhotoImage(
            render_preview(self.report, self.report.original.path, self.page_index)
        )
        self.optimized_image = ImageTk.PhotoImage(
            render_preview(self.report, self.report.optimized.path, self.page_index)
        )
        self.original_image_label.configure(image=self.original_image)
        self.optimized_image_label.configure(image=self.optimized_image)
        if self.report.kind == "pdf":
            self.page_label.configure(text=f"Pagina {self.page_index + 1} / {self.page_count}")
        else:
            self.page_label.configure(text=f"{optimization_label(self.report)}")
        self.prev_button.configure(state="normal" if self.page_index > 0 else "disabled")
        self.next_button.configure(
            state="normal" if self.page_index < self.page_count - 1 else "disabled"
        )

    def previous_page(self):
        if self.page_index > 0:
            self.page_index -= 1
            self.update_previews()

    def next_page(self):
        if self.page_index < self.page_count - 1:
            self.page_index += 1
            self.update_previews()

    def accept(self):
        self.result = True
        self.destroy()

    def reject(self):
        self.result = False
        self.destroy()


class LargeEmailSizeDialog(ctk.CTkToplevel):
    def __init__(self, parent, size_value):
        super().__init__(parent)
        self.result = None
        self.title("Dimensione allegati email")
        self.geometry("560x300")
        self.resizable(False, False)
        self.transient(parent)
        self.grab_set()

        self.grid_columnconfigure(0, weight=1)

        ctk.CTkLabel(
            self,
            text=f"Hai impostato pacchetti da {size_value:.1f} MB.",
            font=ctk.CTkFont(size=16, weight="bold"),
        ).grid(row=0, column=0, padx=22, pady=(22, 10), sticky="w")

        ctk.CTkLabel(
            self,
            text=(
                "Questa dimensione supera 18 MB. Molti provider di posta elettronica "
                "applicano limiti agli allegati, e la codifica email puo aumentare "
                "la dimensione effettiva del messaggio."
            ),
            wraplength=510,
            justify="left",
        ).grid(row=1, column=0, padx=22, pady=(0, 18), sticky="w")

        buttons = ctk.CTkFrame(self, fg_color="transparent")
        buttons.grid(row=2, column=0, padx=22, pady=(8, 22), sticky="e")

        ctk.CTkButton(buttons, text="Annulla", width=100, command=self.cancel).grid(
            row=0, column=0, padx=(0, 10)
        )
        ctk.CTkButton(buttons, text="Riduci a 18 MB", width=130, command=self.reduce).grid(
            row=0, column=1, padx=(0, 10)
        )
        ctk.CTkButton(buttons, text="Mantieni", width=110, command=self.keep).grid(
            row=0, column=2
        )

        self.protocol("WM_DELETE_WINDOW", self.cancel)
        self.after(100, self.lift)
        self.after(120, self.focus_force)

    def keep(self):
        self.result = "keep"
        self.destroy()

    def reduce(self):
        self.result = "reduce"
        self.destroy()

    def cancel(self):
        self.result = "cancel"
        self.destroy()


class ZipBundlerApp(ctk.CTk):
    def __init__(self):
        super().__init__()

        self.title("ZipBundler versione 1.0")
        self.geometry("900x640")
        self.minsize(820, 580)

        ctk.set_appearance_mode("system")
        ctk.set_default_color_theme("green")

        self.source_var = ctk.StringVar()
        self.output_var = ctk.StringVar()
        self.size_var = ctk.StringVar(value=str(DEFAULT_MAX_ZIP_SIZE_MB))
        self.allow_large_email_var = ctk.BooleanVar(value=False)
        self.convert_uncompressed_var = ctk.BooleanVar(value=False)
        self.convert_office_var = ctk.BooleanVar(value=False)
        self.is_running = False

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(2, weight=1)

        self._build_header()
        self._build_form()
        self._build_log()
        self.size_var.trace_add("write", lambda *_: self.update_size_warning())
        self.update_size_warning()

    def _build_header(self):
        header = ctk.CTkFrame(self, fg_color="transparent")
        header.grid(row=0, column=0, padx=22, pady=(18, 8), sticky="ew")
        header.grid_columnconfigure(0, weight=1)

        ctk.CTkLabel(
            header,
            text="ZipBundler",
            font=ctk.CTkFont(size=24, weight="bold"),
        ).grid(row=0, column=0, sticky="w")

        ctk.CTkLabel(
            header,
            text="Pacchetti ZIP entro soglia, con riduzione guidata di PDF, immagini e file Office.",
            text_color=("gray35", "gray72"),
        ).grid(row=1, column=0, pady=(2, 0), sticky="w")

    def _build_form(self):
        form = ctk.CTkFrame(self, corner_radius=8)
        form.grid(row=1, column=0, padx=22, pady=(0, 12), sticky="ew")
        form.grid_columnconfigure(1, weight=1)

        ctk.CTkLabel(form, text="Cartella sorgente", font=ctk.CTkFont(weight="bold")).grid(
            row=0, column=0, padx=(16, 10), pady=(16, 8), sticky="w"
        )
        ctk.CTkEntry(form, textvariable=self.source_var).grid(
            row=0, column=1, padx=0, pady=(16, 8), sticky="ew"
        )
        ctk.CTkButton(form, text="Sfoglia", width=96, command=self.pick_source).grid(
            row=0, column=2, padx=16, pady=(16, 8)
        )

        ctk.CTkLabel(form, text="Destinazione ZIP", font=ctk.CTkFont(weight="bold")).grid(
            row=1, column=0, padx=(16, 10), pady=8, sticky="w"
        )
        ctk.CTkEntry(form, textvariable=self.output_var).grid(
            row=1, column=1, padx=0, pady=8, sticky="ew"
        )
        ctk.CTkButton(form, text="Sfoglia", width=96, command=self.pick_output).grid(
            row=1, column=2, padx=16, pady=8
        )

        ctk.CTkLabel(
            form,
            text="Dimensione max pacchetto (MB)",
            font=ctk.CTkFont(weight="bold"),
        ).grid(
            row=2, column=0, padx=(16, 10), pady=(8, 16), sticky="w"
        )
        size_spinbox = ctk.CTkFrame(form, fg_color="transparent")
        size_spinbox.grid(row=2, column=1, padx=0, pady=(8, 16), sticky="w")

        ctk.CTkButton(size_spinbox, text="-", width=34, command=lambda: self.step_size(-1)).grid(
            row=0, column=0, padx=(0, 6)
        )
        ctk.CTkEntry(size_spinbox, textvariable=self.size_var, width=84, justify="center").grid(
            row=0, column=1
        )
        ctk.CTkButton(size_spinbox, text="+", width=34, command=lambda: self.step_size(1)).grid(
            row=0, column=2, padx=(6, 0)
        )

        self.large_size_panel = ctk.CTkFrame(form, corner_radius=8, fg_color="#fff4e5")
        self.large_size_panel.grid(
            row=3, column=0, columnspan=3, padx=16, pady=(0, 14), sticky="ew"
        )
        self.large_size_panel.grid_columnconfigure(0, weight=1)

        self.large_size_warning = ctk.CTkLabel(
            self.large_size_panel,
            text=(
                "Oltre 18 MB gli allegati possono essere rifiutati da molti provider email."
            ),
            text_color="#7a3e00",
            wraplength=520,
            justify="left",
        )
        self.large_size_warning.grid(row=0, column=0, padx=12, pady=(10, 4), sticky="w")

        self.large_size_checkbox = ctk.CTkCheckBox(
            self.large_size_panel,
            text="Accetto questo limite",
            variable=self.allow_large_email_var,
            text_color="#7a3e00",
        )
        self.large_size_checkbox.grid(row=1, column=0, padx=12, pady=(0, 10), sticky="w")

        ctk.CTkButton(
            self.large_size_panel,
            text="Riduci a 18 MB",
            width=120,
            command=lambda: self.size_var.set("18"),
            fg_color="#a15c00",
            hover_color="#7a3e00",
        ).grid(row=0, column=1, rowspan=2, padx=12, pady=10, sticky="e")

        options = ctk.CTkFrame(form, fg_color="transparent")
        options.grid(row=5, column=0, columnspan=2, padx=16, pady=(0, 16), sticky="w")

        ctk.CTkLabel(
            options,
            text="Opzioni conversione",
            font=ctk.CTkFont(weight="bold"),
            text_color=("gray30", "gray75"),
        ).grid(row=0, column=0, pady=(0, 8), sticky="w")

        self.convert_checkbox = ctk.CTkCheckBox(
            options,
            text="Converti formati non compressi in JPEG (es. BMP/TIFF)",
            variable=self.convert_uncompressed_var,
        )
        self.convert_checkbox.grid(row=1, column=0, pady=(0, 8), sticky="w")

        self.office_checkbox = ctk.CTkCheckBox(
            options,
            text="Converti file Office grandi in PDF (es. DOCX -> PDF)",
            variable=self.convert_office_var,
        )
        self.office_checkbox.grid(row=2, column=0, sticky="w")

        self.start_button = ctk.CTkButton(
            form,
            text="Crea pacchetti ZIP",
            width=160,
            height=36,
            command=self.start_bundle,
        )
        self.start_button.grid(row=6, column=2, padx=16, pady=(0, 16))

    def _build_log(self):
        body = ctk.CTkFrame(self, corner_radius=8)
        body.grid(row=2, column=0, padx=22, pady=(0, 22), sticky="nsew")
        body.grid_columnconfigure(0, weight=1)
        body.grid_rowconfigure(2, weight=1)

        ctk.CTkLabel(
            body,
            text="Avanzamento",
            font=ctk.CTkFont(size=15, weight="bold"),
        ).grid(row=0, column=0, padx=16, pady=(14, 8), sticky="w")

        self.progress = ctk.CTkProgressBar(body, mode="indeterminate")
        self.progress.grid(row=1, column=0, padx=16, pady=(0, 10), sticky="ew")
        self.progress.set(0)

        self.log_box = ctk.CTkTextbox(body, wrap="word")
        self.log_box.grid(row=2, column=0, padx=16, pady=(0, 16), sticky="nsew")
        self.log_box.configure(state="disabled")

    def pick_source(self):
        folder = filedialog.askdirectory(title="Scegli la cartella da comprimere")
        if not folder:
            return

        self.source_var.set(folder)
        if not self.output_var.get():
            source = Path(folder)
            self.output_var.set(str(source.parent / f"{source.name}_zip_{self.size_var.get()}mb"))

    def pick_output(self):
        folder = filedialog.askdirectory(title="Scegli dove salvare i pacchetti ZIP")
        if folder:
            self.output_var.set(folder)

    def append_log(self, text):
        self.log_box.configure(state="normal")
        self.log_box.insert("end", text + "\n")
        self.log_box.see("end")
        self.log_box.configure(state="disabled")

    def set_running(self, running):
        self.is_running = running
        self.start_button.configure(state="disabled" if running else "normal")
        if running:
            self.progress.start()
        else:
            self.progress.stop()
            self.progress.set(0)

    def step_size(self, delta):
        try:
            current = float(self.size_var.get().strip().replace(",", "."))
        except ValueError:
            current = DEFAULT_MAX_ZIP_SIZE_MB

        next_value = max(1, current + delta)
        if next_value.is_integer():
            self.size_var.set(str(int(next_value)))
        else:
            self.size_var.set(f"{next_value:.1f}")

    def update_size_warning(self):
        try:
            size_value = float(self.size_var.get().strip().replace(",", "."))
        except ValueError:
            size_value = DEFAULT_MAX_ZIP_SIZE_MB

        if size_value > 18:
            self.large_size_warning.configure(
                text="Oltre 18 MB gli allegati possono essere rifiutati da molti provider email."
            )
            self.large_size_panel.grid()
        else:
            self.allow_large_email_var.set(False)
            self.large_size_panel.grid_remove()

    def confirm_optimization(self, report):
        event = threading.Event()
        answer = {"value": False}

        def show_dialog():
            dialog = OptimizationDialog(self, report)
            self.wait_window(dialog)
            answer["value"] = dialog.result
            event.set()

        self.after(0, show_dialog)
        event.wait()
        return answer["value"]

    def confirm_large_email_size(self, size_value):
        dialog = LargeEmailSizeDialog(self, size_value)
        self.wait_window(dialog)
        return dialog.result

    def start_bundle(self):
        if self.is_running:
            return

        source = self.source_var.get().strip()
        output = self.output_var.get().strip()
        size = self.size_var.get().strip().replace(",", ".")
        convert_uncompressed = self.convert_uncompressed_var.get()
        convert_office = self.convert_office_var.get()

        if not source:
            messagebox.showerror("Dato mancante", "Scegli la cartella sorgente.")
            return

        if not output:
            messagebox.showerror("Dato mancante", "Scegli la cartella di destinazione.")
            return

        try:
            size_value = float(size)
            if size_value <= 0:
                raise ValueError
        except ValueError:
            messagebox.showerror(
                "Valore non valido",
                "Inserisci una dimensione massima valida, per esempio 20.",
            )
            return

        if size_value > 18 and not self.allow_large_email_var.get():
            self.large_size_warning.configure(
                text=(
                    "Spunta \"Accetto questo limite\" oppure riduci a 18 MB per continuare."
                )
            )
            self.large_size_panel.grid()
            return

        self.log_box.configure(state="normal")
        self.log_box.delete("1.0", "end")
        self.log_box.configure(state="disabled")
        self.set_running(True)

        worker = threading.Thread(
            target=self.run_bundle,
            args=(source, output, size_value, convert_uncompressed, convert_office),
            daemon=True,
        )
        worker.start()

    def run_bundle(self, source, output, size, convert_uncompressed, convert_office):
        def thread_log(message):
            self.after(0, self.append_log, message)

        try:
            bundle_folder(
                source,
                output,
                size,
                log=thread_log,
                confirm_optimization=self.confirm_optimization,
                convert_uncompressed_images=convert_uncompressed,
                convert_office_to_pdf_enabled=convert_office,
            )
        except Exception as exc:
            self.after(0, messagebox.showerror, "Errore", str(exc))
            self.after(0, self.append_log, f"ERRORE: {exc}")
        finally:
            self.after(0, self.set_running, False)


def run_gui():
    app = ZipBundlerApp()
    try:
        import pyi_splash

        pyi_splash.close()
    except Exception:
        pass

    app.mainloop()


def parse_args(argv):
    parser = argparse.ArgumentParser(description="Crea pacchetti ZIP con dimensione massima reale.")
    parser.add_argument("source", nargs="?", help="Cartella sorgente da comprimere.")
    parser.add_argument("--output", "-o", help="Cartella dove salvare gli ZIP.")
    parser.add_argument(
        "--size-mb",
        "-s",
        type=float,
        default=DEFAULT_MAX_ZIP_SIZE_MB,
        help=f"Dimensione massima di ogni ZIP in MB. Default: {DEFAULT_MAX_ZIP_SIZE_MB}.",
    )
    parser.add_argument(
        "--convert-uncompressed-images",
        action="store_true",
        help="Converte immagini BMP/TIFF in JPEG solo per la copia ridotta email.",
    )
    parser.add_argument(
        "--convert-office-to-pdf",
        action="store_true",
        help="Converte file Office grandi in PDF solo quando superano il limite.",
    )
    return parser.parse_args(argv)


def main(argv=None):
    args = parse_args(argv if argv is not None else sys.argv[1:])

    if not args.source:
        run_gui()
        return

    source_folder = Path(args.source).resolve()
    output_folder = Path(args.output).resolve() if args.output else source_folder.parent / f"{source_folder.name}_zip_20mb"

    bundle_folder(
        source_folder,
        output_folder,
        args.size_mb,
        log=print,
        convert_uncompressed_images=args.convert_uncompressed_images,
        convert_office_to_pdf_enabled=args.convert_office_to_pdf,
    )


if __name__ == "__main__":
    main()
