"""Unit tests for filter operations."""

from __future__ import annotations

import zipfile
from pathlib import Path

from PIL import Image

from cascade_fm.executor import OperationExecutor
from cascade_fm.operations.base import CommitIntent, OperationStatus
from cascade_fm.operations.create_archive import CreateArchive
from cascade_fm.operations.filter import FilterExtension, FilterName
from cascade_fm.operations.image_transform import ImageTransform
from cascade_fm.operations.images import (
    CompressImage,
    ConvertImage,
    ResizeImage,
    RotateImage,
    StripExif,
)
from cascade_fm.operations.rename import RenamePattern
from cascade_fm.operations.save_to import SaveTo
from cascade_fm.operations.unarchive import Unarchive


class TestFilterExtension:
    """Tests for FilterExtension operation."""

    def test_name_and_label(self):
        """Test operation metadata."""
        op = FilterExtension()
        assert op.name == "filter_extension"
        assert op.label == "Filter by Extension"
        assert op.description
        assert op.produces_committable_output is False
        assert op.commit_intent is CommitIntent.NONE

    def test_filter_by_single_extension(self, tmp_path: Path):
        """Test filtering with single extension."""
        # Create test files
        (tmp_path / "file1.jpg").touch()
        (tmp_path / "file2.png").touch()
        (tmp_path / "file3.txt").touch()

        files = list(tmp_path.iterdir())

        op = FilterExtension()
        op.set_params({"extensions": [".jpg"]})
        op.set_input_files(files)

        assert op.status == OperationStatus.DONE
        assert len(op.output_files) == 1
        assert op.output_files[0].name == "file1.jpg"

    def test_filter_by_multiple_extensions(self, tmp_path: Path):
        """Test filtering with multiple extensions."""
        # Create test files
        (tmp_path / "file1.jpg").touch()
        (tmp_path / "file2.png").touch()
        (tmp_path / "file3.txt").touch()
        (tmp_path / "file4.jpeg").touch()

        files = list(tmp_path.iterdir())

        op = FilterExtension()
        op.set_params({"extensions": [".jpg", ".png"]})
        op.set_input_files(files)

        assert op.status == OperationStatus.DONE
        assert len(op.output_files) == 2
        file_names = {f.name for f in op.output_files}
        assert file_names == {"file1.jpg", "file2.png"}

    def test_extension_normalization(self, tmp_path: Path):
        """Test that extensions are normalized (with/without dot)."""
        (tmp_path / "file1.jpg").touch()
        (tmp_path / "file2.txt").touch()

        files = list(tmp_path.iterdir())

        op = FilterExtension()

        # Test with dot
        op.set_params({"extensions": [".jpg"]})
        op.set_input_files(files)
        assert op.status == OperationStatus.DONE
        assert len(op.output_files) == 1

        # Test without dot
        op.set_params({"extensions": ["jpg"]})
        op.set_input_files(files)
        assert op.status == OperationStatus.DONE
        assert len(op.output_files) == 1

    def test_case_insensitive(self, tmp_path: Path):
        """Test that extension matching is case-insensitive."""
        (tmp_path / "file1.JPG").touch()
        (tmp_path / "file2.Jpg").touch()
        (tmp_path / "file3.txt").touch()

        files = list(tmp_path.iterdir())

        op = FilterExtension()
        op.set_params({"extensions": [".jpg"]})
        op.set_input_files(files)

        assert op.status == OperationStatus.DONE
        assert len(op.output_files) == 2

    def test_no_extensions_parameter(self, tmp_path: Path):
        """Test pass-through when extensions parameter is empty."""
        (tmp_path / "file1.jpg").touch()
        files = list(tmp_path.iterdir())

        op = FilterExtension()
        op.set_params({"extensions": []})
        op.set_input_files(files)

        assert op.status == OperationStatus.DONE
        assert op.output_files == files

    def test_validate_params_valid(self):
        """Test parameter validation with valid params."""
        op = FilterExtension()
        valid, error = op.validate_params(extensions=[".jpg", ".png"])
        assert valid
        assert error is None

    def test_validate_params_missing(self):
        """Test parameter validation with missing extensions (pass-through mode)."""
        op = FilterExtension()
        valid, error = op.validate_params()
        assert valid
        assert error is None

    def test_validate_params_empty(self):
        """Test parameter validation with empty list (pass-through mode)."""
        op = FilterExtension()
        valid, error = op.validate_params(extensions=[])
        assert valid
        assert error is None


class TestFilterName:
    """Tests for FilterName operation."""

    def test_name_and_label(self):
        """Test operation metadata."""
        op = FilterName()
        assert op.name == "filter_name"
        assert op.label == "Filter by Name"
        assert op.description
        assert op.produces_committable_output is False
        assert op.commit_intent is CommitIntent.NONE

    def test_filter_by_exact_name(self, tmp_path: Path):
        """Test filtering with exact name match."""
        (tmp_path / "test.txt").touch()
        (tmp_path / "other.txt").touch()

        files = list(tmp_path.iterdir())

        op = FilterName()
        op.set_params({"pattern": "test.txt"})
        op.set_input_files(files)

        assert op.status == OperationStatus.DONE
        assert len(op.output_files) == 1
        assert op.output_files[0].name == "test.txt"

    def test_filter_by_wildcard(self, tmp_path: Path):
        """Test filtering with wildcard pattern."""
        (tmp_path / "test1.txt").touch()
        (tmp_path / "test2.txt").touch()
        (tmp_path / "other.txt").touch()

        files = list(tmp_path.iterdir())

        op = FilterName()
        op.set_params({"pattern": "test*.txt"})
        op.set_input_files(files)

        assert op.status == OperationStatus.DONE
        assert len(op.output_files) == 2
        file_names = {f.name for f in op.output_files}
        assert file_names == {"test1.txt", "test2.txt"}

    def test_no_pattern_parameter(self, tmp_path: Path):
        """Test pass-through when pattern parameter is empty."""
        (tmp_path / "file.txt").touch()
        files = list(tmp_path.iterdir())

        op = FilterName()
        op.set_params({"pattern": ""})
        op.set_input_files(files)

        assert op.status == OperationStatus.DONE
        assert op.output_files == files

    def test_validate_params_valid(self):
        """Test parameter validation with valid params."""
        op = FilterName()
        valid, error = op.validate_params(pattern="*.txt")
        assert valid
        assert error is None

    def test_validate_params_missing(self):
        """Test parameter validation with missing pattern (pass-through mode)."""
        op = FilterName()
        valid, error = op.validate_params()
        assert valid
        assert error is None


class TestRenamePattern:
    """Tests for RenamePattern operation."""

    def test_name_and_label(self):
        """Test operation metadata."""
        op = RenamePattern()
        assert op.name == "rename_pattern"
        assert op.label == "Rename"
        assert op.description
        assert op.produces_committable_output is True
        assert op.commit_intent is CommitIntent.PLANNED_OUTPUT_NAMES

    def test_rename_pattern_plans_new_paths(self, tmp_path: Path):
        """Test rename operation creates virtual planned output paths."""
        file1 = tmp_path / "alpha.jpg"
        file2 = tmp_path / "beta.png"
        file1.touch()
        file2.touch()

        op = RenamePattern()
        op.set_params({"pattern": "{index}_{name}"})
        op.set_input_files([file1, file2])

        assert op.status == OperationStatus.DONE
        assert [path.name for path in op.output_files] == ["1_alpha.jpg", "2_beta.png"]
        assert all(not path.exists() for path in op.output_files)

    def test_rename_pattern_validate_invalid_template(self):
        """Test invalid rename template validation."""
        op = RenamePattern()
        valid, error = op.validate_params(pattern="{missing}")

        assert not valid
        assert error is not None

    def test_rename_pattern_executes_on_each_run(self, tmp_path: Path):
        """Rename pattern stays virtual and executes on each run (no artifact cache)."""

        class CountingRenameOperation(RenamePattern):
            execute_impl_calls = 0

            def _execute_impl(self, files: list[Path], **params):
                CountingRenameOperation.execute_impl_calls += 1
                return super()._execute_impl(files, **params)

        file1 = tmp_path / "alpha.jpg"
        file1.touch()

        operation = CountingRenameOperation()
        executor = OperationExecutor()
        params = {"pattern": "new_{name}"}

        first = executor.execute(operation, [file1], params)
        assert first.success
        assert CountingRenameOperation.execute_impl_calls == 1

        second = executor.execute(operation, [file1], params)
        assert second.success
        assert CountingRenameOperation.execute_impl_calls == 3
        assert [path.name for path in second.files] == ["new_alpha.jpg"]
        assert all(not path.exists() for path in second.files)


class TestSaveTo:
    """Tests for SaveTo operation."""

    def test_save_to_copies_files_to_target(self, tmp_path: Path):
        """SaveTo should copy input files into selected target folder."""
        source_dir = tmp_path / "source"
        source_dir.mkdir()
        target_dir = tmp_path / "target"
        target_dir.mkdir()

        source_file = source_dir / "doc.txt"
        source_file.write_text("hello", encoding="utf-8")

        operation = SaveTo()
        operation.set_params({"target_dir": str(target_dir)})
        operation.set_input_files([source_file])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        saved = operation.output_files[0]
        assert saved.exists()
        assert saved.parent == target_dir
        assert saved.read_text(encoding="utf-8") == "hello"

    def test_save_to_validate_requires_target_dir(self):
        """SaveTo should reject missing/invalid target_dir parameter."""
        operation = SaveTo()
        valid, error = operation.validate_params()

        assert not valid
        assert error is not None

    def test_save_to_without_overwrite_auto_renames_conflicts(self, tmp_path: Path):
        """SaveTo with skip policy should keep existing files and skip conflicting input."""
        source_dir = tmp_path / "source"
        source_dir.mkdir()
        target_dir = tmp_path / "target"
        target_dir.mkdir()

        source_file = source_dir / "doc.txt"
        source_file.write_text("new", encoding="utf-8")
        existing = target_dir / "doc.txt"
        existing.write_text("old", encoding="utf-8")

        operation = SaveTo()
        operation.set_params({"target_dir": str(target_dir), "conflict_policy": "skip"})
        operation.set_input_files([source_file])

        assert operation.status == OperationStatus.DONE
        assert existing.read_text(encoding="utf-8") == "old"
        assert operation.output_files == []

    def test_save_to_with_overwrite_replaces_existing(self, tmp_path: Path):
        """SaveTo should replace existing destination when overwrite policy is set."""
        source_dir = tmp_path / "source"
        source_dir.mkdir()
        target_dir = tmp_path / "target"
        target_dir.mkdir()

        source_file = source_dir / "doc.txt"
        source_file.write_text("new", encoding="utf-8")
        existing = target_dir / "doc.txt"
        existing.write_text("old", encoding="utf-8")

        operation = SaveTo()
        operation.set_params({"target_dir": str(target_dir), "conflict_policy": "overwrite"})
        operation.set_input_files([source_file])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        assert operation.output_files[0].name == "doc.txt"
        assert existing.read_text(encoding="utf-8") == "new"

    def test_save_to_with_fail_raises_error_on_conflict(self, tmp_path: Path):
        """SaveTo should fail when conflict policy is fail and destination exists."""
        source_dir = tmp_path / "source"
        source_dir.mkdir()
        target_dir = tmp_path / "target"
        target_dir.mkdir()

        source_file = source_dir / "doc.txt"
        source_file.write_text("new", encoding="utf-8")
        existing = target_dir / "doc.txt"
        existing.write_text("old", encoding="utf-8")

        operation = SaveTo()
        operation.set_params({"target_dir": str(target_dir), "conflict_policy": "fail"})
        operation.set_input_files([source_file])

        assert operation.status == OperationStatus.ERROR
        assert operation.error is not None
        assert "Destination exists" in operation.error
        assert existing.read_text(encoding="utf-8") == "old"

    def test_save_to_materializes_virtual_rename_plan(self, tmp_path: Path):
        """SaveTo should apply planned output names when rename output is virtual."""
        source_dir = tmp_path / "source"
        source_dir.mkdir()
        target_dir = tmp_path / "target"
        target_dir.mkdir()

        source_file = source_dir / "photo.jpg"
        source_file.write_text("data", encoding="utf-8")

        operation = SaveTo()
        operation.set_params(
            {
                "target_dir": str(target_dir),
                "conflict_policy": "skip",
                "planned_source_files": [str(source_file)],
                "planned_output_names": ["renamed.jpg"],
            }
        )
        operation.set_input_files([source_file.parent / "renamed.jpg"])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        saved = operation.output_files[0]
        assert saved.name == "renamed.jpg"
        assert saved.exists()

    def test_save_to_reports_progress(self, tmp_path: Path):
        """SaveTo should expose completed progress after execution."""
        source_dir = tmp_path / "source"
        source_dir.mkdir()
        target_dir = tmp_path / "target"
        target_dir.mkdir()

        source_file = source_dir / "doc.txt"
        source_file.write_text("hello", encoding="utf-8")

        operation = SaveTo()
        operation.set_params({"target_dir": str(target_dir)})
        operation.set_input_files([source_file])

        assert operation.status == OperationStatus.DONE
        assert operation.progress_current == 1
        assert operation.progress_total == 1
        assert operation.progress_message == "Save complete"


def test_filter_name_supports_directories(tmp_path: Path):
    """FilterName should match directories as well as files."""
    photos_dir = tmp_path / "photos_raw"
    photos_dir.mkdir()
    other_dir = tmp_path / "docs"
    other_dir.mkdir()

    operation = FilterName()
    operation.set_params({"pattern": "photos*"})
    operation.set_input_files([photos_dir, other_dir])

    assert operation.status == OperationStatus.DONE
    assert operation.output_files == [photos_dir]


def test_filter_extension_supports_directories_with_suffix(tmp_path: Path):
    """FilterExtension should include directories whose names match the extension filter."""
    jpg_dir = tmp_path / "album.jpg"
    jpg_dir.mkdir()
    text_dir = tmp_path / "notes.txt"
    text_dir.mkdir()

    operation = FilterExtension()
    operation.set_params({"extensions": [".jpg"]})
    operation.set_input_files([jpg_dir, text_dir])

    assert operation.status == OperationStatus.DONE
    assert operation.output_files == [jpg_dir]


class TestImageOperations:
    """Tests for image transform operations."""

    def _create_image(self, path: Path, size: tuple[int, int] = (64, 32)) -> None:
        image = Image.new("RGB", size=size, color="red")
        image.save(path)

    def test_resize_image_basic(self, tmp_path: Path):
        """Resize operation should create resized output image."""
        source = tmp_path / "photo.jpg"
        self._create_image(source, size=(100, 50))

        operation = ResizeImage()
        assert operation.produces_committable_output is True
        assert operation.commit_intent is CommitIntent.MATERIALIZED_OUTPUTS
        operation.set_params({"width": 50})
        operation.set_input_files([source])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1

        output = operation.output_files[0]
        assert output.exists()
        with Image.open(output) as image:
            assert image.width == 50
            assert image.height == 25

    def test_convert_image_basic(self, tmp_path: Path):
        """Convert operation should write output in target format."""
        source = tmp_path / "photo.jpg"
        self._create_image(source)

        operation = ConvertImage()
        operation.set_params({"format": "PNG"})
        operation.set_input_files([source])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        assert operation.output_files[0].suffix.lower() == ".png"
        assert operation.output_files[0].exists()

    def test_rotate_image_basic(self, tmp_path: Path):
        """Rotate operation should swap width/height for 90 degree rotation."""
        source = tmp_path / "photo.jpg"
        self._create_image(source, size=(120, 60))

        operation = RotateImage()
        operation.set_params({"angle": 90})
        operation.set_input_files([source])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        with Image.open(operation.output_files[0]) as image:
            assert image.width == 60
            assert image.height == 120

    def test_strip_exif_basic(self, tmp_path: Path):
        """Strip EXIF operation should produce an output file."""
        source = tmp_path / "photo.jpg"
        self._create_image(source, size=(64, 64))

        operation = StripExif()
        operation.set_params({})
        operation.set_input_files([source])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        assert operation.output_files[0].exists()

    def test_compress_image_basic(self, tmp_path: Path):
        """Compress operation should produce an output file."""
        source = tmp_path / "photo.jpg"
        self._create_image(source, size=(128, 128))

        operation = CompressImage()
        operation.set_params({"quality": 70})
        operation.set_input_files([source])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        assert operation.output_files[0].exists()

    def test_rotate_image_validate_invalid_angle(self):
        """Rotate validation should reject unsupported angles."""
        operation = RotateImage()
        valid, error = operation.validate_params(angle=45)

        assert not valid
        assert error is not None

    def test_compress_image_validate_invalid_quality(self):
        """Compress validation should reject quality outside 1..100."""
        operation = CompressImage()
        valid, error = operation.validate_params(quality=0)

        assert not valid
        assert error is not None

    def test_resize_image_incremental_cache(self, tmp_path: Path):
        """Incremental cache should only execute for newly added files."""

        class CountingResizeOperation(ResizeImage):
            execute_impl_calls = 0

            def _execute_impl(self, files: list[Path], **params):
                CountingResizeOperation.execute_impl_calls += 1
                return super()._execute_impl(files, **params)

        file1 = tmp_path / "a.jpg"
        file2 = tmp_path / "b.jpg"
        file3 = tmp_path / "c.jpg"
        self._create_image(file1)
        self._create_image(file2)
        self._create_image(file3)

        executor = OperationExecutor()
        operation = CountingResizeOperation()
        params = {"width": 40}

        first = executor.execute(operation, [file1, file2], params)
        assert first.success
        assert len(first.files) == 2
        assert CountingResizeOperation.execute_impl_calls == 2

        second = executor.execute(operation, [file1, file2], params)
        assert second.success
        assert len(second.files) == 2
        assert CountingResizeOperation.execute_impl_calls == 2

        third = executor.execute(operation, [file1, file2, file3], params)
        assert third.success
        assert len(third.files) == 3
        assert CountingResizeOperation.execute_impl_calls == 3


class TestImageTransform:
    """Tests for unified image_transform operation."""

    def _create_image(self, path: Path, size: tuple[int, int] = (64, 32)) -> None:
        image = Image.new("RGB", size=size, color="blue")
        image.save(path)

    def test_resize_action(self, tmp_path: Path):
        source = tmp_path / "photo.jpg"
        self._create_image(source, size=(120, 60))

        operation = ImageTransform()
        operation.set_params({"action": "resize", "width": 60})
        operation.set_input_files([source])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        assert operation.output_files[0].name == "photo.jpg"
        with Image.open(operation.output_files[0]) as image:
            assert image.width == 60

    def test_strip_exif_action(self, tmp_path: Path):
        source = tmp_path / "photo.jpg"
        self._create_image(source)

        operation = ImageTransform()
        operation.set_params({"action": "strip_exif"})
        operation.set_input_files([source])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        assert operation.output_files[0].exists()

    def test_validate_rejects_unknown_action(self):
        operation = ImageTransform()
        valid, error = operation.validate_params(action="unknown")

        assert not valid
        assert error is not None

    def test_reports_progress(self, tmp_path: Path):
        """Image transform should expose completed progress after execution."""
        source = tmp_path / "photo.jpg"
        self._create_image(source)

        operation = ImageTransform()
        operation.set_params({"action": "strip_exif"})
        operation.set_input_files([source])

        assert operation.status == OperationStatus.DONE
        assert operation.progress_current == 1
        assert operation.progress_total == 1
        assert operation.progress_message == "Image transform complete"


class TestUnarchive:
    """Tests for unarchive operation."""

    def test_unarchive_extracts_zip_entries(self, tmp_path: Path):
        """Unarchive should extract zip content and expose extracted paths."""
        archive = tmp_path / "bundle.zip"
        with zipfile.ZipFile(archive, "w") as zf:
            zf.writestr("a.txt", "hello")
            zf.writestr("nested/b.txt", "world")

        operation = Unarchive()
        operation.set_params({})
        operation.set_input_files([archive])

        assert operation.status == OperationStatus.DONE
        names = {path.name for path in operation.output_files}
        assert "a.txt" in names
        assert "nested" in names

    def test_unarchive_flattens_single_root_folder_by_default(self, tmp_path: Path):
        """Unarchive should flatten outputs when archive contains one top-level root folder."""
        archive = tmp_path / "single_root.zip"
        with zipfile.ZipFile(archive, "w") as zf:
            zf.writestr("root/a.txt", "hello")
            zf.writestr("root/b.txt", "world")

        operation = Unarchive()
        operation.set_params({})
        operation.set_input_files([archive])

        assert operation.status == OperationStatus.DONE
        names = {path.name for path in operation.output_files}
        assert names == {"a.txt", "b.txt"}

    def test_unarchive_can_keep_single_root_folder(self, tmp_path: Path):
        """Unarchive should keep top-level root folder when flatten is disabled."""
        archive = tmp_path / "single_root_keep.zip"
        with zipfile.ZipFile(archive, "w") as zf:
            zf.writestr("root/a.txt", "hello")

        operation = Unarchive()
        operation.set_params({"flatten_single_root": False})
        operation.set_input_files([archive])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        assert operation.output_files[0].name == "root"
        assert operation.output_files[0].is_dir()

    def test_unarchive_reports_progress(self, tmp_path: Path):
        """Unarchive should expose completed progress after execution."""
        archive = tmp_path / "bundle.zip"
        with zipfile.ZipFile(archive, "w") as zf:
            zf.writestr("a.txt", "hello")

        operation = Unarchive()
        operation.set_params({})
        operation.set_input_files([archive])

        assert operation.status == OperationStatus.DONE
        assert operation.progress_current == 1
        assert operation.progress_total == 1
        assert operation.progress_message == "Unarchive complete"


class TestCreateArchive:
    """Tests for create_archive operation."""

    def test_create_archive_creates_zip_with_files_and_dirs(self, tmp_path: Path):
        """Create archive should bundle selected files and directories."""
        file_path = tmp_path / "a.txt"
        file_path.write_text("hello", encoding="utf-8")

        folder = tmp_path / "docs"
        folder.mkdir()
        (folder / "b.txt").write_text("world", encoding="utf-8")

        operation = CreateArchive()
        operation.set_params({"archive_name": "bundle", "format": "zip"})
        operation.set_input_files([file_path, folder])

        assert operation.status == OperationStatus.DONE
        assert len(operation.output_files) == 1
        archive = operation.output_files[0]
        assert archive.exists()
        assert archive.suffix == ".zip"

        with zipfile.ZipFile(archive, "r") as zf:
            names = set(zf.namelist())
        assert "a.txt" in names
        assert any(name.startswith("docs/") for name in names)

    def test_create_archive_validate_rejects_unknown_format(self):
        """Create archive should validate unsupported formats."""
        operation = CreateArchive()
        valid, error = operation.validate_params(archive_name="x", format="rar")

        assert not valid
        assert error is not None
