"""
PDF export functionality for pyPhotoAlbum

Uses multiprocessing to pre-process images in parallel for faster exports.
"""

import os
import threading
from typing import Any, List, Tuple, Optional, Union, Dict
from dataclasses import dataclass, field
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
import multiprocessing
import io
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from reportlab.platypus import Paragraph
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
from PIL import Image
import math
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.image_utils import (
    apply_pil_rotation,
    convert_to_rgba,
    calculate_center_crop_coords,
    crop_image_to_coords,
)


@dataclass
class ImageTask:
    """Parameters needed to process an image in a worker process."""

    task_id: str
    image_path: str
    pil_rotation_90: int
    crop_info: Tuple[float, float, float, float]
    crop_left: float
    crop_right: float
    target_width: float
    target_height: float
    target_width_px: int
    target_height_px: int
    corner_radius: float


def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional[str]]:
    """
    Process a single image task in a worker process.

    This function runs in a separate process and handles all CPU-intensive
    image operations: loading, rotation, cropping, resizing, and styling.

    Args:
        task: ImageTask with all parameters needed for processing

    Returns:
        Tuple of (task_id, processed_image_bytes or None, error_message or None)
    """
    try:
        # Validate image_path is a string before proceeding
        if not isinstance(task.image_path, str):
            return (task.task_id, None, f"Invalid image_path type: {type(task.image_path).__name__}, expected str")

        # Import PIL first with basic load
        from PIL import Image

        # Try to open the image - this is the most likely failure point
        try:
            img: Image.Image = Image.open(task.image_path)
        except Exception as open_err:
            import traceback

            return (task.task_id, None, f"Image.open failed: {open_err}\n{traceback.format_exc()}")

        # Now import the rest
        try:
            from pyPhotoAlbum.image_utils import (
                apply_pil_rotation,
                convert_to_rgba,
                calculate_center_crop_coords,
                crop_image_to_coords,
                apply_rounded_corners,
            )
        except Exception as import_err:
            import traceback

            return (task.task_id, None, f"Import image_utils failed: {import_err}\n{traceback.format_exc()}")

        # Convert to RGBA
        img = convert_to_rgba(img)

        # Apply PIL-level rotation if needed
        if task.pil_rotation_90 > 0:
            img = apply_pil_rotation(img, task.pil_rotation_90)

        # Calculate final crop bounds (combining element crop with split crop)
        crop_x_min, crop_y_min, crop_x_max, crop_y_max = task.crop_info
        final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * task.crop_left
        final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * task.crop_right

        # Calculate center crop coordinates
        img_width, img_height = img.size
        crop_coords = calculate_center_crop_coords(
            img_width,
            img_height,
            task.target_width,
            task.target_height,
            (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
        )

        # Crop the image
        cropped_img = crop_image_to_coords(img, crop_coords)

        # Downsample if needed
        current_width, current_height = cropped_img.size
        if current_width > task.target_width_px or current_height > task.target_height_px:
            cropped_img = cropped_img.resize(
                (task.target_width_px, task.target_height_px),
                Image.Resampling.LANCZOS,
            )

        # Apply rounded corners if needed
        if task.corner_radius > 0:
            cropped_img = apply_rounded_corners(cropped_img, task.corner_radius)

        # Serialize image: JPEG for photos (much smaller), PNG only when alpha is needed
        buffer = io.BytesIO()
        has_transparency = cropped_img.mode == "RGBA" and task.corner_radius > 0
        if has_transparency:
            cropped_img.save(buffer, format="PNG", optimize=False)
        else:
            # Convert to RGB for JPEG (drop alpha channel if present but unused)
            rgb_img = cropped_img.convert("RGB")
            rgb_img.save(buffer, format="JPEG", quality=92, optimize=True)
        return (task.task_id, buffer.getvalue(), None)

    except Exception as e:
        import traceback

        return (task.task_id, None, f"{str(e)}\n{traceback.format_exc()}")


@dataclass
class RenderContext:
    """Parameters for rendering an image element"""

    canvas: canvas.Canvas
    image_element: ImageData
    x_pt: float
    y_pt: float
    width_pt: float
    height_pt: float
    page_number: int
    crop_left: float = 0.0
    crop_right: float = 1.0
    original_width_pt: Optional[float] = None
    original_height_pt: Optional[float] = None


@dataclass
class SplitRenderParams:
    """Parameters for rendering a split element"""

    canvas: canvas.Canvas
    element: Any
    x_offset_mm: float
    split_line_mm: float
    page_width_pt: float
    page_height_pt: float
    page_number: int
    side: str


class PDFExporter:
    """Handles PDF export of photo album projects"""

    # Conversion constants
    MM_TO_POINTS = 2.834645669  # 1mm = 2.834645669 points
    SPLIT_THRESHOLD_RATIO = 0.002  # 1:500 threshold for tiny elements

    def __init__(self, project, export_dpi: int = 300, max_workers: Optional[int] = None):
        """
        Initialize PDF exporter with a project.

        Args:
            project: The Project instance to export
            export_dpi: Target DPI for images in the PDF (default 300 for print quality)
                       Use 300 for high-quality print, 150 for screen/draft
            max_workers: Maximum number of worker processes for parallel image processing.
                        Defaults to number of CPU cores.
        """
        self.project = project
        self.export_dpi = export_dpi
        self.warnings: List[str] = []
        self.current_pdf_page = 1
        self.max_workers = max_workers or multiprocessing.cpu_count()
        self._processed_images: Dict[str, Image.Image] = {}

    def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
        """
        Export the project to PDF.

        Uses multiprocessing to pre-process all images in parallel, then renders
        each page to its own PDF buffer in parallel (via threads), and finally
        merges the per-page PDFs into the output file.

        Args:
            output_path: Path where PDF should be saved
            progress_callback: Optional callback(current, total, message) for progress updates

        Returns:
            Tuple of (success: bool, warnings: List[str])
        """
        self.warnings = []
        self._processed_images = {}

        try:
            # Calculate total pages for progress (cover counts as 1)
            total_pages = sum(
                1 if page.is_cover else (2 if page.is_double_spread else 1) for page in self.project.pages
            )

            # Get page dimensions from project (in mm)
            page_width_mm, page_height_mm = self.project.page_size_mm
            bleed_mm = self.project.page_bleed_mm
            bleed_pt = bleed_mm * self.MM_TO_POINTS
            page_width_pt = page_width_mm * self.MM_TO_POINTS
            page_height_pt = page_height_mm * self.MM_TO_POINTS

            # Phase 1: parallel image pre-processing (unchanged)
            if progress_callback:
                progress_callback(0, total_pages, "Collecting images for processing...")
            image_tasks = self._collect_image_tasks(page_width_pt, page_height_pt)
            if image_tasks:
                if progress_callback:
                    progress_callback(0, total_pages, f"Processing {len(image_tasks)} images in parallel...")
                self._preprocess_images_parallel(image_tasks, progress_callback, total_pages)

            # Phase 2: determine ordered page sequence (inserts blank pages for spread alignment)
            page_sequence = self._compute_page_sequence()

            # Phase 3: render each page to its own PDF bytes in parallel
            n = len(page_sequence)
            if progress_callback:
                progress_callback(0, total_pages, f"Rendering {n} pages in parallel...")
            pdf_bytes_list: List[Optional[bytes]] = [None] * n

            with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
                future_to_idx = {
                    executor.submit(self._render_item_to_bytes, item, page_width_pt, page_height_pt, bleed_pt): i
                    for i, item in enumerate(page_sequence)
                }
                completed = 0
                for future in as_completed(future_to_idx):
                    i = future_to_idx[future]
                    try:
                        pdf_bytes_list[i] = future.result()
                    except Exception as e:
                        self.warnings.append(f"Error rendering page: {str(e)}")
                        pdf_bytes_list[i] = self._make_blank_page_bytes(page_width_pt, page_height_pt, bleed_pt)
                    completed += 1
                    if progress_callback:
                        progress_callback(completed, n, f"Rendering pages: {completed}/{n}...")

            # Phase 4: merge all per-page PDFs into the output file
            if progress_callback:
                progress_callback(n, total_pages, "Merging pages...")
            self._merge_page_pdfs(pdf_bytes_list, output_path)

            self._processed_images = {}
            if progress_callback:
                progress_callback(total_pages, total_pages, "Export complete!")
            return True, self.warnings

        except Exception as e:
            self.warnings.append(f"Export failed: {str(e)}")
            return False, self.warnings

    def _compute_page_sequence(self) -> List[Tuple[str, Any]]:
        """
        Build an ordered list of (page_type, page) items to render.

        Inserts ('blank', None) entries before double-page spreads that would
        otherwise start on an odd-numbered PDF page (spreads must start on even pages).
        """
        sequence: List[Tuple[str, Any]] = []
        pdf_page_num = 1
        for page in self.project.pages:
            if page.is_cover:
                sequence.append(("cover", page))
                pdf_page_num += 1
            elif page.is_double_spread:
                if pdf_page_num % 2 == 1:
                    sequence.append(("blank", None))
                    pdf_page_num += 1
                sequence.append(("spread", page))
                pdf_page_num += 2
            else:
                sequence.append(("single", page))
                pdf_page_num += 1
        return sequence

    def _render_item_to_bytes(
        self,
        item: Tuple[str, Any],
        page_width_pt: float,
        page_height_pt: float,
        bleed_pt: float,
    ) -> bytes:
        """
        Render a single page item to a self-contained PDF (as bytes).

        Each call creates its own Canvas / BytesIO so pages can be rendered
        concurrently without sharing state.
        """
        page_type, page = item
        expanded_width_pt = page_width_pt + 2 * bleed_pt
        expanded_height_pt = page_height_pt + 2 * bleed_pt
        buf = io.BytesIO()

        if page_type == "blank":
            c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
            c.showPage()
            c.save()

        elif page_type == "cover":
            cover_width_mm, cover_height_mm = page.layout.size
            cover_width_pt = cover_width_mm * self.MM_TO_POINTS
            cover_height_pt = cover_height_mm * self.MM_TO_POINTS
            c = canvas.Canvas(buf, pagesize=(cover_width_pt, cover_height_pt))
            for element in sorted(page.layout.elements, key=lambda x: x.z_index):
                self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")
            c.showPage()
            c.save()

        elif page_type == "single":
            c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
            for element in sorted(page.layout.elements, key=lambda x: x.z_index):
                self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
            c.showPage()
            c.save()

        elif page_type == "spread":
            c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
            self._export_spread(c, page, page_width_pt, page_height_pt, bleed_pt)
            c.save()

        return buf.getvalue()

    def _make_blank_page_bytes(self, page_width_pt: float, page_height_pt: float, bleed_pt: float) -> bytes:
        """Return a minimal single-blank-page PDF for use as an error placeholder."""
        buf = io.BytesIO()
        c = canvas.Canvas(buf, pagesize=(page_width_pt + 2 * bleed_pt, page_height_pt + 2 * bleed_pt))
        c.showPage()
        c.save()
        return buf.getvalue()

    def _merge_page_pdfs(self, pdf_bytes_list: List[Optional[bytes]], output_path: str):
        """Merge a list of single-page PDF byte strings into one output file."""
        from pypdf import PdfWriter, PdfReader

        writer = PdfWriter()
        for pdf_bytes in pdf_bytes_list:
            if pdf_bytes is None:
                continue
            reader = PdfReader(io.BytesIO(pdf_bytes))
            for page in reader.pages:
                writer.add_page(page)
        with open(output_path, "wb") as f:
            writer.write(f)

    def _make_task_id(
        self,
        element: ImageData,
        crop_left: float = 0.0,
        crop_right: float = 1.0,
        width_pt: float = 0.0,
        height_pt: float = 0.0,
    ) -> str:
        """Generate a unique task ID for an image element with specific render params."""
        return f"{id(element)}_{crop_left:.4f}_{crop_right:.4f}_{width_pt:.2f}_{height_pt:.2f}"

    def _collect_image_tasks(self, page_width_pt: float, page_height_pt: float) -> List[ImageTask]:
        """
        Collect all image processing tasks from the project.

        Scans all pages and elements to build a list of ImageTask objects
        that can be processed in parallel.
        """
        tasks: List[ImageTask] = []
        dpi = self.project.working_dpi

        for page in self.project.pages:
            if page.is_cover:
                cover_width_mm, cover_height_mm = page.layout.size
                cover_width_pt = cover_width_mm * self.MM_TO_POINTS
                cover_height_pt = cover_height_mm * self.MM_TO_POINTS
                self._collect_page_tasks(tasks, page, 0, cover_width_pt, cover_height_pt)
            elif page.is_double_spread:
                # Collect tasks for both left and right pages of the spread
                page_width_mm = self.project.page_size_mm[0]
                center_mm = page_width_mm
                self._collect_spread_tasks(tasks, page, page_width_pt, page_height_pt, center_mm)
            else:
                self._collect_page_tasks(tasks, page, 0, page_width_pt, page_height_pt)

        return tasks

    def _collect_page_tasks(
        self,
        tasks: List[ImageTask],
        page,
        x_offset_mm: float,
        page_width_pt: float,
        page_height_pt: float,
    ):
        """Collect image tasks from a single page."""
        dpi = self.project.working_dpi

        for element in page.layout.elements:
            if not isinstance(element, ImageData):
                continue

            image_path = element.resolve_image_path()
            if not image_path:
                continue

            # Calculate dimensions
            element_x_px, element_y_px = element.position
            element_width_px, element_height_px = element.size

            element_width_mm = element_width_px * 25.4 / dpi
            element_height_mm = element_height_px * 25.4 / dpi

            width_pt = element_width_mm * self.MM_TO_POINTS
            height_pt = element_height_mm * self.MM_TO_POINTS

            target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
            target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)

            task_id = self._make_task_id(element, 0.0, 1.0, width_pt, height_pt)

            task = ImageTask(
                task_id=task_id,
                image_path=image_path,
                pil_rotation_90=getattr(element, "pil_rotation_90", 0),
                crop_info=element.crop_info,
                crop_left=0.0,
                crop_right=1.0,
                target_width=width_pt,
                target_height=height_pt,
                target_width_px=max(1, target_width_px),
                target_height_px=max(1, target_height_px),
                corner_radius=element.style.corner_radius,
            )
            tasks.append(task)

    def _collect_spread_tasks(
        self,
        tasks: List[ImageTask],
        page,
        page_width_pt: float,
        page_height_pt: float,
        center_mm: float,
    ):
        """Collect image tasks from a double-page spread, handling split elements."""
        dpi = self.project.working_dpi
        center_px = center_mm * dpi / 25.4
        threshold_px = self.project.page_size_mm[0] * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4

        for element in page.layout.elements:
            if not isinstance(element, ImageData):
                continue

            image_path = element.resolve_image_path()
            if not image_path:
                continue

            element_x_px, element_y_px = element.position
            element_width_px, element_height_px = element.size

            element_x_mm = element_x_px * 25.4 / dpi
            element_width_mm = element_width_px * 25.4 / dpi
            element_height_mm = element_height_px * 25.4 / dpi

            width_pt = element_width_mm * self.MM_TO_POINTS
            height_pt = element_height_mm * self.MM_TO_POINTS

            # Check if element spans the center
            if element_x_px + element_width_px <= center_px + threshold_px:
                # Entirely on left page
                self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
            elif element_x_px >= center_px - threshold_px:
                # Entirely on right page
                self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
            else:
                # Spanning element - create tasks for left and right portions
                # Left portion
                crop_width_mm_left = center_mm - element_x_mm
                crop_right_left = crop_width_mm_left / element_width_mm
                left_width_pt = crop_width_mm_left * self.MM_TO_POINTS
                self._add_image_task(tasks, element, image_path, 0.0, crop_right_left, left_width_pt, height_pt)

                # Right portion
                crop_x_start_right = center_mm - element_x_mm
                crop_left_right = crop_x_start_right / element_width_mm
                right_width_pt = (element_width_mm - crop_x_start_right) * self.MM_TO_POINTS
                self._add_image_task(tasks, element, image_path, crop_left_right, 1.0, right_width_pt, height_pt)

    def _add_image_task(
        self,
        tasks: List[ImageTask],
        element: ImageData,
        image_path: str,
        crop_left: float,
        crop_right: float,
        width_pt: float,
        height_pt: float,
    ):
        """Helper to add an image task to the list."""
        # Use original dimensions for aspect ratio calculation
        original_width_pt = width_pt / (crop_right - crop_left) if crop_right != crop_left else width_pt
        original_height_pt = height_pt

        target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
        target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)

        task_id = self._make_task_id(element, crop_left, crop_right, width_pt, height_pt)

        task = ImageTask(
            task_id=task_id,
            image_path=image_path,
            pil_rotation_90=getattr(element, "pil_rotation_90", 0),
            crop_info=element.crop_info,
            crop_left=crop_left,
            crop_right=crop_right,
            target_width=original_width_pt,
            target_height=original_height_pt,
            target_width_px=max(1, target_width_px),
            target_height_px=max(1, target_height_px),
            corner_radius=element.style.corner_radius,
        )
        tasks.append(task)

    def _preprocess_images_parallel(
        self,
        tasks: List[ImageTask],
        progress_callback,
        total_pages: int,
    ):
        """
        Process all image tasks in parallel using a process pool.

        Results are stored in self._processed_images for use during PDF assembly.
        """
        completed = 0
        total_tasks = len(tasks)

        with ProcessPoolExecutor(max_workers=self.max_workers) as executor:
            future_to_task = {executor.submit(_process_image_task, task): task for task in tasks}

            for future in as_completed(future_to_task):
                task = future_to_task[future]
                completed += 1

                try:
                    task_id, image_bytes, error = future.result()

                    if error:
                        warning = f"Error processing image {task.image_path}: {error}"
                        print(f"WARNING: {warning}")
                        self.warnings.append(warning)
                    elif image_bytes:
                        # Deserialize the image from bytes
                        buffer = io.BytesIO(image_bytes)
                        img = Image.open(buffer)
                        # Force load the image data into memory
                        img.load()
                        # Store both image and buffer reference to prevent garbage collection
                        # Some PIL operations may still reference the source buffer
                        img._ppa_buffer = buffer  # type: ignore[attr-defined]  # Keep buffer alive with image
                        self._processed_images[task_id] = img

                except Exception as e:
                    warning = f"Error processing image {task.image_path}: {str(e)}"
                    print(f"WARNING: {warning}")
                    self.warnings.append(warning)

                if progress_callback and completed % 5 == 0:
                    progress_callback(
                        0,
                        total_pages,
                        f"Processing images: {completed}/{total_tasks}...",
                    )

    def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
        """
        Export a cover page to PDF.
        Cover has different dimensions (wrap-around: front + spine + back + bleed).
        """
        # Get cover dimensions (already calculated in page.layout.size)
        cover_width_mm, cover_height_mm = page.layout.size

        # Convert to PDF points
        cover_width_pt = cover_width_mm * self.MM_TO_POINTS
        cover_height_pt = cover_height_mm * self.MM_TO_POINTS

        # Create a new page with cover dimensions
        c.setPageSize((cover_width_pt, cover_height_pt))

        # Render all elements on the cover
        for element in sorted(page.layout.elements, key=lambda x: x.z_index):
            self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")

        c.showPage()  # Finish cover page

    def _export_single_page(
        self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
    ):
        """Export a single page to PDF"""
        expanded_width_pt = page_width_pt + 2 * bleed_pt
        expanded_height_pt = page_height_pt + 2 * bleed_pt
        c.setPageSize((expanded_width_pt, expanded_height_pt))

        # Render all elements, offset by bleed
        for element in sorted(page.layout.elements, key=lambda x: x.z_index):
            self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)

        c.showPage()  # Finish this page

    def _export_spread(
        self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
    ):
        """Export a double-page spread as two PDF pages"""
        # Get center line position in mm
        page_width_mm = self.project.page_size_mm[0]
        center_mm = page_width_mm  # Center of the spread (which is 2x width)

        # Convert center line to pixels for comparison
        dpi = self.project.working_dpi
        center_px = center_mm * dpi / 25.4

        # Calculate threshold for tiny elements (1:500) in pixels
        threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4

        expanded_width_pt = page_width_pt + 2 * bleed_pt
        expanded_height_pt = page_height_pt + 2 * bleed_pt

        # Process elements for left page
        c.setPageSize((expanded_width_pt, expanded_height_pt))
        for element in sorted(page.layout.elements, key=lambda x: x.z_index):
            element_x_px, element_y_px = element.position
            element_width_px, element_height_px = element.size

            # Check if element is on left page, right page, or spanning (compare in pixels)
            if element_x_px + element_width_px <= center_px + threshold_px:
                # Entirely on left page
                self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
            elif element_x_px >= center_px - threshold_px:
                # Skip for now, will render on right page
                pass
            else:
                # Spanning element - render left portion
                params = SplitRenderParams(
                    canvas=c,
                    element=element,
                    x_offset_mm=0,
                    split_line_mm=center_mm,
                    page_width_pt=page_width_pt,
                    page_height_pt=page_height_pt,
                    page_number=page.page_number,
                    side="left",
                )
                self._render_split_element(params, bleed_pt)

        c.showPage()  # Finish left page

        # Process elements for right page
        c.setPageSize((expanded_width_pt, expanded_height_pt))
        for element in sorted(page.layout.elements, key=lambda x: x.z_index):
            element_x_px, element_y_px = element.position
            element_width_px, element_height_px = element.size

            # Check if element is on right page or spanning (compare in pixels)
            if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px:
                # Entirely on right page or mostly on right
                self._render_element(
                    c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1, bleed_pt
                )
            elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
                # Spanning element - render right portion
                params = SplitRenderParams(
                    canvas=c,
                    element=element,
                    x_offset_mm=center_mm,
                    split_line_mm=center_mm,
                    page_width_pt=page_width_pt,
                    page_height_pt=page_height_pt,
                    page_number=page.page_number + 1,
                    side="right",
                )
                self._render_split_element(params, bleed_pt)

        c.showPage()  # Finish right page

    def _render_element(
        self,
        c: canvas.Canvas,
        element,
        x_offset_mm: float,
        page_width_pt: float,
        page_height_pt: float,
        page_number: Union[int, str],
        bleed_pt: float = 0.0,
    ):
        """
        Render a single element on the PDF canvas.

        Args:
            c: ReportLab canvas
            element: The layout element to render
            x_offset_mm: X offset in mm (for right page of spread)
            page_width_pt: Page width in points (cut/trim size, excluding bleed)
            page_height_pt: Page height in points (cut/trim size, excluding bleed)
            page_number: Current page number (for error messages)
            bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
        """
        # Skip placeholders
        if isinstance(element, PlaceholderData):
            return

        # Get element position and size (in PIXELS from OpenGL coordinates)
        element_x_px, element_y_px = element.position
        element_width_px, element_height_px = element.size

        # Convert from pixels to mm using the working DPI
        dpi = self.project.working_dpi
        element_x_mm = element_x_px * 25.4 / dpi
        element_y_mm = element_y_px * 25.4 / dpi
        element_width_mm = element_width_px * 25.4 / dpi
        element_height_mm = element_height_px * 25.4 / dpi

        # Adjust x position for offset (now in mm)
        adjusted_x_mm = element_x_mm - x_offset_mm

        # Convert to PDF points and flip Y coordinate (PDF origin is bottom-left).
        # bleed_pt shifts content so the cut/trim line is bleed_pt from the PDF page edge.
        x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
        y_pt = page_height_pt + bleed_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
        width_pt = element_width_mm * self.MM_TO_POINTS
        height_pt = element_height_mm * self.MM_TO_POINTS

        if isinstance(element, ImageData):
            ctx = RenderContext(
                canvas=c,
                image_element=element,
                x_pt=x_pt,
                y_pt=y_pt,
                width_pt=width_pt,
                height_pt=height_pt,
                page_number=int(page_number),
            )
            self._render_image(ctx)
        elif isinstance(element, TextBoxData):
            self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)

    def _render_split_element(self, params: SplitRenderParams, bleed_pt: float = 0.0):
        """
        Render a split element (only the portion on one side of the split line).

        Args:
            params: SplitRenderParams containing all rendering parameters
            bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
        """
        # Skip placeholders
        if isinstance(params.element, PlaceholderData):
            return

        # Get element position and size in pixels
        element_x_px, element_y_px = params.element.position
        element_width_px, element_height_px = params.element.size

        # Convert to mm
        dpi = self.project.working_dpi
        element_x_mm = element_x_px * 25.4 / dpi
        element_y_mm = element_y_px * 25.4 / dpi
        element_width_mm = element_width_px * 25.4 / dpi
        element_height_mm = element_height_px * 25.4 / dpi

        if isinstance(params.element, ImageData):
            # Calculate which portion of the image to render
            if params.side == "left":
                # Render from element start to split line
                crop_width_mm = params.split_line_mm - element_x_mm
                crop_x_start = 0
                render_x_mm = element_x_mm
            else:  # right
                # Render from split line to element end
                crop_width_mm = (element_x_mm + element_width_mm) - params.split_line_mm
                crop_x_start = params.split_line_mm - element_x_mm
                render_x_mm = params.split_line_mm  # Start at split line in spread coordinates

            # Adjust render position for offset
            adjusted_x_mm = render_x_mm - params.x_offset_mm

            # Convert to points (bleed_pt shifts content inside the expanded PDF page)
            x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
            y_pt = (
                params.page_height_pt
                + bleed_pt
                - (element_y_mm * self.MM_TO_POINTS)
                - (element_height_mm * self.MM_TO_POINTS)
            )
            width_pt = crop_width_mm * self.MM_TO_POINTS
            height_pt = element_height_mm * self.MM_TO_POINTS

            # Calculate original element dimensions in points (before splitting)
            original_width_pt = element_width_mm * self.MM_TO_POINTS
            original_height_pt = element_height_mm * self.MM_TO_POINTS

            # Render cropped image with original dimensions for correct aspect ratio
            ctx = RenderContext(
                canvas=params.canvas,
                image_element=params.element,
                x_pt=x_pt,
                y_pt=y_pt,
                width_pt=width_pt,
                height_pt=height_pt,
                page_number=params.page_number,
                crop_left=crop_x_start / element_width_mm,
                crop_right=(crop_x_start + crop_width_mm) / element_width_mm,
                original_width_pt=original_width_pt,
                original_height_pt=original_height_pt,
            )
            self._render_image(ctx)

        elif isinstance(params.element, TextBoxData):
            # For text boxes spanning the split, we'll render the whole text on the side
            # where most of it appears (simpler than trying to split text)
            element_center_mm = element_x_mm + element_width_mm / 2
            if (params.side == "left" and element_center_mm < params.split_line_mm) or (
                params.side == "right" and element_center_mm >= params.split_line_mm
            ):
                self._render_element(
                    params.canvas,
                    params.element,
                    params.x_offset_mm,
                    params.page_width_pt,
                    params.page_height_pt,
                    params.page_number,
                    bleed_pt,
                )

    def _render_image(self, ctx: RenderContext):
        """
        Render an image element on the PDF canvas.

        Uses pre-processed images from the cache when available (parallel processing),
        otherwise falls back to processing the image on-demand.

        Args:
            ctx: RenderContext containing all rendering parameters
        """
        # Check for pre-processed image in cache
        task_id = self._make_task_id(ctx.image_element, ctx.crop_left, ctx.crop_right, ctx.width_pt, ctx.height_pt)
        cropped_img = self._processed_images.get(task_id)

        if cropped_img is None:
            # Fallback: process image on-demand (for backwards compatibility or cache miss)
            cropped_img = self._process_image_fallback(ctx)
            if cropped_img is None:
                return

        try:
            style = ctx.image_element.style

            # Save state for transformations
            ctx.canvas.saveState()

            # Draw drop shadow first (behind image)
            if style.shadow_enabled:
                self._draw_shadow_pdf(ctx)

            # Apply rotation to canvas if needed
            if ctx.image_element.rotation != 0:
                # Move to element center
                center_x = ctx.x_pt + ctx.width_pt / 2
                center_y = ctx.y_pt + ctx.height_pt / 2
                ctx.canvas.translate(center_x, center_y)
                ctx.canvas.rotate(-ctx.image_element.rotation)
                ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2)
                # Draw at origin after transformation
                ctx.canvas.drawImage(
                    ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, mask="auto", preserveAspectRatio=False
                )
            else:
                # Draw without rotation
                ctx.canvas.drawImage(
                    ImageReader(cropped_img),
                    ctx.x_pt,
                    ctx.y_pt,
                    ctx.width_pt,
                    ctx.height_pt,
                    mask="auto",
                    preserveAspectRatio=False,
                )

            # Draw border on top of image
            if style.border_width > 0:
                self._draw_border_pdf(ctx)

            # Draw decorative frame if specified
            if style.frame_style:
                self._draw_frame_pdf(ctx)

            ctx.canvas.restoreState()

        except Exception as e:
            warning = f"Page {ctx.page_number}: Error rendering image {ctx.image_element.image_path}: {str(e)}"
            print(f"WARNING: {warning}")
            self.warnings.append(warning)

    def _process_image_fallback(self, ctx: RenderContext) -> Optional[Image.Image]:
        """
        Process an image on-demand when not found in the pre-processed cache.

        This is a fallback for backwards compatibility or cache misses.

        Returns:
            Processed PIL Image or None if processing failed.
        """
        # Resolve image path using ImageData's method
        image_full_path = ctx.image_element.resolve_image_path()

        # Check if image exists
        if not image_full_path:
            warning = f"Page {ctx.page_number}: Image not found: {ctx.image_element.image_path}"
            print(f"WARNING: {warning}")
            self.warnings.append(warning)
            return None

        try:
            # Load image using resolved path
            img: Image.Image = Image.open(image_full_path)
            img = convert_to_rgba(img)

            # Apply PIL-level rotation if needed
            if hasattr(ctx.image_element, "pil_rotation_90") and ctx.image_element.pil_rotation_90 > 0:
                img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90)

            # Get element's crop_info and combine with split cropping if applicable
            crop_x_min, crop_y_min, crop_x_max, crop_y_max = ctx.image_element.crop_info
            final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_left
            final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_right

            # Determine target dimensions for aspect ratio
            if ctx.original_width_pt is not None and ctx.original_height_pt is not None:
                target_width = ctx.original_width_pt
                target_height = ctx.original_height_pt
            else:
                target_width = ctx.width_pt
                target_height = ctx.height_pt

            # Calculate center crop coordinates
            img_width, img_height = img.size
            crop_coords = calculate_center_crop_coords(
                img_width,
                img_height,
                target_width,
                target_height,
                (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
            )

            # Crop the image
            cropped_img = crop_image_to_coords(img, crop_coords)

            # Downsample image to target resolution based on export DPI
            target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
            target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)

            current_width, current_height = cropped_img.size
            if current_width > target_width_px or current_height > target_height_px:
                cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS)

            # Apply styling to image (rounded corners)
            style = ctx.image_element.style
            if style.corner_radius > 0:
                from pyPhotoAlbum.image_utils import apply_rounded_corners

                cropped_img = apply_rounded_corners(cropped_img, style.corner_radius)

            return cropped_img

        except Exception as e:
            warning = f"Page {ctx.page_number}: Error processing image {ctx.image_element.image_path}: {str(e)}"
            print(f"WARNING: {warning}")
            self.warnings.append(warning)
            return None

    def _draw_shadow_pdf(self, ctx: RenderContext):
        """Draw drop shadow in PDF output."""
        style = ctx.image_element.style

        # Convert shadow offset from mm to points
        offset_x_pt = style.shadow_offset[0] * self.MM_TO_POINTS
        offset_y_pt = -style.shadow_offset[1] * self.MM_TO_POINTS  # Y is inverted in PDF

        # Shadow color (normalize to 0-1)
        r, g, b, a = style.shadow_color
        shadow_alpha = a / 255.0

        # Draw shadow rectangle
        ctx.canvas.saveState()
        ctx.canvas.setFillColorRGB(r / 255.0, g / 255.0, b / 255.0, shadow_alpha)

        # Calculate corner radius in points
        if style.corner_radius > 0:
            shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
            radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
            ctx.canvas.roundRect(
                ctx.x_pt + offset_x_pt,
                ctx.y_pt + offset_y_pt,
                ctx.width_pt,
                ctx.height_pt,
                radius_pt,
                stroke=0,
                fill=1,
            )
        else:
            ctx.canvas.rect(
                ctx.x_pt + offset_x_pt,
                ctx.y_pt + offset_y_pt,
                ctx.width_pt,
                ctx.height_pt,
                stroke=0,
                fill=1,
            )
        ctx.canvas.restoreState()

    def _draw_border_pdf(self, ctx: RenderContext):
        """Draw styled border in PDF output."""
        style = ctx.image_element.style

        # Border width in points
        border_width_pt = style.border_width * self.MM_TO_POINTS

        # Border color (normalize to 0-1)
        r, g, b = style.border_color

        ctx.canvas.saveState()
        ctx.canvas.setStrokeColorRGB(r / 255.0, g / 255.0, b / 255.0)
        ctx.canvas.setLineWidth(border_width_pt)

        # Calculate corner radius in points
        if style.corner_radius > 0:
            shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
            radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
            ctx.canvas.roundRect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, radius_pt, stroke=1, fill=0)
        else:
            ctx.canvas.rect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, stroke=1, fill=0)

        ctx.canvas.restoreState()

    def _draw_frame_pdf(self, ctx: RenderContext):
        """Draw decorative frame in PDF output."""
        from pyPhotoAlbum.frame_manager import get_frame_manager

        style = ctx.image_element.style
        frame_manager = get_frame_manager()

        frame_manager.render_frame_pdf(
            canvas=ctx.canvas,
            frame_name=style.frame_style,  # type: ignore[arg-type]
            x_pt=ctx.x_pt,
            y_pt=ctx.y_pt,
            width_pt=ctx.width_pt,
            height_pt=ctx.height_pt,
            color=style.frame_color,
            corners=style.frame_corners,
        )

    def _render_textbox(
        self, c: canvas.Canvas, text_element: "TextBoxData", x_pt: float, y_pt: float, width_pt: float, height_pt: float
    ):
        """
        Render a text box element on the PDF canvas with transparent background.
        Text is word-wrapped to fit within the box boundaries.

        Args:
            c: ReportLab canvas
            text_element: TextBoxData instance
            x_pt, y_pt, width_pt, height_pt: Position and size in points
        """
        if not text_element.text_content:
            return

        # Get font settings
        font_family = text_element.font_settings.get("family", "Helvetica")
        font_size_px = text_element.font_settings.get("size", 12)
        font_color = text_element.font_settings.get("color", (0, 0, 0))

        # Convert font size from pixels to PDF points (same conversion as element dimensions)
        # Font size is stored in pixels at working_dpi, same as element position/size
        dpi = self.project.working_dpi
        font_size_mm = font_size_px * 25.4 / dpi
        font_size = font_size_mm * self.MM_TO_POINTS

        # Map common font names to ReportLab standard fonts
        font_map = {
            "Arial": "Helvetica",
            "Times New Roman": "Times-Roman",
            "Courier New": "Courier",
        }
        font_family = font_map.get(font_family, font_family)

        # Normalize color to hex for Paragraph style
        if all(isinstance(x, int) and x > 1 for x in font_color):
            color_hex = "#{:02x}{:02x}{:02x}".format(*font_color)
        else:
            # Convert 0-1 range to 0-255 then to hex
            color_hex = "#{:02x}{:02x}{:02x}".format(
                int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255)
            )

        # Map alignment to ReportLab constants
        alignment_map = {
            "left": TA_LEFT,
            "center": TA_CENTER,
            "right": TA_RIGHT,
            "justify": TA_JUSTIFY,
        }
        text_alignment = alignment_map.get(text_element.alignment, TA_LEFT)

        # Create paragraph style with word wrapping
        style = ParagraphStyle(
            "textbox",
            fontName=font_family,
            fontSize=font_size,
            leading=font_size * 1.2,  # Line spacing (120% of font size)
            textColor=color_hex,
            alignment=text_alignment,
        )

        # Escape special XML characters and convert newlines to <br/> tags
        text_content = text_element.text_content
        text_content = text_content.replace("&", "&amp;")
        text_content = text_content.replace("<", "&lt;")
        text_content = text_content.replace(">", "&gt;")
        text_content = text_content.replace("\n", "<br/>")

        # Create paragraph with the text
        para = Paragraph(text_content, style)

        # Save state for transformations
        c.saveState()

        try:
            # Apply rotation if needed
            if text_element.rotation != 0:
                # Move to element center
                center_x = x_pt + width_pt / 2
                center_y = y_pt + height_pt / 2
                c.translate(center_x, center_y)
                c.rotate(text_element.rotation)

                # Wrap and draw paragraph relative to center
                # wrapOn calculates the actual height needed
                para_width, para_height = para.wrapOn(c, width_pt, height_pt)

                # Position at top-left of box (relative to center after rotation)
                draw_x = -width_pt / 2
                draw_y = height_pt / 2 - para_height

                para.drawOn(c, draw_x, draw_y)
            else:
                # No rotation - draw normally
                # wrapOn calculates the actual height needed for the wrapped text
                para_width, para_height = para.wrapOn(c, width_pt, height_pt)

                # drawOn draws from bottom-left of the paragraph
                # We want text at top of box, so: draw_y = box_top - para_height
                draw_x = x_pt
                draw_y = y_pt + height_pt - para_height

                para.drawOn(c, draw_x, draw_y)

        except Exception as e:
            warning = f"Error rendering text box: {str(e)}"
            print(f"WARNING: {warning}")
            self.warnings.append(warning)

        finally:
            c.restoreState()
