"""
Unit tests for pyPhotoAlbum models
"""

import pytest
import os
import tempfile
from datetime import datetime, timezone
from unittest.mock import Mock, patch
from PIL import Image
from pyPhotoAlbum.models import (
    ImageData,
    PlaceholderData,
    TextBoxData,
    GhostPageData,
    BaseLayoutElement,
    set_asset_resolution_context,
    get_asset_search_paths,
)


class TestBaseLayoutElement:
    """Tests for BaseLayoutElement abstract class"""

    def test_cannot_instantiate_abstract_class(self):
        """Test that BaseLayoutElement cannot be instantiated directly"""
        with pytest.raises(TypeError):
            BaseLayoutElement()

    def test_mark_modified(self):
        """Test that mark_modified updates the last_modified timestamp"""
        img = ImageData()
        original_modified = img.last_modified

        # Wait a tiny bit to ensure timestamp changes
        import time
        time.sleep(0.01)

        img.mark_modified()
        assert img.last_modified != original_modified
        # Verify it's a valid ISO format timestamp
        datetime.fromisoformat(img.last_modified)

    def test_mark_deleted(self):
        """Test that mark_deleted properly marks an element as deleted"""
        img = ImageData()
        assert img.deleted is False
        assert img.deleted_at is None

        img.mark_deleted()

        assert img.deleted is True
        assert img.deleted_at is not None
        # Verify deleted_at is a valid ISO format timestamp
        datetime.fromisoformat(img.deleted_at)

    def test_serialize_base_fields(self):
        """Test that base fields are included in serialization"""
        img = ImageData()
        data = img.serialize()

        assert "uuid" in data
        assert "created" in data
        assert "last_modified" in data
        assert "deleted" in data
        assert "deleted_at" in data
        assert data["deleted"] is False

    def test_deserialize_base_fields_with_uuid(self):
        """Test deserializing base fields when uuid is present"""
        img = ImageData()
        test_uuid = "test-uuid-12345"
        test_created = "2024-01-01T00:00:00+00:00"
        test_modified = "2024-01-02T00:00:00+00:00"

        data = {
            "uuid": test_uuid,
            "created": test_created,
            "last_modified": test_modified,
            "deleted": False,
            "deleted_at": None,
        }
        img.deserialize(data)

        assert img.uuid == test_uuid
        assert img.created == test_created
        assert img.last_modified == test_modified
        assert img.deleted is False
        assert img.deleted_at is None

    def test_deserialize_base_fields_generates_uuid_if_missing(self):
        """Test that deserialize generates UUID if not present (backwards compatibility)"""
        img = ImageData()
        data = {}  # No UUID
        img.deserialize(data)

        # Should generate a UUID
        assert img.uuid is not None
        assert len(img.uuid) > 0


class TestImageData:
    """Tests for ImageData class"""

    def test_initialization_default(self):
        """Test ImageData initialization with default values"""
        img = ImageData()
        assert img.image_path == ""
        assert img.position == (0, 0)
        assert img.size == (100, 100)
        assert img.rotation == 0
        assert img.z_index == 0
        assert img.crop_info == (0, 0, 1, 1)

    def test_initialization_with_parameters(self, temp_image_file):
        """Test ImageData initialization with custom parameters"""
        img = ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=200.0, height=150.0, rotation=45.0, z_index=5)
        assert img.image_path == temp_image_file
        assert img.position == (10.0, 20.0)
        assert img.size == (200.0, 150.0)
        assert img.rotation == 45.0
        assert img.z_index == 5

    def test_initialization_with_crop_info(self):
        """Test ImageData initialization with custom crop info"""
        crop = (0.1, 0.2, 0.8, 0.9)
        img = ImageData(image_path="test.jpg", crop_info=crop)
        assert img.crop_info == crop

    def test_serialization(self, temp_image_file):
        """Test ImageData serialization to dictionary"""
        img = ImageData(image_path=temp_image_file, x=15.0, y=25.0, width=180.0, height=120.0, rotation=30.0, z_index=3)
        data = img.serialize()

        assert data["type"] == "image"
        assert data["image_path"] == temp_image_file
        assert data["position"] == (15.0, 25.0)
        assert data["size"] == (180.0, 120.0)
        assert data["rotation"] == 30.0
        assert data["z_index"] == 3
        assert data["crop_info"] == (0, 0, 1, 1)

    def test_deserialization(self):
        """Test ImageData deserialization from dictionary"""
        img = ImageData()
        data = {
            "position": (30.0, 40.0),
            "size": (220.0, 180.0),
            "rotation": 90.0,
            "z_index": 7,
            "image_path": "new_image.jpg",
            "crop_info": (0.2, 0.3, 0.7, 0.8),
        }
        img.deserialize(data)

        assert img.position == (30.0, 40.0)
        assert img.size == (220.0, 180.0)
        # After rotation refactoring, old visual rotation is converted to pil_rotation_90
        assert img.rotation == 0  # Visual rotation reset to 0
        assert img.pil_rotation_90 == 1  # 90 degrees converted to pil_rotation_90
        assert img.z_index == 7
        assert img.image_path == "new_image.jpg"
        assert img.crop_info == (0.2, 0.3, 0.7, 0.8)

    def test_deserialization_with_defaults(self):
        """Test ImageData deserialization with missing fields uses defaults"""
        img = ImageData()
        data = {"image_path": "test.jpg"}
        img.deserialize(data)

        assert img.position == (0, 0)
        assert img.size == (100, 100)
        assert img.rotation == 0
        assert img.z_index == 0
        assert img.crop_info == (0, 0, 1, 1)

    def test_serialize_deserialize_roundtrip(self, temp_image_file):
        """Test that serialize and deserialize are inverse operations"""
        # Note: After rotation refactoring, ImageData uses pil_rotation_90 for 90-degree rotations
        # Setting rotation directly is not the typical workflow anymore, but we test it works
        original = ImageData(
            image_path=temp_image_file,
            x=50.0,
            y=60.0,
            width=300.0,
            height=200.0,
            rotation=0,  # Visual rotation should be 0 for images
            z_index=2,
            crop_info=(0.1, 0.1, 0.9, 0.9),
        )
        original.pil_rotation_90 = 1  # Set PIL rotation to 90 degrees

        data = original.serialize()
        restored = ImageData()
        restored.deserialize(data)

        assert restored.image_path == original.image_path
        assert restored.position == original.position
        assert restored.size == original.size
        assert restored.rotation == 0  # Should remain 0
        assert restored.pil_rotation_90 == 1  # PIL rotation preserved
        assert restored.z_index == original.z_index
        assert restored.crop_info == original.crop_info

    def test_position_modification(self):
        """Test modifying position after initialization"""
        img = ImageData()
        img.position = (100.0, 200.0)
        assert img.position == (100.0, 200.0)

    def test_size_modification(self):
        """Test modifying size after initialization"""
        img = ImageData()
        img.size = (400.0, 300.0)
        assert img.size == (400.0, 300.0)

    def test_resolve_image_path_absolute_exists(self, temp_image_file):
        """Test resolve_image_path with absolute path that exists"""
        img = ImageData(image_path=temp_image_file)
        resolved = img.resolve_image_path()
        assert resolved == temp_image_file
        assert os.path.isabs(resolved)

    def test_resolve_image_path_absolute_not_exists(self):
        """Test resolve_image_path with absolute path that doesn't exist"""
        img = ImageData(image_path="/nonexistent/absolute/path.jpg")
        resolved = img.resolve_image_path()
        assert resolved is None

    def test_resolve_image_path_empty(self):
        """Test resolve_image_path with empty path"""
        img = ImageData(image_path="")
        resolved = img.resolve_image_path()
        assert resolved is None

    def test_resolve_image_path_relative_with_project_folder(self, temp_dir):
        """Test resolve_image_path with relative path and project folder set"""
        # Create a test image in the temp directory
        test_image_path = os.path.join(temp_dir, "test_image.jpg")
        img = Image.new("RGB", (50, 50), color="blue")
        img.save(test_image_path)

        # Set the asset resolution context
        set_asset_resolution_context(temp_dir)

        # Create ImageData with relative path
        img_data = ImageData(image_path="test_image.jpg")
        resolved = img_data.resolve_image_path()

        assert resolved is not None
        assert os.path.exists(resolved)
        assert resolved == test_image_path

        # Reset context
        set_asset_resolution_context(None)

    def test_resolve_image_path_relative_no_project_folder(self):
        """Test resolve_image_path with relative path but no project folder set"""
        # Reset context
        set_asset_resolution_context(None)

        img = ImageData(image_path="relative/path.jpg")
        resolved = img.resolve_image_path()
        assert resolved is None

    def test_rotation_conversion_90_degrees(self):
        """Test conversion of old visual rotation (90°) to pil_rotation_90"""
        img = ImageData()
        data = {
            "rotation": 90.0,
            "pil_rotation_90": 0,  # Old data without PIL rotation
        }
        img.deserialize(data)

        assert img.rotation == 0  # Visual rotation should be reset
        assert img.pil_rotation_90 == 1  # Should be converted to 1 (90°)

    def test_rotation_conversion_180_degrees(self):
        """Test conversion of old visual rotation (180°) to pil_rotation_90"""
        img = ImageData()
        data = {
            "rotation": 180.0,
            "pil_rotation_90": 0,
        }
        img.deserialize(data)

        assert img.rotation == 0
        assert img.pil_rotation_90 == 2

    def test_rotation_conversion_270_degrees(self):
        """Test conversion of old visual rotation (270°) to pil_rotation_90"""
        img = ImageData()
        data = {
            "rotation": 270.0,
            "pil_rotation_90": 0,
        }
        img.deserialize(data)

        assert img.rotation == 0
        assert img.pil_rotation_90 == 3

    def test_no_rotation_conversion_when_pil_rotation_set(self):
        """Test that rotation conversion doesn't happen if pil_rotation_90 is already set"""
        img = ImageData()
        data = {
            "rotation": 90.0,
            "pil_rotation_90": 2,  # Already set to 180°
        }
        img.deserialize(data)

        # Should keep existing pil_rotation_90, not convert
        assert img.pil_rotation_90 == 2
        assert img.rotation == 90.0  # Keeps original rotation

    def test_image_dimensions_serialization(self):
        """Test that image_dimensions are serialized when available"""
        img = ImageData()
        img.image_dimensions = (800, 600)
        data = img.serialize()

        assert "image_dimensions" in data
        assert data["image_dimensions"] == (800, 600)

    def test_image_dimensions_deserialization(self):
        """Test that image_dimensions are deserialized correctly"""
        img = ImageData()
        data = {
            "image_dimensions": [1920, 1080]  # List format from JSON
        }
        img.deserialize(data)

        assert img.image_dimensions == (1920, 1080)  # Converted to tuple

    def test_pil_rotation_serialization(self):
        """Test that pil_rotation_90 is serialized"""
        img = ImageData()
        img.pil_rotation_90 = 2
        data = img.serialize()

        assert data["pil_rotation_90"] == 2

    def test_async_loading_initial_state(self):
        """Test initial state of async loading flags"""
        img = ImageData()
        assert img._async_loading is False
        assert img._async_load_requested is False

    def test_on_async_image_loaded_callback(self):
        """Test _on_async_image_loaded callback sets pending image"""
        img = ImageData(image_path="test.jpg")

        # Create a mock PIL image
        pil_image = Image.new("RGBA", (200, 100), color="red")

        # Call the callback
        img._on_async_image_loaded(pil_image)

        # Verify the pending image is set
        assert hasattr(img, "_pending_pil_image")
        assert img._pending_pil_image is not None
        assert img._img_width == 200
        assert img._img_height == 100
        assert img._async_loading is False
        assert img.image_dimensions == (200, 100)

    def test_on_async_image_loaded_with_pil_rotation(self):
        """Test _on_async_image_loaded applies PIL rotation"""
        img = ImageData(image_path="test.jpg")
        img.pil_rotation_90 = 1  # 90 degrees

        # Create a mock PIL image (100x200)
        pil_image = Image.new("RGBA", (100, 200), color="blue")

        # Call the callback
        img._on_async_image_loaded(pil_image)

        # After 90° rotation, dimensions should be swapped (200x100)
        assert img._img_width == 200
        assert img._img_height == 100
        assert img.image_dimensions == (200, 100)

    def test_on_async_image_load_failed_callback(self):
        """Test _on_async_image_load_failed callback"""
        img = ImageData(image_path="test.jpg")
        img._async_loading = True
        img._async_load_requested = True

        # Call the failure callback
        img._on_async_image_load_failed("File not found")

        # Verify flags are reset
        assert img._async_loading is False
        assert img._async_load_requested is False

    def test_on_async_image_loaded_exception_handling(self):
        """Test _on_async_image_loaded handles exceptions gracefully"""
        img = ImageData(image_path="test.jpg")

        # Create a mock object that will raise an exception when accessed
        class BadImage:
            @property
            def size(self):
                raise RuntimeError("Simulated error")

            @property
            def width(self):
                raise RuntimeError("Simulated error")

        bad_image = BadImage()

        # Call should not raise, but should handle the error
        img._on_async_image_loaded(bad_image)

        # Verify that async loading is reset and pending image is None
        assert img._async_loading is False
        assert not hasattr(img, "_pending_pil_image") or img._pending_pil_image is None

    @patch("pyPhotoAlbum.models.glColor3f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    def test_render_without_texture(self, mock_glEnd, mock_glBegin, mock_glVertex2f, mock_glColor3f):
        """Test ImageData.render() without texture (placeholder mode)"""
        img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)

        img.render()

        # Should draw a light blue placeholder rectangle
        mock_glColor3f.assert_any_call(0.7, 0.85, 1.0)
        # Should draw a black border
        mock_glColor3f.assert_any_call(0.0, 0.0, 0.0)
        # Verify GL_QUADS and GL_LINE_LOOP were used
        assert mock_glBegin.call_count >= 2
        assert mock_glEnd.call_count >= 2

    @patch("pyPhotoAlbum.models.glDisable")
    @patch("pyPhotoAlbum.models.glEnable")
    @patch("pyPhotoAlbum.models.glBindTexture")
    @patch("pyPhotoAlbum.models.glTexCoord2f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glColor4f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    def test_render_with_texture(
        self,
        mock_glEnd,
        mock_glBegin,
        mock_glColor4f,
        mock_glVertex2f,
        mock_glTexCoord2f,
        mock_glBindTexture,
        mock_glEnable,
        mock_glDisable,
    ):
        """Test ImageData.render() with texture"""
        img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
        img._texture_id = 123
        img._img_width = 200
        img._img_height = 100

        img.render()

        # Should enable and bind texture
        from pyPhotoAlbum.models import GL_TEXTURE_2D

        mock_glEnable.assert_called()
        mock_glBindTexture.assert_called_with(GL_TEXTURE_2D, 123)
        mock_glColor4f.assert_called_with(1.0, 1.0, 1.0, 1.0)

        # Should set texture coordinates and vertices
        assert mock_glTexCoord2f.call_count >= 4
        assert mock_glVertex2f.call_count >= 4

        # Should disable texture after rendering
        mock_glDisable.assert_called()

    @patch("pyPhotoAlbum.models.glGetString")
    @patch("pyPhotoAlbum.models.glGenTextures")
    @patch("pyPhotoAlbum.models.glBindTexture")
    @patch("pyPhotoAlbum.models.glTexParameteri")
    @patch("pyPhotoAlbum.models.glTexImage2D")
    def test_create_texture_from_pending_image_success(
        self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString
    ):
        """Test _create_texture_from_pending_image successfully creates texture"""
        img = ImageData(image_path="test.jpg")

        # Mock GL context is available
        mock_glGetString.return_value = b"4.5.0"
        mock_glGenTextures.return_value = 456

        # Create a pending PIL image
        pil_image = Image.new("RGBA", (100, 50), color="green")
        img._pending_pil_image = pil_image

        result = img._create_texture_from_pending_image()

        assert result is True
        assert img._texture_id == 456
        assert img._pending_pil_image is None  # Should be cleared
        mock_glGenTextures.assert_called_once_with(1)
        mock_glTexImage2D.assert_called_once()

    @patch("pyPhotoAlbum.models.glGetString")
    def test_create_texture_from_pending_image_no_gl_context(self, mock_glGetString):
        """Test _create_texture_from_pending_image defers when no GL context"""
        img = ImageData(image_path="test.jpg")

        # Mock no GL context available
        mock_glGetString.return_value = None

        # Create a pending PIL image
        pil_image = Image.new("RGBA", (100, 50), color="yellow")
        img._pending_pil_image = pil_image

        result = img._create_texture_from_pending_image()

        assert result is False
        assert img._pending_pil_image is not None  # Should keep pending image
        assert not hasattr(img, "_texture_id") or img._texture_id is None

    @patch("pyPhotoAlbum.models.glGenTextures")
    @patch("pyPhotoAlbum.models.glGetString")
    def test_create_texture_from_pending_image_gl_error(self, mock_glGetString, mock_glGenTextures):
        """Test _create_texture_from_pending_image handles GL errors"""
        img = ImageData(image_path="test.jpg")

        # Mock GL context available but genTextures fails with GL error
        mock_glGetString.return_value = b"4.5.0"
        mock_glGenTextures.side_effect = Exception("GLError 1282: Invalid operation")

        pil_image = Image.new("RGBA", (100, 50), color="purple")
        img._pending_pil_image = pil_image

        result = img._create_texture_from_pending_image()

        # Should return False and keep trying on next render
        assert result is False
        assert img._pending_pil_image is not None

    @patch("pyPhotoAlbum.models.glGenTextures")
    @patch("pyPhotoAlbum.models.glGetString")
    def test_create_texture_from_pending_image_other_error(self, mock_glGetString, mock_glGenTextures):
        """Test _create_texture_from_pending_image handles non-GL errors"""
        img = ImageData(image_path="test.jpg")

        # Mock GL context available but other error occurs
        mock_glGetString.return_value = b"4.5.0"
        mock_glGenTextures.side_effect = Exception("Some other error")

        pil_image = Image.new("RGBA", (100, 50), color="cyan")
        img._pending_pil_image = pil_image

        result = img._create_texture_from_pending_image()

        # Should return False and clear pending image (give up)
        assert result is False
        assert img._pending_pil_image is None
        assert img._texture_id is None

    @patch("pyPhotoAlbum.models.glDeleteTextures")
    @patch("pyPhotoAlbum.models.glGetString")
    @patch("pyPhotoAlbum.models.glGenTextures")
    @patch("pyPhotoAlbum.models.glBindTexture")
    @patch("pyPhotoAlbum.models.glTexParameteri")
    @patch("pyPhotoAlbum.models.glTexImage2D")
    def test_create_texture_deletes_old_texture(
        self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString, mock_glDeleteTextures
    ):
        """Test _create_texture_from_pending_image deletes old texture"""
        img = ImageData(image_path="test.jpg")
        img._texture_id = 789  # Old texture

        mock_glGetString.return_value = b"4.5.0"
        mock_glGenTextures.return_value = 999

        pil_image = Image.new("RGBA", (100, 50), color="red")
        img._pending_pil_image = pil_image

        result = img._create_texture_from_pending_image()

        assert result is True
        mock_glDeleteTextures.assert_called_once_with([789])
        assert img._texture_id == 999

    def test_create_texture_from_pending_image_no_pending(self):
        """Test _create_texture_from_pending_image returns False when no pending image"""
        img = ImageData(image_path="test.jpg")
        # No pending image set

        result = img._create_texture_from_pending_image()

        assert result is False

    @patch("pyPhotoAlbum.models.glGetString")
    @patch("pyPhotoAlbum.models.glGenTextures")
    @patch("pyPhotoAlbum.models.glBindTexture")
    @patch("pyPhotoAlbum.models.glTexParameteri")
    @patch("pyPhotoAlbum.models.glTexImage2D")
    def test_create_texture_converts_non_rgba(
        self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString
    ):
        """Test _create_texture_from_pending_image converts non-RGBA images"""
        img = ImageData(image_path="test.jpg")

        mock_glGetString.return_value = b"4.5.0"
        mock_glGenTextures.return_value = 456

        # Create RGB image (not RGBA)
        pil_image = Image.new("RGB", (100, 50), color="blue")
        img._pending_pil_image = pil_image

        result = img._create_texture_from_pending_image()

        assert result is True
        # Image should have been converted to RGBA
        assert img._texture_id == 456

    @patch("pyPhotoAlbum.models.glGetString")
    @patch("pyPhotoAlbum.models.glGenTextures")
    @patch("pyPhotoAlbum.models.glBindTexture")
    @patch("pyPhotoAlbum.models.glTexParameteri")
    @patch("pyPhotoAlbum.models.glTexImage2D")
    def test_create_texture_clears_warning_flag(
        self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString
    ):
        """Test _create_texture_from_pending_image clears GL context warning flag on success"""
        img = ImageData(image_path="test.jpg")
        img._gl_context_warned = True  # Set warning flag

        mock_glGetString.return_value = b"4.5.0"
        mock_glGenTextures.return_value = 456

        pil_image = Image.new("RGBA", (100, 50), color="green")
        img._pending_pil_image = pil_image

        result = img._create_texture_from_pending_image()

        assert result is True
        # Warning flag should be cleared
        assert not hasattr(img, "_gl_context_warned")

    @patch("pyPhotoAlbum.models.calculate_center_crop_coords")
    @patch("pyPhotoAlbum.models.glDisable")
    @patch("pyPhotoAlbum.models.glEnable")
    @patch("pyPhotoAlbum.models.glBindTexture")
    @patch("pyPhotoAlbum.models.glTexCoord2f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glColor4f")
    @patch("pyPhotoAlbum.models.glColor3f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    def test_render_with_texture_using_image_dimensions(
        self,
        mock_glEnd,
        mock_glBegin,
        mock_glColor3f,
        mock_glColor4f,
        mock_glVertex2f,
        mock_glTexCoord2f,
        mock_glBindTexture,
        mock_glEnable,
        mock_glDisable,
        mock_calculate_coords,
    ):
        """Test render() with texture but no _img_width/_img_height, using image_dimensions"""
        img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
        img._texture_id = 123
        img.image_dimensions = (800, 600)  # Has dimensions but not _img_width/_img_height

        mock_calculate_coords.return_value = (0, 0, 1, 1)

        img.render()

        # Should use image_dimensions for crop calculation
        mock_calculate_coords.assert_called_once()
        args = mock_calculate_coords.call_args[0]
        assert args[0] == 800  # img_width from image_dimensions
        assert args[1] == 600  # img_height from image_dimensions

    @patch("pyPhotoAlbum.models.calculate_center_crop_coords")
    @patch("pyPhotoAlbum.models.glDisable")
    @patch("pyPhotoAlbum.models.glEnable")
    @patch("pyPhotoAlbum.models.glBindTexture")
    @patch("pyPhotoAlbum.models.glTexCoord2f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glColor4f")
    @patch("pyPhotoAlbum.models.glColor3f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    def test_render_with_texture_no_dimensions(
        self,
        mock_glEnd,
        mock_glBegin,
        mock_glColor3f,
        mock_glColor4f,
        mock_glVertex2f,
        mock_glTexCoord2f,
        mock_glBindTexture,
        mock_glEnable,
        mock_glDisable,
        mock_calculate_coords,
    ):
        """Test render() with texture but no dimensions at all"""
        img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
        img._texture_id = 123
        # No _img_width/_img_height and no image_dimensions

        mock_calculate_coords.return_value = (0, 0, 1, 1)

        img.render()

        # Should use element size as fallback
        mock_calculate_coords.assert_called_once()
        args = mock_calculate_coords.call_args[0]
        assert args[0] == 100  # Uses width as img_width
        assert args[1] == 50  # Uses height as img_height

    @patch("pyPhotoAlbum.models.glGetString")
    @patch("pyPhotoAlbum.models.glGenTextures")
    @patch("pyPhotoAlbum.models.glBindTexture")
    @patch("pyPhotoAlbum.models.glTexParameteri")
    @patch("pyPhotoAlbum.models.glTexImage2D")
    @patch("pyPhotoAlbum.models.glColor3f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    @patch("pyPhotoAlbum.models.glEnable")
    @patch("pyPhotoAlbum.models.glDisable")
    def test_render_calls_create_texture_from_pending(
        self,
        mock_glDisable,
        mock_glEnable,
        mock_glEnd,
        mock_glBegin,
        mock_glVertex2f,
        mock_glColor3f,
        mock_glTexImage2D,
        mock_glTexParameteri,
        mock_glBindTexture,
        mock_glGenTextures,
        mock_glGetString,
    ):
        """Test render() calls _create_texture_from_pending_image when pending image exists"""
        img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)

        # Set up pending image
        pil_image = Image.new("RGBA", (100, 50), color="orange")
        img._pending_pil_image = pil_image

        mock_glGetString.return_value = b"4.5.0"
        mock_glGenTextures.return_value = 999

        img.render()

        # Should have created texture from pending image
        assert img._texture_id == 999
        assert img._pending_pil_image is None  # Should be cleared


class TestPlaceholderData:
    """Tests for PlaceholderData class"""

    def test_initialization_default(self):
        """Test PlaceholderData initialization with default values"""
        placeholder = PlaceholderData()
        assert placeholder.placeholder_type == "image"
        assert placeholder.default_content == ""
        assert placeholder.position == (0, 0)
        assert placeholder.size == (100, 100)
        assert placeholder.rotation == 0
        assert placeholder.z_index == 0

    def test_initialization_with_parameters(self):
        """Test PlaceholderData initialization with custom parameters"""
        placeholder = PlaceholderData(
            placeholder_type="text",
            default_content="Sample",
            x=20.0,
            y=30.0,
            width=150.0,
            height=100.0,
            rotation=10.0,
            z_index=4,
        )
        assert placeholder.placeholder_type == "text"
        assert placeholder.default_content == "Sample"
        assert placeholder.position == (20.0, 30.0)
        assert placeholder.size == (150.0, 100.0)
        assert placeholder.rotation == 10.0
        assert placeholder.z_index == 4

    def test_serialization(self):
        """Test PlaceholderData serialization to dictionary"""
        placeholder = PlaceholderData(
            placeholder_type="image",
            default_content="placeholder.jpg",
            x=40.0,
            y=50.0,
            width=200.0,
            height=150.0,
            rotation=20.0,
            z_index=2,
        )
        data = placeholder.serialize()

        assert data["type"] == "placeholder"
        assert data["placeholder_type"] == "image"
        assert data["default_content"] == "placeholder.jpg"
        assert data["position"] == (40.0, 50.0)
        assert data["size"] == (200.0, 150.0)
        assert data["rotation"] == 20.0
        assert data["z_index"] == 2

    def test_deserialization(self):
        """Test PlaceholderData deserialization from dictionary"""
        placeholder = PlaceholderData()
        data = {
            "position": (60.0, 70.0),
            "size": (250.0, 180.0),
            "rotation": 45.0,
            "z_index": 6,
            "placeholder_type": "text",
            "default_content": "Default Text",
        }
        placeholder.deserialize(data)

        assert placeholder.position == (60.0, 70.0)
        assert placeholder.size == (250.0, 180.0)
        assert placeholder.rotation == 45.0
        assert placeholder.z_index == 6
        assert placeholder.placeholder_type == "text"
        assert placeholder.default_content == "Default Text"

    def test_deserialization_with_defaults(self):
        """Test PlaceholderData deserialization with missing fields uses defaults"""
        placeholder = PlaceholderData()
        data = {"placeholder_type": "image"}
        placeholder.deserialize(data)

        assert placeholder.position == (0, 0)
        assert placeholder.size == (100, 100)
        assert placeholder.rotation == 0
        assert placeholder.z_index == 0
        assert placeholder.default_content == ""

    def test_serialize_deserialize_roundtrip(self):
        """Test that serialize and deserialize are inverse operations"""
        original = PlaceholderData(
            placeholder_type="image",
            default_content="test.jpg",
            x=80.0,
            y=90.0,
            width=300.0,
            height=250.0,
            rotation=60.0,
            z_index=8,
        )
        data = original.serialize()
        restored = PlaceholderData()
        restored.deserialize(data)

        assert restored.placeholder_type == original.placeholder_type
        assert restored.default_content == original.default_content
        assert restored.position == original.position
        assert restored.size == original.size
        assert restored.rotation == original.rotation
        assert restored.z_index == original.z_index

    @patch("pyPhotoAlbum.models.glPopMatrix")
    @patch("pyPhotoAlbum.models.glPushMatrix")
    @patch("pyPhotoAlbum.models.glRotatef")
    @patch("pyPhotoAlbum.models.glTranslatef")
    @patch("pyPhotoAlbum.models.glDisable")
    @patch("pyPhotoAlbum.models.glEnable")
    @patch("pyPhotoAlbum.models.glLineStipple")
    @patch("pyPhotoAlbum.models.glColor3f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    def test_render_without_rotation(
        self,
        mock_glEnd,
        mock_glBegin,
        mock_glVertex2f,
        mock_glColor3f,
        mock_glLineStipple,
        mock_glEnable,
        mock_glDisable,
        mock_glTranslatef,
        mock_glRotatef,
        mock_glPushMatrix,
        mock_glPopMatrix,
    ):
        """Test PlaceholderData.render() without rotation"""
        placeholder = PlaceholderData(x=10, y=20, width=100, height=50, rotation=0)

        placeholder.render()

        # Should draw light gray rectangle
        mock_glColor3f.assert_any_call(0.9, 0.9, 0.9)
        # Should draw gray dashed border
        mock_glColor3f.assert_any_call(0.5, 0.5, 0.5)
        # Should enable and disable line stipple
        from pyPhotoAlbum.models import GL_LINE_STIPPLE

        mock_glEnable.assert_called_with(GL_LINE_STIPPLE)
        mock_glLineStipple.assert_called_once_with(1, 0x00FF)
        mock_glDisable.assert_called_with(GL_LINE_STIPPLE)

        # Should NOT push/pop matrix when rotation is 0
        mock_glPushMatrix.assert_not_called()
        mock_glPopMatrix.assert_not_called()

    @patch("pyPhotoAlbum.models.glPopMatrix")
    @patch("pyPhotoAlbum.models.glPushMatrix")
    @patch("pyPhotoAlbum.models.glRotatef")
    @patch("pyPhotoAlbum.models.glTranslatef")
    @patch("pyPhotoAlbum.models.glDisable")
    @patch("pyPhotoAlbum.models.glEnable")
    @patch("pyPhotoAlbum.models.glLineStipple")
    @patch("pyPhotoAlbum.models.glColor3f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    def test_render_with_rotation(
        self,
        mock_glEnd,
        mock_glBegin,
        mock_glVertex2f,
        mock_glColor3f,
        mock_glLineStipple,
        mock_glEnable,
        mock_glDisable,
        mock_glTranslatef,
        mock_glRotatef,
        mock_glPushMatrix,
        mock_glPopMatrix,
    ):
        """Test PlaceholderData.render() with rotation"""
        placeholder = PlaceholderData(x=10, y=20, width=100, height=50, rotation=45)

        placeholder.render()

        # Should push/pop matrix for rotation
        mock_glPushMatrix.assert_called_once()
        mock_glPopMatrix.assert_called_once()

        # Should translate to center and rotate
        assert mock_glTranslatef.call_count == 2
        mock_glRotatef.assert_called_once_with(45, 0, 0, 1)


class TestTextBoxData:
    """Tests for TextBoxData class"""

    def test_initialization_default(self):
        """Test TextBoxData initialization with default values"""
        textbox = TextBoxData()
        assert textbox.text_content == ""
        assert textbox.font_settings == {"family": "Arial", "size": 12, "color": (0, 0, 0)}
        assert textbox.alignment == "left"
        assert textbox.position == (0, 0)
        assert textbox.size == (100, 100)
        assert textbox.rotation == 0
        assert textbox.z_index == 0

    def test_initialization_with_parameters(self):
        """Test TextBoxData initialization with custom parameters"""
        font_settings = {"family": "Times", "size": 14, "color": (255, 0, 0)}
        textbox = TextBoxData(
            text_content="Hello World",
            font_settings=font_settings,
            alignment="center",
            x=25.0,
            y=35.0,
            width=180.0,
            height=60.0,
            rotation=5.0,
            z_index=3,
        )
        assert textbox.text_content == "Hello World"
        assert textbox.font_settings == font_settings
        assert textbox.alignment == "center"
        assert textbox.position == (25.0, 35.0)
        assert textbox.size == (180.0, 60.0)
        assert textbox.rotation == 5.0
        assert textbox.z_index == 3

    def test_serialization(self):
        """Test TextBoxData serialization to dictionary"""
        font_settings = {"family": "Helvetica", "size": 16, "color": (0, 0, 255)}
        textbox = TextBoxData(
            text_content="Test Text",
            font_settings=font_settings,
            alignment="right",
            x=45.0,
            y=55.0,
            width=220.0,
            height=80.0,
            rotation=15.0,
            z_index=5,
        )
        data = textbox.serialize()

        assert data["type"] == "textbox"
        assert data["text_content"] == "Test Text"
        assert data["font_settings"] == font_settings
        assert data["alignment"] == "right"
        assert data["position"] == (45.0, 55.0)
        assert data["size"] == (220.0, 80.0)
        assert data["rotation"] == 15.0
        assert data["z_index"] == 5

    def test_deserialization(self):
        """Test TextBoxData deserialization from dictionary"""
        textbox = TextBoxData()
        font_settings = {"family": "Courier", "size": 18, "color": (128, 128, 128)}
        data = {
            "position": (65.0, 75.0),
            "size": (260.0, 100.0),
            "rotation": 30.0,
            "z_index": 7,
            "text_content": "Deserialized Text",
            "font_settings": font_settings,
            "alignment": "justify",
        }
        textbox.deserialize(data)

        assert textbox.position == (65.0, 75.0)
        assert textbox.size == (260.0, 100.0)
        assert textbox.rotation == 30.0
        assert textbox.z_index == 7
        assert textbox.text_content == "Deserialized Text"
        assert textbox.font_settings == font_settings
        assert textbox.alignment == "justify"

    def test_deserialization_with_defaults(self):
        """Test TextBoxData deserialization with missing fields uses defaults"""
        textbox = TextBoxData()
        data = {"text_content": "Minimal"}
        textbox.deserialize(data)

        assert textbox.position == (0, 0)
        assert textbox.size == (100, 100)
        assert textbox.rotation == 0
        assert textbox.z_index == 0
        assert textbox.font_settings == {"family": "Arial", "size": 12, "color": (0, 0, 0)}
        assert textbox.alignment == "left"

    def test_serialize_deserialize_roundtrip(self):
        """Test that serialize and deserialize are inverse operations"""
        font_settings = {"family": "Georgia", "size": 20, "color": (255, 255, 0)}
        original = TextBoxData(
            text_content="Round Trip Test",
            font_settings=font_settings,
            alignment="center",
            x=85.0,
            y=95.0,
            width=320.0,
            height=120.0,
            rotation=25.0,
            z_index=9,
        )
        data = original.serialize()
        restored = TextBoxData()
        restored.deserialize(data)

        assert restored.text_content == original.text_content
        assert restored.font_settings == original.font_settings
        assert restored.alignment == original.alignment
        assert restored.position == original.position
        assert restored.size == original.size
        assert restored.rotation == original.rotation
        assert restored.z_index == original.z_index

    def test_text_content_modification(self):
        """Test modifying text content after initialization"""
        textbox = TextBoxData()
        textbox.text_content = "Modified Text"
        assert textbox.text_content == "Modified Text"

    def test_font_settings_modification(self):
        """Test modifying font settings after initialization"""
        textbox = TextBoxData()
        new_font = {"family": "Verdana", "size": 24, "color": (100, 200, 50)}
        textbox.font_settings = new_font
        assert textbox.font_settings == new_font

    def test_alignment_modification(self):
        """Test modifying alignment after initialization"""
        textbox = TextBoxData()
        textbox.alignment = "right"
        assert textbox.alignment == "right"

    @patch("pyPhotoAlbum.models.glPopMatrix")
    @patch("pyPhotoAlbum.models.glPushMatrix")
    @patch("pyPhotoAlbum.models.glRotatef")
    @patch("pyPhotoAlbum.models.glTranslatef")
    @patch("pyPhotoAlbum.models.glDisable")
    @patch("pyPhotoAlbum.models.glEnable")
    @patch("pyPhotoAlbum.models.glLineStipple")
    @patch("pyPhotoAlbum.models.glColor3f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    def test_render_without_rotation(
        self,
        mock_glEnd,
        mock_glBegin,
        mock_glVertex2f,
        mock_glColor3f,
        mock_glLineStipple,
        mock_glEnable,
        mock_glDisable,
        mock_glTranslatef,
        mock_glRotatef,
        mock_glPushMatrix,
        mock_glPopMatrix,
    ):
        """Test TextBoxData.render() without rotation"""
        textbox = TextBoxData(text_content="Test", x=10, y=20, width=100, height=50, rotation=0)

        textbox.render()

        # Should enable and disable line stipple for dashed border
        from pyPhotoAlbum.models import GL_LINE_STIPPLE

        mock_glEnable.assert_called_with(GL_LINE_STIPPLE)
        mock_glLineStipple.assert_called_once_with(2, 0xAAAA)
        mock_glDisable.assert_called_with(GL_LINE_STIPPLE)

        # Should draw light gray dashed border
        mock_glColor3f.assert_called_with(0.7, 0.7, 0.7)

        # Should NOT push/pop matrix when rotation is 0
        mock_glPushMatrix.assert_not_called()
        mock_glPopMatrix.assert_not_called()

    @patch("pyPhotoAlbum.models.glPopMatrix")
    @patch("pyPhotoAlbum.models.glPushMatrix")
    @patch("pyPhotoAlbum.models.glRotatef")
    @patch("pyPhotoAlbum.models.glTranslatef")
    @patch("pyPhotoAlbum.models.glDisable")
    @patch("pyPhotoAlbum.models.glEnable")
    @patch("pyPhotoAlbum.models.glBlendFunc")
    @patch("pyPhotoAlbum.models.glColor4f")
    @patch("pyPhotoAlbum.models.glColor3f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    def test_render_with_rotation(
        self,
        mock_glEnd,
        mock_glBegin,
        mock_glVertex2f,
        mock_glColor3f,
        mock_glColor4f,
        mock_glBlendFunc,
        mock_glEnable,
        mock_glDisable,
        mock_glTranslatef,
        mock_glRotatef,
        mock_glPushMatrix,
        mock_glPopMatrix,
    ):
        """Test TextBoxData.render() with rotation"""
        textbox = TextBoxData(text_content="Test", x=10, y=20, width=100, height=50, rotation=30)

        textbox.render()

        # Should push/pop matrix for rotation
        mock_glPushMatrix.assert_called_once()
        mock_glPopMatrix.assert_called_once()

        # Should translate to center and rotate
        assert mock_glTranslatef.call_count == 2
        mock_glRotatef.assert_called_once_with(30, 0, 0, 1)


class TestElementComparison:
    """Tests comparing different element types"""

    def test_different_element_types_serialize_differently(self):
        """Test that different element types have different serialization"""
        img = ImageData(x=10, y=10)
        placeholder = PlaceholderData(x=10, y=10)
        textbox = TextBoxData(x=10, y=10)

        img_data = img.serialize()
        placeholder_data = placeholder.serialize()
        textbox_data = textbox.serialize()

        assert img_data["type"] == "image"
        assert placeholder_data["type"] == "placeholder"
        assert textbox_data["type"] == "textbox"

    def test_z_index_comparison(self):
        """Test that z_index can be used for layering"""
        img1 = ImageData(z_index=1)
        img2 = ImageData(z_index=5)
        img3 = ImageData(z_index=3)

        elements = [img1, img2, img3]
        sorted_elements = sorted(elements, key=lambda e: e.z_index)

        assert sorted_elements[0].z_index == 1
        assert sorted_elements[1].z_index == 3
        assert sorted_elements[2].z_index == 5


class TestAssetResolution:
    """Tests for asset resolution functions"""

    def test_set_and_get_asset_resolution_context(self, temp_dir):
        """Test setting and getting asset resolution context"""
        additional_paths = ["/path1", "/path2"]
        set_asset_resolution_context(temp_dir, additional_paths)

        project_folder, search_paths = get_asset_search_paths()

        assert project_folder == temp_dir
        assert search_paths == additional_paths

    def test_set_asset_resolution_context_no_additional_paths(self, temp_dir):
        """Test setting context without additional search paths"""
        set_asset_resolution_context(temp_dir)

        project_folder, search_paths = get_asset_search_paths()

        assert project_folder == temp_dir
        assert search_paths == []

    def test_get_asset_search_paths_default(self):
        """Test getting asset search paths when not set"""
        # This depends on global state, so we just verify it returns a tuple
        result = get_asset_search_paths()
        assert isinstance(result, tuple)
        assert len(result) == 2


class TestGhostPageData:
    """Tests for GhostPageData class"""

    def test_initialization_default(self):
        """Test GhostPageData initialization with default values"""
        ghost = GhostPageData()
        assert ghost.page_size == (210, 297)  # A4 size in mm
        assert ghost.is_ghost is True
        assert ghost.position == (0, 0)
        assert ghost.size == (100, 100)

    def test_initialization_with_custom_page_size(self):
        """Test GhostPageData initialization with custom page size"""
        custom_size = (200, 250)
        ghost = GhostPageData(page_size=custom_size, x=10, y=20)
        assert ghost.page_size == custom_size
        assert ghost.position == (10, 20)
        assert ghost.is_ghost is True

    def test_serialization(self):
        """Test GhostPageData serialization"""
        ghost = GhostPageData(page_size=(200, 280), x=5, y=10)
        data = ghost.serialize()

        assert data["type"] == "ghostpage"
        assert data["page_size"] == (200, 280)
        assert data["position"] == (5, 10)
        # Check base fields
        assert "uuid" in data
        assert "created" in data
        assert "last_modified" in data

    def test_deserialization(self):
        """Test GhostPageData deserialization"""
        ghost = GhostPageData()
        data = {
            "position": (15, 25),
            "size": (150, 200),
            "page_size": (220, 300),
        }
        ghost.deserialize(data)

        assert ghost.position == (15, 25)
        assert ghost.size == (150, 200)
        assert ghost.page_size == (220, 300)

    def test_deserialization_with_defaults(self):
        """Test GhostPageData deserialization with missing fields"""
        ghost = GhostPageData()
        data = {}
        ghost.deserialize(data)

        assert ghost.position == (0, 0)
        assert ghost.size == (100, 100)
        assert ghost.page_size == (210, 297)

    def test_serialize_deserialize_roundtrip(self):
        """Test that serialize and deserialize are inverse operations"""
        original = GhostPageData(page_size=(200, 250), x=20, y=30)
        data = original.serialize()
        restored = GhostPageData()
        restored.deserialize(data)

        assert restored.page_size == original.page_size
        assert restored.position == original.position
        assert restored.is_ghost is True

    def test_get_page_rect(self):
        """Test get_page_rect returns correct bounding box"""
        ghost = GhostPageData(page_size=(210, 297))
        rect = ghost.get_page_rect()

        assert len(rect) == 4
        x, y, w, h = rect
        assert x == 0
        assert y == 0
        # Width and height should be calculated from page_size at 300 DPI
        # 210mm * 300 DPI / 25.4 mm/inch ≈ 2480px
        assert w > 0
        assert h > 0

    def test_page_size_modification(self):
        """Test modifying page_size after initialization"""
        ghost = GhostPageData()
        ghost.page_size = (250, 350)
        assert ghost.page_size == (250, 350)

    @patch("pyPhotoAlbum.models.glDisable")
    @patch("pyPhotoAlbum.models.glEnable")
    @patch("pyPhotoAlbum.models.glBlendFunc")
    @patch("pyPhotoAlbum.models.glLineStipple")
    @patch("pyPhotoAlbum.models.glColor4f")
    @patch("pyPhotoAlbum.models.glColor3f")
    @patch("pyPhotoAlbum.models.glVertex2f")
    @patch("pyPhotoAlbum.models.glBegin")
    @patch("pyPhotoAlbum.models.glEnd")
    def test_render(
        self,
        mock_glEnd,
        mock_glBegin,
        mock_glVertex2f,
        mock_glColor3f,
        mock_glColor4f,
        mock_glLineStipple,
        mock_glBlendFunc,
        mock_glEnable,
        mock_glDisable,
    ):
        """Test GhostPageData.render()"""
        ghost = GhostPageData(page_size=(210, 297))

        ghost.render()

        # Should enable and disable blending
        from pyPhotoAlbum.models import GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_LINE_STIPPLE

        mock_glEnable.assert_any_call(GL_BLEND)
        mock_glBlendFunc.assert_called_once_with(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)

        # Should draw semi-transparent grey background
        mock_glColor4f.assert_called_with(0.8, 0.8, 0.8, 0.5)

        # Should draw grey dashed border
        mock_glColor3f.assert_called_with(0.5, 0.5, 0.5)
        mock_glEnable.assert_any_call(GL_LINE_STIPPLE)
        mock_glLineStipple.assert_called_once_with(2, 0x0F0F)
        mock_glDisable.assert_any_call(GL_LINE_STIPPLE)

        # Should have drawn quads and line loop
        assert mock_glBegin.call_count >= 2
        assert mock_glEnd.call_count >= 2
        assert mock_glVertex2f.call_count >= 8  # 4 vertices for quad + 4 for border
