"""Tests for validate_task_graph() — post-decompose pruning pass.

Covers:
  - Pre-completed tasks are marked/pruned
  - Dependency repair when a task is pruned
  - Folding trivial tasks into their parent
  - All tasks pruned returns empty remaining list
  - Delete mode vs mark mode behaviour
  - No pruning when no codebase matches
  - Cycle detection raises error
  - Stats dict has correct counts
"""

from __future__ import annotations

from pathlib import Path

import pytest

from loom.scanner import CodebaseSummary, FileSymbols, scan_codebase
from loom.validator import (
    PruningDecision,
    ValidationResult,
    _CycleError,
    _detect_cycles,
    _task_complexity,
    validate_task_graph,
)


# ── Helpers ──────────────────────────────────────────────────────────


def _summary(*file_specs: tuple[str, list[str], list[str]]) -> CodebaseSummary:
    """Build a CodebaseSummary from (path, functions, classes) tuples."""
    files = [
        FileSymbols(path=path, functions=funcs, classes=classes)
        for path, funcs, classes in file_specs
    ]
    return CodebaseSummary(root="/tmp/project", files=files)


SAMPLE_SUMMARY = _summary(
    ("loom/graph/store.py", ["create_task", "get_task", "update_task"], ["TaskStore"]),
    ("loom/graph/cache.py", ["sync_task", "get_cached_task"], ["CacheManager"]),
    ("loom/cli.py", ["main", "parse_args"], []),
)


def _task(
    tid: str,
    title: str,
    *,
    depends_on: list[str] | None = None,
    parent_id: str | None = None,
    context: dict | None = None,
) -> dict:
    """Build a minimal task dict."""
    return {
        "id": tid,
        "title": title,
        "depends_on": depends_on or [],
        "parent_id": parent_id,
        "context": context or {},
    }


# ── Tests: pre-completed tasks ──────────────────────────────────────


class TestPreCompletedTasksMarked:
    """Tasks matching existing code should be pruned."""

    def test_exact_file_path_match_pruned(self) -> None:
        """A task referencing an existing file path is pre-completed."""
        tasks = [
            _task("t1", "Create loom/graph/store.py module"),
            _task("t2", "Deploy infrastructure to AWS"),
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY)

        assert len(result.pruned) >= 1
        pruned_ids = {d.task_id for d in result.pruned}
        assert "t1" in pruned_ids
        assert "t2" not in pruned_ids

    def test_symbol_match_pruned(self) -> None:
        """A task creating an existing symbol is pre-completed."""
        tasks = [
            _task("t1", "Implement create_task function in store"),
            _task("t2", "Write API documentation"),
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY)

        pruned_ids = {d.task_id for d in result.pruned}
        # create_task exists and the file path matches too — should be pruned
        assert "t1" in pruned_ids

    def test_mark_mode_keeps_task_with_annotation(self) -> None:
        """In mark mode, pruned tasks remain but get context annotations."""
        tasks = [
            _task("t1", "Create loom/graph/store.py module"),
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY, mode="mark")

        # Task is still in the list
        assert len(result.tasks) == 1
        assert result.tasks[0]["context"]["pruned"] is True
        assert result.tasks[0]["context"]["pruned_reason"] == "pre_completed"


# ── Tests: dependency repair ────────────────────────────────────────


class TestDependencyRepairOnPrune:
    """When a task is pruned, dependents must have their deps updated."""

    def test_pruned_dep_removed(self) -> None:
        """A -> B -> C: if B is pruned (pre-completed), C should lose B from deps."""
        tasks = [
            _task("A", "Deploy infrastructure to cloud"),
            _task("B", "Create loom/graph/store.py module"),  # will be pruned
            _task("C", "Run integration tests", depends_on=["B"]),
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY, mode="delete")

        # B is pruned, C should have empty deps
        remaining_map = {t["id"]: t for t in result.tasks}
        assert "B" not in remaining_map
        assert "C" in remaining_map
        assert "B" not in remaining_map["C"]["depends_on"]

    def test_folded_dep_hoisted(self) -> None:
        """When a folded task is removed, its deps are hoisted to dependents."""
        # Make F a trivially small task with a parent
        tasks = [
            _task("E", "Infrastructure epic"),
            _task("D", "Set up base infrastructure"),
            _task("F", "Fix", depends_on=["D"], parent_id="E"),  # trivially small
            _task("G", "Run full test suite", depends_on=["F"]),
        ]

        result = validate_task_graph(
            tasks,
            CodebaseSummary(root="/tmp", files=[]),  # empty summary so nothing is pre-completed
            complexity_threshold=0.5,  # high threshold so F gets folded
            mode="delete",
        )

        remaining_map = {t["id"]: t for t in result.tasks}
        # F should be folded; G should now depend on D instead of F
        assert "F" not in remaining_map
        assert "G" in remaining_map
        assert "D" in remaining_map["G"]["depends_on"]
        assert "F" not in remaining_map["G"]["depends_on"]


# ── Tests: fold trivial into parent ─────────────────────────────────


class TestFoldTrivialIntoParent:
    """Small tasks with a parent get folded."""

    def test_trivial_task_folded(self) -> None:
        """A single-word title task with a parent should be folded."""
        tasks = [
            _task("epic1", "Major refactoring epic"),
            _task("t1", "Fix", parent_id="epic1"),  # tiny complexity
            _task("t2", "Implement comprehensive caching layer with fallback"),
        ]

        result = validate_task_graph(
            tasks,
            CodebaseSummary(root="/tmp", files=[]),
            complexity_threshold=0.5,
        )

        folded_ids = {d.task_id for d in result.folded}
        assert "t1" in folded_ids

    def test_task_without_parent_not_folded(self) -> None:
        """A trivially small task without a parent_id is NOT folded."""
        tasks = [
            _task("t1", "Fix"),  # tiny but no parent
        ]

        result = validate_task_graph(
            tasks,
            CodebaseSummary(root="/tmp", files=[]),
            complexity_threshold=0.5,
        )

        folded_ids = {d.task_id for d in result.folded}
        assert "t1" not in folded_ids

    def test_complex_task_not_folded(self) -> None:
        """A task with enough tokens should not be folded even with a parent."""
        tasks = [
            _task("epic1", "Major refactoring epic"),
            _task(
                "t1",
                "Implement comprehensive caching layer with Redis fallback and circuit breaker pattern",
                parent_id="epic1",
                context={"description": "This is a complex task with many requirements and sub-steps to implement properly across multiple files"},
            ),
        ]

        result = validate_task_graph(
            tasks,
            CodebaseSummary(root="/tmp", files=[]),
            complexity_threshold=0.2,
        )

        folded_ids = {d.task_id for d in result.folded}
        assert "t1" not in folded_ids


# ── Tests: all pruned ───────────────────────────────────────────────


class TestAllPrunedReturnsEmpty:
    """If all tasks match existing code, remaining list should be empty."""

    def test_all_tasks_pruned_delete_mode(self) -> None:
        tasks = [
            _task("t1", "Create loom/graph/store.py module"),
            _task("t2", "Create loom/graph/cache.py module"),
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY, mode="delete")

        remaining_non_pruned = [
            t for t in result.tasks
            if t["id"] not in {d.task_id for d in result.pruned}
        ]
        assert len(remaining_non_pruned) == 0
        assert result.stats["remaining_count"] == 0

    def test_all_tasks_pruned_mark_mode(self) -> None:
        tasks = [
            _task("t1", "Create loom/graph/store.py module"),
            _task("t2", "Create loom/graph/cache.py module"),
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY, mode="mark")

        # All tasks still present but marked
        assert len(result.tasks) == 2
        for t in result.tasks:
            assert t["context"]["pruned"] is True


# ── Tests: delete mode ──────────────────────────────────────────────


class TestModeDeleteRemovesTasks:
    """Delete mode physically removes tasks from the output list."""

    def test_pruned_task_absent_in_delete_mode(self) -> None:
        tasks = [
            _task("t1", "Create loom/graph/store.py module"),
            _task("t2", "Deploy to production"),
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY, mode="delete")

        task_ids = {t["id"] for t in result.tasks}
        assert "t1" not in task_ids
        assert "t2" in task_ids

    def test_mark_mode_preserves_all(self) -> None:
        tasks = [
            _task("t1", "Create loom/graph/store.py module"),
            _task("t2", "Deploy to production"),
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY, mode="mark")

        task_ids = {t["id"] for t in result.tasks}
        assert "t1" in task_ids
        assert "t2" in task_ids


# ── Tests: no pruning when no matches ───────────────────────────────


class TestNoPruningWhenNoMatches:
    """Tasks that don't match existing code pass through unchanged."""

    def test_unmatched_tasks_pass_through(self) -> None:
        tasks = [
            _task("t1", "Deploy infrastructure to cloud"),
            _task("t2", "Write user documentation"),
            _task("t3", "Set up monitoring dashboard"),
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY)

        assert len(result.pruned) == 0
        assert len(result.folded) == 0
        assert len(result.tasks) == 3
        assert result.stats["remaining_count"] == 3

    def test_empty_summary_no_pruning(self) -> None:
        tasks = [
            _task("t1", "Create user service"),
            _task("t2", "Implement auth flow"),
        ]

        empty = CodebaseSummary(root="/tmp", files=[])
        result = validate_task_graph(tasks, empty)

        assert len(result.pruned) == 0
        assert len(result.tasks) == 2


# ── Tests: cycle detection ──────────────────────────────────────────


class TestCycleDetection:
    """Circular dependencies should raise an error."""

    def test_simple_cycle_raises(self) -> None:
        tasks = [
            _task("A", "Task A", depends_on=["B"]),
            _task("B", "Task B", depends_on=["A"]),
        ]

        with pytest.raises(_CycleError, match="Dependency cycle"):
            validate_task_graph(tasks, SAMPLE_SUMMARY)

    def test_three_node_cycle_raises(self) -> None:
        tasks = [
            _task("A", "Task A", depends_on=["C"]),
            _task("B", "Task B", depends_on=["A"]),
            _task("C", "Task C", depends_on=["B"]),
        ]

        with pytest.raises(_CycleError, match="Dependency cycle"):
            validate_task_graph(tasks, SAMPLE_SUMMARY)

    def test_self_cycle_raises(self) -> None:
        tasks = [
            _task("A", "Task A", depends_on=["A"]),
        ]

        with pytest.raises(_CycleError, match="Dependency cycle"):
            validate_task_graph(tasks, SAMPLE_SUMMARY)

    def test_no_cycle_succeeds(self) -> None:
        tasks = [
            _task("A", "Deploy infrastructure to cloud"),
            _task("B", "Run tests", depends_on=["A"]),
            _task("C", "Merge results", depends_on=["A", "B"]),
        ]

        # Should not raise
        result = validate_task_graph(tasks, SAMPLE_SUMMARY)
        assert isinstance(result, ValidationResult)


# ── Tests: stats correctness ────────────────────────────────────────


class TestStatsCountsCorrect:
    """The stats dict should have accurate counts."""

    def test_stats_with_mixed_outcomes(self) -> None:
        tasks = [
            _task("t1", "Create loom/graph/store.py module"),     # pre-completed
            _task("t2", "Deploy to production"),                   # kept
            _task("t3", "Create loom/graph/cache.py module"),      # pre-completed
            _task("t4", "Write integration tests"),                # kept
        ]

        result = validate_task_graph(tasks, SAMPLE_SUMMARY, mode="delete")

        assert result.stats["total_input"] == 4
        assert result.stats["pre_completed_count"] >= 2
        assert result.stats["remaining_count"] == (
            4 - result.stats["pre_completed_count"] - result.stats["folded_count"]
        )

    def test_stats_empty_input(self) -> None:
        result = validate_task_graph([], SAMPLE_SUMMARY)

        assert result.stats["total_input"] == 0
        assert result.stats["pre_completed_count"] == 0
        assert result.stats["folded_count"] == 0
        assert result.stats["remaining_count"] == 0

    def test_stats_all_kept(self) -> None:
        tasks = [
            _task("t1", "Deploy infrastructure to cloud"),
            _task("t2", "Write user docs"),
        ]

        result = validate_task_graph(
            tasks,
            CodebaseSummary(root="/tmp", files=[]),
        )

        assert result.stats["total_input"] == 2
        assert result.stats["pre_completed_count"] == 0
        assert result.stats["folded_count"] == 0
        assert result.stats["remaining_count"] == 2

    def test_stats_with_folding(self) -> None:
        tasks = [
            _task("epic1", "Major epic"),
            _task("t1", "Fix", parent_id="epic1"),  # tiny, will be folded
            _task("t2", "Deploy infrastructure to cloud"),
        ]

        result = validate_task_graph(
            tasks,
            CodebaseSummary(root="/tmp", files=[]),
            complexity_threshold=0.5,
        )

        assert result.stats["folded_count"] == 1
        assert result.stats["remaining_count"] == 2  # epic1 + t2


# ── Tests: real scan_codebase integration ───────────────────────────


class TestWithRealScanCodebase:
    """Integration test using tmp_path and scan_codebase for a real summary."""

    def test_scanned_project_prunes_existing(self, tmp_path: Path) -> None:
        """Create a fake project, scan it, and verify pruning works."""
        # Create a simple Python file
        src_dir = tmp_path / "myproject"
        src_dir.mkdir()
        (src_dir / "utils.py").write_text(
            "def compute_hash(data):\n    pass\n\nclass HashManager:\n    pass\n"
        )
        (src_dir / "api.py").write_text(
            "def handle_request(req):\n    pass\n"
        )

        summary = scan_codebase(tmp_path)

        tasks = [
            _task("t1", "Implement compute_hash function"),
            _task("t2", "Build HashManager class"),
            _task("t3", "Deploy to production server"),
        ]

        result = validate_task_graph(tasks, summary)

        pruned_ids = {d.task_id for d in result.pruned}
        # At least one of the tasks referencing existing code should be pruned
        assert len(pruned_ids) >= 1
        # t3 should never be pruned
        assert "t3" not in pruned_ids


# ── Tests: complexity scoring ───────────────────────────────────────


class TestTaskComplexity:
    """The _task_complexity helper should produce reasonable scores."""

    def test_tiny_task(self) -> None:
        t = _task("t1", "Fix")
        score = _task_complexity(t)
        assert score < 0.2

    def test_normal_task(self) -> None:
        t = _task(
            "t1",
            "Implement comprehensive caching layer with Redis fallback",
            context={"description": "Build a caching module that handles failover"},
        )
        score = _task_complexity(t)
        assert score > 0.2

    def test_empty_title(self) -> None:
        t = _task("t1", "")
        score = _task_complexity(t)
        assert score == 0.0

    def test_capped_at_one(self) -> None:
        long_title = " ".join(f"word{i}" for i in range(200))
        t = _task("t1", long_title)
        score = _task_complexity(t)
        assert score <= 1.0


# ── Tests: input not mutated ────────────────────────────────────────


class TestInputNotMutated:
    """validate_task_graph should not mutate the input list."""

    def test_original_tasks_unchanged(self) -> None:
        tasks = [
            _task("t1", "Create loom/graph/store.py module"),
            _task("t2", "Deploy to production", depends_on=["t1"]),
        ]
        import copy
        original = copy.deepcopy(tasks)

        validate_task_graph(tasks, SAMPLE_SUMMARY, mode="delete")

        assert tasks == original
