"""Convert images in a directory to a single PDF file.

This module provides functionality to convert multiple image files in a directory
into a single PDF file. It supports various image formats and offers options
to normalize images (scale, rotate) before conversion.

Command: img2pdf [--normalize]
"""

from __future__ import annotations

import atexit
import concurrent.futures
import json
import logging
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING

from PIL import Image
from PIL.Image import Resampling

if TYPE_CHECKING:
    import argparse

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


CONFIG_FILE = Path.home() / ".pytola" / "img2pdf.json"


@dataclass
class ImageToPdfConfig:
    """Configuration for image to PDF conversion."""

    DPI: int = 300
    EXTENSIONS: set[str] | None = None

    def __post_init__(self) -> None:
        """Initialize default extensions if not provided."""
        if self.EXTENSIONS is None:
            self.EXTENSIONS = {
                ".jpg",
                ".jpeg",
                ".png",
                ".gif",
                ".bmp",
                ".webp",
                ".tiff",
                ".ico",
            }

        if CONFIG_FILE.exists():
            try:
                config_data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
                # Update configuration items, keeping defaults as fallback
                for key, value in config_data.items():
                    if hasattr(self, key) and isinstance(
                        value,
                        type(getattr(self, key)),
                    ):
                        if key == "EXTENSIONS":
                            setattr(self, key, set(value))
                        else:
                            setattr(self, key, value)
            except (json.JSONDecodeError, TypeError, AttributeError):
                pass

    def save(self) -> None:
        """Save current configuration to file."""
        CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
        config_dict = {"DPI": self.DPI, "EXTENSIONS": list(self.EXTENSIONS or set())}
        CONFIG_FILE.write_text(json.dumps(config_dict, indent=4), encoding="utf-8")


conf = ImageToPdfConfig()
atexit.register(conf.save)


# Magic numbers for image file headers.
_MAGIC_NUMBERS: dict[str, bytes] = {
    "jpg": b"\xff\xd8\xff",
    "jpeg": b"\xff\xd8\xff",
    "png": b"\x89PNG\r\n\x1a\n",
    "gif": b"GIF87a",
    "bmp": b"BM",
    "webp": b"RIFFf\x00\x00\x00WEBP",
    "tiff": b"II*\x00",
    "ico": b"ICON",
    "svg": b"<svg",
}


def is_valid_image(file_path: Path) -> bool:
    """Validate image file.

    Args:
        file_path: Path to the image file to validate

    Returns
    -------
        bool: True if the file is a valid image file, False otherwise
    """
    # Basic validation.
    try:
        stat_result = file_path.stat()
        if stat_result.st_size == 0:
            logger.debug(f"Empty file: {file_path}")
            return False
    except OSError:
        logger.debug(f"File not found or inaccessible: {file_path}")
        return False

    # Extension validation.
    ext = file_path.suffix.lower()
    if not conf.EXTENSIONS or ext not in conf.EXTENSIONS:
        logger.debug(f"Invalid image extension: {ext}, {file_path}")
        return False

    # File header validation.
    try:
        with file_path.open("rb") as f:
            header = f.read(16)  # Read more bytes to improve detection
            if not any(header.startswith(v) for v in _MAGIC_NUMBERS.values()):
                logger.debug(f"Invalid image header: {header[:8]}")
                return False
    except OSError:
        logger.debug(f"Cannot read file header: {file_path}")
        return False

    logger.info(f"Valid image: {file_path}")
    return True


@dataclass(frozen=True)
class ImageToPDFRunner:
    """Image to PDF converter processor.

    Processes image files in a directory and converts them to a single PDF file.
    Supports normalization (scaling, rotating) of images before conversion.
    """

    root_dir: Path  # Directory containing images to convert
    dpi: int  # DPI setting for PDF output
    normalize: bool = True  # Whether to normalize images (scale, rotate)

    def run(self) -> None:
        """Execute the image to PDF conversion process.

        Converts all valid images in the root directory to a single PDF file.
        The resulting PDF is saved in the same directory with the directory name as filename.
        """
        logger.info(f"Start converting, using dpi={self.dpi}")
        converted_images = self.converted_images
        if not converted_images:
            logger.error(f"No converted image file found in: {self.root_dir}")
            return

        # Save the PDF with optimized settings
        converted_images[0].save(
            self.output_pdf,
            "PDF",
            resolution=100.0,
            save_all=True,
            append_images=converted_images[1:],
            optimize=True,
        )
        logger.info(f"Create pdf file: {self.output_pdf}")

    @cached_property
    def output_pdf(self) -> Path:
        """Get the output PDF file path.

        Returns
        -------
            Path object for the output PDF file, located in the root directory
            with the directory name as the filename.
        """
        return self.root_dir / f"{self.root_dir.name}.pdf"

    @cached_property
    def size(self) -> tuple[int, int]:
        """Get page size based on DPI setting.

        Returns
        -------
            Tuple of (width, height) in pixels based on DPI
        """
        return (int(8.27 * self.dpi), int(11.69 * self.dpi))

    @cached_property
    def page_size(self) -> tuple[int, int]:
        """Get page size based on DPI setting.

        Returns
        -------
            Tuple of (width, height) in pixels based on DPI
        """
        return (int(8.27 * self.dpi), int(11.69 * self.dpi))

    @cached_property
    def converted_images(self) -> list[Image.Image]:
        """Convert all image files to PIL Image objects.

        Processes images in batches to manage memory usage and uses ThreadPoolExecutor
        for parallel conversion within each batch.

        Returns
        -------
            List of PIL Image objects representing converted images.
        """
        # Process images in batches to manage memory usage
        batch_size = 10  # Adjust batch size based on available memory
        all_results = []

        for i in range(0, len(self.image_files), batch_size):
            batch = self.image_files[i : i + batch_size]
            logger.info(
                f"Processing batch {i // batch_size + 1}/{(len(self.image_files) - 1) // batch_size + 1}",
            )

            # Use ThreadPoolExecutor to convert images in parallel within each batch
            with concurrent.futures.ThreadPoolExecutor(
                max_workers=min(len(batch), 4),
            ) as executor:
                # Submit all conversion tasks for this batch
                futures = [executor.submit(self._convert, file, normalize=self.normalize) for file in batch]

                # Collect results, filtering out None values
                batch_results = [future.result() for future in futures if future.result() is not None]

                all_results.extend(batch_results)

                # Explicitly clean up completed futures to free memory
                for future in futures:
                    del future

        return all_results

    @cached_property
    def image_files(self) -> list[Path]:
        """Get list of valid image files in the root directory.

        Returns
        -------
            List of Path objects representing valid image files, sorted alphabetically.
        """
        all_files = list(self.root_dir.iterdir())
        image_filepath = sorted([file for file in all_files if is_valid_image(file)])

        if not image_filepath:
            logger.warning(f"No valid image files found in: {self.root_dir}")
            logger.info(f"Total files in directory: {len(all_files)}")
            return []
        logger.info(
            f"Found {len(image_filepath)} valid image files out of {len(all_files)} total files",
        )

        return image_filepath

    def _convert(
        self,
        filepath: Path,
        normalize: bool = True,
    ) -> Image.Image | None:
        """Convert image to PDF.

        Args:
            filepath: Path to the image file to convert
            normalize: Whether to normalize the image (scale, rotate).
                Defaults to True.

        Returns
        -------
            PIL Image object if conversion successful, None otherwise.

        Raises
        ------
            Exception: If there's an error opening the image
        """
        try:
            # Open and convert to RGB immediately to reduce memory usage
            with Image.open(str(filepath)) as img:
                # Convert to RGB if needed to ensure compatibility with PDF
                if img.mode in {"RGBA", "LA", "P"}:
                    # Handle transparency by compositing on white background
                    rgb_img = Image.new("RGB", img.size, (255, 255, 255))
                    if img.mode == "P" and "transparency" in img.info:
                        img = img.convert("RGBA")
                    if img.mode in {"RGBA", "LA"}:
                        rgb_img.paste(img, mask=img.split()[-1])
                    else:
                        rgb_img.paste(img)
                    image = rgb_img
                else:
                    image = img.convert("RGB")
        except Exception as e:
            logger.exception(f"Failed to open image {filepath}: {e}")
            return None

        if normalize:
            logger.info(f"Normalizing image: {filepath}")

            image = self._auto_rotate_image(image)
            image = self._auto_scale_image(image)
            image.thumbnail(self.page_size, Resampling.LANCZOS)

            # Create new image with white background and paste the thumbnail
            converted_image = Image.new(
                "RGB",
                self.page_size,
                (255, 255, 255),
            )
            converted_image.paste(
                image,
                (
                    (self.page_size[0] - image.size[0]) // 2,
                    (self.page_size[1] - image.size[1]) // 2,
                ),
            )
            logger.info(f"Image normalized: {filepath}")
        else:
            # Ensure image is in RGB mode
            converted_image = image

        logger.debug(f"Convert image: {filepath} successfully")
        # Return RGB converted image
        return converted_image

        # Ensure cleanup of any created image objects
        try:
            if "image" in locals():
                del image
            if "rgb_img" in locals():
                del rgb_img
        except Exception:
            logger.exception(f"Cleanup image: {filepath} failed")
        return None

    def _auto_rotate_image(self, image: Image.Image) -> Image.Image:
        """Auto rotate image to correct orientation.

        If the image width is greater than its height, rotates the image 90 degrees
        clockwise to convert from landscape to portrait orientation.

        Args:
            image: PIL Image object to rotate

        Returns
        -------
            PIL Image object in portrait orientation
        """
        width, height = image.size
        if width > height:
            image = image.rotate(90, expand=True)

        return image

    def _auto_scale_image(self, image: Image.Image) -> Image.Image:
        """Auto scale image to fit page dimensions.

        If the image is smaller than the page size, scales it up proportionally
        to fit within the page dimensions while maintaining aspect ratio.

        Args:
            image: PIL Image object to scale

        Returns
        -------
            Scaled PIL Image object
        """
        if image.size[0] < self.page_size[0] or image.size[1] < self.page_size[1]:
            scale_w = self.page_size[0] / image.size[0]
            scale_h = self.page_size[1] / image.size[1]
            scale = max(
                scale_w,
                scale_h,
            )

            new_size = (
                int(image.size[0] * scale),
                int(image.size[1] * scale),
            )
            image = image.resize(new_size, Resampling.LANCZOS)
        return image


def parse_args() -> argparse.Namespace:
    """Parse command line arguments for the img2pdf tool.

    Returns
    -------
        Namespace object containing parsed arguments:
        - directory: Path to the directory containing images to convert
        - dpi: DPI setting for the output PDF
        - normalize: Whether to normalize images (scale, rotate)
    """
    import argparse

    parser = argparse.ArgumentParser(description="Convert images to PDF.")
    parser.add_argument(
        "directory",
        type=str,
        nargs="?",
        default=str(Path.cwd()),
        help="Image directory",
    )
    parser.add_argument("--dpi", type=int, default=300, help="DPI for PDF")
    parser.add_argument(
        "--normalize",
        "-n",
        action="store_true",
        dest="normalize",
        help="Normalize images (scale, rotate)",
    )
    parser.add_argument(
        "--no-normalize",
        action="store_false",
        dest="normalize",
        help="Disable image normalization",
    )
    return parser.parse_args()


def main() -> None:
    """Run entry point for the command-line interface.

    Img2Pdf utility tool

    Examples
    --------
      img2pdf [options] <arguments>
    """
    args = parse_args()

    # Convert string path to Path object
    directory_path = Path(args.directory)

    # Validate directory exists
    if not directory_path.exists():
        logger.error(f"Directory does not exist: {directory_path}")
        return

    if not directory_path.is_dir():
        logger.error(f"Path is not a directory: {directory_path}")
        return

    proc = ImageToPDFRunner(
        root_dir=directory_path,
        dpi=args.dpi,
        normalize=args.normalize,
    )
    proc.run()
