"""Tests for loom.runner.clean module."""

from pathlib import Path
from unittest.mock import patch

import pytest

from loom.runner import PipelineConfig
from loom.runner.clean import (
    THUMBNAIL_DIR_NAME,
    CleanResult,
    clean_pipeline_data,
    get_cleanable_paths,
)


@pytest.fixture
def sample_pipeline_dir(tmp_path: Path) -> Path:
    """Create a sample pipeline directory with data files.

    This pipeline has a step that produces 'output' from 'input',
    so 'input' is source data and 'output' is generated data.
    """
    # Create pipeline config
    config_file = tmp_path / "pipeline.yml"
    config_file.write_text("""
data:
  input:
    type: txt
    path: data/input.txt
  output:
    type: csv
    path: data/output.csv
  missing:
    type: txt
    path: data/missing.txt

parameters:
  batch_size: 32

pipeline:
  - name: process
    task: tasks/process.py
    inputs:
      data: $input
    outputs:
      result: $output
""")

    # Create data directory and files
    data_dir = tmp_path / "data"
    data_dir.mkdir()
    (data_dir / "input.txt").write_text("input data")
    (data_dir / "output.csv").write_text("col1,col2\n1,2")
    # Note: missing.txt is intentionally not created

    return tmp_path


@pytest.fixture
def config(sample_pipeline_dir: Path) -> PipelineConfig:
    """Load pipeline config from sample directory."""
    return PipelineConfig.from_yaml(sample_pipeline_dir / "pipeline.yml")


class TestGetCleanablePaths:
    """Tests for get_cleanable_paths function."""

    def test_excludes_source_data_by_default(self, config: PipelineConfig) -> None:
        """Should exclude source data by default to protect original inputs."""
        paths = get_cleanable_paths(config)

        # Get just the names (first element of each tuple)
        names = {name for name, _, _ in paths}

        # 'input' is source data (not produced by any step), should be excluded
        assert "input" not in names
        # 'missing' is also source data
        assert "missing" not in names
        # 'output' is generated by the 'process' step, should be included
        assert "output" in names

    def test_includes_source_data_when_requested(self, config: PipelineConfig) -> None:
        """Should include source data when include_source=True."""
        paths = get_cleanable_paths(config, include_source=True)

        # Get just the names (first element of each tuple)
        names = {name for name, _, _ in paths}

        # Should include all data nodes when include_source=True
        assert "input" in names
        assert "output" in names
        assert "missing" in names

    def test_includes_thumbnails_by_default(self, config: PipelineConfig) -> None:
        """Should include .loom-thumbnails directory by default."""
        paths = get_cleanable_paths(config)

        names = {name for name, _, _ in paths}
        assert THUMBNAIL_DIR_NAME in names

    def test_excludes_thumbnails_when_disabled(self, config: PipelineConfig) -> None:
        """Should exclude .loom-thumbnails when include_thumbnails=False."""
        paths = get_cleanable_paths(config, include_thumbnails=False)

        names = {name for name, _, _ in paths}
        assert THUMBNAIL_DIR_NAME not in names

    def test_reports_existence_correctly(
        self, config: PipelineConfig, sample_pipeline_dir: Path
    ) -> None:
        """Should correctly report which paths exist."""
        # Use include_source=True to check all paths
        paths = get_cleanable_paths(config, include_source=True)

        # Build a map of name -> exists
        exists_map = {name: exists for name, _, exists in paths}

        # input.txt and output.csv exist
        assert exists_map["input"] is True
        assert exists_map["output"] is True

        # missing.txt does not exist
        assert exists_map["missing"] is False

        # .loom-thumbnails doesn't exist yet
        assert exists_map[THUMBNAIL_DIR_NAME] is False

    def test_includes_thumbnails_when_exists(
        self, config: PipelineConfig, sample_pipeline_dir: Path
    ) -> None:
        """Should report thumbnails as existing when the directory exists."""
        # Create the thumbnails directory
        thumb_dir = sample_pipeline_dir / THUMBNAIL_DIR_NAME
        thumb_dir.mkdir()

        paths = get_cleanable_paths(config)
        exists_map = {name: exists for name, _, exists in paths}

        assert exists_map[THUMBNAIL_DIR_NAME] is True


class TestCleanPipelineData:
    """Tests for clean_pipeline_data function."""

    def test_protects_source_data_by_default(
        self, config: PipelineConfig, sample_pipeline_dir: Path
    ) -> None:
        """Should protect source data (not produced by any step) by default."""
        input_path = sample_pipeline_dir / "data" / "input.txt"
        output_path = sample_pipeline_dir / "data" / "output.csv"

        assert input_path.exists()
        assert output_path.exists()

        # Clean with default settings (include_source=False)
        results = clean_pipeline_data(config, permanent=True)

        # Source data (input) should still exist
        assert input_path.exists()
        # Generated data (output) should be deleted
        assert not output_path.exists()

        # Results should only show one deleted file (output)
        deleted_results = [r for r in results if r.action == "deleted"]
        assert len(deleted_results) == 1

    def test_deletes_source_data_when_include_source_true(
        self, config: PipelineConfig, sample_pipeline_dir: Path
    ) -> None:
        """Should delete source data when include_source=True."""
        input_path = sample_pipeline_dir / "data" / "input.txt"
        output_path = sample_pipeline_dir / "data" / "output.csv"

        assert input_path.exists()
        assert output_path.exists()

        results = clean_pipeline_data(config, permanent=True, include_source=True)

        # Both files should be deleted
        assert not input_path.exists()
        assert not output_path.exists()

        # Results should reflect successful deletion of both
        deleted_results = [r for r in results if r.action == "deleted"]
        assert len(deleted_results) == 2

    def test_trash_mode_uses_send2trash(
        self, config: PipelineConfig, sample_pipeline_dir: Path
    ) -> None:
        """Should use send2trash when permanent=False."""
        with patch("loom.runner.clean.send2trash") as mock_trash:
            clean_pipeline_data(config, permanent=False)

        # send2trash should have been called only for generated data (output.csv)
        assert mock_trash.call_count == 1

    def test_trash_mode_with_include_source(
        self, config: PipelineConfig, sample_pipeline_dir: Path
    ) -> None:
        """Should trash all files when include_source=True."""
        with patch("loom.runner.clean.send2trash") as mock_trash:
            clean_pipeline_data(config, permanent=False, include_source=True)

        # send2trash should have been called for both existing files
        assert mock_trash.call_count == 2

    def test_skips_nonexistent_files(self, config: PipelineConfig) -> None:
        """Should skip files that don't exist."""
        results = clean_pipeline_data(config, permanent=True)

        # Find skipped results (.loom-thumbnails doesn't exist)
        skipped = [r for r in results if r.action == "skipped"]

        # Should have skipped at least the .loom-thumbnails
        assert len(skipped) >= 1

        # All skipped should be successful
        for result in skipped:
            assert result.success is True

    def test_reports_errors_on_failure(
        self, config: PipelineConfig, sample_pipeline_dir: Path
    ) -> None:
        """Should report errors when deletion fails."""
        with patch("loom.runner.clean.send2trash") as mock_trash:
            mock_trash.side_effect = PermissionError("Access denied")
            results = clean_pipeline_data(config, permanent=False)

        # Find failed results
        failed = [r for r in results if not r.success]

        # Should have failed for the generated file (output.csv)
        assert len(failed) == 1
        for result in failed:
            assert result.error is not None
            assert "Access denied" in result.error

    def test_cleans_thumbnail_directory(
        self, config: PipelineConfig, sample_pipeline_dir: Path
    ) -> None:
        """Should clean the .loom-thumbnails directory."""
        # Create thumbnails directory with content
        thumb_dir = sample_pipeline_dir / THUMBNAIL_DIR_NAME
        thumb_dir.mkdir()
        (thumb_dir / "thumb1.png").write_bytes(b"fake png")
        (thumb_dir / "thumb2.png").write_bytes(b"fake png 2")

        assert thumb_dir.exists()

        results = clean_pipeline_data(config, permanent=True)

        # Thumbnails directory should be deleted
        assert not thumb_dir.exists()

        # Find the thumbnail result
        thumb_result = next((r for r in results if THUMBNAIL_DIR_NAME in str(r.path)), None)
        assert thumb_result is not None
        assert thumb_result.success is True
        assert thumb_result.action == "deleted"

    def test_excludes_thumbnails_when_disabled(
        self, config: PipelineConfig, sample_pipeline_dir: Path
    ) -> None:
        """Should not clean thumbnails when include_thumbnails=False."""
        # Create thumbnails directory
        thumb_dir = sample_pipeline_dir / THUMBNAIL_DIR_NAME
        thumb_dir.mkdir()
        (thumb_dir / "thumb.png").write_bytes(b"fake png")

        results = clean_pipeline_data(config, permanent=True, include_thumbnails=False)

        # Thumbnails directory should still exist
        assert thumb_dir.exists()

        # No result should reference the thumbnails directory
        thumb_results = [r for r in results if THUMBNAIL_DIR_NAME in str(r.path)]
        assert len(thumb_results) == 0


class TestCleanResult:
    """Tests for CleanResult dataclass."""

    def test_default_action_is_skipped(self) -> None:
        """Default action should be 'skipped'."""
        result = CleanResult(path=Path("/test"), success=True)
        assert result.action == "skipped"

    def test_error_is_none_by_default(self) -> None:
        """Error should be None by default."""
        result = CleanResult(path=Path("/test"), success=True)
        assert result.error is None

    def test_can_create_with_error(self) -> None:
        """Should be able to create a result with an error."""
        result = CleanResult(
            path=Path("/test"),
            success=False,
            error="Something went wrong",
        )
        assert result.success is False
        assert result.error == "Something went wrong"


class TestIsSourceData:
    """Tests for PipelineConfig.is_source_data() method."""

    def test_source_data_not_produced_by_step(self, config: PipelineConfig) -> None:
        """Should return True for data not produced by any step."""
        # 'input' is only used as input to 'process' step, not as output
        assert config.is_source_data("input") is True
        # 'missing' is also not produced by any step
        assert config.is_source_data("missing") is True

    def test_generated_data_produced_by_step(self, config: PipelineConfig) -> None:
        """Should return False for data produced by a step."""
        # 'output' is produced by the 'process' step
        assert config.is_source_data("output") is False

    def test_unknown_data_considered_source(self, config: PipelineConfig) -> None:
        """Unknown data names should be considered source (not in _output_producers)."""
        # Non-existent data nodes are not in _output_producers
        assert config.is_source_data("nonexistent") is True
