"""
Tests for FileOperationsMixin
"""

import pytest
from unittest.mock import Mock, MagicMock, patch, call
from PyQt6.QtWidgets import QMainWindow, QDialog
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.operations.file_ops import FileOperationsMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.commands import CommandHistory


class TestFileOpsWindow(FileOperationsMixin, ApplicationStateMixin, QMainWindow):
    """Test window with file operations mixin"""

    def __init__(self):
        super().__init__()
        self._gl_widget = Mock()
        self._gl_widget.current_page_index = 0
        self._gl_widget.zoom_level = 1.0
        self._gl_widget.pan_offset = [0, 0]
        self._gl_widget._page_renderers = []
        self._gl_widget.width = Mock(return_value=800)
        self._gl_widget.height = Mock(return_value=600)
        self._gl_widget.export_pdf_async = Mock(return_value=True)
        self._project = Project(name="Test")
        self._project.page_size_mm = (210, 297)
        self._project.working_dpi = 96
        self._project.export_dpi = 300
        self._project.history = CommandHistory()
        self._update_view_called = False
        self._status_message = None
        self._status_timeout = None
        self._info_title = None
        self._info_message = None
        self._warning_title = None
        self._warning_message = None
        self._error_title = None
        self._error_message = None

    @property
    def gl_widget(self):
        return self._gl_widget

    @property
    def project(self):
        return self._project

    @project.setter
    def project(self, value):
        self._project = value

    def update_view(self):
        self._update_view_called = True

    def show_status(self, message, timeout=0):
        self._status_message = message
        self._status_timeout = timeout

    def show_info(self, title, message):
        self._info_title = title
        self._info_message = message

    def show_warning(self, title, message):
        self._warning_title = title
        self._warning_message = message

    def show_error(self, title, message):
        self._error_title = title
        self._error_message = message

    def resolve_asset_path(self, path):
        """Mock asset path resolution"""
        if path.startswith("assets/") and path.endswith("exists.jpg"):
            return "/fake/path/exists.jpg"
        return None


class TestNewProject:
    """Test new_project method"""

    def test_new_project_dialog_cancelled(self, qtbot):
        """Test returns when user cancels dialog"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        old_project = window.project

        # Patch QDialog.exec to return rejected
        with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
            window.new_project()

        # Should keep old project
        assert window.project == old_project

    @patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
    def test_new_project_creates_project_default_values(self, mock_set_context, qtbot):
        """Test creates new project with default dialog values"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        old_project = window.project
        old_project.cleanup = Mock()

        # Patch QDialog.exec to accept with default values (140x140, 300 DPI)
        with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
            window.new_project()

        # Verify new project was created with default values
        assert window.project != old_project
        assert window.project.name == "New Project"  # Default name
        assert window.project.page_size_mm == (140.0, 140.0)  # Default size
        assert window.project.working_dpi == 300  # Default working DPI
        assert window.project.export_dpi == 300  # Default export DPI
        assert window._update_view_called
        mock_set_context.assert_called_once()
        old_project.cleanup.assert_called_once()


class TestOpenProject:
    """Test open_project method"""

    @patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getOpenFileName")
    def test_open_project_dialog_cancelled(self, mock_file_dialog, qtbot):
        """Test returns when user cancels file dialog"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        # Mock dialog to return empty path
        mock_file_dialog.return_value = ("", "")

        window.open_project()

        # Should not create loader
        assert not hasattr(window, "_project_loader")

    @patch("pyPhotoAlbum.mixins.operations.file_ops.AsyncProjectLoader")
    @patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
    @patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getOpenFileName")
    def test_open_project_starts_async_loading(self, mock_file_dialog, mock_loading_widget, mock_loader, qtbot):
        """Test starts async loading when file selected"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        # Mock dialog to return file path
        mock_file_dialog.return_value = ("/path/to/project.ppz", "")

        # Mock loader
        mock_loader_instance = Mock()
        mock_loader.return_value = mock_loader_instance

        # Mock loading widget
        mock_loading_instance = Mock()
        mock_loading_widget.return_value = mock_loading_instance

        window.open_project()

        # Verify loader was created and started
        mock_loader.assert_called_once_with("/path/to/project.ppz")
        mock_loader_instance.start.assert_called_once()
        mock_loading_instance.show_loading.assert_called_once()


class TestLoadCallbacks:
    """Test async loading callback methods"""

    def test_on_load_progress(self, qtbot):
        """Test progress callback updates loading widget"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        # Create mock loading widget
        window._loading_widget = Mock()

        window._on_load_progress(5, 10, "Loading page 5...")

        window._loading_widget.set_progress.assert_called_once_with(5, 10)
        window._loading_widget.set_status.assert_called_once_with("Loading page 5...")

    @patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
    def test_on_load_complete_success(self, mock_set_context, qtbot):
        """Test successful load callback"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        # Create mock loading widget
        window._loading_widget = Mock()
        window._opening_file_path = "/path/to/project.ppz"

        # Create mock project
        new_project = Mock()
        new_project.name = "Loaded Project"
        new_project.mark_clean = Mock()

        # Mock check missing assets
        window._check_missing_assets = Mock(return_value=[])

        old_project = window.project
        old_project.cleanup = Mock()

        window._on_load_complete(new_project)

        # Verify old project was cleaned up
        old_project.cleanup.assert_called_once()

        # Verify new project was set
        assert window.project == new_project
        assert window.project.file_path == "/path/to/project.ppz"
        new_project.mark_clean.assert_called_once()

        # Verify UI was updated
        window._loading_widget.hide_loading.assert_called_once()
        assert window._update_view_called
        assert "Project opened" in window._status_message

    @patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
    def test_on_load_complete_with_missing_assets(self, mock_set_context, qtbot):
        """Test load callback with missing assets"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        window._loading_widget = Mock()
        window._opening_file_path = "/path/to/project.ppz"

        new_project = Mock()
        new_project.name = "Project with Missing"
        new_project.mark_clean = Mock()

        # Mock missing assets
        window._check_missing_assets = Mock(return_value=["/missing/image1.jpg", "/missing/image2.jpg"])
        window._show_missing_assets_warning = Mock()

        window._on_load_complete(new_project)

        # Verify warning was shown
        window._show_missing_assets_warning.assert_called_once_with(["/missing/image1.jpg", "/missing/image2.jpg"])
        assert "2 missing images" in window._status_message

    def test_on_load_failed(self, qtbot):
        """Test load failure callback"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        window._loading_widget = Mock()

        window._on_load_failed("File corrupted")

        # Verify error was shown
        window._loading_widget.hide_loading.assert_called_once()
        assert "Failed to open project" in window._error_message
        assert "File corrupted" in window._error_message


class TestSaveProject:
    """Test save_project method"""

    @patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip_async")
    @patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
    def test_save_project_with_existing_path(self, mock_loading_widget, mock_save_async, qtbot):
        """Test saves to existing file path"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        window.project.file_path = "/path/to/existing.ppz"

        # Mock loading widget
        mock_loading_instance = Mock()
        mock_loading_widget.return_value = mock_loading_instance

        # Capture status messages
        status_messages = []
        original_show_status = window.show_status
        def capture_status(msg, timeout=0):
            status_messages.append(msg)
            original_show_status(msg, timeout)
        window.show_status = capture_status

        # Mock save_to_zip_async to call on_complete immediately with success
        def mock_save_call(project, path, on_complete=None, on_progress=None):
            if on_complete:
                on_complete(True, None)
            return Mock()  # Return mock thread

        mock_save_async.side_effect = mock_save_call

        window.save_project()

        # Verify save was called with existing path
        assert mock_save_async.call_count == 1
        call_args = mock_save_async.call_args
        assert call_args[0][0] == window.project
        assert call_args[0][1] == "/path/to/existing.ppz"
        # Check that "Project saved" was shown at some point
        assert any("Project saved" in msg for msg in status_messages)
        assert not window.project.is_dirty()  # is_dirty() is a method

    @patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip_async")
    @patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
    @patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
    def test_save_project_prompts_for_path(self, mock_file_dialog, mock_loading_widget, mock_save_async, qtbot):
        """Test prompts for path when none exists"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        # No existing file path
        window.project.file_path = None

        # Mock dialog to return path
        mock_file_dialog.return_value = ("/path/to/new.ppz", "")

        # Mock loading widget
        mock_loading_instance = Mock()
        mock_loading_widget.return_value = mock_loading_instance

        # Mock save_to_zip_async to call on_complete immediately with success
        def mock_save_call(project, path, on_complete=None, on_progress=None):
            if on_complete:
                on_complete(True, None)
            return Mock()  # Return mock thread

        mock_save_async.side_effect = mock_save_call

        window.save_project()

        # Verify dialog was shown and save was called
        mock_file_dialog.assert_called_once()
        assert mock_save_async.call_count == 1
        call_args = mock_save_async.call_args
        assert call_args[0][0] == window.project
        assert call_args[0][1] == "/path/to/new.ppz"
        assert window.project.file_path == "/path/to/new.ppz"
        assert not window.project.is_dirty()

    @patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip_async")
    @patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
    def test_save_project_user_cancels(self, mock_file_dialog, mock_save_async, qtbot):
        """Test returns when user cancels file dialog"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        window.project.file_path = None
        mock_file_dialog.return_value = ("", "")

        window.save_project()

        # Should not call save
        mock_save_async.assert_not_called()

    @patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip_async")
    @patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
    def test_save_project_handles_error(self, mock_loading_widget, mock_save_async, qtbot):
        """Test handles save errors"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        window.project.file_path = "/path/to/project.ppz"

        # Mock loading widget
        mock_loading_instance = Mock()
        mock_loading_widget.return_value = mock_loading_instance

        # Mock save_to_zip_async to call on_complete with error
        def mock_save_call(project, path, on_complete=None, on_progress=None):
            if on_complete:
                on_complete(False, "Disk full")
            return Mock()  # Return mock thread

        mock_save_async.side_effect = mock_save_call

        window.save_project()

        # Verify error was shown
        assert "Failed to save project" in window._error_message
        assert "Disk full" in window._error_message


class TestHealAssets:
    """Test heal_assets method"""

    @patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
    def test_heal_assets_opens_dialog(self, mock_dialog, qtbot):
        """Test opens asset heal dialog"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        mock_dialog_instance = Mock()
        mock_dialog.return_value = mock_dialog_instance

        window.heal_assets()

        # Verify dialog was created and executed
        mock_dialog.assert_called_once_with(window.project, window)
        mock_dialog_instance.exec.assert_called_once()
        assert window._update_view_called


class TestCheckMissingAssets:
    """Test _check_missing_assets method"""

    def test_check_missing_assets_absolute_path(self, qtbot):
        """Test detects absolute path as missing"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        # Create page with absolute path
        layout = PageLayout(width=210, height=297)
        layout.elements = [ImageData(image_path="/absolute/path/image.jpg", x=0, y=0, width=100, height=100)]
        page = Page(layout=layout, page_number=1)
        window.project.pages = [page]

        missing = window._check_missing_assets()

        assert len(missing) == 1
        assert "/absolute/path/image.jpg" in missing

    def test_check_missing_assets_non_assets_path(self, qtbot):
        """Test detects non-assets path as missing"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=210, height=297)
        layout.elements = [ImageData(image_path="relative/path/image.jpg", x=0, y=0, width=100, height=100)]
        page = Page(layout=layout, page_number=1)
        window.project.pages = [page]

        missing = window._check_missing_assets()

        assert len(missing) == 1
        assert "relative/path/image.jpg" in missing

    def test_check_missing_assets_not_found(self, qtbot):
        """Test detects assets that don't exist"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=210, height=297)
        layout.elements = [ImageData(image_path="assets/missing.jpg", x=0, y=0, width=100, height=100)]
        page = Page(layout=layout, page_number=1)
        window.project.pages = [page]

        missing = window._check_missing_assets()

        assert len(missing) == 1
        assert "assets/missing.jpg" in missing

    def test_check_missing_assets_found(self, qtbot):
        """Test ignores assets that exist"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=210, height=297)
        layout.elements = [ImageData(image_path="assets/exists.jpg", x=0, y=0, width=100, height=100)]
        page = Page(layout=layout, page_number=1)
        window.project.pages = [page]

        missing = window._check_missing_assets()

        assert len(missing) == 0

    def test_check_missing_assets_removes_duplicates(self, qtbot):
        """Test removes duplicate missing paths"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout1 = PageLayout(width=210, height=297)
        layout1.elements = [ImageData(image_path="/missing/image.jpg", x=0, y=0, width=100, height=100)]
        page1 = Page(layout=layout1, page_number=1)

        layout2 = PageLayout(width=210, height=297)
        layout2.elements = [ImageData(image_path="/missing/image.jpg", x=0, y=0, width=100, height=100)]
        page2 = Page(layout=layout2, page_number=2)

        window.project.pages = [page1, page2]

        missing = window._check_missing_assets()

        assert len(missing) == 1
        assert "/missing/image.jpg" in missing


class TestShowMissingAssetsWarning:
    """Test _show_missing_assets_warning method"""

    @patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
    def test_show_warning_few_assets(self, mock_heal_dialog, qtbot):
        """Test shows all assets when count is small"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        # Patch QMessageBox.exec to return Ok (not Open)
        from PyQt6.QtWidgets import QMessageBox

        with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Ok):
            missing = ["/path1.jpg", "/path2.jpg", "/path3.jpg"]
            window._show_missing_assets_warning(missing)

        # Heal dialog should not be opened for Ok
        mock_heal_dialog.assert_not_called()

    @patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
    def test_show_warning_many_assets(self, mock_heal_dialog, qtbot):
        """Test truncates list when many assets missing"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        from PyQt6.QtWidgets import QMessageBox

        with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Ok):
            missing = [f"/path{i}.jpg" for i in range(10)]
            window._show_missing_assets_warning(missing)

        # Verify it completed without error
        mock_heal_dialog.assert_not_called()

    @patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
    def test_show_warning_opens_heal_dialog(self, mock_heal_dialog, qtbot):
        """Test opens heal dialog when user clicks Open"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        from PyQt6.QtWidgets import QMessageBox

        mock_heal_instance = Mock()
        mock_heal_dialog.return_value = mock_heal_instance

        # Patch QMessageBox.exec to return Open
        with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Open):
            missing = ["/path1.jpg"]
            window._show_missing_assets_warning(missing)

        # Verify heal dialog was opened
        mock_heal_dialog.assert_called_once()
        mock_heal_instance.exec.assert_called_once()


class TestProjectSettings:
    """Test project_settings method"""

    def test_project_settings_cancelled(self, qtbot):
        """Test returns when user cancels"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        old_size = window.project.page_size_mm

        # Patch QDialog.exec to return rejected
        with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
            window.project_settings()

        # Size should not change
        assert window.project.page_size_mm == old_size

    def test_project_settings_updates_values_default(self, qtbot):
        """Test updates project settings with dialog defaults"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        # Set initial values
        window.project.page_size_mm = (210, 297)
        window.project.working_dpi = 96
        window.project.export_dpi = 300

        # Patch QDialog.exec to accept (will use current project values as defaults)
        with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
            window.project_settings()

        # Values should remain the same (since dialog uses current values as defaults)
        assert window.project.page_size_mm == (210, 297)
        assert window.project.working_dpi == 96
        assert window.project.export_dpi == 300
        assert window._update_view_called


class TestApplyPageSizeToProject:
    """Test _apply_page_size_to_project method"""

    def test_apply_page_size_skips_manual_pages(self, qtbot):
        """Test skips manually sized pages"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=210, height=297)
        layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
        page = Page(layout=layout, page_number=1)
        page.manually_sized = True
        window.project.pages = [page]

        window._apply_page_size_to_project((210, 297), (200, 200), "proportional")

        # Page size should not change
        assert page.layout.size == (210, 297)

    def test_apply_page_size_proportional_scaling(self, qtbot):
        """Test proportional scaling mode"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=100, height=100)
        layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
        page = Page(layout=layout, page_number=1)
        page.manually_sized = False
        window.project.pages = [page]

        # Double the size
        window._apply_page_size_to_project((100, 100), (200, 200), "proportional")

        # Page should be resized
        assert page.layout.size == (200, 200)
        # Elements should be scaled uniformly
        assert layout.elements[0].position == (20.0, 20.0)
        assert layout.elements[0].size == (100.0, 100.0)

    def test_apply_page_size_stretch_scaling(self, qtbot):
        """Test stretch scaling mode"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=100, height=100)
        layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
        page = Page(layout=layout, page_number=1)
        page.manually_sized = False
        window.project.pages = [page]

        # Stretch to different aspect ratio
        window._apply_page_size_to_project((100, 100), (200, 150), "stretch")

        # Page should be resized
        assert page.layout.size == (200, 150)
        # Elements should be scaled independently
        assert layout.elements[0].position == (20.0, 15.0)  # x*2, y*1.5
        assert layout.elements[0].size == (100.0, 75.0)  # w*2, h*1.5

    def test_apply_page_size_reposition_mode(self, qtbot):
        """Test reposition mode"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=100, height=100)
        layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
        page = Page(layout=layout, page_number=1)
        page.manually_sized = False
        window.project.pages = [page]

        # Increase size
        window._apply_page_size_to_project((100, 100), (150, 150), "reposition")

        # Page should be resized
        assert page.layout.size == (150, 150)
        # Elements should be offset to center
        assert layout.elements[0].position == (35.0, 35.0)  # +25, +25
        assert layout.elements[0].size == (50, 50)  # unchanged

    def test_apply_page_size_none_mode(self, qtbot):
        """Test none mode (no element changes)"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=100, height=100)
        layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
        page = Page(layout=layout, page_number=1)
        page.manually_sized = False
        window.project.pages = [page]

        window._apply_page_size_to_project((100, 100), (200, 200), "none")

        # Page should be resized
        assert page.layout.size == (200, 200)
        # Elements should not change
        assert layout.elements[0].position == (10, 10)
        assert layout.elements[0].size == (50, 50)

    def test_apply_page_size_double_spread(self, qtbot):
        """Test handles double spread pages correctly"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=200, height=100)
        page = Page(layout=layout, page_number=1)
        page.manually_sized = False
        page.is_double_spread = True
        window.project.pages = [page]

        window._apply_page_size_to_project((100, 100), (150, 150), "none")

        # Double spread should be 2x width
        assert page.layout.size == (300, 150)


class TestScalePageElements:
    """Test _scale_page_elements method"""

    def test_scale_elements_uniform(self, qtbot):
        """Test uniform scaling"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=100, height=100)
        layout.elements = [
            ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60),
            ImageData(image_path="test2.jpg", x=5, y=10, width=30, height=40),
        ]
        page = Page(layout=layout, page_number=1)

        window._scale_page_elements(page, 2.0, 2.0)

        # Check first element
        assert layout.elements[0].position == (20.0, 40.0)
        assert layout.elements[0].size == (100.0, 120.0)

        # Check second element
        assert layout.elements[1].position == (10.0, 20.0)
        assert layout.elements[1].size == (60.0, 80.0)

    def test_scale_elements_non_uniform(self, qtbot):
        """Test non-uniform scaling"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=100, height=100)
        layout.elements = [ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60)]
        page = Page(layout=layout, page_number=1)

        window._scale_page_elements(page, 2.0, 1.5)

        assert layout.elements[0].position == (20.0, 30.0)
        assert layout.elements[0].size == (100.0, 90.0)


class TestRepositionPageElements:
    """Test _reposition_page_elements method"""

    def test_reposition_elements_larger_page(self, qtbot):
        """Test repositioning on larger page"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=100, height=100)
        layout.elements = [
            ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60),
            ImageData(image_path="test2.jpg", x=5, y=10, width=30, height=40),
        ]
        page = Page(layout=layout, page_number=1)

        window._reposition_page_elements(page, (100, 100), (150, 130))

        # Offset should be (25, 15)
        assert layout.elements[0].position == (35.0, 35.0)
        assert layout.elements[1].position == (30.0, 25.0)

    def test_reposition_elements_smaller_page(self, qtbot):
        """Test repositioning on smaller page"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=100, height=100)
        layout.elements = [ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60)]
        page = Page(layout=layout, page_number=1)

        window._reposition_page_elements(page, (100, 100), (80, 90))

        # Offset should be (-10, -5)
        assert layout.elements[0].position == (0.0, 15.0)


class TestExportPdf:
    """Test export_pdf method"""

    @patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
    def test_export_pdf_no_pages(self, mock_file_dialog, qtbot):
        """Test returns early when no pages"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        window.project.pages = []

        window.export_pdf()

        # Should not show file dialog
        mock_file_dialog.assert_not_called()
        assert "No pages to export" in window._status_message

    @patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
    def test_export_pdf_user_cancels(self, mock_file_dialog, qtbot):
        """Test returns when user cancels"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=210, height=297)
        page = Page(layout=layout, page_number=1)
        window.project.pages = [page]

        mock_file_dialog.return_value = ("", "")

        window.export_pdf()

        # Should not call export
        window.gl_widget.export_pdf_async.assert_not_called()

    @patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
    def test_export_pdf_success(self, mock_file_dialog, qtbot):
        """Test successful PDF export"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=210, height=297)
        page = Page(layout=layout, page_number=1)
        window.project.pages = [page]

        window.project.export_dpi = 150
        mock_file_dialog.return_value = ("/path/to/output.pdf", "")
        window.gl_widget.export_pdf_async.return_value = True

        window.export_pdf()

        # Verify export was called with project's export_dpi, not a hardcoded value
        window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=150)
        assert "PDF export started" in window._status_message

    @patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
    def test_export_pdf_adds_extension(self, mock_file_dialog, qtbot):
        """Test adds .pdf extension if missing"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=210, height=297)
        page = Page(layout=layout, page_number=1)
        window.project.pages = [page]

        mock_file_dialog.return_value = ("/path/to/output", "")
        window.gl_widget.export_pdf_async.return_value = True

        window.export_pdf()

        # Verify .pdf was added and project's export_dpi is used
        window.gl_widget.export_pdf_async.assert_called_once_with(
            window.project, "/path/to/output.pdf", export_dpi=window.project.export_dpi
        )

    @patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
    def test_export_pdf_failed_to_start(self, mock_file_dialog, qtbot):
        """Test handles export failure"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        layout = PageLayout(width=210, height=297)
        page = Page(layout=layout, page_number=1)
        window.project.pages = [page]

        mock_file_dialog.return_value = ("/path/to/output.pdf", "")
        window.gl_widget.export_pdf_async.return_value = False

        window.export_pdf()

        assert "PDF export failed to start" in window._status_message


class TestShowAbout:
    """Test show_about method"""

    @patch("pyPhotoAlbum.mixins.operations.file_ops.format_version_info")
    @patch("pyPhotoAlbum.mixins.operations.file_ops.QDialog")
    def test_show_about_displays_dialog(self, mock_dialog_class, mock_format_version, qtbot):
        """Test shows about dialog with version info"""
        window = TestFileOpsWindow()
        qtbot.addWidget(window)

        mock_dialog = Mock()
        mock_dialog_class.return_value = mock_dialog
        mock_format_version.return_value = "Version 1.0.0\nData Format: v2"

        window.show_about()

        # Verify dialog was created and executed
        mock_dialog_class.assert_called_once()
        mock_dialog.exec.assert_called_once()
        mock_format_version.assert_called_once()
