"""Tests for issue_history module."""

from __future__ import annotations

from datetime import date, timedelta
from pathlib import Path

import pytest

from little_loops.issue_history import (
    AgentEffectivenessAnalysis,
    AgentOutcome,
    CompletedIssue,
    ComplexityProxy,
    ComplexityProxyAnalysis,
    ConfigGap,
    ConfigGapsAnalysis,
    CouplingAnalysis,
    CouplingPair,
    CrossCuttingAnalysis,
    CrossCuttingSmell,
    Hotspot,
    HotspotAnalysis,
    ManualPattern,
    ManualPatternAnalysis,
    RegressionAnalysis,
    RegressionCluster,
    RejectionAnalysis,
    RejectionMetrics,
    TestGap,
    TestGapAnalysis,
    _detect_processing_agent,
    _extract_paths_from_issue,
    _parse_resolution_action,
    analyze_agent_effectiveness,
    analyze_complexity_proxy,
    analyze_coupling,
    analyze_hotspots,
    analyze_regression_clustering,
    analyze_rejection_rates,
    analyze_test_gaps,
    detect_config_gaps,
    detect_cross_cutting_smells,
    detect_manual_patterns,
)


class TestHotspot:
    """Tests for Hotspot dataclass."""

    def test_to_dict(self) -> None:
        """Test to_dict serialization."""
        hotspot = Hotspot(
            path="src/core/processor.py",
            issue_count=5,
            issue_ids=["BUG-001", "BUG-002", "ENH-003", "BUG-004", "ENH-005"],
            issue_types={"BUG": 3, "ENH": 2},
            bug_ratio=0.6,
            churn_indicator="high",
        )
        result = hotspot.to_dict()

        assert result["path"] == "src/core/processor.py"
        assert result["issue_count"] == 5
        assert result["bug_ratio"] == 0.6
        assert result["churn_indicator"] == "high"
        assert result["issue_types"] == {"BUG": 3, "ENH": 2}

    def test_to_dict_limits_issue_ids(self) -> None:
        """Test that to_dict limits issue_ids to 10."""
        hotspot = Hotspot(
            path="test.py",
            issue_count=15,
            issue_ids=[f"BUG-{i:03d}" for i in range(15)],
        )
        result = hotspot.to_dict()

        assert len(result["issue_ids"]) == 10


class TestHotspotAnalysis:
    """Tests for HotspotAnalysis dataclass."""

    def test_to_dict_empty(self) -> None:
        """Test to_dict with empty lists."""
        analysis = HotspotAnalysis()
        result = analysis.to_dict()

        assert result["file_hotspots"] == []
        assert result["directory_hotspots"] == []
        assert result["bug_magnets"] == []

    def test_to_dict_with_hotspots(self) -> None:
        """Test to_dict with hotspots."""
        hotspot = Hotspot(path="test.py", issue_count=3)
        analysis = HotspotAnalysis(file_hotspots=[hotspot])
        result = analysis.to_dict()

        assert len(result["file_hotspots"]) == 1
        assert result["file_hotspots"][0]["path"] == "test.py"


class TestExtractPathsFromIssue:
    """Tests for _extract_paths_from_issue function."""

    def test_extract_file_pattern(self) -> None:
        """Test extracting **File**: pattern."""
        content = "**File**: `scripts/little_loops/cli.py`"
        paths = _extract_paths_from_issue(content)
        assert "scripts/little_loops/cli.py" in paths

    def test_extract_backtick_pattern(self) -> None:
        """Test extracting backtick file paths."""
        content = "The bug is in `src/core/processor.py` and `src/utils/helper.py`"
        paths = _extract_paths_from_issue(content)
        assert "src/core/processor.py" in paths
        assert "src/utils/helper.py" in paths

    def test_extract_path_with_line_number(self) -> None:
        """Test extracting paths with line numbers."""
        content = "See scripts/cli.py:123 for the issue"
        paths = _extract_paths_from_issue(content)
        assert "scripts/cli.py" in paths

    def test_no_paths_found(self) -> None:
        """Test with no file paths."""
        content = "This is a general issue with no specific files."
        paths = _extract_paths_from_issue(content)
        assert paths == []

    def test_multiple_file_extensions(self) -> None:
        """Test various file extensions."""
        content = """
        config.json
        readme.md
        test.yaml
        app.ts
        """
        paths = _extract_paths_from_issue(content)
        assert "config.json" in paths
        assert "readme.md" in paths
        assert "test.yaml" in paths
        assert "app.ts" in paths


class TestAnalyzeHotspots:
    """Tests for analyze_hotspots function."""

    def test_empty_issues(self) -> None:
        """Test with empty issues list."""
        result = analyze_hotspots([])
        assert result.file_hotspots == []
        assert result.directory_hotspots == []
        assert result.bug_magnets == []

    def test_single_issue(self, tmp_path: Path) -> None:
        """Test with a single issue containing file paths."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("**File**: `src/core/processor.py`\n\nBug in processor.")

        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]

        result = analyze_hotspots(issues)
        assert len(result.file_hotspots) == 1
        assert result.file_hotspots[0].path == "src/core/processor.py"
        assert result.file_hotspots[0].issue_count == 1

    def test_bug_magnet_detection(self, tmp_path: Path) -> None:
        """Test detection of bug magnets (>60% bug ratio)."""
        # Create 4 issues for same file: 3 bugs, 1 enhancement
        issues = []
        for i, issue_type in enumerate(["BUG", "BUG", "BUG", "ENH"]):
            issue_file = tmp_path / f"P1-{issue_type}-{i:03d}.md"
            issue_file.write_text("**File**: `src/problematic.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type=issue_type,
                    priority="P1",
                    issue_id=f"{issue_type}-{i:03d}",
                )
            )

        result = analyze_hotspots(issues)
        assert len(result.bug_magnets) == 1
        assert result.bug_magnets[0].path == "src/problematic.py"
        assert result.bug_magnets[0].bug_ratio == 0.75  # 3/4

    def test_churn_indicator_high(self, tmp_path: Path) -> None:
        """Test high churn indicator assignment (5+ issues)."""
        issues = []
        for i in range(5):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text("**File**: `src/churny.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                )
            )

        result = analyze_hotspots(issues)
        assert result.file_hotspots[0].churn_indicator == "high"

    def test_churn_indicator_medium(self, tmp_path: Path) -> None:
        """Test medium churn indicator assignment (3-4 issues)."""
        issues = []
        for i in range(3):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text("**File**: `src/moderate.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                )
            )

        result = analyze_hotspots(issues)
        assert result.file_hotspots[0].churn_indicator == "medium"

    def test_churn_indicator_low(self, tmp_path: Path) -> None:
        """Test low churn indicator assignment (1-2 issues)."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("**File**: `src/stable.py`")

        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]

        result = analyze_hotspots(issues)
        assert result.file_hotspots[0].churn_indicator == "low"

    def test_directory_hotspot(self, tmp_path: Path) -> None:
        """Test directory hotspot aggregation."""
        issues = []
        for i, filename in enumerate(["file1.py", "file2.py"]):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text(f"**File**: `src/core/{filename}`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                )
            )

        result = analyze_hotspots(issues)
        # Should have 2 file hotspots
        assert len(result.file_hotspots) == 2
        # Should have 1 directory hotspot with 2 issues
        dir_hotspot = next((h for h in result.directory_hotspots if h.path == "src/core/"), None)
        assert dir_hotspot is not None
        assert dir_hotspot.issue_count == 2

    def test_bug_magnet_threshold(self, tmp_path: Path) -> None:
        """Test bug magnet requires at least 3 issues."""
        # Create 2 issues for same file: 2 bugs (100% but under threshold)
        issues = []
        for i in range(2):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text("**File**: `src/small.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                )
            )

        result = analyze_hotspots(issues)
        # Should not be a bug magnet (only 2 issues, needs 3+)
        assert len(result.bug_magnets) == 0


class TestCouplingPair:
    """Tests for CouplingPair dataclass."""

    def test_to_dict(self) -> None:
        """Test to_dict serialization."""
        pair = CouplingPair(
            file_a="src/a.py",
            file_b="src/b.py",
            co_occurrence_count=5,
            coupling_strength=0.75,
            issue_ids=["BUG-001", "BUG-002"],
        )
        result = pair.to_dict()

        assert result["file_a"] == "src/a.py"
        assert result["file_b"] == "src/b.py"
        assert result["co_occurrence_count"] == 5
        assert result["coupling_strength"] == 0.75
        assert result["issue_ids"] == ["BUG-001", "BUG-002"]

    def test_to_dict_limits_issue_ids(self) -> None:
        """Test to_dict limits issue_ids to 10."""
        pair = CouplingPair(
            file_a="src/a.py",
            file_b="src/b.py",
            co_occurrence_count=15,
            coupling_strength=0.9,
            issue_ids=[f"BUG-{i:03d}" for i in range(15)],
        )
        result = pair.to_dict()
        assert len(result["issue_ids"]) == 10


class TestCouplingAnalysis:
    """Tests for CouplingAnalysis dataclass."""

    def test_to_dict_empty(self) -> None:
        """Test to_dict with empty analysis."""
        analysis = CouplingAnalysis()
        result = analysis.to_dict()

        assert result["pairs"] == []
        assert result["clusters"] == []
        assert result["hotspots"] == []

    def test_to_dict_with_data(self) -> None:
        """Test to_dict with populated analysis."""
        pair = CouplingPair(
            file_a="src/a.py",
            file_b="src/b.py",
            co_occurrence_count=3,
            coupling_strength=0.6,
        )
        analysis = CouplingAnalysis(
            pairs=[pair],
            clusters=[["src/a.py", "src/b.py"]],
            hotspots=["src/hub.py"],
        )
        result = analysis.to_dict()

        assert len(result["pairs"]) == 1
        assert result["pairs"][0]["file_a"] == "src/a.py"
        assert result["clusters"] == [["src/a.py", "src/b.py"]]
        assert result["hotspots"] == ["src/hub.py"]


class TestAnalyzeCoupling:
    """Tests for analyze_coupling function."""

    def test_empty_issues(self) -> None:
        """Test with empty issues list."""
        result = analyze_coupling([])
        assert result.pairs == []
        assert result.clusters == []
        assert result.hotspots == []

    def test_coupling_detected(self, tmp_path: Path) -> None:
        """Test detection of coupled files."""
        # Create 3 issues where src/a.py and src/b.py appear together
        issues = []
        for i in range(3):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text("**File**: `src/a.py`\n**File**: `src/b.py`\nBug in both.")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                )
            )

        result = analyze_coupling(issues)
        assert len(result.pairs) >= 1
        pair = result.pairs[0]
        assert {pair.file_a, pair.file_b} == {"src/a.py", "src/b.py"}
        assert pair.co_occurrence_count == 3
        assert pair.coupling_strength == 1.0  # Perfect coupling (3/3 union)

    def test_no_coupling_single_occurrence(self, tmp_path: Path) -> None:
        """Test that single co-occurrence is not reported."""
        # Files appear together only once
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("**File**: `src/a.py`\n**File**: `src/b.py`")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]

        result = analyze_coupling(issues)
        assert len(result.pairs) == 0  # Requires 2+ co-occurrences

    def test_weak_coupling_filtered(self, tmp_path: Path) -> None:
        """Test that weak coupling (< 0.3) is filtered out."""
        # Create issues where files have low Jaccard similarity
        # a.py appears in 5 issues, b.py in 5 different issues, with only 2 overlap
        # Jaccard = 2 / (5 + 5 - 2) = 2/8 = 0.25
        issues = []
        for i in range(5):
            issue_file = tmp_path / f"P1-BUG-A{i:03d}.md"
            issue_file.write_text("**File**: `src/a.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-A{i:03d}",
                )
            )

        for i in range(5):
            issue_file = tmp_path / f"P1-BUG-B{i:03d}.md"
            issue_file.write_text("**File**: `src/b.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-B{i:03d}",
                )
            )

        # Add 2 issues where both appear (co-occurrence)
        for i in range(2):
            issue_file = tmp_path / f"P1-BUG-AB{i:03d}.md"
            issue_file.write_text("**File**: `src/a.py`\n**File**: `src/b.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-AB{i:03d}",
                )
            )

        result = analyze_coupling(issues)
        # With 2 co-occurrences but Jaccard = 2/(7+7-2) = 2/12 = 0.167 < 0.3
        # Should be filtered out
        matching_pairs = [
            p for p in result.pairs if {p.file_a, p.file_b} == {"src/a.py", "src/b.py"}
        ]
        assert len(matching_pairs) == 0

    def test_coupling_hotspot_detection(self, tmp_path: Path) -> None:
        """Test detection of coupling hotspots (files coupled with 3+ others)."""
        # Create issues where src/hub.py is coupled with 3 different files
        # Each pair appears in exactly the same issues to get Jaccard = 1.0
        issues = []

        # Create 2 issues where hub.py + a.py appear (Jaccard = 1.0)
        for i in range(2):
            issue_file = tmp_path / f"P1-BUG-HA{i}.md"
            issue_file.write_text("**File**: `src/hub.py`\n**File**: `src/a.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-HA{i}",
                )
            )

        # Create 2 issues where hub.py + b.py appear (separate issues)
        for i in range(2):
            issue_file = tmp_path / f"P1-BUG-HB{i}.md"
            issue_file.write_text("**File**: `src/hub.py`\n**File**: `src/b.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-HB{i}",
                )
            )

        # Create 2 issues where hub.py + c.py appear (separate issues)
        for i in range(2):
            issue_file = tmp_path / f"P1-BUG-HC{i}.md"
            issue_file.write_text("**File**: `src/hub.py`\n**File**: `src/c.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-HC{i}",
                )
            )

        result = analyze_coupling(issues)
        # hub.py is coupled with 3 other files (a, b, c), so it should be a hotspot
        assert "src/hub.py" in result.hotspots

    def test_cluster_formation(self, tmp_path: Path) -> None:
        """Test that highly coupled files form clusters."""
        # Create a triangle of files that all appear together
        # (a, b, c) all appear in same 3 issues -> all pairwise strength = 1.0
        issues = []
        for i in range(3):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text(
                "**File**: `src/a.py`\n**File**: `src/b.py`\n**File**: `src/c.py`"
            )
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                )
            )

        result = analyze_coupling(issues)
        # Should have at least 1 cluster containing all three files
        assert len(result.clusters) >= 1
        # The cluster should contain all three files
        cluster = result.clusters[0]
        assert set(cluster) == {"src/a.py", "src/b.py", "src/c.py"}

    def test_coupling_strength_calculation(self, tmp_path: Path) -> None:
        """Test Jaccard similarity calculation."""
        # a.py in issues 1, 2, 3 (3 issues)
        # b.py in issues 2, 3 (2 issues)
        # co-occurrence = 2 (issues 2, 3)
        # union = 3 (issues 1, 2, 3)
        # Jaccard = 2/3 = 0.667
        issues = []

        # Issue 1: only a.py
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("**File**: `src/a.py`")
        issues.append(
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        )

        # Issues 2 and 3: both a.py and b.py
        for i in range(2, 4):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text("**File**: `src/a.py`\n**File**: `src/b.py`")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                )
            )

        result = analyze_coupling(issues)
        assert len(result.pairs) == 1
        pair = result.pairs[0]
        assert pair.co_occurrence_count == 2
        # Jaccard = 2/3 ~ 0.667
        assert abs(pair.coupling_strength - 0.667) < 0.01

    def test_disconnected_clusters(self, tmp_path: Path) -> None:
        """Test that two independent file pairs form two separate clusters (BFS disconnected components)."""
        # Cluster 1: a.py + b.py co-occur in 3 issues (Jaccard=1.0)
        # Cluster 2: c.py + d.py co-occur in 3 different issues (Jaccard=1.0)
        # No overlap between the two groups → two disconnected components in the graph
        issues = []
        for i in range(3):
            f = tmp_path / f"P1-BUG-AB{i}.md"
            f.write_text("**File**: `src/a.py`\n**File**: `src/b.py`")
            issues.append(
                CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id=f"BUG-AB{i}")
            )

        for i in range(3):
            f = tmp_path / f"P1-BUG-CD{i}.md"
            f.write_text("**File**: `src/c.py`\n**File**: `src/d.py`")
            issues.append(
                CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id=f"BUG-CD{i}")
            )

        result = analyze_coupling(issues)
        assert len(result.clusters) == 2
        cluster_sets = [set(c) for c in result.clusters]
        assert {"src/a.py", "src/b.py"} in cluster_sets
        assert {"src/c.py", "src/d.py"} in cluster_sets

    def test_boundary_coupling_strength_included(self, tmp_path: Path) -> None:
        """Test that coupling_strength exactly 0.5 meets the cluster threshold (>= 0.5, inclusive)."""
        # 2 issues with both a.py + b.py (co-occur=2), 2 issues with only a.py (solo)
        # union = 4, Jaccard = 2/4 = 0.5 → must be included in cluster
        issues = []
        for i in range(2):
            f = tmp_path / f"P1-BUG-AB{i}.md"
            f.write_text("**File**: `src/a.py`\n**File**: `src/b.py`")
            issues.append(
                CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id=f"BUG-AB{i}")
            )

        for i in range(2):
            f = tmp_path / f"P1-BUG-A{i}.md"
            f.write_text("**File**: `src/a.py`")
            issues.append(
                CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id=f"BUG-A{i}")
            )

        result = analyze_coupling(issues)
        assert len(result.clusters) == 1
        assert set(result.clusters[0]) == {"src/a.py", "src/b.py"}

    def test_below_threshold_excluded_from_clusters(self, tmp_path: Path) -> None:
        """Test that pairs with coupling_strength < 0.5 are excluded from clusters."""
        # 2 co-occur issues, 2 solo-a issues, 1 solo-b issue
        # union = 5, Jaccard = 2/5 = 0.4 → passes analyze_coupling (>=0.3) but not cluster threshold (<0.5)
        issues = []
        for i in range(2):
            f = tmp_path / f"P1-BUG-AB{i}.md"
            f.write_text("**File**: `src/a.py`\n**File**: `src/b.py`")
            issues.append(
                CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id=f"BUG-AB{i}")
            )

        for i in range(2):
            f = tmp_path / f"P1-BUG-A{i}.md"
            f.write_text("**File**: `src/a.py`")
            issues.append(
                CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id=f"BUG-A{i}")
            )

        f = tmp_path / "P1-BUG-B0.md"
        f.write_text("**File**: `src/b.py`")
        issues.append(CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id="BUG-B0"))

        result = analyze_coupling(issues)
        # Pair is detected (strength 0.4 >= 0.3) but forms no cluster
        assert len(result.pairs) >= 1
        assert result.clusters == []

    def test_single_node_not_in_cluster(self, tmp_path: Path) -> None:
        """Test that a file with only sub-threshold pairs never appears in any cluster."""
        # Same 0.4-strength fixture: a.py and b.py are detected as a pair but
        # never enter the adjacency graph (strength < 0.5), so no cluster is built
        issues = []
        for i in range(2):
            f = tmp_path / f"P1-BUG-AB{i}.md"
            f.write_text("**File**: `src/a.py`\n**File**: `src/b.py`")
            issues.append(
                CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id=f"BUG-AB{i}")
            )

        for i in range(2):
            f = tmp_path / f"P1-BUG-A{i}.md"
            f.write_text("**File**: `src/a.py`")
            issues.append(
                CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id=f"BUG-A{i}")
            )

        f = tmp_path / "P1-BUG-B0.md"
        f.write_text("**File**: `src/b.py`")
        issues.append(CompletedIssue(path=f, issue_type="BUG", priority="P1", issue_id="BUG-B0"))

        result = analyze_coupling(issues)
        all_cluster_files = {file for cluster in result.clusters for file in cluster}
        assert "src/a.py" not in all_cluster_files
        assert "src/b.py" not in all_cluster_files


class TestRegressionCluster:
    """Tests for RegressionCluster dataclass."""

    def test_to_dict(self) -> None:
        """Test to_dict serialization."""
        cluster = RegressionCluster(
            primary_file="src/core/processor.py",
            regression_count=3,
            fix_bug_pairs=[
                ("BUG-001", "BUG-002"),
                ("BUG-002", "BUG-003"),
                ("BUG-003", "BUG-004"),
            ],
            related_files=["src/core/state.py", "src/core/events.py"],
            time_pattern="chronic",
            severity="critical",
        )
        result = cluster.to_dict()

        assert result["primary_file"] == "src/core/processor.py"
        assert result["regression_count"] == 3
        assert result["time_pattern"] == "chronic"
        assert result["severity"] == "critical"
        assert len(result["fix_bug_pairs"]) == 3

    def test_to_dict_limits_lists(self) -> None:
        """Test that to_dict limits lists to 10 items."""
        cluster = RegressionCluster(
            primary_file="test.py",
            regression_count=15,
            fix_bug_pairs=[(f"BUG-{i:03d}", f"BUG-{i + 1:03d}") for i in range(15)],
            related_files=[f"file{i}.py" for i in range(15)],
        )
        result = cluster.to_dict()

        assert len(result["fix_bug_pairs"]) == 10
        assert len(result["related_files"]) == 10


class TestRegressionAnalysis:
    """Tests for RegressionAnalysis dataclass."""

    def test_to_dict_empty(self) -> None:
        """Test to_dict with empty data."""
        analysis = RegressionAnalysis()
        result = analysis.to_dict()

        assert result["clusters"] == []
        assert result["total_regression_chains"] == 0
        assert result["most_fragile_files"] == []

    def test_to_dict_with_clusters(self) -> None:
        """Test to_dict with clusters."""
        cluster = RegressionCluster(primary_file="test.py", regression_count=2)
        analysis = RegressionAnalysis(
            clusters=[cluster],
            total_regression_chains=1,
            most_fragile_files=["test.py"],
        )
        result = analysis.to_dict()

        assert len(result["clusters"]) == 1
        assert result["total_regression_chains"] == 1
        assert result["most_fragile_files"] == ["test.py"]


class TestAnalyzeRegressionClustering:
    """Tests for analyze_regression_clustering function."""

    def test_empty_issues(self) -> None:
        """Test with empty issues list."""
        result = analyze_regression_clustering([])
        assert result.clusters == []
        assert result.total_regression_chains == 0

    def test_no_bugs(self, tmp_path: Path) -> None:
        """Test with no bug issues."""
        issue_file = tmp_path / "P1-ENH-001.md"
        issue_file.write_text("**File**: `src/core/processor.py`")

        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="ENH",
                priority="P1",
                issue_id="ENH-001",
                completed_date=date(2026, 1, 1),
            )
        ]

        result = analyze_regression_clustering(issues)
        assert result.clusters == []

    def test_single_bug(self, tmp_path: Path) -> None:
        """Test with single bug issue."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("**File**: `src/core/processor.py`")

        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 1),
            )
        ]

        result = analyze_regression_clustering(issues)
        assert result.clusters == []

    def test_regression_detected(self, tmp_path: Path) -> None:
        """Test detection of regression chain."""
        # Create two bugs affecting same file within 7 days
        bug1_file = tmp_path / "P1-BUG-001.md"
        bug1_file.write_text("**File**: `src/core/processor.py`")

        bug2_file = tmp_path / "P1-BUG-002.md"
        bug2_file.write_text("**File**: `src/core/processor.py`")

        issues = [
            CompletedIssue(
                path=bug1_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 1),
            ),
            CompletedIssue(
                path=bug2_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-002",
                completed_date=date(2026, 1, 3),  # 2 days later
            ),
        ]

        result = analyze_regression_clustering(issues)
        assert result.total_regression_chains == 1
        assert len(result.clusters) == 1
        assert result.clusters[0].primary_file == "src/core/processor.py"
        assert result.clusters[0].regression_count == 1
        assert ("BUG-001", "BUG-002") in result.clusters[0].fix_bug_pairs

    def test_no_regression_beyond_7_days(self, tmp_path: Path) -> None:
        """Test that bugs >7 days apart are not considered regressions."""
        bug1_file = tmp_path / "P1-BUG-001.md"
        bug1_file.write_text("**File**: `src/core/processor.py`")

        bug2_file = tmp_path / "P1-BUG-002.md"
        bug2_file.write_text("**File**: `src/core/processor.py`")

        issues = [
            CompletedIssue(
                path=bug1_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 1),
            ),
            CompletedIssue(
                path=bug2_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-002",
                completed_date=date(2026, 1, 10),  # 9 days later
            ),
        ]

        result = analyze_regression_clustering(issues)
        assert result.total_regression_chains == 0
        assert len(result.clusters) == 0

    def test_no_regression_different_files(self, tmp_path: Path) -> None:
        """Test that bugs affecting different files are not regressions."""
        bug1_file = tmp_path / "P1-BUG-001.md"
        bug1_file.write_text("**File**: `src/core/processor.py`")

        bug2_file = tmp_path / "P1-BUG-002.md"
        bug2_file.write_text("**File**: `src/api/handlers.py`")

        issues = [
            CompletedIssue(
                path=bug1_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 1),
            ),
            CompletedIssue(
                path=bug2_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-002",
                completed_date=date(2026, 1, 3),
            ),
        ]

        result = analyze_regression_clustering(issues)
        assert result.total_regression_chains == 0

    def test_severity_classification(self, tmp_path: Path) -> None:
        """Test severity classification based on regression count."""
        # Create 5 bugs with same file to trigger critical severity (4+ pairs)
        issues = []
        for i in range(5):
            bug_file = tmp_path / f"P1-BUG-{i:03d}.md"
            bug_file.write_text("**File**: `src/critical.py`")
            issues.append(
                CompletedIssue(
                    path=bug_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                    completed_date=date(2026, 1, 1) + timedelta(days=i),
                )
            )

        result = analyze_regression_clustering(issues)
        assert result.clusters[0].severity == "critical"

    def test_time_pattern_immediate(self, tmp_path: Path) -> None:
        """Test immediate time pattern (<3 days)."""
        bug1_file = tmp_path / "P1-BUG-001.md"
        bug1_file.write_text("**File**: `src/fast.py`")

        bug2_file = tmp_path / "P1-BUG-002.md"
        bug2_file.write_text("**File**: `src/fast.py`")

        issues = [
            CompletedIssue(
                path=bug1_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 1),
            ),
            CompletedIssue(
                path=bug2_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-002",
                completed_date=date(2026, 1, 2),  # 1 day later
            ),
        ]

        result = analyze_regression_clustering(issues)
        assert result.clusters[0].time_pattern == "immediate"

    def test_most_fragile_files(self, tmp_path: Path) -> None:
        """Test most fragile files list."""
        # Create two separate regression chains
        files = [
            ("src/fragile1.py", 0),
            ("src/fragile1.py", 1),
            ("src/fragile2.py", 3),
            ("src/fragile2.py", 4),
        ]
        issues = []
        for i, (file_path, day_offset) in enumerate(files):
            bug_file = tmp_path / f"P1-BUG-{i:03d}.md"
            bug_file.write_text(f"**File**: `{file_path}`")
            issues.append(
                CompletedIssue(
                    path=bug_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                    completed_date=date(2026, 1, 1) + timedelta(days=day_offset),
                )
            )

        result = analyze_regression_clustering(issues)
        assert "src/fragile1.py" in result.most_fragile_files
        assert "src/fragile2.py" in result.most_fragile_files


class TestTestGap:
    """Tests for TestGap dataclass."""

    def test_to_dict(self) -> None:
        """Test to_dict serialization."""
        gap = TestGap(
            source_file="src/core/processor.py",
            bug_count=5,
            bug_ids=["BUG-001", "BUG-002", "BUG-003", "BUG-004", "BUG-005"],
            has_test_file=False,
            test_file_path=None,
            gap_score=50.0,
            priority="critical",
        )
        result = gap.to_dict()

        assert result["source_file"] == "src/core/processor.py"
        assert result["bug_count"] == 5
        assert result["has_test_file"] is False
        assert result["gap_score"] == 50.0
        assert result["priority"] == "critical"

    def test_to_dict_with_test_file(self) -> None:
        """Test to_dict with existing test file."""
        gap = TestGap(
            source_file="src/utils/helper.py",
            bug_count=2,
            bug_ids=["BUG-010", "BUG-011"],
            has_test_file=True,
            test_file_path="tests/test_helper.py",
            gap_score=2.0,
            priority="low",
        )
        result = gap.to_dict()

        assert result["has_test_file"] is True
        assert result["test_file_path"] == "tests/test_helper.py"

    def test_to_dict_limits_bug_ids(self) -> None:
        """Test that to_dict limits bug_ids to 10."""
        gap = TestGap(
            source_file="src/core.py",
            bug_count=15,
            bug_ids=[f"BUG-{i:03d}" for i in range(15)],
        )
        result = gap.to_dict()

        assert len(result["bug_ids"]) == 10


class TestTestGapAnalysis:
    """Tests for TestGapAnalysis dataclass."""

    def test_to_dict_empty(self) -> None:
        """Test to_dict with empty analysis."""
        analysis = TestGapAnalysis()
        result = analysis.to_dict()

        assert result["gaps"] == []
        assert result["untested_bug_magnets"] == []
        assert result["files_with_tests_avg_bugs"] == 0.0
        assert result["files_without_tests_avg_bugs"] == 0.0
        assert result["priority_test_targets"] == []

    def test_to_dict_with_data(self) -> None:
        """Test to_dict with populated data."""
        gap = TestGap(
            source_file="src/core.py",
            bug_count=3,
            bug_ids=["BUG-001"],
            has_test_file=False,
            gap_score=30.0,
            priority="high",
        )
        analysis = TestGapAnalysis(
            gaps=[gap],
            untested_bug_magnets=["src/core.py"],
            files_with_tests_avg_bugs=1.5,
            files_without_tests_avg_bugs=4.2,
            priority_test_targets=["src/core.py"],
        )
        result = analysis.to_dict()

        assert len(result["gaps"]) == 1
        assert result["files_with_tests_avg_bugs"] == 1.5
        assert result["files_without_tests_avg_bugs"] == 4.2


class TestAnalyzeTestGaps:
    """Tests for analyze_test_gaps function."""

    def test_empty_hotspots(self) -> None:
        """Test with empty hotspot analysis."""
        hotspots = HotspotAnalysis()
        result = analyze_test_gaps([], hotspots)

        assert result.gaps == []
        assert result.priority_test_targets == []

    def test_no_bugs_in_hotspots(self) -> None:
        """Test with hotspots that have no bugs."""
        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="src/feature.py",
                    issue_count=3,
                    issue_ids=["ENH-001", "ENH-002", "ENH-003"],
                    issue_types={"ENH": 3},
                )
            ]
        )
        result = analyze_test_gaps([], hotspots)

        assert result.gaps == []

    def test_with_bug_hotspots(self) -> None:
        """Test with hotspots containing bugs."""
        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="src/problematic.py",
                    issue_count=5,
                    issue_ids=["BUG-001", "BUG-002", "BUG-003", "ENH-001", "ENH-002"],
                    issue_types={"BUG": 3, "ENH": 2},
                    bug_ratio=0.6,
                )
            ],
            bug_magnets=[
                Hotspot(
                    path="src/problematic.py",
                    issue_count=5,
                    issue_ids=["BUG-001", "BUG-002", "BUG-003"],
                    issue_types={"BUG": 3},
                    bug_ratio=0.6,
                )
            ],
        )

        result = analyze_test_gaps([], hotspots)

        assert len(result.gaps) >= 1
        assert result.gaps[0].source_file == "src/problematic.py"
        assert result.gaps[0].bug_count == 3
        assert result.gaps[0].has_test_file is False  # No test file exists
        assert "src/problematic.py" in result.untested_bug_magnets

    def test_priority_critical(self) -> None:
        """Test priority classification for critical (5+ bugs, no test)."""
        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="src/critical.py",
                    issue_count=5,
                    issue_ids=["BUG-001", "BUG-002", "BUG-003", "BUG-004", "BUG-005"],
                    issue_types={"BUG": 5},
                )
            ]
        )
        result = analyze_test_gaps([], hotspots)

        assert result.gaps[0].priority == "critical"

    def test_priority_high(self) -> None:
        """Test priority classification for high (3-4 bugs, no test)."""
        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="src/high_priority.py",
                    issue_count=3,
                    issue_ids=["BUG-001", "BUG-002", "BUG-003"],
                    issue_types={"BUG": 3},
                )
            ]
        )
        result = analyze_test_gaps([], hotspots)

        assert result.gaps[0].priority == "high"

    def test_priority_low(self) -> None:
        """Test priority classification for low (1-2 bugs, no test)."""
        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="src/low_priority.py",
                    issue_count=2,
                    issue_ids=["BUG-001", "BUG-002"],
                    issue_types={"BUG": 2},
                )
            ]
        )
        result = analyze_test_gaps([], hotspots)

        assert result.gaps[0].priority == "medium"  # 2 bugs without test is medium

    def test_gap_score_calculation(self) -> None:
        """Test gap score is higher for untested files."""
        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="src/untested.py",
                    issue_count=3,
                    issue_ids=["BUG-001", "BUG-002", "BUG-003"],
                    issue_types={"BUG": 3},
                )
            ]
        )
        result = analyze_test_gaps([], hotspots)

        # Untested files get 10x multiplier
        assert result.gaps[0].gap_score == 30.0

    def test_averages_calculation(self) -> None:
        """Test average bug calculation."""
        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="src/file1.py",
                    issue_count=4,
                    issue_ids=["BUG-001", "BUG-002", "BUG-003", "BUG-004"],
                    issue_types={"BUG": 4},
                ),
                Hotspot(
                    path="src/file2.py",
                    issue_count=2,
                    issue_ids=["BUG-005", "BUG-006"],
                    issue_types={"BUG": 2},
                ),
            ]
        )
        result = analyze_test_gaps([], hotspots)

        # Both files are untested, so files_without_tests_avg_bugs = (4 + 2) / 2 = 3.0
        assert result.files_without_tests_avg_bugs == 3.0
        assert result.files_with_tests_avg_bugs == 0.0

    def test_priority_targets_sorted_by_gap_score(self) -> None:
        """Test that priority targets are sorted by bug count."""
        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="src/low.py",
                    issue_count=1,
                    issue_ids=["BUG-001"],
                    issue_types={"BUG": 1},
                ),
                Hotspot(
                    path="src/high.py",
                    issue_count=5,
                    issue_ids=["BUG-002", "BUG-003", "BUG-004", "BUG-005", "BUG-006"],
                    issue_types={"BUG": 5},
                ),
            ]
        )
        result = analyze_test_gaps([], hotspots)

        # Higher bug count should come first
        assert result.priority_test_targets[0] == "src/high.py"

    def test_project_root_anchors_path_existence(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """project_root anchors file existence checks away from CWD."""
        # Create a test file under tmp_path matching the little-loops pattern
        test_file = tmp_path / "scripts" / "tests" / "test_mymodule.py"
        test_file.parent.mkdir(parents=True)
        test_file.write_text("# test")

        # Change CWD away so CWD-relative lookup would fail
        other_dir = tmp_path / "other"
        other_dir.mkdir()
        monkeypatch.chdir(other_dir)

        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="scripts/little_loops/mymodule.py",
                    issue_count=2,
                    issue_ids=["BUG-001", "BUG-002"],
                    issue_types={"BUG": 2},
                )
            ]
        )

        result = analyze_test_gaps([], hotspots, project_root=tmp_path)

        assert len(result.gaps) == 1
        assert result.gaps[0].has_test_file is True
        assert result.gaps[0].test_file_path == "scripts/tests/test_mymodule.py"

    def test_none_project_root_falls_back_to_cwd(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """project_root=None falls back to CWD for path resolution."""
        # Create test file relative to tmp_path (which will become CWD)
        test_file = tmp_path / "scripts" / "tests" / "test_mymodule.py"
        test_file.parent.mkdir(parents=True)
        test_file.write_text("# test")

        monkeypatch.chdir(tmp_path)

        hotspots = HotspotAnalysis(
            file_hotspots=[
                Hotspot(
                    path="scripts/little_loops/mymodule.py",
                    issue_count=2,
                    issue_ids=["BUG-001", "BUG-002"],
                    issue_types={"BUG": 2},
                )
            ]
        )

        result = analyze_test_gaps([], hotspots, project_root=None)

        assert len(result.gaps) == 1
        assert result.gaps[0].has_test_file is True


class TestParseResolutionAction:
    """Tests for _parse_resolution_action helper."""

    def test_completed_with_action(self) -> None:
        """Test normal completion with Action field."""
        content = """## Resolution

- **Action**: fix
- **Completed**: 2026-01-15
- **Status**: Completed
"""
        assert _parse_resolution_action(content) == "completed"

    def test_rejected_with_reason(self) -> None:
        """Test rejection with explicit reason."""
        content = """## Resolution

- **Status**: Closed - Rejected
- **Closed**: 2026-01-15
- **Reason**: rejected
"""
        assert _parse_resolution_action(content) == "rejected"

    def test_invalid_with_reason(self) -> None:
        """Test invalid with explicit reason."""
        content = """## Resolution

- **Status**: Closed - Invalid
- **Closed**: 2026-01-15
- **Reason**: invalid_ref
"""
        assert _parse_resolution_action(content) == "invalid"

    def test_duplicate_with_reason(self) -> None:
        """Test duplicate detection."""
        content = """## Resolution

- **Status**: Closed
- **Reason**: duplicate of BUG-001
"""
        assert _parse_resolution_action(content) == "duplicate"

    def test_deferred_with_reason(self) -> None:
        """Test deferred detection."""
        content = """## Resolution

- **Status**: Closed
- **Reason**: deferred to v2
"""
        assert _parse_resolution_action(content) == "deferred"

    def test_closed_without_reason(self) -> None:
        """Test closed without specific reason defaults to rejected."""
        content = """## Resolution

- **Status**: Closed - Already Fixed
- **Closed**: 2026-01-15
"""
        assert _parse_resolution_action(content) == "rejected"

    def test_no_resolution_section(self) -> None:
        """Test content without resolution section defaults to completed."""
        content = """# Issue Title

Some description.
"""
        assert _parse_resolution_action(content) == "completed"


class TestRejectionMetrics:
    """Tests for RejectionMetrics dataclass."""

    def test_rates_empty(self) -> None:
        """Test rates with no data."""
        metrics = RejectionMetrics()
        assert metrics.rejection_rate == 0.0
        assert metrics.invalid_rate == 0.0

    def test_rates_calculation(self) -> None:
        """Test rate calculations."""
        metrics = RejectionMetrics(
            total_closed=100,
            rejected_count=10,
            invalid_count=5,
            completed_count=85,
        )
        assert metrics.rejection_rate == 0.1
        assert metrics.invalid_rate == 0.05

    def test_to_dict(self) -> None:
        """Test serialization."""
        metrics = RejectionMetrics(
            total_closed=100,
            rejected_count=10,
            invalid_count=5,
        )
        result = metrics.to_dict()
        assert result["total_closed"] == 100
        assert result["rejection_rate"] == 0.1
        assert result["invalid_rate"] == 0.05


class TestRejectionAnalysis:
    """Tests for RejectionAnalysis dataclass."""

    def test_to_dict_empty(self) -> None:
        """Test serialization with no data."""
        analysis = RejectionAnalysis()
        result = analysis.to_dict()
        assert result["overall"]["total_closed"] == 0
        assert result["by_type"] == {}
        assert result["by_month"] == {}

    def test_to_dict_with_data(self) -> None:
        """Test serialization with data."""
        analysis = RejectionAnalysis(
            overall=RejectionMetrics(total_closed=10, rejected_count=2),
            by_type={"BUG": RejectionMetrics(total_closed=5, rejected_count=1)},
            common_reasons=[("duplicate", 3), ("invalid", 2)],
            trend="improving",
        )
        result = analysis.to_dict()
        assert result["overall"]["total_closed"] == 10
        assert "BUG" in result["by_type"]
        assert len(result["common_reasons"]) == 2
        assert result["trend"] == "improving"


class TestAnalyzeRejectionRates:
    """Tests for analyze_rejection_rates function."""

    def test_empty_issues(self) -> None:
        """Test with empty list."""
        result = analyze_rejection_rates([])
        assert result.overall.total_closed == 0

    def test_all_completed(self, tmp_path: Path) -> None:
        """Test with all completed issues."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("""## Resolution

- **Action**: fix
- **Completed**: 2026-01-15
- **Status**: Completed
""")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 15),
            )
        ]
        result = analyze_rejection_rates(issues)
        assert result.overall.total_closed == 1
        assert result.overall.completed_count == 1
        assert result.overall.rejected_count == 0

    def test_mixed_outcomes(self, tmp_path: Path) -> None:
        """Test with mixed completion outcomes."""
        completed = tmp_path / "P1-BUG-001.md"
        completed.write_text("- **Action**: fix\n- **Status**: Completed")

        rejected = tmp_path / "P2-BUG-002.md"
        rejected.write_text("- **Status**: Closed\n- **Reason**: rejected")

        invalid = tmp_path / "P3-ENH-001.md"
        invalid.write_text("- **Status**: Closed\n- **Reason**: invalid_ref")

        issues = [
            CompletedIssue(
                path=completed,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 15),
            ),
            CompletedIssue(
                path=rejected,
                issue_type="BUG",
                priority="P2",
                issue_id="BUG-002",
                completed_date=date(2026, 1, 16),
            ),
            CompletedIssue(
                path=invalid,
                issue_type="ENH",
                priority="P3",
                issue_id="ENH-001",
                completed_date=date(2026, 1, 17),
            ),
        ]
        result = analyze_rejection_rates(issues)
        assert result.overall.total_closed == 3
        assert result.overall.completed_count == 1
        assert result.overall.rejected_count == 1
        assert result.overall.invalid_count == 1

    def test_by_type_grouping(self, tmp_path: Path) -> None:
        """Test grouping by issue type."""
        bug = tmp_path / "P1-BUG-001.md"
        bug.write_text("- **Status**: Closed\n- **Reason**: rejected")

        enh = tmp_path / "P2-ENH-001.md"
        enh.write_text("- **Action**: improve\n- **Status**: Completed")

        issues = [
            CompletedIssue(
                path=bug,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 15),
            ),
            CompletedIssue(
                path=enh,
                issue_type="ENH",
                priority="P2",
                issue_id="ENH-001",
                completed_date=date(2026, 1, 16),
            ),
        ]
        result = analyze_rejection_rates(issues)
        assert "BUG" in result.by_type
        assert "ENH" in result.by_type
        assert result.by_type["BUG"].rejected_count == 1
        assert result.by_type["ENH"].completed_count == 1

    def test_by_month_grouping(self, tmp_path: Path) -> None:
        """Test grouping by month."""
        jan = tmp_path / "P1-BUG-001.md"
        jan.write_text("- **Action**: fix")

        feb = tmp_path / "P2-BUG-002.md"
        feb.write_text("- **Status**: Closed\n- **Reason**: rejected")

        issues = [
            CompletedIssue(
                path=jan,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 15),
            ),
            CompletedIssue(
                path=feb,
                issue_type="BUG",
                priority="P2",
                issue_id="BUG-002",
                completed_date=date(2026, 2, 15),
            ),
        ]
        result = analyze_rejection_rates(issues)
        assert "2026-01" in result.by_month
        assert "2026-02" in result.by_month

    def test_common_reasons_extracted(self, tmp_path: Path) -> None:
        """Test common reasons extraction."""
        f1 = tmp_path / "P1-BUG-001.md"
        f1.write_text("- **Status**: Closed\n- **Reason**: duplicate of BUG-100")

        f2 = tmp_path / "P2-BUG-002.md"
        f2.write_text("- **Status**: Closed\n- **Reason**: duplicate of BUG-100")

        issues = [
            CompletedIssue(
                path=f1,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 15),
            ),
            CompletedIssue(
                path=f2,
                issue_type="BUG",
                priority="P2",
                issue_id="BUG-002",
                completed_date=date(2026, 1, 16),
            ),
        ]
        result = analyze_rejection_rates(issues)
        assert len(result.common_reasons) > 0
        assert result.common_reasons[0][0] == "duplicate of BUG-100"
        assert result.common_reasons[0][1] == 2


class TestManualPattern:
    """Tests for ManualPattern dataclass."""

    def test_to_dict(self) -> None:
        """Test to_dict serialization."""
        pattern = ManualPattern(
            pattern_type="test",
            pattern_description="Test execution after code changes",
            occurrence_count=5,
            affected_issues=["BUG-001", "BUG-002", "ENH-003"],
            example_commands=["pytest", "python -m pytest"],
            suggested_automation="Add post-edit hook for automatic test runs",
            automation_complexity="trivial",
        )
        result = pattern.to_dict()

        assert result["pattern_type"] == "test"
        assert result["pattern_description"] == "Test execution after code changes"
        assert result["occurrence_count"] == 5
        assert len(result["affected_issues"]) == 3
        assert result["automation_complexity"] == "trivial"

    def test_to_dict_limits_lists(self) -> None:
        """Test that to_dict limits affected_issues and example_commands."""
        pattern = ManualPattern(
            pattern_type="test",
            pattern_description="Test execution",
            occurrence_count=15,
            affected_issues=[f"BUG-{i:03d}" for i in range(15)],
            example_commands=[f"cmd{i}" for i in range(10)],
        )
        result = pattern.to_dict()

        assert len(result["affected_issues"]) == 10
        assert len(result["example_commands"]) == 5


class TestManualPatternAnalysis:
    """Tests for ManualPatternAnalysis dataclass."""

    def test_to_dict_empty(self) -> None:
        """Test to_dict with empty analysis."""
        analysis = ManualPatternAnalysis()
        result = analysis.to_dict()

        assert result["patterns"] == []
        assert result["total_manual_interventions"] == 0
        assert result["automatable_count"] == 0
        assert result["automatable_percentage"] == 0.0

    def test_automatable_percentage(self) -> None:
        """Test automatable_percentage calculation."""
        analysis = ManualPatternAnalysis(
            total_manual_interventions=100,
            automatable_count=67,
        )
        assert analysis.automatable_percentage == 67.0

    def test_automatable_percentage_zero_total(self) -> None:
        """Test automatable_percentage with zero total."""
        analysis = ManualPatternAnalysis(
            total_manual_interventions=0,
            automatable_count=0,
        )
        assert analysis.automatable_percentage == 0.0

    def test_to_dict_with_patterns(self) -> None:
        """Test to_dict with patterns."""
        pattern = ManualPattern(
            pattern_type="test",
            pattern_description="Test execution",
            occurrence_count=5,
        )
        analysis = ManualPatternAnalysis(
            patterns=[pattern],
            total_manual_interventions=5,
            automatable_count=5,
            automation_suggestions=["Add post-edit hook"],
        )
        result = analysis.to_dict()

        assert len(result["patterns"]) == 1
        assert result["total_manual_interventions"] == 5
        assert result["automatable_percentage"] == 100.0


class TestDetectManualPatterns:
    """Tests for detect_manual_patterns function."""

    def test_empty_issues(self) -> None:
        """Test with empty list."""
        result = detect_manual_patterns([])
        assert result.total_manual_interventions == 0
        assert result.patterns == []

    def test_detects_pytest(self, tmp_path: Path) -> None:
        """Test detection of pytest commands."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("""## Changes Made

Ran `pytest tests/` to verify the fix.
Also executed `python -m pytest -v` for verbose output.
""")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        result = detect_manual_patterns(issues)

        assert result.total_manual_interventions >= 2
        test_patterns = [p for p in result.patterns if p.pattern_type == "test"]
        assert len(test_patterns) == 1
        assert test_patterns[0].occurrence_count >= 2
        assert "BUG-001" in test_patterns[0].affected_issues

    def test_detects_lint_commands(self, tmp_path: Path) -> None:
        """Test detection of lint/format commands."""
        issue_file = tmp_path / "P2-ENH-001.md"
        issue_file.write_text("""## Implementation

Ran `ruff check scripts/` and `ruff format scripts/` to fix issues.
""")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="ENH",
                priority="P2",
                issue_id="ENH-001",
            )
        ]
        result = detect_manual_patterns(issues)

        lint_patterns = [p for p in result.patterns if p.pattern_type == "lint"]
        assert len(lint_patterns) == 1
        assert lint_patterns[0].occurrence_count >= 2

    def test_detects_multiple_patterns(self, tmp_path: Path) -> None:
        """Test detection of multiple pattern types."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("""## Implementation

1. Ran `pytest` to identify failing tests
2. Ran `mypy scripts/` for type checking
3. Ran `ruff format` to fix formatting
""")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        result = detect_manual_patterns(issues)

        pattern_types = {p.pattern_type for p in result.patterns}
        assert "test" in pattern_types
        assert "type_check" in pattern_types
        assert "lint" in pattern_types

    def test_aggregates_across_issues(self, tmp_path: Path) -> None:
        """Test pattern aggregation across multiple issues."""
        issue1 = tmp_path / "P1-BUG-001.md"
        issue1.write_text("Ran `pytest` to verify.")

        issue2 = tmp_path / "P2-BUG-002.md"
        issue2.write_text("Executed `pytest tests/` to check.")

        issues = [
            CompletedIssue(
                path=issue1,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            ),
            CompletedIssue(
                path=issue2,
                issue_type="BUG",
                priority="P2",
                issue_id="BUG-002",
            ),
        ]
        result = detect_manual_patterns(issues)

        test_patterns = [p for p in result.patterns if p.pattern_type == "test"]
        assert len(test_patterns) == 1
        assert test_patterns[0].occurrence_count >= 2
        assert "BUG-001" in test_patterns[0].affected_issues
        assert "BUG-002" in test_patterns[0].affected_issues

    def test_file_read_error_handled(self, tmp_path: Path) -> None:
        """Test that file read errors are handled gracefully."""
        nonexistent = tmp_path / "nonexistent.md"
        issues = [
            CompletedIssue(
                path=nonexistent,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        # Should not raise
        result = detect_manual_patterns(issues)
        assert result.total_manual_interventions == 0

    def test_automation_suggestions_populated(self, tmp_path: Path) -> None:
        """Test that automation suggestions are populated from patterns."""
        issue1 = tmp_path / "P1-BUG-001.md"
        issue1.write_text("Ran pytest and mypy.")

        issue2 = tmp_path / "P1-BUG-002.md"
        issue2.write_text("Ran pytest and mypy again.")

        issues = [
            CompletedIssue(
                path=issue1,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            ),
            CompletedIssue(
                path=issue2,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-002",
            ),
        ]
        result = detect_manual_patterns(issues)

        # Should have suggestions for patterns with >= 2 occurrences
        assert len(result.automation_suggestions) > 0

    def test_patterns_sorted_by_occurrence(self, tmp_path: Path) -> None:
        """Test that patterns are sorted by occurrence count descending."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("""
pytest pytest pytest pytest pytest
mypy mypy
ruff check
""")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        result = detect_manual_patterns(issues)

        # Patterns should be sorted by occurrence count descending
        if len(result.patterns) >= 2:
            for i in range(len(result.patterns) - 1):
                assert (
                    result.patterns[i].occurrence_count >= result.patterns[i + 1].occurrence_count
                )


class TestAgentOutcome:
    """Tests for AgentOutcome dataclass."""

    def test_rates_empty(self) -> None:
        """Test rates with no data."""
        outcome = AgentOutcome(agent_name="ll-auto", issue_type="BUG")
        assert outcome.total_count == 0
        assert outcome.success_rate == 0.0

    def test_rates_calculation(self) -> None:
        """Test rate calculations."""
        outcome = AgentOutcome(
            agent_name="ll-auto",
            issue_type="BUG",
            success_count=8,
            failure_count=1,
            rejection_count=1,
        )
        assert outcome.total_count == 10
        assert outcome.success_rate == 0.8

    def test_to_dict(self) -> None:
        """Test serialization."""
        outcome = AgentOutcome(
            agent_name="ll-parallel",
            issue_type="ENH",
            success_count=5,
            failure_count=0,
            rejection_count=0,
        )
        result = outcome.to_dict()
        assert result["agent_name"] == "ll-parallel"
        assert result["issue_type"] == "ENH"
        assert result["success_rate"] == 1.0


class TestAgentEffectivenessAnalysis:
    """Tests for AgentEffectivenessAnalysis dataclass."""

    def test_to_dict_empty(self) -> None:
        """Test serialization with no data."""
        analysis = AgentEffectivenessAnalysis()
        result = analysis.to_dict()
        assert result["outcomes"] == []
        assert result["best_agent_by_type"] == {}
        assert result["problematic_combinations"] == []

    def test_to_dict_with_data(self) -> None:
        """Test serialization with data."""
        analysis = AgentEffectivenessAnalysis(
            outcomes=[
                AgentOutcome("ll-auto", "BUG", success_count=10),
            ],
            best_agent_by_type={"BUG": "ll-auto"},
            problematic_combinations=[("ll-parallel", "FEAT", "40% success")],
        )
        result = analysis.to_dict()
        assert len(result["outcomes"]) == 1
        assert result["best_agent_by_type"]["BUG"] == "ll-auto"


class TestDetectProcessingAgent:
    """Tests for _detect_processing_agent function."""

    def test_detected_from_discovered_source_parallel(self) -> None:
        """Test detection from discovered_source field."""
        result = _detect_processing_agent("", "ll-parallel-blender-agents-debug.log")
        assert result == "ll-parallel"

    def test_detected_from_discovered_source_auto(self) -> None:
        """Test detection from discovered_source field."""
        result = _detect_processing_agent("", "ll-auto-run-2026-01-01.log")
        assert result == "ll-auto"

    def test_detected_from_log_type_field(self) -> None:
        """Test detection from Log Type field in content."""
        content = "**Log Type**: ll-parallel\n"
        result = _detect_processing_agent(content, None)
        assert result == "ll-parallel"

    def test_detected_from_tool_field(self) -> None:
        """Test detection from Tool field in content."""
        content = "**Tool**: ll-auto\n"
        result = _detect_processing_agent(content, None)
        assert result == "ll-auto"

    def test_default_to_manual(self) -> None:
        """Test default to manual when no indicators."""
        result = _detect_processing_agent("Regular issue content", None)
        assert result == "manual"

    def test_discovered_source_takes_priority(self) -> None:
        """Test that discovered_source is checked first."""
        content = "**Tool**: ll-auto\n"
        result = _detect_processing_agent(content, "ll-parallel-debug.log")
        assert result == "ll-parallel"


class TestAnalyzeAgentEffectiveness:
    """Tests for analyze_agent_effectiveness function."""

    def test_empty_issues(self) -> None:
        """Test with empty list."""
        result = analyze_agent_effectiveness([])
        assert result.outcomes == []

    def test_single_completed_issue(self, tmp_path: Path) -> None:
        """Test with single completed issue."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("**Log Type**: ll-auto\n\n## Resolution\n\n- **Action**: fix\n")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        result = analyze_agent_effectiveness(issues)
        assert len(result.outcomes) == 1
        assert result.outcomes[0].agent_name == "ll-auto"
        assert result.outcomes[0].success_count == 1

    def test_grouped_by_agent_and_type(self, tmp_path: Path) -> None:
        """Test grouping by agent and type."""
        issue1 = tmp_path / "P1-BUG-001.md"
        issue1.write_text("**Tool**: ll-auto\n- **Action**: fix")

        issue2 = tmp_path / "P1-BUG-002.md"
        issue2.write_text("**Tool**: ll-auto\n- **Action**: fix")

        issue3 = tmp_path / "P1-ENH-001.md"
        issue3.write_text("**Tool**: ll-auto\n- **Action**: improve")

        issues = [
            CompletedIssue(path=issue1, issue_type="BUG", priority="P1", issue_id="BUG-001"),
            CompletedIssue(path=issue2, issue_type="BUG", priority="P1", issue_id="BUG-002"),
            CompletedIssue(path=issue3, issue_type="ENH", priority="P1", issue_id="ENH-001"),
        ]
        result = analyze_agent_effectiveness(issues)

        # Should have 2 outcomes: (ll-auto, BUG) and (ll-auto, ENH)
        assert len(result.outcomes) == 2

        bug_outcome = next(o for o in result.outcomes if o.issue_type == "BUG")
        assert bug_outcome.success_count == 2

        enh_outcome = next(o for o in result.outcomes if o.issue_type == "ENH")
        assert enh_outcome.success_count == 1

    def test_rejection_counted(self, tmp_path: Path) -> None:
        """Test that rejections are counted correctly."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("- **Status**: Closed\n- **Reason**: rejected")

        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        result = analyze_agent_effectiveness(issues)
        assert result.outcomes[0].rejection_count == 1
        assert result.outcomes[0].success_count == 0

    def test_best_agent_requires_minimum_samples(self, tmp_path: Path) -> None:
        """Test that best_agent_by_type requires minimum sample size."""
        # Create only 2 issues (below threshold of 3)
        for i in range(2):
            issue = tmp_path / f"P1-BUG-{i:03d}.md"
            issue.write_text("**Tool**: ll-auto\n- **Action**: fix")

        issues = [
            CompletedIssue(
                path=tmp_path / f"P1-BUG-{i:03d}.md",
                issue_type="BUG",
                priority="P1",
                issue_id=f"BUG-{i:03d}",
            )
            for i in range(2)
        ]
        result = analyze_agent_effectiveness(issues)

        # Should not have best agent due to low sample size
        assert "BUG" not in result.best_agent_by_type

    def test_best_agent_with_sufficient_samples(self, tmp_path: Path) -> None:
        """Test that best_agent_by_type works with sufficient samples."""
        # Create 3 issues (meets threshold)
        for i in range(3):
            issue = tmp_path / f"P1-BUG-{i:03d}.md"
            issue.write_text("**Tool**: ll-auto\n- **Action**: fix")

        issues = [
            CompletedIssue(
                path=tmp_path / f"P1-BUG-{i:03d}.md",
                issue_type="BUG",
                priority="P1",
                issue_id=f"BUG-{i:03d}",
            )
            for i in range(3)
        ]
        result = analyze_agent_effectiveness(issues)

        # Should have best agent
        assert result.best_agent_by_type.get("BUG") == "ll-auto"

    def test_problematic_combination_detected(self, tmp_path: Path) -> None:
        """Test problematic combinations are detected."""
        # Create 6 issues with 2 success and 4 failures for ll-auto BUG
        for i in range(6):
            issue = tmp_path / f"P1-BUG-{i:03d}.md"
            if i < 2:
                issue.write_text("**Tool**: ll-auto\n- **Action**: fix")
            else:
                issue.write_text("**Tool**: ll-auto\n- **Status**: Closed\n- **Reason**: rejected")

        issues = [
            CompletedIssue(
                path=tmp_path / f"P1-BUG-{i:03d}.md",
                issue_type="BUG",
                priority="P1",
                issue_id=f"BUG-{i:03d}",
            )
            for i in range(6)
        ]
        result = analyze_agent_effectiveness(issues)

        # Should have problematic combination (33% success < 50%)
        assert len(result.problematic_combinations) == 1
        assert result.problematic_combinations[0][0] == "ll-auto"
        assert result.problematic_combinations[0][1] == "BUG"

    def test_multiple_agents(self, tmp_path: Path) -> None:
        """Test with multiple different agents."""
        issue1 = tmp_path / "P1-BUG-001.md"
        issue1.write_text("**Tool**: ll-auto\n- **Action**: fix")

        issue2 = tmp_path / "P1-BUG-002.md"
        issue2.write_text("**Tool**: ll-parallel\n- **Action**: fix")

        issue3 = tmp_path / "P1-BUG-003.md"
        issue3.write_text("Regular issue content\n- **Action**: fix")  # manual

        issues = [
            CompletedIssue(path=issue1, issue_type="BUG", priority="P1", issue_id="BUG-001"),
            CompletedIssue(path=issue2, issue_type="BUG", priority="P1", issue_id="BUG-002"),
            CompletedIssue(path=issue3, issue_type="BUG", priority="P1", issue_id="BUG-003"),
        ]
        result = analyze_agent_effectiveness(issues)

        # Should have 3 outcomes: (ll-auto, BUG), (ll-parallel, BUG), (manual, BUG)
        assert len(result.outcomes) == 3
        agent_names = {o.agent_name for o in result.outcomes}
        assert agent_names == {"ll-auto", "ll-parallel", "manual"}


class TestComplexityProxy:
    """Tests for ComplexityProxy dataclass."""

    def test_to_dict(self) -> None:
        """Test to_dict serialization."""
        proxy = ComplexityProxy(
            path="src/core/processor.py",
            avg_resolution_days=6.8,
            median_resolution_days=5.2,
            issue_count=12,
            slowest_issue=("BUG-031", 14.5),
            complexity_score=0.89,
            comparison_to_baseline="2.7x baseline",
        )
        result = proxy.to_dict()

        assert result["path"] == "src/core/processor.py"
        assert result["avg_resolution_days"] == 6.8
        assert result["median_resolution_days"] == 5.2
        assert result["issue_count"] == 12
        assert result["slowest_issue"]["issue_id"] == "BUG-031"
        assert result["slowest_issue"]["days"] == 14.5
        assert result["complexity_score"] == 0.89
        assert result["comparison_to_baseline"] == "2.7x baseline"


class TestComplexityProxyAnalysis:
    """Tests for ComplexityProxyAnalysis dataclass."""

    def test_to_dict_empty(self) -> None:
        """Test to_dict with empty lists."""
        analysis = ComplexityProxyAnalysis()
        result = analysis.to_dict()

        assert result["file_complexity"] == []
        assert result["directory_complexity"] == []
        assert result["baseline_days"] == 0.0
        assert result["complexity_outliers"] == []

    def test_to_dict_with_data(self) -> None:
        """Test to_dict with complexity data."""
        proxy = ComplexityProxy(
            path="test.py",
            avg_resolution_days=5.0,
            median_resolution_days=4.0,
            issue_count=3,
            slowest_issue=("BUG-001", 8.0),
            complexity_score=0.5,
            comparison_to_baseline="2.0x baseline",
        )
        analysis = ComplexityProxyAnalysis(
            file_complexity=[proxy],
            baseline_days=2.5,
            complexity_outliers=["test.py"],
        )
        result = analysis.to_dict()

        assert len(result["file_complexity"]) == 1
        assert result["baseline_days"] == 2.5
        assert result["complexity_outliers"] == ["test.py"]


class TestAnalyzeComplexityProxy:
    """Tests for analyze_complexity_proxy function."""

    def test_empty_issues(self) -> None:
        """Test with empty issues list."""
        result = analyze_complexity_proxy([], HotspotAnalysis())
        assert result.file_complexity == []
        assert result.directory_complexity == []
        assert result.baseline_days == 0.0

    def test_issues_without_dates(self, tmp_path: Path) -> None:
        """Test with issues missing discovered_date."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("**File**: `src/test.py`\n\nBug description.")

        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                completed_date=date(2026, 1, 15),
                # No discovered_date
            )
        ]

        result = analyze_complexity_proxy(issues, HotspotAnalysis())
        assert result.file_complexity == []  # No duration calculable

    def test_duration_calculation(self, tmp_path: Path) -> None:
        """Test that durations are calculated correctly."""
        # Create two issues for same file with different durations
        issues = []
        for i, (disc, comp) in enumerate(
            [
                (date(2026, 1, 1), date(2026, 1, 5)),  # 4 days
                (date(2026, 1, 10), date(2026, 1, 20)),  # 10 days
            ]
        ):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text("**File**: `src/complex.py`\n\nBug in complex code.")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                    discovered_date=disc,
                    completed_date=comp,
                )
            )

        result = analyze_complexity_proxy(issues, HotspotAnalysis())

        # Should have file complexity for src/complex.py
        assert len(result.file_complexity) == 1
        fc = result.file_complexity[0]
        assert fc.path == "src/complex.py"
        assert fc.avg_resolution_days == 7.0  # (4 + 10) / 2
        assert fc.issue_count == 2

    def test_outlier_detection(self, tmp_path: Path) -> None:
        """Test that outliers >2x baseline are detected."""
        issues = []
        # Create 3 issues: 2 fast (2 days each), 1 slow (10 days)
        durations = [
            (date(2026, 1, 1), date(2026, 1, 3), "fast1.py"),  # 2 days
            (date(2026, 1, 1), date(2026, 1, 3), "fast2.py"),  # 2 days
            (date(2026, 1, 1), date(2026, 1, 11), "slow.py"),  # 10 days
        ]
        for i, (disc, comp, file) in enumerate(durations):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text(f"**File**: `src/{file}`\n\nBug.")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                    discovered_date=disc,
                    completed_date=comp,
                )
            )
        # Add duplicate issues for same files to meet 2-issue threshold
        for i, (disc, comp, file) in enumerate(durations):
            issue_file = tmp_path / f"P1-ENH-{i:03d}.md"
            issue_file.write_text(f"**File**: `src/{file}`\n\nEnhancement.")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="ENH",
                    priority="P1",
                    issue_id=f"ENH-{i:03d}",
                    discovered_date=disc,
                    completed_date=comp,
                )
            )

        result = analyze_complexity_proxy(issues, HotspotAnalysis())

        # Baseline should be 2 days (median of [2, 2, 2, 2, 10, 10])
        # slow.py at 10 days should be an outlier (>4 days = 2x baseline)
        assert "src/slow.py" in result.complexity_outliers

    def test_single_issue_per_file_excluded(self, tmp_path: Path) -> None:
        """Test that files with only 1 issue are excluded from file complexity."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("**File**: `src/single.py`\n\nBug.")

        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
                discovered_date=date(2026, 1, 1),
                completed_date=date(2026, 1, 5),
            )
        ]

        result = analyze_complexity_proxy(issues, HotspotAnalysis())

        # Should have no file complexity (need at least 2 data points)
        assert len(result.file_complexity) == 0
        # But baseline should still be calculated
        assert result.baseline_days == 4.0

    def test_baseline_calculation(self, tmp_path: Path) -> None:
        """Test baseline is calculated as median."""
        issues = []
        # 5 issues with durations: 1, 2, 3, 4, 10 days
        # Median should be 3
        for i, days in enumerate([1, 2, 3, 4, 10]):
            issue_file = tmp_path / f"P1-BUG-{i:03d}.md"
            issue_file.write_text(f"**File**: `src/file{i}.py`\n\nBug.")
            issues.append(
                CompletedIssue(
                    path=issue_file,
                    issue_type="BUG",
                    priority="P1",
                    issue_id=f"BUG-{i:03d}",
                    discovered_date=date(2026, 1, 1),
                    completed_date=date(2026, 1, 1) + timedelta(days=days),
                )
            )

        result = analyze_complexity_proxy(issues, HotspotAnalysis())

        # Median of [1, 2, 3, 4, 10] = 3
        assert result.baseline_days == 3.0


class TestConfigGap:
    """Tests for ConfigGap dataclass."""

    def test_creation(self) -> None:
        """Test ConfigGap creation with all fields."""
        gap = ConfigGap(
            gap_type="hook",
            description="Automatic test execution",
            evidence=["BUG-001", "BUG-002"],
            suggested_config="hooks/hooks.json: {...}",
            priority="high",
            pattern_type="test",
        )

        assert gap.gap_type == "hook"
        assert gap.description == "Automatic test execution"
        assert gap.priority == "high"
        assert gap.pattern_type == "test"
        assert len(gap.evidence) == 2

    def test_to_dict(self) -> None:
        """Test ConfigGap serialization."""
        gap = ConfigGap(
            gap_type="hook",
            description="Test execution",
            evidence=["BUG-001", "BUG-002", "BUG-003"],
            suggested_config="config here",
            priority="medium",
            pattern_type="test",
        )

        d = gap.to_dict()
        assert d["gap_type"] == "hook"
        assert d["priority"] == "medium"
        assert d["pattern_type"] == "test"
        assert len(d["evidence"]) == 3

    def test_to_dict_truncates_evidence(self) -> None:
        """Test that to_dict truncates long evidence lists."""
        evidence = [f"BUG-{i:03d}" for i in range(20)]
        gap = ConfigGap(
            gap_type="hook",
            description="Test",
            evidence=evidence,
        )

        d = gap.to_dict()
        assert len(d["evidence"]) == 10  # Truncated to 10


class TestConfigGapsAnalysis:
    """Tests for ConfigGapsAnalysis dataclass."""

    def test_creation(self) -> None:
        """Test ConfigGapsAnalysis creation."""
        analysis = ConfigGapsAnalysis(
            gaps=[
                ConfigGap(
                    gap_type="hook",
                    description="Test hook",
                    priority="high",
                )
            ],
            current_hooks=["SessionStart", "Stop"],
            current_skills=["skill1"],
            current_agents=["agent1"],
            coverage_score=0.65,
        )

        assert len(analysis.gaps) == 1
        assert analysis.coverage_score == 0.65
        assert len(analysis.current_hooks) == 2
        assert len(analysis.current_skills) == 1
        assert len(analysis.current_agents) == 1

    def test_to_dict(self) -> None:
        """Test ConfigGapsAnalysis serialization."""
        analysis = ConfigGapsAnalysis(
            gaps=[
                ConfigGap(
                    gap_type="hook",
                    description="Test hook",
                    priority="high",
                )
            ],
            current_hooks=["SessionStart"],
            current_skills=["skill1", "skill2"],
            current_agents=["agent1"],
            coverage_score=0.666,
        )

        d = analysis.to_dict()
        assert d["coverage_score"] == 0.67  # Rounded to 2 decimal places
        assert len(d["gaps"]) == 1
        assert len(d["current_hooks"]) == 1
        assert len(d["current_skills"]) == 2


class TestDetectConfigGaps:
    """Tests for detect_config_gaps function."""

    def test_empty_manual_patterns(self) -> None:
        """Test detect_config_gaps with empty manual patterns."""
        mpa = ManualPatternAnalysis()
        result = detect_config_gaps(mpa)

        # Empty patterns should give full coverage (nothing to cover)
        assert result.coverage_score == 1.0
        assert len(result.gaps) == 0

    def test_with_no_config_files(self, tmp_path: Path) -> None:
        """Test detect_config_gaps when no config files exist."""
        mpa = ManualPatternAnalysis(
            patterns=[
                ManualPattern(
                    pattern_type="test",
                    pattern_description="Test execution",
                    occurrence_count=8,
                    affected_issues=["BUG-001", "BUG-002"],
                ),
            ],
            total_manual_interventions=8,
            automatable_count=8,
        )

        result = detect_config_gaps(mpa, project_root=tmp_path)

        # No hooks, skills, or agents
        assert len(result.current_hooks) == 0
        assert len(result.current_skills) == 0
        assert len(result.current_agents) == 0
        # Should identify a gap for test
        assert len(result.gaps) == 1
        assert result.gaps[0].pattern_type == "test"

    def test_with_existing_hooks(self, tmp_path: Path) -> None:
        """Test detect_config_gaps recognizes existing hooks."""
        # Create a hooks.json with PostToolUse hook
        hooks_dir = tmp_path / "hooks"
        hooks_dir.mkdir()
        hooks_file = hooks_dir / "hooks.json"
        hooks_file.write_text('{"hooks": {"PostToolUse": [], "SessionStart": []}}')

        mpa = ManualPatternAnalysis(
            patterns=[
                ManualPattern(
                    pattern_type="test",  # Maps to PostToolUse
                    pattern_description="Test execution",
                    occurrence_count=8,
                    affected_issues=["BUG-001"],
                ),
                ManualPattern(
                    pattern_type="lint",  # Maps to PreToolUse
                    pattern_description="Lint fixes",
                    occurrence_count=5,
                    affected_issues=["ENH-001"],
                ),
            ],
            total_manual_interventions=13,
            automatable_count=13,
        )

        result = detect_config_gaps(mpa, project_root=tmp_path)

        # Should have PostToolUse and SessionStart hooks
        assert "PostToolUse" in result.current_hooks
        assert "SessionStart" in result.current_hooks
        # Only lint should be a gap (test is covered by PostToolUse)
        assert len(result.gaps) == 1
        assert result.gaps[0].pattern_type == "lint"
        # Coverage should be 50% (1 of 2 patterns covered)
        assert result.coverage_score == 0.5

    def test_priority_assignment(self, tmp_path: Path) -> None:
        """Test that priority is assigned based on occurrence count."""
        mpa = ManualPatternAnalysis(
            patterns=[
                ManualPattern(
                    pattern_type="test",
                    pattern_description="Test execution",
                    occurrence_count=15,  # >= 10 -> high
                    affected_issues=["BUG-001"],
                ),
                ManualPattern(
                    pattern_type="lint",
                    pattern_description="Lint fixes",
                    occurrence_count=7,  # >= 5 -> medium
                    affected_issues=["ENH-001"],
                ),
                ManualPattern(
                    pattern_type="type_check",
                    pattern_description="Type checking",
                    occurrence_count=3,  # < 5 -> low
                    affected_issues=["ENH-002"],
                ),
            ],
        )

        result = detect_config_gaps(mpa, project_root=tmp_path)

        # Gaps should be sorted by priority
        assert len(result.gaps) == 3
        assert result.gaps[0].priority == "high"
        assert result.gaps[1].priority == "medium"
        assert result.gaps[2].priority == "low"

    def test_discovers_agents_and_skills(self, tmp_path: Path) -> None:
        """Test that detect_config_gaps discovers agents and skills."""
        # Create agents directory with some agent files
        agents_dir = tmp_path / "agents"
        agents_dir.mkdir()
        (agents_dir / "test-agent.md").write_text("# Test Agent")
        (agents_dir / "another-agent.md").write_text("# Another Agent")

        # Create skills directory with skill subdirectories
        skills_dir = tmp_path / "skills"
        skills_dir.mkdir()
        skill1_dir = skills_dir / "skill-one"
        skill1_dir.mkdir()
        (skill1_dir / "SKILL.md").write_text("# Skill One")
        skill2_dir = skills_dir / "skill-two"
        skill2_dir.mkdir()
        (skill2_dir / "SKILL.md").write_text("# Skill Two")
        # This one shouldn't be detected (no SKILL.md)
        skill3_dir = skills_dir / "not-a-skill"
        skill3_dir.mkdir()

        mpa = ManualPatternAnalysis()
        result = detect_config_gaps(mpa, project_root=tmp_path)

        assert len(result.current_agents) == 2
        assert "test-agent" in result.current_agents
        assert "another-agent" in result.current_agents

        assert len(result.current_skills) == 2
        assert "skill-one" in result.current_skills
        assert "skill-two" in result.current_skills
        assert "not-a-skill" not in result.current_skills

    def test_unrecognized_pattern_type(self, tmp_path: Path) -> None:
        """Test that unrecognized pattern types don't create gaps."""
        mpa = ManualPatternAnalysis(
            patterns=[
                ManualPattern(
                    pattern_type="unknown_type",
                    pattern_description="Unknown pattern",
                    occurrence_count=10,
                    affected_issues=["BUG-001"],
                ),
            ],
        )

        result = detect_config_gaps(mpa, project_root=tmp_path)

        # Unknown pattern types should be ignored
        assert len(result.gaps) == 0
        # Coverage is 1.0 since there are no recognized patterns to cover
        assert result.coverage_score == 1.0


class TestCrossCuttingSmell:
    """Tests for CrossCuttingSmell dataclass."""

    def test_to_dict(self) -> None:
        """Test to_dict conversion."""
        smell = CrossCuttingSmell(
            concern_type="logging",
            affected_directories=["src/api", "src/core", "src/utils"],
            issue_count=3,
            issue_ids=["BUG-001", "ENH-002", "BUG-003"],
            scatter_score=0.75,
            suggested_pattern="decorator",
        )
        result = smell.to_dict()

        assert result["concern_type"] == "logging"
        assert len(result["affected_directories"]) == 3
        assert result["issue_count"] == 3
        assert result["scatter_score"] == 0.75
        assert result["suggested_pattern"] == "decorator"

    def test_to_dict_limits_lists(self) -> None:
        """Test that to_dict limits list lengths to 10."""
        smell = CrossCuttingSmell(
            concern_type="error-handling",
            affected_directories=[f"dir{i}" for i in range(15)],
            issue_ids=[f"BUG-{i:03d}" for i in range(15)],
        )
        result = smell.to_dict()

        assert len(result["affected_directories"]) == 10
        assert len(result["issue_ids"]) == 10


class TestCrossCuttingAnalysis:
    """Tests for CrossCuttingAnalysis dataclass."""

    def test_to_dict_empty(self) -> None:
        """Test to_dict with empty analysis."""
        analysis = CrossCuttingAnalysis()
        result = analysis.to_dict()

        assert result["smells"] == []
        assert result["most_scattered_concern"] == ""
        assert result["consolidation_opportunities"] == []

    def test_to_dict_with_smells(self) -> None:
        """Test to_dict with smells."""
        smell = CrossCuttingSmell(
            concern_type="logging",
            issue_count=2,
            scatter_score=0.5,
        )
        analysis = CrossCuttingAnalysis(
            smells=[smell],
            most_scattered_concern="logging",
            consolidation_opportunities=["Centralize logging (2 issues would benefit)"],
        )
        result = analysis.to_dict()

        assert len(result["smells"]) == 1
        assert result["most_scattered_concern"] == "logging"
        assert len(result["consolidation_opportunities"]) == 1


class TestDetectCrossCuttingSmells:
    """Tests for detect_cross_cutting_smells function."""

    def test_empty_issues(self) -> None:
        """Test with empty list."""
        result = detect_cross_cutting_smells([], HotspotAnalysis())
        assert result.smells == []
        assert result.most_scattered_concern == ""

    def test_issues_without_multi_directory(self, tmp_path: Path) -> None:
        """Test issues that don't touch multiple directories."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("""## Changes
Fixed logging in `src/api/handler.py`.
""")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        result = detect_cross_cutting_smells(issues, HotspotAnalysis())
        # Should not detect smell since only 1 directory
        assert result.smells == [] or all(s.issue_count == 0 for s in result.smells)

    def test_detects_logging_concern(self, tmp_path: Path) -> None:
        """Test detection of logging concern across directories."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("""## Summary
Added logging throughout the application.

## Changes Made
- `src/api/routes.py`: Added logger
- `src/core/engine.py`: Added debug logging
- `src/utils/helpers.py`: Added trace logging
- `scripts/cli.py`: Added logging setup
""")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        result = detect_cross_cutting_smells(issues, HotspotAnalysis())

        logging_smells = [s for s in result.smells if s.concern_type == "logging"]
        assert len(logging_smells) == 1
        assert logging_smells[0].issue_count == 1
        assert "BUG-001" in logging_smells[0].issue_ids

    def test_detects_error_handling_concern(self, tmp_path: Path) -> None:
        """Test detection of error handling concern."""
        issue_file = tmp_path / "P2-ENH-001.md"
        issue_file.write_text("""## Summary
Improved error handling across modules.

## Changes Made
- `src/api/handler.py`: Added try/except blocks
- `src/core/processor.py`: Improved exception handling
- `src/services/client.py`: Added error recovery
- `tests/test_api.py`: Added error test cases
""")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="ENH",
                priority="P2",
                issue_id="ENH-001",
            )
        ]
        result = detect_cross_cutting_smells(issues, HotspotAnalysis())

        error_smells = [s for s in result.smells if s.concern_type == "error-handling"]
        assert len(error_smells) == 1
        assert error_smells[0].suggested_pattern == "middleware"

    def test_aggregates_across_issues(self, tmp_path: Path) -> None:
        """Test aggregation across multiple issues."""
        issue1 = tmp_path / "P1-BUG-001.md"
        issue1.write_text(
            "Added validation to `src/api/v1/routes.py`, `src/core/models.py`, "
            "`src/utils/validators.py`."
        )

        issue2 = tmp_path / "P2-ENH-002.md"
        issue2.write_text(
            "More validation in `src/api/v2/routes.py`, `src/services/auth.py`, "
            "`tests/test_valid.py`."
        )

        issues = [
            CompletedIssue(path=issue1, issue_type="BUG", priority="P1", issue_id="BUG-001"),
            CompletedIssue(path=issue2, issue_type="ENH", priority="P2", issue_id="ENH-002"),
        ]
        result = detect_cross_cutting_smells(issues, HotspotAnalysis())

        validation_smells = [s for s in result.smells if s.concern_type == "validation"]
        if validation_smells:
            assert validation_smells[0].issue_count >= 1

    def test_file_read_error_handled(self, tmp_path: Path) -> None:
        """Test that file read errors are handled gracefully."""
        nonexistent = tmp_path / "nonexistent.md"
        issues = [
            CompletedIssue(
                path=nonexistent,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        # Should not raise
        result = detect_cross_cutting_smells(issues, HotspotAnalysis())
        assert result is not None

    def test_smells_sorted_by_scatter_score(self, tmp_path: Path) -> None:
        """Test that smells are sorted by scatter score descending."""
        issue_file = tmp_path / "P1-BUG-001.md"
        issue_file.write_text("""
logging logging logging
error error
validation
auth auth auth auth
caching

Files: `a/f1.py`, `b/f2.py`, `c/f3.py`, `d/f4.py`
""")
        issues = [
            CompletedIssue(
                path=issue_file,
                issue_type="BUG",
                priority="P1",
                issue_id="BUG-001",
            )
        ]
        result = detect_cross_cutting_smells(issues, HotspotAnalysis())

        if len(result.smells) >= 2:
            for i in range(len(result.smells) - 1):
                assert result.smells[i].scatter_score >= result.smells[i + 1].scatter_score
