"""Tests for loom.pathcheck — path extraction, fuzzy matching, check_paths.

Phase 7, Stream F: Comprehensive path verification tests.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from loom.graph.task import Task, TaskStatus, Priority
from loom.ids import task_id as gen_task_id
from loom.pathcheck import (
    PathCheckReport,
    PathCheckResult,
    PathCorrection,
    check_paths,
    extract_paths,
    find_best_match,
)


def _task(
    title: str = "Test task",
    context: dict | None = None,
    done_when: str | None = None,
) -> Task:
    """Helper to build a minimal Task for testing."""
    return Task(
        id=gen_task_id(),
        project_id="00000000-0000-0000-0000-000000000001",
        title=title,
        status=TaskStatus.PENDING,
        priority=Priority.P1,
        context=context or {},
        done_when=done_when,
    )


# ── extract_paths ─────────────────────────────────────────────────────


class TestExtractPaths:
    def test_paths_in_title(self):
        task = _task(title="Implement loom/graph/store.py changes")
        paths = extract_paths(task)
        assert ("loom/graph/store.py", "title") in paths

    def test_paths_in_done_when(self):
        task = _task(done_when="File loom/mcp/tools.py has the new function")
        paths = extract_paths(task)
        assert ("loom/mcp/tools.py", "done_when") in paths

    def test_paths_in_context_description(self):
        task = _task(context={"description": "Edit loom/config.py to add the new field"})
        paths = extract_paths(task)
        assert ("loom/config.py", "context.description") in paths

    def test_paths_in_context_notes(self):
        task = _task(context={"notes": "See loom/bus/channels.py for patterns"})
        paths = extract_paths(task)
        assert ("loom/bus/channels.py", "context.notes") in paths

    def test_context_files_list(self):
        task = _task(context={"files": ["loom/scanner.py", "loom/validator.py"]})
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert "loom/scanner.py" in path_strs
        assert "loom/validator.py" in path_strs

    def test_urls_filtered_out(self):
        task = _task(title="See https://github.com/foo/bar.py for details")
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert not any("github.com" in p for p in path_strs)

    def test_ignores_urls_with_paths(self):
        """Should not extract paths from URLs, even with file-like segments."""
        task = _task(title="See https://example.com/path/to/file")
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert not any("example.com" in p for p in path_strs)

    def test_bare_filename(self):
        task = _task(title="Edit store.py to fix the bug")
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert "store.py" in path_strs

    def test_bare_filename_known_extension_only(self):
        """Only known extensions should be extracted as bare filenames."""
        task = _task(title="Edit config.yaml and data.json")
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert "config.yaml" in path_strs
        assert "data.json" in path_strs

    def test_no_duplicates(self):
        task = _task(
            title="Fix loom/graph/store.py",
            context={"description": "The file loom/graph/store.py needs changes"},
        )
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert path_strs.count("loom/graph/store.py") == 1

    def test_no_duplicates_title_and_done_when(self):
        """Same path in title and done_when should appear only once."""
        task = _task(
            title="Fix loom/config.py",
            done_when="loom/config.py has new field",
        )
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert path_strs.count("loom/config.py") == 1

    def test_empty_task(self):
        task = _task(title="Do something", context={})
        paths = extract_paths(task)
        assert paths == []

    def test_empty_title_empty_context(self):
        """Task with empty string title and empty context yields no paths."""
        task = _task(title="", context={})
        paths = extract_paths(task)
        assert paths == []

    def test_context_notes_list(self):
        task = _task(context={"notes": ["check loom/cli.py", "also loom/config.py"]})
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert "loom/cli.py" in path_strs
        assert "loom/config.py" in path_strs

    def test_context_files_with_whitespace(self):
        """Whitespace-padded filenames in context.files are trimmed."""
        task = _task(context={"files": ["  loom/cli.py  ", " loom/config.py"]})
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert "loom/cli.py" in path_strs
        assert "loom/config.py" in path_strs

    def test_context_files_skips_empty_strings(self):
        """Empty strings in context.files are not extracted."""
        task = _task(context={"files": ["", "  ", "loom/cli.py"]})
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert len(path_strs) == 1
        assert "loom/cli.py" in path_strs

    def test_multiple_paths_in_single_text(self):
        """Multiple paths in one text field are all extracted."""
        task = _task(
            title="Modify loom/graph/store.py and loom/graph/cache.py together",
        )
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert "loom/graph/store.py" in path_strs
        assert "loom/graph/cache.py" in path_strs

    def test_deeply_nested_path(self):
        """Deeply nested paths are extracted correctly."""
        task = _task(title="Check loom/dashboard/templates/board.html")
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        assert "loom/dashboard/templates/board.html" in path_strs

    def test_bare_filename_not_duplicated_with_full_path(self):
        """If full path is extracted, bare filename tail should not be duplicated."""
        task = _task(title="Fix loom/graph/store.py")
        paths = extract_paths(task)
        path_strs = [p for p, _ in paths]
        # Should not have both "loom/graph/store.py" and "store.py"
        assert "loom/graph/store.py" in path_strs
        assert "store.py" not in path_strs


# ── find_best_match ───────────────────────────────────────────────────


class TestFindBestMatch:
    def test_directory_rename(self):
        """loom/orchestrator.py -> loom/orchestration/loop.py."""
        real = ["loom/orchestration/loop.py", "loom/graph/store.py", "loom/config.py"]
        best, conf = find_best_match("loom/orchestrator.py", real, set())
        assert best == "loom/orchestration/loop.py"
        assert conf >= 0.5

    def test_same_stem_different_dir(self):
        """loom/skills/builtins/merge.py -> loom/skills/merge.py."""
        real = ["loom/skills/merge.py", "loom/graph/store.py"]
        best, conf = find_best_match("loom/skills/builtins/merge.py", real, set())
        assert best == "loom/skills/merge.py"
        assert conf >= 0.5

    def test_exact_stem_match(self):
        """Same filename in different directory should match well."""
        real_paths = ["loom/graph/store.py", "loom/config.py"]
        match, confidence = find_best_match("loom/store.py", real_paths, set())
        assert match == "loom/graph/store.py"
        assert confidence > 0.5

    def test_no_reasonable_match(self):
        """Completely unrelated path returns None."""
        real = ["loom/graph/store.py", "loom/config.py"]
        best, conf = find_best_match("totally/unrelated/file.rs", real, set())
        assert best is None
        assert conf == 0.0

    def test_no_match_for_unrelated(self):
        """Completely unrelated path should not match."""
        real_paths = ["loom/graph/store.py"]
        match, confidence = find_best_match(
            "totally/different/path.rs", real_paths, set()
        )
        # May or may not match depending on threshold
        if match:
            assert confidence >= 0.5

    def test_exact_dir_reference(self):
        """Directory reference matches when in real_dirs."""
        real = ["loom/graph/store.py"]
        dirs = {"loom", "loom/graph"}
        best, conf = find_best_match("loom/graph", real, dirs)
        assert best == "loom/graph"
        assert conf == 1.0

    def test_directory_match(self):
        """Directory reference should match with confidence 1.0."""
        match, confidence = find_best_match(
            "loom/graph", [], {"loom/graph", "loom/bus"}
        )
        assert match == "loom/graph"
        assert confidence == 1.0

    def test_plural_correction(self):
        """loom/skills/builtin/decomposer.py -> loom/skills/decomposer.py."""
        real = ["loom/skills/decomposer.py", "loom/config.py"]
        best, conf = find_best_match("loom/skills/builtin/decomposer.py", real, set())
        assert best == "loom/skills/decomposer.py"
        assert conf >= 0.5

    def test_similar_paths_ranked(self):
        """More similar path should score higher."""
        real_paths = [
            "loom/orchestration/loop.py",
            "loom/orchestration/sweeper.py",
        ]
        match, confidence = find_best_match(
            "loom/orchestrator.py", real_paths, set()
        )
        # Should match something with reasonable confidence
        assert match is not None
        assert confidence >= 0.5

    def test_confidence_capped_below_one(self):
        """Fuzzy match confidence should never reach 1.0 (reserved for exact)."""
        real = ["loom/graph/store.py"]
        best, conf = find_best_match("loom/graph/stor.py", real, set())
        if best:
            assert conf < 1.0

    def test_empty_real_paths(self):
        """No real paths to match against yields no match."""
        best, conf = find_best_match("loom/config.py", [], set())
        assert best is None
        assert conf == 0.0

    def test_shared_directory_boost(self):
        """Paths sharing directory components should score higher."""
        real = ["loom/graph/deps.py", "tests/test_store.py"]
        # "loom/graph/store.py" shares the "loom" and "graph" dir components with deps.py
        best, conf = find_best_match("loom/graph/store.py", real, set())
        assert best == "loom/graph/deps.py"
        assert conf >= 0.5


# ── check_paths ───────────────────────────────────────────────────────


class TestCheckPaths:
    @pytest.fixture()
    def project_dir(self, tmp_path: Path) -> Path:
        """Create a mini project file tree."""
        (tmp_path / "loom").mkdir()
        (tmp_path / "loom" / "graph").mkdir()
        (tmp_path / "loom" / "graph" / "store.py").write_text("# store")
        (tmp_path / "loom" / "graph" / "cache.py").write_text("# cache")
        (tmp_path / "loom" / "config.py").write_text("# config")
        (tmp_path / "loom" / "scanner.py").write_text("# scanner")
        (tmp_path / "loom" / "orchestration").mkdir()
        (tmp_path / "loom" / "orchestration" / "loop.py").write_text("# loop")
        return tmp_path

    def test_valid_paths_pass(self, project_dir: Path):
        task = _task(context={"files": ["loom/graph/store.py", "loom/config.py"]})
        report = check_paths([task], project_dir)
        assert report.tasks_with_issues == 0
        result = report.results[0]
        assert "loom/graph/store.py" in result.valid_paths
        assert "loom/config.py" in result.valid_paths

    def test_all_paths_valid(self, project_dir: Path):
        """Tasks with all valid paths should report no issues."""
        task = _task(context={"files": ["loom/graph/store.py"]})
        report = check_paths([task], project_dir)
        assert report.tasks_with_issues == 0

    def test_stale_paths_corrected(self, project_dir: Path):
        task = _task(title="Fix loom/orchestrator.py")
        report = check_paths([task], project_dir)
        assert report.tasks_with_issues == 1
        result = report.results[0]
        assert len(result.corrections) >= 1
        primary = result.corrections[0]
        assert primary.stale_path == "loom/orchestrator.py"
        assert primary.corrected_path == "loom/orchestration/loop.py"

    def test_stale_path_gets_correction(self, project_dir: Path):
        """Stale path should get a fuzzy correction suggestion."""
        task = _task(context={"files": ["loom/store.py"]})
        report = check_paths([task], project_dir)
        assert report.total_tasks == 1
        has_corrections = any(
            len(r.corrections) > 0 for r in report.results
        )
        assert has_corrections

    def test_missing_paths_flagged(self, project_dir: Path):
        task = _task(context={"files": ["totally/nonexistent/file.rs"]})
        report = check_paths([task], project_dir)
        assert report.tasks_with_issues == 1
        result = report.results[0]
        assert "totally/nonexistent/file.rs" in result.missing_paths

    def test_missing_path_no_match(self, project_dir: Path):
        """Path with no good match should go to missing_paths."""
        task = _task(context={"files": ["totally/unrelated/thing.rs"]})
        report = check_paths([task], project_dir)
        has_missing = any(
            len(r.missing_paths) > 0 for r in report.results
        )
        assert has_missing

    def test_empty_tasks(self, project_dir: Path):
        task = _task(title="Do something generic")
        report = check_paths([task], project_dir)
        assert report.tasks_with_issues == 0
        assert report.results == []

    def test_report_serialization(self, project_dir: Path):
        task = _task(title="Fix loom/orchestrator.py")
        report = check_paths([task], project_dir)
        data = report.model_dump(mode="json")
        assert isinstance(data["project_root"], str)
        assert isinstance(data["total_tasks"], int)
        assert isinstance(data["results"], list)
        assert "tasks_with_issues" in data
        # Round-trip
        report2 = PathCheckReport.model_validate(data)
        assert report2.tasks_with_issues == report.tasks_with_issues

    def test_report_serialization_all_fields(self, project_dir: Path):
        """PathCheckReport serialization should include all expected fields."""
        task = _task(context={"files": ["loom/config.py"]})
        report = check_paths([task], project_dir)
        d = report.model_dump(mode="json")
        assert "project_root" in d
        assert "total_tasks" in d
        assert "tasks_with_issues" in d
        assert "results" in d

    def test_bare_filename_resolved(self, project_dir: Path):
        task = _task(title="Edit store.py to fix bug")
        report = check_paths([task], project_dir)
        result = report.results[0]
        # store.py should match loom/graph/store.py
        assert any("store.py" in p for p in result.valid_paths)

    def test_directory_reference_valid(self, project_dir: Path):
        task = _task(title="Changes in loom/graph directory")
        report = check_paths([task], project_dir)
        result = report.results[0]
        assert "loom/graph" in result.valid_paths

    def test_multiple_tasks_counted(self, project_dir: Path):
        """Report should track total_tasks correctly for multiple tasks."""
        task1 = _task(context={"files": ["loom/config.py"]})
        task2 = _task(context={"files": ["totally/missing/file.rs"]})
        task3 = _task(title="Fix loom/orchestrator.py")
        report = check_paths([task1, task2, task3], project_dir)
        assert report.total_tasks == 3
        assert report.tasks_with_issues == 2  # task2 missing, task3 stale

    def test_mixed_valid_and_stale(self, project_dir: Path):
        """Task with both valid and stale paths should report issue."""
        task = _task(context={
            "files": ["loom/config.py", "loom/orchestrator.py"]
        })
        report = check_paths([task], project_dir)
        assert report.tasks_with_issues == 1
        result = report.results[0]
        assert "loom/config.py" in result.valid_paths
        assert len(result.corrections) >= 1

    def test_no_tasks(self, project_dir: Path):
        """Empty task list should produce clean report."""
        report = check_paths([], project_dir)
        assert report.total_tasks == 0
        assert report.tasks_with_issues == 0
        assert report.results == []


# ── Pydantic model tests ─────────────────────────────────────────────


class TestPathCheckModels:
    def test_path_correction_model(self):
        pc = PathCorrection(
            stale_path="loom/store.py",
            corrected_path="loom/graph/store.py",
            confidence=0.85,
            source="context.files",
        )
        assert pc.stale_path == "loom/store.py"
        assert pc.corrected_path == "loom/graph/store.py"
        assert pc.confidence == 0.85
        assert pc.source == "context.files"

    def test_path_correction_defaults(self):
        pc = PathCorrection(stale_path="some/path.py")
        assert pc.corrected_path is None
        assert pc.confidence == 0.0
        assert pc.source == ""

    def test_path_correction_serialization(self):
        pc = PathCorrection(
            stale_path="loom/store.py",
            corrected_path="loom/graph/store.py",
            confidence=0.85,
            source="title",
        )
        d = pc.model_dump(mode="json")
        assert d["stale_path"] == "loom/store.py"
        assert d["confidence"] == 0.85
        pc2 = PathCorrection.model_validate(d)
        assert pc2 == pc

    def test_path_check_result_model(self):
        result = PathCheckResult(
            task_id="loom-abc12345",
            task_title="Test task",
            corrections=[],
            valid_paths=["loom/config.py"],
            missing_paths=[],
        )
        assert result.task_id == "loom-abc12345"
        assert len(result.valid_paths) == 1
        assert result.valid_paths[0] == "loom/config.py"
        assert result.corrections == []
        assert result.missing_paths == []

    def test_path_check_result_defaults(self):
        result = PathCheckResult(
            task_id="loom-test01",
            task_title="Test",
        )
        assert result.corrections == []
        assert result.valid_paths == []
        assert result.missing_paths == []

    def test_path_check_result_with_corrections(self):
        correction = PathCorrection(
            stale_path="loom/store.py",
            corrected_path="loom/graph/store.py",
            confidence=0.9,
            source="context.files",
        )
        result = PathCheckResult(
            task_id="loom-test01",
            task_title="Fix store",
            corrections=[correction],
            valid_paths=["loom/config.py"],
            missing_paths=["totally/gone.rs"],
        )
        assert len(result.corrections) == 1
        assert result.corrections[0].confidence == 0.9
        assert len(result.missing_paths) == 1

    def test_path_check_report_model(self):
        report = PathCheckReport(
            project_root="/tmp/project",
            total_tasks=5,
            tasks_with_issues=2,
            results=[],
        )
        assert report.project_root == "/tmp/project"
        assert report.total_tasks == 5
        assert report.tasks_with_issues == 2

    def test_path_check_report_defaults(self):
        report = PathCheckReport(
            project_root="/tmp",
            total_tasks=0,
            tasks_with_issues=0,
        )
        assert report.results == []

    def test_path_check_report_round_trip(self):
        correction = PathCorrection(
            stale_path="old/path.py",
            corrected_path="new/path.py",
            confidence=0.75,
            source="title",
        )
        result = PathCheckResult(
            task_id="loom-test01",
            task_title="Test",
            corrections=[correction],
            valid_paths=["ok/file.py"],
            missing_paths=["gone/file.rs"],
        )
        report = PathCheckReport(
            project_root="/tmp/proj",
            total_tasks=1,
            tasks_with_issues=1,
            results=[result],
        )
        data = report.model_dump(mode="json")
        report2 = PathCheckReport.model_validate(data)
        assert report2.total_tasks == 1
        assert report2.tasks_with_issues == 1
        assert len(report2.results) == 1
        assert report2.results[0].corrections[0].stale_path == "old/path.py"
