"""
Data model classes for pyPhotoAlbum
"""

from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict, Any, List
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from PIL import Image

from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords
from pyPhotoAlbum.gl_imports import (
    GL_AVAILABLE,
    glBegin,
    glEnd,
    glVertex2f,
    glColor3f,
    glColor4f,
    GL_QUADS,
    GL_LINE_LOOP,
    glEnable,
    glDisable,
    GL_TEXTURE_2D,
    glBindTexture,
    glTexCoord2f,
    glTexParameteri,
    GL_TEXTURE_MIN_FILTER,
    GL_TEXTURE_MAG_FILTER,
    GL_LINEAR,
    glGenTextures,
    glTexImage2D,
    GL_RGBA,
    GL_UNSIGNED_BYTE,
    glDeleteTextures,
    glGetString,
    GL_VERSION,
    glLineStipple,
    GL_LINE_STIPPLE,
    glPushMatrix,
    glPopMatrix,
    glTranslatef,
    glRotatef,
    GL_BLEND,
    glBlendFunc,
    GL_SRC_ALPHA,
    GL_ONE_MINUS_SRC_ALPHA,
)

logger = logging.getLogger(__name__)


# =============================================================================
# Image Styling
# =============================================================================


class ImageStyle:
    """
    Styling properties for images and placeholders.

    This class encapsulates all visual styling that can be applied to images:
    - Rounded corners
    - Borders (width, color)
    - Drop shadows
    - Decorative frames

    Styles are attached to both ImageData and PlaceholderData. When an image
    is dropped onto a placeholder, it inherits the placeholder's style.
    """

    def __init__(
        self,
        corner_radius: float = 0.0,
        border_width: float = 0.0,
        border_color: Tuple[int, int, int] = (0, 0, 0),
        shadow_enabled: bool = False,
        shadow_offset: Tuple[float, float] = (2.0, 2.0),
        shadow_blur: float = 3.0,
        shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
        frame_style: Optional[str] = None,
        frame_color: Tuple[int, int, int] = (0, 0, 0),
        frame_corners: Optional[Tuple[bool, bool, bool, bool]] = None,
    ):
        """
        Initialize image style.

        Args:
            corner_radius: Corner radius as percentage of shorter side (0-50)
            border_width: Border width in mm (0 = no border)
            border_color: Border color as RGB tuple (0-255)
            shadow_enabled: Whether drop shadow is enabled
            shadow_offset: Shadow offset in mm (x, y)
            shadow_blur: Shadow blur radius in mm
            shadow_color: Shadow color as RGBA tuple (0-255)
            frame_style: Name of decorative frame style (None = no frame)
            frame_color: Frame tint color as RGB tuple (0-255)
            frame_corners: Which corners get frame decoration (TL, TR, BR, BL).
                           None means all corners, (True, True, True, True) means all,
                           (True, False, False, True) means only left corners, etc.
        """
        self.corner_radius = corner_radius
        self.border_width = border_width
        self.border_color: Tuple[int, int, int] = border_color
        self.shadow_enabled = shadow_enabled
        self.shadow_offset: Tuple[float, float] = shadow_offset
        self.shadow_blur = shadow_blur
        self.shadow_color: Tuple[int, int, int, int] = shadow_color
        self.frame_style = frame_style
        self.frame_color: Tuple[int, int, int] = frame_color
        # frame_corners: (top_left, top_right, bottom_right, bottom_left)
        self.frame_corners: Tuple[bool, bool, bool, bool] = frame_corners if frame_corners else (True, True, True, True)

    def copy(self) -> "ImageStyle":
        """Create a copy of this style."""
        return ImageStyle(
            corner_radius=self.corner_radius,
            border_width=self.border_width,
            border_color=self.border_color,
            shadow_enabled=self.shadow_enabled,
            shadow_offset=self.shadow_offset,
            shadow_blur=self.shadow_blur,
            shadow_color=self.shadow_color,
            frame_style=self.frame_style,
            frame_color=self.frame_color,
            frame_corners=self.frame_corners,
        )

    def has_styling(self) -> bool:
        """Check if any styling is applied (non-default values)."""
        return self.corner_radius > 0 or self.border_width > 0 or self.shadow_enabled or self.frame_style is not None

    def serialize(self) -> Dict[str, Any]:
        """Serialize style to dictionary."""
        return {
            "corner_radius": self.corner_radius,
            "border_width": self.border_width,
            "border_color": list(self.border_color),
            "shadow_enabled": self.shadow_enabled,
            "shadow_offset": list(self.shadow_offset),
            "shadow_blur": self.shadow_blur,
            "shadow_color": list(self.shadow_color),
            "frame_style": self.frame_style,
            "frame_color": list(self.frame_color),
            "frame_corners": list(self.frame_corners),
        }

    @classmethod
    def deserialize(cls, data: Optional[Dict[str, Any]]) -> "ImageStyle":
        """Deserialize style from dictionary."""
        if data is None:
            return cls()
        frame_corners_data = data.get("frame_corners")
        frame_corners = tuple(frame_corners_data) if frame_corners_data else None
        return cls(
            corner_radius=data.get("corner_radius", 0.0),
            border_width=data.get("border_width", 0.0),
            border_color=tuple(data.get("border_color", (0, 0, 0))),
            shadow_enabled=data.get("shadow_enabled", False),
            shadow_offset=tuple(data.get("shadow_offset", (2.0, 2.0))),
            shadow_blur=data.get("shadow_blur", 3.0),
            shadow_color=tuple(data.get("shadow_color", (0, 0, 0, 128))),
            frame_style=data.get("frame_style"),
            frame_color=tuple(data.get("frame_color", (0, 0, 0))),
            frame_corners=frame_corners,
        )

    def __eq__(self, other):
        if not isinstance(other, ImageStyle):
            return False
        return (
            self.corner_radius == other.corner_radius
            and self.border_width == other.border_width
            and self.border_color == other.border_color
            and self.shadow_enabled == other.shadow_enabled
            and self.shadow_offset == other.shadow_offset
            and self.shadow_blur == other.shadow_blur
            and self.shadow_color == other.shadow_color
            and self.frame_style == other.frame_style
            and self.frame_color == other.frame_color
            and self.frame_corners == other.frame_corners
        )

    def __repr__(self):
        if not self.has_styling():
            return "ImageStyle()"
        parts = []
        if self.corner_radius > 0:
            parts.append(f"corner_radius={self.corner_radius}")
        if self.border_width > 0:
            parts.append(f"border_width={self.border_width}")
        if self.shadow_enabled:
            parts.append("shadow_enabled=True")
        if self.frame_style:
            parts.append(f"frame_style='{self.frame_style}'")
        return f"ImageStyle({', '.join(parts)})"


# Global configuration for asset path resolution
_asset_search_paths: List[str] = []
_primary_project_folder: Optional[str] = None


def set_asset_resolution_context(project_folder: str, additional_search_paths: Optional[List[str]] = None):
    """
    Set the context for resolving asset paths.

    Args:
        project_folder: Primary project folder path
        additional_search_paths: Optional list of additional paths to search for assets
    """
    global _primary_project_folder, _asset_search_paths
    _primary_project_folder = project_folder
    _asset_search_paths = additional_search_paths or []
    print(f"Asset resolution context set: project={project_folder}, search_paths={_asset_search_paths}")


def get_asset_search_paths() -> Tuple[Optional[str], List[str]]:
    """Get the current asset resolution context."""
    return _primary_project_folder, _asset_search_paths


class BaseLayoutElement(ABC):
    """Abstract base class for all layout elements"""

    def __init__(
        self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0
    ):
        self.position = (x, y)
        self.size = (width, height)
        self.rotation = rotation
        self.z_index = z_index

        # UUID for merge conflict resolution (v3.0+)
        self.uuid = str(uuid.uuid4())

        # Timestamps for merge conflict resolution (v3.0+)
        now = datetime.now(timezone.utc).isoformat()
        self.created = now
        self.last_modified = now

        # Deletion tracking for merge (v3.0+)
        self.deleted = False
        self.deleted_at: Optional[str] = None

    def mark_modified(self):
        """Update the last_modified timestamp to now."""
        self.last_modified = datetime.now(timezone.utc).isoformat()

    def mark_deleted(self):
        """Mark this element as deleted."""
        self.deleted = True
        self.deleted_at = datetime.now(timezone.utc).isoformat()
        self.mark_modified()

    def _serialize_base_fields(self) -> Dict[str, Any]:
        """Serialize base fields common to all elements (v3.0+)."""
        return {
            "uuid": self.uuid,
            "created": self.created,
            "last_modified": self.last_modified,
            "deleted": self.deleted,
            "deleted_at": self.deleted_at,
        }

    def _deserialize_base_fields(self, data: Dict[str, Any]):
        """Deserialize base fields common to all elements (v3.0+)."""
        # UUID (required in v3.0+, generate if missing for backwards compatibility)
        self.uuid = data.get("uuid", str(uuid.uuid4()))

        # Timestamps (required in v3.0+, use current time if missing)
        now = datetime.now(timezone.utc).isoformat()
        self.created = data.get("created", now)
        self.last_modified = data.get("last_modified", now)

        # Deletion tracking (default to not deleted)
        self.deleted = data.get("deleted", False)
        self.deleted_at = data.get("deleted_at", None)

    @abstractmethod
    def render(self):
        """Render the element using OpenGL"""
        pass

    @abstractmethod
    def serialize(self) -> Dict[str, Any]:
        """Serialize the element to a dictionary"""
        pass

    @abstractmethod
    def deserialize(self, data: Dict[str, Any]):
        """Deserialize from a dictionary"""
        pass


class ImageData(BaseLayoutElement):
    """Class to store image data and properties"""

    def __init__(
        self,
        image_path: str = "",
        crop_info: Optional[Tuple] = None,
        image_dimensions: Optional[Tuple[int, int]] = None,
        style: Optional["ImageStyle"] = None,
        **kwargs,
    ):
        super().__init__(**kwargs)
        self.image_path = image_path
        self.crop_info = crop_info or (0, 0, 1, 1)  # Default: no crop

        # Metadata: Store image dimensions for aspect ratio calculations before full load
        # This allows correct rendering even while async loading is in progress
        self.image_dimensions = image_dimensions  # (width, height) or None

        # PIL-level rotation: number of 90° rotations to apply to the loaded image
        # This is separate from the visual rotation field (which should stay at 0)
        self.pil_rotation_90 = 0  # 0, 1, 2, or 3 (for 0°, 90°, 180°, 270°)

        # Styling properties (rounded corners, borders, shadows, frames)
        self.style = style if style is not None else ImageStyle()

        # If dimensions not provided and we have a path, try to extract them quickly
        if not self.image_dimensions and self.image_path:
            self._extract_dimensions_metadata()

        # Async loading state
        self._async_loading = False
        self._async_load_requested = False

    def resolve_image_path(self) -> Optional[str]:
        """
        Resolve the image path to an absolute path.

        Returns the absolute path if the image exists, None otherwise.
        """
        if not self.image_path:
            return None

        # Already absolute
        if os.path.isabs(self.image_path):
            if os.path.exists(self.image_path):
                return self.image_path
            return None

        # Relative path - look in project folder
        project_folder, _ = get_asset_search_paths()
        if project_folder:
            full_path = os.path.join(project_folder, self.image_path)
            if os.path.exists(full_path):
                return full_path

        return None

    def _extract_dimensions_metadata(self):
        """
        Extract image dimensions without loading the full image.
        Uses the centralized get_image_dimensions() utility.
        """
        from pyPhotoAlbum.async_backend import get_image_dimensions

        image_path = self.resolve_image_path()
        if image_path:
            # Use centralized utility (max 2048px for texture loading)
            self.image_dimensions = get_image_dimensions(image_path, max_size=2048)
            if self.image_dimensions:
                print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}")

    def render(self):
        """Render the image using OpenGL"""

        x, y = self.position
        w, h = self.size
        texture_id = None

        # Create texture from pending image if one exists (deferred from async load)
        # Texture creation must happen during render when GL context is active
        if hasattr(self, "_pending_pil_image") and self._pending_pil_image is not None:
            self._create_texture_from_pending_image()

        # Check if style changed and texture needs regeneration
        if hasattr(self, "_texture_id") and self._texture_id:
            current_hash = self._get_style_hash()
            cached_hash = getattr(self, "_texture_style_hash", None)
            if cached_hash is None:
                # First time check - assume texture was loaded without styling
                # Set hash to 0 (no corner radius) to match legacy behavior
                self._texture_style_hash = hash((0.0,))
                cached_hash = self._texture_style_hash
            if cached_hash != current_hash:
                # Style changed - mark for reload
                self._async_load_requested = False
                glDeleteTextures([self._texture_id])
                delattr(self, "_texture_id")  # Remove attribute so async loader will re-trigger

        # Draw drop shadow first (behind everything)
        if self.style.shadow_enabled:
            self._render_shadow(x, y, w, h)

        # Use cached texture if available
        if hasattr(self, "_texture_id") and self._texture_id:
            texture_id = self._texture_id

            # Check if texture was pre-cropped (for styled images with rounded corners)
            if getattr(self, "_texture_precropped", False):
                # Texture is already cropped to visible region - use full texture
                tx_min, ty_min, tx_max, ty_max = 0.0, 0.0, 1.0, 1.0
            else:
                # Get image dimensions (from loaded texture or metadata)
                if hasattr(self, "_img_width") and hasattr(self, "_img_height"):
                    img_width, img_height = self._img_width, self._img_height
                elif self.image_dimensions:
                    img_width, img_height = self.image_dimensions
                else:
                    # No dimensions available, render without aspect ratio correction
                    img_width, img_height = int(w), int(h)

                # Calculate texture coordinates for center crop with element's crop_info
                tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(
                    img_width, img_height, w, h, self.crop_info
                )

            # Enable blending for transparency (rounded corners)
            glEnable(GL_BLEND)
            glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

            # Enable texturing and draw with crop
            glEnable(GL_TEXTURE_2D)
            glBindTexture(GL_TEXTURE_2D, texture_id)
            glColor4f(1.0, 1.0, 1.0, 1.0)  # White color to show texture as-is

            glBegin(GL_QUADS)
            glTexCoord2f(tx_min, ty_min)
            glVertex2f(x, y)
            glTexCoord2f(tx_max, ty_min)
            glVertex2f(x + w, y)
            glTexCoord2f(tx_max, ty_max)
            glVertex2f(x + w, y + h)
            glTexCoord2f(tx_min, ty_max)
            glVertex2f(x, y + h)
            glEnd()

            glDisable(GL_TEXTURE_2D)
            glDisable(GL_BLEND)

        # If no image or loading failed, draw placeholder
        if not texture_id:
            glColor3f(0.7, 0.85, 1.0)  # Light blue
            glBegin(GL_QUADS)
            glVertex2f(x, y)
            glVertex2f(x + w, y)
            glVertex2f(x + w, y + h)
            glVertex2f(x, y + h)
            glEnd()

        # Draw styled border if specified, otherwise default thin black border
        if self.style.border_width > 0:
            self._render_border(x, y, w, h)
        else:
            # Default thin border for visibility
            glColor3f(0.0, 0.0, 0.0)  # Black border
            glBegin(GL_LINE_LOOP)
            glVertex2f(x, y)
            glVertex2f(x + w, y)
            glVertex2f(x + w, y + h)
            glVertex2f(x, y + h)
            glEnd()

        # Draw decorative frame if specified
        if self.style.frame_style:
            self._render_frame(x, y, w, h)

    def _render_shadow(self, x: float, y: float, w: float, h: float):
        """Render drop shadow behind the image."""
        # Convert shadow offset from mm to pixels (approximate, assuming 96 DPI for screen)
        dpi = 96.0
        mm_to_px = dpi / 25.4
        offset_x = self.style.shadow_offset[0] * mm_to_px
        offset_y = self.style.shadow_offset[1] * mm_to_px

        # Shadow color with alpha
        r, g, b, a = self.style.shadow_color
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        glColor4f(r / 255.0, g / 255.0, b / 255.0, a / 255.0)

        # Draw shadow quad (slightly offset)
        shadow_x = x + offset_x
        shadow_y = y + offset_y
        glBegin(GL_QUADS)
        glVertex2f(shadow_x, shadow_y)
        glVertex2f(shadow_x + w, shadow_y)
        glVertex2f(shadow_x + w, shadow_y + h)
        glVertex2f(shadow_x, shadow_y + h)
        glEnd()

        glDisable(GL_BLEND)

    def _render_border(self, x: float, y: float, w: float, h: float):
        """Render styled border around the image."""
        # Convert border width from mm to pixels
        dpi = 96.0
        mm_to_px = dpi / 25.4
        border_px = self.style.border_width * mm_to_px

        # Border color
        r, g, b = self.style.border_color
        glColor3f(r / 255.0, g / 255.0, b / 255.0)

        # Draw border as thick line (OpenGL line width)
        from OpenGL.GL import glLineWidth

        glLineWidth(max(1.0, border_px))
        glBegin(GL_LINE_LOOP)
        glVertex2f(x, y)
        glVertex2f(x + w, y)
        glVertex2f(x + w, y + h)
        glVertex2f(x, y + h)
        glEnd()
        glLineWidth(1.0)  # Reset to default

    def _render_frame(self, x: float, y: float, w: float, h: float):
        """Render decorative frame around the image."""
        from pyPhotoAlbum.frame_manager import get_frame_manager

        frame_manager = get_frame_manager()
        frame_manager.render_frame_opengl(
            frame_name=self.style.frame_style,  # type: ignore[arg-type]
            x=x,
            y=y,
            width=w,
            height=h,
            color=self.style.frame_color,
            corners=self.style.frame_corners,
        )

    def serialize(self) -> Dict[str, Any]:
        """Serialize image data to dictionary"""
        data = {
            "type": "image",
            "position": self.position,
            "size": self.size,
            "rotation": self.rotation,
            "z_index": self.z_index,
            "image_path": self.image_path,
            "crop_info": self.crop_info,
            "pil_rotation_90": getattr(self, "pil_rotation_90", 0),
        }
        # Include image dimensions metadata if available
        if self.image_dimensions:
            data["image_dimensions"] = self.image_dimensions

        # Include style if non-default (v3.1+)
        if self.style.has_styling():
            data["style"] = self.style.serialize()

        # Add base fields (v3.0+)
        data.update(self._serialize_base_fields())

        return data

    def deserialize(self, data: Dict[str, Any]):
        """Deserialize from dictionary"""
        # Deserialize base fields first (v3.0+)
        self._deserialize_base_fields(data)

        self.position = tuple(data.get("position", (0, 0)))
        self.size = tuple(data.get("size", (100, 100)))
        self.rotation = data.get("rotation", 0)
        self.z_index = data.get("z_index", 0)
        self.image_path = data.get("image_path", "")
        self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1)))
        self.pil_rotation_90 = data.get("pil_rotation_90", 0)

        # Backwards compatibility: convert old visual rotation to PIL rotation
        if self.pil_rotation_90 == 0 and self.rotation != 0:
            # Old project with visual rotation - convert to PIL rotation
            # Round to nearest 90 degrees
            normalized_rotation = round(self.rotation / 90) * 90
            if normalized_rotation == 90:
                self.pil_rotation_90 = 1
            elif normalized_rotation == 180:
                self.pil_rotation_90 = 2
            elif normalized_rotation == 270:
                self.pil_rotation_90 = 3
            # Reset visual rotation
            self.rotation = 0
            print(f"ImageData: Converted old visual rotation to pil_rotation_90={self.pil_rotation_90}")

        # Load image dimensions metadata if available
        self.image_dimensions = data.get("image_dimensions", None)
        if self.image_dimensions:
            self.image_dimensions = tuple(self.image_dimensions)

        # Load style (v3.1+, backwards compatible - defaults to no styling)
        self.style = ImageStyle.deserialize(data.get("style"))

    def _on_async_image_loaded(self, pil_image):
        """
        Callback when async image loading completes.

        NOTE: This is called from a signal, potentially before GL context is ready.
        We store the image and create the texture during the next render() call
        when the GL context is guaranteed to be active.

        Args:
            pil_image: Loaded PIL Image (already RGBA, already resized)
        """
        try:
            logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}")

            # Apply PIL-level rotation if needed
            if hasattr(self, "pil_rotation_90") and self.pil_rotation_90 > 0:
                pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90)
                logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}")

            # For rounded corners, we need to pre-crop the image to the visible region
            # so that the corners are applied to what will actually be displayed.
            # Calculate the crop region based on element aspect ratio and crop_info.
            if self.style.corner_radius > 0:
                from pyPhotoAlbum.image_utils import apply_rounded_corners, crop_image_to_coords

                # Get element dimensions for aspect ratio calculation
                element_width, element_height = self.size

                # Calculate crop coordinates (same logic as render-time)
                crop_coords = calculate_center_crop_coords(
                    pil_image.width, pil_image.height, element_width, element_height, self.crop_info
                )

                # Pre-crop the image to the visible region
                pil_image = crop_image_to_coords(pil_image, crop_coords)
                logger.debug(f"ImageData: Pre-cropped to {pil_image.size} for styling")

                # Now apply rounded corners to the cropped image
                pil_image = apply_rounded_corners(pil_image, self.style.corner_radius)
                logger.debug(f"ImageData: Applied {self.style.corner_radius}% corner radius to {self.image_path}")

                # Mark that texture is pre-cropped (no further crop needed at render time)
                self._texture_precropped = True
            else:
                self._texture_precropped = False

            # Store the image for texture creation during next render()
            # This avoids GL context issues when callback runs on wrong thread/timing
            self._pending_pil_image = pil_image
            self._img_width = pil_image.width
            self._img_height = pil_image.height
            self._async_loading = False

            # Track which style was applied to this texture (for cache invalidation)
            self._texture_style_hash = self._get_style_hash()

            # Update metadata for future renders - always update to reflect dimensions
            self.image_dimensions = (pil_image.width, pil_image.height)

            logger.debug(f"ImageData: Queued for texture creation: {self.image_path}")

        except Exception as e:
            logger.error(f"ImageData: Error processing async loaded image {self.image_path}: {e}")
            self._pending_pil_image = None
            self._async_loading = False

    def _get_style_hash(self) -> int:
        """Get a hash of the current style settings that affect texture rendering."""
        # Corner radius affects the texture, and when styled, crop_info and size also matter
        # because we pre-crop the image before applying rounded corners
        if self.style.corner_radius > 0:
            return hash((self.style.corner_radius, self.crop_info, self.size))
        return hash((self.style.corner_radius,))

    def _create_texture_from_pending_image(self):
        """
        Create OpenGL texture from pending PIL image.
        Called during render() when GL context is active.
        """
        if not hasattr(self, "_pending_pil_image") or self._pending_pil_image is None:
            return False

        try:
            # Verify GL context is actually current before creating textures
            # glGetString returns None if no context is active
            gl_version = glGetString(GL_VERSION)
            if gl_version is None:
                # No GL context - keep pending image and try again next render
                logger.debug(f"ImageData: No GL context for texture creation, deferring: {self.image_path}")
                return False

            logger.debug(f"ImageData: Creating texture for {self.image_path} (GL version: {gl_version})")
            pil_image = self._pending_pil_image

            # Ensure RGBA format for GL_RGBA texture (defensive check)
            if pil_image.mode != "RGBA":
                pil_image = pil_image.convert("RGBA")

            # Delete old texture if it exists
            if hasattr(self, "_texture_id") and self._texture_id:
                glDeleteTextures([self._texture_id])

            # Create GPU texture from pre-processed PIL image
            img_data = pil_image.tobytes()

            texture_id = glGenTextures(1)
            glBindTexture(GL_TEXTURE_2D, texture_id)
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
            glTexImage2D(
                GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
            )

            # Cache texture
            self._texture_id = texture_id
            self._texture_path = self.image_path

            # Clear pending image to free memory
            self._pending_pil_image = None

            # Clear the warning flag if we successfully created the texture
            if hasattr(self, "_gl_context_warned"):
                delattr(self, "_gl_context_warned")

            logger.info(f"ImageData: Successfully created texture for {self.image_path}")
            return True

        except Exception as e:
            error_str = str(e)
            # Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION)
            # These are typically caused by no GL context being current
            if "GLError" in error_str and "1282" in error_str:
                # GL context not ready - keep pending image and try again next render
                # Don't spam the console with repeated messages
                if not hasattr(self, "_gl_context_warned"):
                    logger.warning(
                        f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render"
                    )
                    self._gl_context_warned = True
                return False
            else:
                # Other error - give up on this image
                logger.error(f"ImageData: Error creating texture for {self.image_path}: {e}")
                self._texture_id = None
                self._pending_pil_image = None
                return False

    def _on_async_image_load_failed(self, error_msg: str):
        """
        Callback when async image loading fails.

        Args:
            error_msg: Error message
        """
        logger.error(f"ImageData: Async load failed for {self.image_path}: {error_msg}")
        self._async_loading = False
        self._async_load_requested = False


class PlaceholderData(BaseLayoutElement):
    """Class to store placeholder data"""

    def __init__(
        self,
        placeholder_type: str = "image",
        default_content: str = "",
        style: Optional["ImageStyle"] = None,
        **kwargs,
    ):
        super().__init__(**kwargs)
        self.placeholder_type = placeholder_type
        self.default_content = default_content
        # Style to apply when an image is dropped onto this placeholder
        self.style = style if style is not None else ImageStyle()

    def render(self):
        """Render the placeholder using OpenGL"""

        x, y = self.position
        w, h = self.size

        # Apply rotation if needed
        if self.rotation != 0:
            glPushMatrix()
            # Translate to center of element
            center_x = x + w / 2
            center_y = y + h / 2
            glTranslatef(center_x, center_y, 0)
            glRotatef(self.rotation, 0, 0, 1)
            glTranslatef(-w / 2, -h / 2, 0)
            # Now render at origin (rotation pivot is at element center)
            x, y = 0, 0

        # Draw a light gray rectangle as placeholder background
        glColor3f(0.9, 0.9, 0.9)  # Light gray
        glBegin(GL_QUADS)
        glVertex2f(x, y)
        glVertex2f(x + w, y)
        glVertex2f(x + w, y + h)
        glVertex2f(x, y + h)
        glEnd()

        # Draw dashed border for placeholder
        glEnable(GL_LINE_STIPPLE)
        glLineStipple(1, 0x00FF)  # Dashed pattern
        glColor3f(0.5, 0.5, 0.5)  # Gray border
        glBegin(GL_LINE_LOOP)
        glVertex2f(x, y)
        glVertex2f(x + w, y)
        glVertex2f(x + w, y + h)
        glVertex2f(x, y + h)
        glEnd()
        glDisable(GL_LINE_STIPPLE)

        # Pop matrix if we pushed for rotation
        if self.rotation != 0:
            glPopMatrix()

    def serialize(self) -> Dict[str, Any]:
        """Serialize placeholder data to dictionary"""
        data = {
            "type": "placeholder",
            "position": self.position,
            "size": self.size,
            "rotation": self.rotation,
            "z_index": self.z_index,
            "placeholder_type": self.placeholder_type,
            "default_content": self.default_content,
        }
        # Include style if non-default (v3.1+) - for templatable styling
        if self.style.has_styling():
            data["style"] = self.style.serialize()
        # Add base fields (v3.0+)
        data.update(self._serialize_base_fields())
        return data

    def deserialize(self, data: Dict[str, Any]):
        """Deserialize from dictionary"""
        # Deserialize base fields first (v3.0+)
        self._deserialize_base_fields(data)

        self.position = tuple(data.get("position", (0, 0)))
        self.size = tuple(data.get("size", (100, 100)))
        self.rotation = data.get("rotation", 0)
        self.z_index = data.get("z_index", 0)
        self.placeholder_type = data.get("placeholder_type", "image")
        self.default_content = data.get("default_content", "")
        # Load style (v3.1+, backwards compatible)
        self.style = ImageStyle.deserialize(data.get("style"))


class TextBoxData(BaseLayoutElement):
    """Class to store text box data"""

    def __init__(self, text_content: str = "", font_settings: Optional[Dict] = None, alignment: str = "left", **kwargs):
        super().__init__(**kwargs)
        self.text_content = text_content
        self.font_settings = font_settings or {"family": "Arial", "size": 12, "color": (0, 0, 0)}
        self.alignment = alignment

    def render(self):
        """Render the text box using OpenGL"""
        x, y = self.position
        w, h = self.size

        # Apply rotation if needed
        if self.rotation != 0:
            glPushMatrix()
            # Translate to center of element
            center_x = x + w / 2
            center_y = y + h / 2
            glTranslatef(center_x, center_y, 0)
            glRotatef(self.rotation, 0, 0, 1)
            glTranslatef(-w / 2, -h / 2, 0)
            # Now render at origin (rotation pivot is at element center)
            x, y = 0, 0

        # No background fill - text boxes are transparent in final output
        # Just draw a light dashed border for editing visibility
        glEnable(GL_LINE_STIPPLE)
        glLineStipple(2, 0xAAAA)  # Dashed line pattern
        glColor3f(0.7, 0.7, 0.7)  # Light gray border
        glBegin(GL_LINE_LOOP)
        glVertex2f(x, y)
        glVertex2f(x + w, y)
        glVertex2f(x + w, y + h)
        glVertex2f(x, y + h)
        glEnd()
        glDisable(GL_LINE_STIPPLE)

        # Pop matrix if we pushed for rotation
        if self.rotation != 0:
            glPopMatrix()

        # Note: Text content is rendered using QPainter overlay in GLWidget.paintGL()

    def serialize(self) -> Dict[str, Any]:
        """Serialize text box data to dictionary"""
        data = {
            "type": "textbox",
            "position": self.position,
            "size": self.size,
            "rotation": self.rotation,
            "z_index": self.z_index,
            "text_content": self.text_content,
            "font_settings": self.font_settings,
            "alignment": self.alignment,
        }
        # Add base fields (v3.0+)
        data.update(self._serialize_base_fields())
        return data

    def deserialize(self, data: Dict[str, Any]):
        """Deserialize from dictionary"""
        # Deserialize base fields first (v3.0+)
        self._deserialize_base_fields(data)

        self.position = tuple(data.get("position", (0, 0)))
        self.size = tuple(data.get("size", (100, 100)))
        self.rotation = data.get("rotation", 0)
        self.z_index = data.get("z_index", 0)
        self.text_content = data.get("text_content", "")
        self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)})
        self.alignment = data.get("alignment", "left")


class GhostPageData(BaseLayoutElement):
    """Class to represent a ghost page placeholder for alignment in double-page spreads"""

    def __init__(self, page_size: Tuple[float, float] = (210, 297), **kwargs):
        super().__init__(**kwargs)
        self.page_size = page_size  # Size in mm
        self.is_ghost = True

    def render(self):
        """Render the ghost page with 'Add Page' button in page-local coordinates"""

        # Render at page origin (0,0) in page-local coordinates
        # PageRenderer will handle transformation to screen coordinates
        x, y = 0, 0

        # Calculate dimensions from page_size (in mm) - assume 300 DPI for now
        # This will be overridden by proper size calculation in PageRenderer
        dpi = 300  # Default DPI for rendering
        w = self.page_size[0] * dpi / 25.4
        h = self.page_size[1] * dpi / 25.4

        # Enable alpha blending for transparency
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

        # Draw a light grey semi-transparent rectangle as ghost page background
        glColor4f(0.8, 0.8, 0.8, 0.5)  # Light grey with 50% opacity
        glBegin(GL_QUADS)
        glVertex2f(x, y)
        glVertex2f(x + w, y)
        glVertex2f(x + w, y + h)
        glVertex2f(x, y + h)
        glEnd()

        glDisable(GL_BLEND)

        # Draw dashed border
        glEnable(GL_LINE_STIPPLE)
        glLineStipple(2, 0x0F0F)  # Dashed pattern
        glColor3f(0.5, 0.5, 0.5)  # Grey border
        glBegin(GL_LINE_LOOP)
        glVertex2f(x, y)
        glVertex2f(x + w, y)
        glVertex2f(x + w, y + h)
        glVertex2f(x, y + h)
        glEnd()
        glDisable(GL_LINE_STIPPLE)

        # Note: "Click to Add Page" text is rendered using QPainter overlay in GLWidget
        # The entire page is clickable

    def get_page_rect(self) -> Tuple[float, float, float, float]:
        """Get the bounding box of the entire ghost page in page-local coordinates (x, y, width, height)"""
        # Return in page-local coordinates (matching render method)
        x, y = 0, 0
        dpi = 300  # Default DPI
        w = self.page_size[0] * dpi / 25.4
        h = self.page_size[1] * dpi / 25.4
        return (x, y, w, h)

    def serialize(self) -> Dict[str, Any]:
        """Serialize ghost page data to dictionary"""
        data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size}
        # Add base fields (v3.0+)
        data.update(self._serialize_base_fields())
        return data

    def deserialize(self, data: Dict[str, Any]):
        """Deserialize from dictionary"""
        # Deserialize base fields first (v3.0+)
        self._deserialize_base_fields(data)

        self.position = tuple(data.get("position", (0, 0)))
        self.size = tuple(data.get("size", (100, 100)))
        self.page_size = tuple(data.get("page_size", (210, 297)))
