"""Comprehensive unit tests for loom/validator.py.

Focuses on gaps NOT covered by:
  - tests/test_enrichment_validation.py (scanner + validator integration)
  - tests/test_graph_pruning.py (validate_task_graph)
  - tests/test_merge_trivial.py (merge_trivial_tasks)
  - tests/test_complexity.py (compute_complexity_score)

Test classes:
  1. TestNormalise — string normalisation helper
  2. TestContextText — _context_text helper
  3. TestFileExistenceRule — individual rule evaluation
  4. TestSymbolExistenceRule — individual rule evaluation
  5. TestKeywordPatternRule — individual rule evaluation
  6. TestMatchTaskToCodebase — aggregator function edge cases
  7. TestClassifyMatch — boundary / threshold tests
  8. TestValidateTaskGraphEdgeCases — edge cases not in test_graph_pruning
  9. TestMergeTrivialEdgeCases — edge cases not in test_merge_trivial
"""

from __future__ import annotations

import time

import pytest

from loom.scanner import CodebaseSummary, FileSymbols
from loom.validator import (
    FileExistenceRule,
    KeywordPatternRule,
    MatchResult,
    SymbolExistenceRule,
    ValidationResult,
    ValidationStatus,
    ValidatorConfig,
    _CycleError,
    _context_text,
    classify_match,
    match_task_to_codebase,
    merge_trivial_tasks,
    normalise,
    validate_task_graph,
)


# ── Factories ────────────────────────────────────────────────────────


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


def _empty_summary() -> CodebaseSummary:
    return CodebaseSummary(root="/tmp/empty", files=[])


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


def _merge_task(
    tid: str,
    title: str,
    *,
    description: str = "",
    task_type: str = "task",
    parent_id: str | None = None,
    dependencies: list[str] | None = None,
    files: list[str] | None = None,
    subtasks: list[str] | None = None,
    notes: str | None = None,
) -> dict:
    """Build a task dict for merge_trivial_tasks."""
    return {
        "id": tid,
        "title": title,
        "description": description,
        "type": task_type,
        "parent_id": parent_id,
        "dependencies": dependencies or [],
        "files": files or [],
        "subtasks": subtasks or [],
        "notes": notes,
    }


# ── 1. TestNormalise ─────────────────────────────────────────────────


class TestNormalise:
    """Tests for the normalise() string normalisation function."""

    def test_camel_case_split(self) -> None:
        tokens = normalise("createUserProfile")
        assert tokens == ["create", "user", "profile"]

    def test_pascal_case_split(self) -> None:
        tokens = normalise("CreateUserProfile")
        assert tokens == ["create", "user", "profile"]

    def test_stopword_removal(self) -> None:
        tokens = normalise("add the user for the system")
        assert "the" not in tokens
        assert "for" not in tokens
        assert "add" in tokens
        assert "user" in tokens
        assert "system" in tokens

    def test_empty_string(self) -> None:
        assert normalise("") == []

    def test_only_stopwords(self) -> None:
        assert normalise("the and or but") == []

    def test_underscore_split(self) -> None:
        tokens = normalise("create_user_profile")
        assert tokens == ["create", "user", "profile"]

    def test_hyphen_split(self) -> None:
        tokens = normalise("create-user-profile")
        assert tokens == ["create", "user", "profile"]

    def test_numeric_tokens_preserved(self) -> None:
        tokens = normalise("redis7 cluster setup")
        assert "redis7" in tokens
        assert "cluster" in tokens
        assert "setup" in tokens

    def test_unicode_lowered(self) -> None:
        tokens = normalise("Creer un profil utilisateur")
        assert "creer" in tokens
        assert "profil" in tokens
        assert "utilisateur" in tokens

    def test_mixed_case_acronym(self) -> None:
        """CamelCase with consecutive uppercase like 'HTTPServer' splits correctly."""
        tokens = normalise("HTTPServer")
        # Should split into ["http", "server"]
        assert "server" in tokens

    def test_single_word(self) -> None:
        tokens = normalise("deploy")
        assert tokens == ["deploy"]

    def test_special_characters_removed(self) -> None:
        tokens = normalise("create @user #profile!")
        assert "create" in tokens
        assert "user" in tokens
        assert "profile" in tokens


# ── 2. TestContextText ───────────────────────────────────────────────


class TestContextText:
    """Tests for the _context_text() helper."""

    def test_string_context(self) -> None:
        assert _context_text({"context": "some description text"}) == "some description text"

    def test_dict_context_with_description(self) -> None:
        result = _context_text({"context": {"description": "a task desc"}})
        assert "a task desc" in result

    def test_dict_context_with_notes(self) -> None:
        result = _context_text({"context": {"notes": "some notes"}})
        assert "some notes" in result

    def test_dict_context_with_files_list(self) -> None:
        result = _context_text({"context": {"files": ["file1.py", "file2.py"]}})
        assert "file1.py" in result
        assert "file2.py" in result

    def test_dict_context_multiple_fields(self) -> None:
        result = _context_text({
            "context": {
                "description": "main desc",
                "notes": "extra notes",
                "files": ["foo.py"],
            }
        })
        assert "main desc" in result
        assert "extra notes" in result
        assert "foo.py" in result

    def test_missing_context_key(self) -> None:
        assert _context_text({}) == ""

    def test_none_context(self) -> None:
        assert _context_text({"context": None}) == ""

    def test_integer_context(self) -> None:
        """Non-dict, non-string context returns empty."""
        assert _context_text({"context": 42}) == ""

    def test_dict_context_with_unknown_keys_ignored(self) -> None:
        result = _context_text({"context": {"random_key": "value"}})
        assert result == ""

    def test_files_list_with_non_strings(self) -> None:
        result = _context_text({"context": {"files": [42, None, "real.py"]}})
        assert "real.py" in result
        assert "42" in result


# ── 3. TestFileExistenceRule ─────────────────────────────────────────


class TestFileExistenceRule:
    """Tests for FileExistenceRule.evaluate()."""

    def setup_method(self) -> None:
        self.rule = FileExistenceRule()

    def test_exact_path_match_returns_confidence_1(self) -> None:
        summary = _summary(("loom/graph/store.py", [], []))
        task = {"title": "Update loom/graph/store.py", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 1.0
        assert result.rule_name == "file_existence"
        assert result.matched_artifact == "loom/graph/store.py"

    def test_stem_match_returns_lower_confidence(self) -> None:
        summary = _summary(("loom/graph/store.py", [], []))
        task = {"title": "Update store module", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 0.75
        assert "store" in result.rationale.lower()

    def test_no_match_returns_none(self) -> None:
        summary = _summary(("loom/graph/store.py", [], []))
        task = {"title": "Deploy to Kubernetes", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is None

    def test_empty_files_returns_none(self) -> None:
        summary = _empty_summary()
        task = {"title": "Create something", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is None

    def test_short_stem_skipped(self) -> None:
        """File stems shorter than 3 chars should be skipped to avoid false positives."""
        summary = _summary(("a/b.py", [], []))
        task = {"title": "Do something with b", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is None

    def test_path_match_in_context_field(self) -> None:
        """Path can be matched from context, not just title."""
        summary = _summary(("loom/graph/cache.py", [], []))
        task = {
            "title": "Some task",
            "context": {"files": ["loom/graph/cache.py"]},
        }
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 1.0

    def test_case_insensitive_path_match(self) -> None:
        summary = _summary(("Loom/Graph/Store.py", [], []))
        task = {"title": "Work on loom/graph/store.py", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 1.0

    def test_best_match_wins(self) -> None:
        """If both stem and exact match exist for different files, exact wins."""
        summary = _summary(
            ("loom/cache.py", [], []),
            ("loom/graph/cache.py", [], []),
        )
        task = {"title": "Update loom/graph/cache.py module", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 1.0
        assert result.matched_artifact == "loom/graph/cache.py"


# ── 4. TestSymbolExistenceRule ───────────────────────────────────────


class TestSymbolExistenceRule:
    """Tests for SymbolExistenceRule.evaluate()."""

    def setup_method(self) -> None:
        self.rule = SymbolExistenceRule()

    def test_exact_function_name_match(self) -> None:
        summary = _summary(("store.py", ["create_task", "get_task"], []))
        task = {"title": "Implement create_task", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 0.90
        assert "create_task" in result.matched_artifact

    def test_exact_class_name_match(self) -> None:
        summary = _summary(("models.py", [], ["TaskManager"]))
        task = {"title": "Build TaskManager class", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 0.90
        assert "TaskManager" in result.matched_artifact

    def test_no_match_returns_none(self) -> None:
        summary = _summary(("store.py", ["create_task"], []))
        task = {"title": "Deploy infrastructure", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is None

    def test_empty_files_returns_none(self) -> None:
        summary = _empty_summary()
        task = {"title": "Implement something", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is None

    def test_short_symbols_skipped(self) -> None:
        """Symbols shorter than MIN_SYMBOL_LEN (4) are skipped."""
        summary = _summary(("utils.py", ["get", "set", "add"], []))
        task = {"title": "Add get function", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is None

    def test_fuzzy_match_below_exact(self) -> None:
        """Fuzzy matches should have lower confidence than exact matches."""
        summary = _summary(("store.py", ["create_user_profile"], []))
        # Close but not exact — "create_user_profil" tokens might fuzzy-match
        task = {"title": "Implement create user profiles handler", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        if result is not None:
            # Fuzzy matches are scaled by 0.85, so should be < 0.90
            assert result.confidence <= 0.90

    def test_camel_case_symbol_match(self) -> None:
        """CamelCase symbols should match tokens from camelCase-normalised title."""
        summary = _summary(("mgr.py", [], ["CacheManager"]))
        task = {"title": "Define cache manager class", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert "CacheManager" in result.matched_artifact

    def test_multiple_symbols_best_wins(self) -> None:
        """When multiple symbols match, highest confidence wins."""
        summary = _summary(
            ("store.py", ["create_task", "update_task"], ["TaskStore"]),
        )
        task = {"title": "Implement create_task in TaskStore", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 0.90


# ── 5. TestKeywordPatternRule ────────────────────────────────────────


class TestKeywordPatternRule:
    """Tests for KeywordPatternRule.evaluate()."""

    def setup_method(self) -> None:
        self.rule = KeywordPatternRule()

    def test_create_verb_with_matching_file(self) -> None:
        summary = _summary(("config.py", ["load_config"], ["Config"]))
        task = {"title": "Create config manager", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.rule_name == "keyword_pattern"

    def test_implement_verb_with_matching_function(self) -> None:
        summary = _summary(("auth.py", ["authenticate_user"], []))
        task = {"title": "Implement user authentication", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        # "implement" is a creation verb, "user" + "authentication"
        # should match "authenticate_user" partially
        if result is not None:
            assert result.rule_name == "keyword_pattern"

    def test_non_creation_verb_no_match(self) -> None:
        """Verbs not in _VERB_ARTIFACT_MAP should not trigger the rule."""
        summary = _summary(("store.py", ["create_task"], []))
        task = {"title": "Review create_task code", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        # "review" is not a creation verb
        assert result is None

    def test_empty_files_returns_none(self) -> None:
        summary = _empty_summary()
        task = {"title": "Create user module", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is None

    def test_empty_title_returns_none(self) -> None:
        summary = _summary(("store.py", ["create_task"], []))
        task = {"title": "", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is None

    def test_verb_only_title_returns_none(self) -> None:
        """A title that is just a verb with no object should return None."""
        summary = _summary(("store.py", ["create_task"], []))
        task = {"title": "Create", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is None

    def test_build_verb_matches_class(self) -> None:
        summary = _summary(("auth.py", [], ["AuthHandler"]))
        task = {"title": "Build auth handler", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        assert result is not None
        assert "AuthHandler" in result.matched_artifact

    def test_confidence_capped_at_095(self) -> None:
        """Confidence should never exceed 0.95."""
        summary = _summary(("task_store.py", ["task_store_init"], ["TaskStore"]))
        task = {"title": "Create task store", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        if result is not None:
            assert result.confidence <= 0.95

    def test_overlap_score_below_half_no_match(self) -> None:
        """Token overlap < 50% should not fire."""
        summary = _summary(("store.py", ["create_task_with_validation_logic"], []))
        task = {"title": "Create widget", "context": {}}
        tokens = normalise(task["title"])

        result = self.rule.evaluate(tokens, task, summary)

        # "widget" doesn't overlap with "create_task_with_validation_logic"
        assert result is None


# ── 6. TestMatchTaskToCodebase ───────────────────────────────────────


class TestMatchTaskToCodebase:
    """Tests for the match_task_to_codebase() aggregator."""

    def test_no_rules_fire_returns_zero_confidence(self) -> None:
        summary = _summary(("store.py", ["create_task"], []))
        task = {"title": "Deploy to Mars", "context": {}}

        result = match_task_to_codebase(task, summary)

        assert result.confidence == 0.0
        assert result.rule_name == "none"
        assert result.matched_artifact == ""

    def test_empty_summary_returns_zero_confidence(self) -> None:
        summary = _empty_summary()
        task = {"title": "Create store module", "context": {}}

        result = match_task_to_codebase(task, summary)

        assert result.confidence == 0.0

    def test_highest_confidence_rule_wins(self) -> None:
        """When multiple rules fire, the highest confidence result is returned."""
        summary = _summary(
            ("loom/graph/store.py", ["create_task", "get_task"], ["TaskStore"]),
        )
        # Title references exact path (confidence 1.0 from FileExistence)
        # AND symbol (confidence 0.90 from SymbolExistence)
        task = {"title": "Implement create_task in loom/graph/store.py", "context": {}}

        result = match_task_to_codebase(task, summary)

        assert result.confidence == 1.0
        assert result.rule_name == "file_existence"

    def test_custom_rules_override_default(self) -> None:
        """Passing custom rules replaces default rules entirely."""
        summary = _summary(("store.py", ["create_task"], []))
        task = {"title": "Create loom/graph/store.py", "context": {}}

        # Only use symbol rule — should not match the exact file path
        result = match_task_to_codebase(task, summary, rules=[SymbolExistenceRule()])

        # FileExistenceRule was NOT used, so no exact path match
        if result.confidence > 0:
            assert result.rule_name == "symbol_existence"

    def test_task_with_empty_title(self) -> None:
        summary = _summary(("store.py", ["create_task"], []))
        task = {"title": "", "context": {}}

        result = match_task_to_codebase(task, summary)

        assert result.confidence == 0.0

    def test_task_without_title_key(self) -> None:
        summary = _summary(("store.py", ["create_task"], []))
        task = {"context": {}}  # no title key at all

        result = match_task_to_codebase(task, summary)

        assert result.confidence == 0.0

    def test_unicode_in_description(self) -> None:
        """Tasks with Unicode characters should not cause errors."""
        summary = _summary(("store.py", ["create_task"], []))
        task = {
            "title": "Creer le module d'authentification",
            "context": {"description": "Implementer l'authentification avec acces securise"},
        }

        result = match_task_to_codebase(task, summary)

        # Should not raise — result can be anything
        assert isinstance(result, MatchResult)

    def test_task_with_file_hints_in_context(self) -> None:
        """file_hints in context should help matching via _context_text."""
        summary = _summary(("loom/graph/store.py", [], []))
        task = {
            "title": "Update the store module",
            "context": {"files": ["loom/graph/store.py"]},
        }

        result = match_task_to_codebase(task, summary)

        assert result.confidence == 1.0

    def test_multiple_tasks_matched_independently(self) -> None:
        """Each call to match_task_to_codebase is independent."""
        summary = _summary(
            ("store.py", ["create_task"], ["TaskStore"]),
            ("cache.py", ["sync_task"], ["CacheManager"]),
        )

        r1 = match_task_to_codebase(
            {"title": "Implement create_task", "context": {}}, summary
        )
        r2 = match_task_to_codebase(
            {"title": "Build CacheManager class", "context": {}}, summary
        )

        # Both should independently match
        assert r1.confidence > 0
        assert r2.confidence > 0
        assert "create_task" in r1.matched_artifact or "TaskStore" in r1.matched_artifact
        assert "CacheManager" in r2.matched_artifact

    def test_config_parameter_accepted(self) -> None:
        """config parameter should be accepted without error."""
        summary = _summary(("store.py", ["create_task"], []))
        task = {"title": "Deploy to Mars", "context": {}}
        config = ValidatorConfig(high_threshold=0.99, uncertain_threshold=0.50)

        result = match_task_to_codebase(task, summary, config=config)

        assert isinstance(result, MatchResult)


# ── 7. TestClassifyMatch ────────────────────────────────────────────


class TestClassifyMatch:
    """Boundary tests for classify_match()."""

    def test_exactly_at_high_threshold(self) -> None:
        """Confidence exactly equal to high_threshold => PRE_COMPLETED."""
        result = MatchResult(
            rule_name="test", confidence=0.85, matched_artifact="x", rationale="test"
        )
        assert classify_match(result) == ValidationStatus.PRE_COMPLETED

    def test_one_epsilon_below_high_threshold(self) -> None:
        """Confidence just below high_threshold => UNCERTAIN."""
        result = MatchResult(
            rule_name="test", confidence=0.8499999, matched_artifact="x", rationale="test"
        )
        assert classify_match(result) == ValidationStatus.UNCERTAIN

    def test_exactly_at_uncertain_threshold(self) -> None:
        """Confidence exactly at uncertain_threshold => UNCERTAIN."""
        result = MatchResult(
            rule_name="test", confidence=0.65, matched_artifact="x", rationale="test"
        )
        assert classify_match(result) == ValidationStatus.UNCERTAIN

    def test_one_epsilon_below_uncertain_threshold(self) -> None:
        """Confidence just below uncertain_threshold => PENDING."""
        result = MatchResult(
            rule_name="test", confidence=0.6499999, matched_artifact="x", rationale="test"
        )
        assert classify_match(result) == ValidationStatus.PENDING

    def test_zero_confidence(self) -> None:
        result = MatchResult(
            rule_name="none", confidence=0.0, matched_artifact="", rationale="none"
        )
        assert classify_match(result) == ValidationStatus.PENDING

    def test_full_confidence(self) -> None:
        result = MatchResult(
            rule_name="test", confidence=1.0, matched_artifact="x", rationale="test"
        )
        assert classify_match(result) == ValidationStatus.PRE_COMPLETED

    def test_custom_config_high_threshold(self) -> None:
        """Custom config with lower high_threshold changes the boundary."""
        config = ValidatorConfig(high_threshold=0.50, uncertain_threshold=0.30)
        result = MatchResult(
            rule_name="test", confidence=0.50, matched_artifact="x", rationale="test"
        )
        assert classify_match(result, config=config) == ValidationStatus.PRE_COMPLETED

    def test_custom_config_uncertain_threshold(self) -> None:
        config = ValidatorConfig(high_threshold=0.90, uncertain_threshold=0.70)
        result = MatchResult(
            rule_name="test", confidence=0.70, matched_artifact="x", rationale="test"
        )
        assert classify_match(result, config=config) == ValidationStatus.UNCERTAIN

    def test_custom_config_below_uncertain(self) -> None:
        config = ValidatorConfig(high_threshold=0.90, uncertain_threshold=0.70)
        result = MatchResult(
            rule_name="test", confidence=0.69, matched_artifact="x", rationale="test"
        )
        assert classify_match(result, config=config) == ValidationStatus.PENDING

    def test_config_where_thresholds_equal(self) -> None:
        """When high == uncertain, values at that level are PRE_COMPLETED."""
        config = ValidatorConfig(high_threshold=0.50, uncertain_threshold=0.50)
        at_boundary = MatchResult(
            rule_name="test", confidence=0.50, matched_artifact="x", rationale="test"
        )
        below = MatchResult(
            rule_name="test", confidence=0.49, matched_artifact="x", rationale="test"
        )
        assert classify_match(at_boundary, config=config) == ValidationStatus.PRE_COMPLETED
        assert classify_match(below, config=config) == ValidationStatus.PENDING

    @pytest.mark.parametrize(
        "confidence,expected",
        [
            (0.0, ValidationStatus.PENDING),
            (0.30, ValidationStatus.PENDING),
            (0.6499, ValidationStatus.PENDING),
            (0.65, ValidationStatus.UNCERTAIN),
            (0.75, ValidationStatus.UNCERTAIN),
            (0.8499, ValidationStatus.UNCERTAIN),
            (0.85, ValidationStatus.PRE_COMPLETED),
            (0.95, ValidationStatus.PRE_COMPLETED),
            (1.0, ValidationStatus.PRE_COMPLETED),
        ],
    )
    def test_default_thresholds_parametrized(
        self, confidence: float, expected: ValidationStatus
    ) -> None:
        result = MatchResult(
            rule_name="test",
            confidence=confidence,
            matched_artifact="x",
            rationale="test",
        )
        assert classify_match(result) == expected


# ── 8. TestValidateTaskGraphEdgeCases ────────────────────────────────


class TestValidateTaskGraphEdgeCases:
    """Edge cases for validate_task_graph not covered in test_graph_pruning.py."""

    def test_empty_task_list_returns_valid_result(self) -> None:
        result = validate_task_graph([], _empty_summary())

        assert isinstance(result, ValidationResult)
        assert result.tasks == []
        assert result.pruned == []
        assert result.folded == []
        assert result.stats["total_input"] == 0

    def test_task_already_pre_completed_idempotent(self) -> None:
        """Pruning a task twice (already annotated) should not break anything."""
        summary = _summary(("loom/graph/store.py", ["create_task"], []))
        tasks = [
            _task("t1", "Create loom/graph/store.py module"),
        ]

        # First pass
        result1 = validate_task_graph(tasks, summary, mode="mark")
        assert result1.tasks[0]["context"]["pruned"] is True

        # Second pass on the result — should still work
        result2 = validate_task_graph(result1.tasks, summary, mode="mark")
        assert result2.tasks[0]["context"]["pruned"] is True

    def test_large_task_graph_completes_in_reasonable_time(self) -> None:
        """100+ nodes should complete in under 5 seconds."""
        summary = _empty_summary()
        tasks = []
        for i in range(150):
            deps = [f"t{i-1}"] if i > 0 else []
            tasks.append(_task(f"t{i}", f"Task number {i}", depends_on=deps))

        start = time.monotonic()
        result = validate_task_graph(tasks, summary)
        elapsed = time.monotonic() - start

        assert elapsed < 5.0, f"Large graph took {elapsed:.2f}s (expected < 5s)"
        assert result.stats["total_input"] == 150
        assert result.stats["remaining_count"] == 150

    def test_tasks_with_circular_deps_detected(self) -> None:
        """Cycle detection should raise _CycleError."""
        tasks = [
            _task("A", "Task A", depends_on=["C"]),
            _task("B", "Task B", depends_on=["A"]),
            _task("C", "Task C", depends_on=["B"]),
        ]

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

    def test_task_with_string_context_in_mark_mode(self) -> None:
        """When context is a string (not dict), mark mode should handle gracefully."""
        summary = _summary(("loom/graph/store.py", [], []))
        tasks = [
            _task("t1", "Create loom/graph/store.py module", context="some string context"),
        ]

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

        # The context should be converted to a dict for annotation
        assert isinstance(result.tasks[0]["context"], dict)
        assert result.tasks[0]["context"]["pruned"] is True

    def test_deps_referencing_nonexistent_tasks_ignored(self) -> None:
        """Dependencies pointing to task IDs not in the graph should be handled."""
        summary = _empty_summary()
        tasks = [
            _task("t1", "Task one"),
            _task("t2", "Task two", depends_on=["nonexistent"]),
        ]

        # Should not raise
        result = validate_task_graph(tasks, summary)

        assert isinstance(result, ValidationResult)
        assert len(result.tasks) == 2

    def test_deep_linear_chain_no_cycles(self) -> None:
        """A long linear chain (no cycles) should validate cleanly."""
        summary = _empty_summary()
        tasks = []
        for i in range(50):
            deps = [f"t{i-1}"] if i > 0 else []
            tasks.append(_task(f"t{i}", f"Task {i}", depends_on=deps))

        result = validate_task_graph(tasks, summary)

        assert result.stats["total_input"] == 50
        assert result.stats["remaining_count"] == 50

    def test_diamond_dependency_pattern(self) -> None:
        """Diamond: A -> B, A -> C, B -> D, C -> D — should work fine."""
        summary = _empty_summary()
        tasks = [
            _task("A", "Root task"),
            _task("B", "Branch B", depends_on=["A"]),
            _task("C", "Branch C", depends_on=["A"]),
            _task("D", "Convergence", depends_on=["B", "C"]),
        ]

        result = validate_task_graph(tasks, summary)

        assert result.stats["total_input"] == 4
        assert result.stats["remaining_count"] == 4


# ── 9. TestMergeTrivialEdgeCases ─────────────────────────────────────


class TestMergeTrivialEdgeCases:
    """Edge cases for merge_trivial_tasks not covered in test_merge_trivial.py."""

    def test_unicode_description(self) -> None:
        """Task with Unicode characters in description merges without error."""
        parent = _merge_task(
            "P", "Parent epic",
            description="Implementer un systeme complet d'authentification",
            task_type="epic",
            files=["auth.py", "models.py"],
            subtasks=["design", "implement", "test"],
        )
        child = _merge_task(
            "c1", "Add entry",
            description="Ajouter une entree de configuration",
            parent_id="P",
        )

        remaining, report = merge_trivial_tasks([parent, child])

        remaining_ids = {t["id"] for t in remaining}
        assert "c1" not in remaining_ids
        assert len(report) == 1

    def test_very_long_description_in_fold(self) -> None:
        """Long descriptions should be handled without truncation issues."""
        parent = _merge_task(
            "P", "Parent epic",
            description="A very long " + "description " * 200,
            task_type="epic",
            files=["a.py", "b.py", "c.py", "d.py"],
            subtasks=["s1", "s2", "s3"],
        )
        child = _merge_task(
            "c1", "Add config entry",
            description="Short task " + "extra words " * 50,
            parent_id="P",
        )

        remaining, report = merge_trivial_tasks([parent, child])

        if report:
            parent_task = next(t for t in remaining if t["id"] == "P")
            assert "[Folded from:" in parent_task["notes"]

    def test_dependency_chain_all_trivial(self) -> None:
        """A chain where all tasks are trivial should fold cascadingly."""
        parent = _merge_task(
            "P", "Epic parent",
            description="Implement full system with comprehensive features and integration",
            task_type="epic",
            files=["a.py", "b.py", "c.py", "d.py"],
            subtasks=["s1", "s2", "s3"],
        )
        t1 = _merge_task("t1", "Add import", description="fix", parent_id="P")
        t2 = _merge_task("t2", "Add entry", description="fix", parent_id="P", dependencies=["t1"])
        t3 = _merge_task("t3", "Add line", description="fix", parent_id="P", dependencies=["t2"])

        remaining, report = merge_trivial_tasks([parent, t1, t2, t3])

        remaining_ids = {t["id"] for t in remaining}
        assert "t1" not in remaining_ids
        assert "t2" not in remaining_ids
        assert "t3" not in remaining_ids
        assert "P" in remaining_ids
        assert len(report) == 3

    def test_task_with_special_characters_in_title(self) -> None:
        """Special characters in title should not cause errors."""
        parent = _merge_task(
            "P", "Parent epic",
            description="Implement full system with many features and integration testing",
            task_type="epic",
            files=["a.py", "b.py", "c.py", "d.py"],
            subtasks=["s1", "s2", "s3"],
        )
        child = _merge_task(
            "c1", "Fix: 'config.yaml' [#123]",
            description="fix",
            parent_id="P",
        )

        remaining, report = merge_trivial_tasks([parent, child])

        if report:
            parent_task = next(t for t in remaining if t["id"] == "P")
            assert "[Folded from: Fix: 'config.yaml' [#123]]" in parent_task["notes"]

    def test_single_nontrivial_task_unchanged(self) -> None:
        """A single non-trivial task with no parent/deps passes through."""
        task = _merge_task(
            "t1",
            "Implement comprehensive caching layer",
            description="Build a full caching system with Redis fallback and circuit breaker pattern",
            files=["cache.py", "fallback.py", "circuit.py", "metrics.py"],
            subtasks=["design", "implement", "test"],
        )

        remaining, report = merge_trivial_tasks([task])

        assert len(remaining) == 1
        assert remaining[0]["id"] == "t1"
        assert report == []


# ── 10. TestMatchResultDataclass ─────────────────────────────────────


class TestMatchResultDataclass:
    """Tests for MatchResult frozen dataclass."""

    def test_frozen(self) -> None:
        result = MatchResult(
            rule_name="test", confidence=0.9, matched_artifact="x", rationale="test"
        )
        with pytest.raises(AttributeError):
            result.confidence = 0.5  # type: ignore[misc]

    def test_equality(self) -> None:
        a = MatchResult(rule_name="r", confidence=0.9, matched_artifact="x", rationale="test")
        b = MatchResult(rule_name="r", confidence=0.9, matched_artifact="x", rationale="test")
        assert a == b

    def test_fields_accessible(self) -> None:
        r = MatchResult(rule_name="test", confidence=0.75, matched_artifact="a.py", rationale="reason")
        assert r.rule_name == "test"
        assert r.confidence == 0.75
        assert r.matched_artifact == "a.py"
        assert r.rationale == "reason"


# ── 11. TestValidatorConfig ──────────────────────────────────────────


class TestValidatorConfig:
    """Tests for ValidatorConfig dataclass."""

    def test_defaults(self) -> None:
        config = ValidatorConfig()
        assert config.high_threshold == 0.85
        assert config.uncertain_threshold == 0.65

    def test_custom_values(self) -> None:
        config = ValidatorConfig(high_threshold=0.90, uncertain_threshold=0.50)
        assert config.high_threshold == 0.90
        assert config.uncertain_threshold == 0.50


# ── 12. TestValidationStatusEnum ─────────────────────────────────────


class TestValidationStatusEnum:
    """Tests for ValidationStatus enum values."""

    def test_values(self) -> None:
        assert ValidationStatus.PENDING == "pending"
        assert ValidationStatus.PRE_COMPLETED == "pre_completed"
        assert ValidationStatus.UNCERTAIN == "uncertain"

    def test_string_comparison(self) -> None:
        assert ValidationStatus.PENDING == "pending"
        assert str(ValidationStatus.PRE_COMPLETED) == "pre_completed"

    def test_all_members(self) -> None:
        members = set(ValidationStatus)
        assert len(members) == 3


# ── 13. TestPruningDecisionModel ─────────────────────────────────────


class TestPruningDecisionModel:
    """Tests for PruningDecision Pydantic model."""

    def test_create(self) -> None:
        from loom.validator import PruningDecision

        d = PruningDecision(
            task_id="t1", action="pre_completed", reason="test", confidence=0.9
        )
        assert d.task_id == "t1"
        assert d.action == "pre_completed"

    def test_serialisation(self) -> None:
        from loom.validator import PruningDecision

        d = PruningDecision(
            task_id="t1", action="folded", reason="too small", confidence=0.1
        )
        data = d.model_dump()
        assert data["task_id"] == "t1"
        assert data["action"] == "folded"


# ── 14. TestValidationResultModel ────────────────────────────────────


class TestValidationResultModel:
    """Tests for ValidationResult Pydantic model."""

    def test_defaults(self) -> None:
        result = ValidationResult()
        assert result.tasks == []
        assert result.pruned == []
        assert result.folded == []
        assert result.stats == {}

    def test_serialisation_roundtrip(self) -> None:
        from loom.validator import PruningDecision

        result = ValidationResult(
            tasks=[{"id": "t1", "title": "test"}],
            pruned=[PruningDecision(task_id="t1", action="pre_completed", reason="r", confidence=0.9)],
            folded=[],
            stats={"total_input": 1},
        )
        data = result.model_dump()
        restored = ValidationResult.model_validate(data)
        assert restored.tasks == result.tasks
        assert restored.stats == result.stats


# ── 15. TestFileExistenceRuleContextText ─────────────────────────────


class TestFileExistenceRuleContextText:
    """Ensure FileExistenceRule searches context text, not just title."""

    def test_match_from_context_description(self) -> None:
        rule = FileExistenceRule()
        summary = _summary(("loom/graph/store.py", [], []))
        task = {
            "title": "Generic task name",
            "context": {"description": "We need to update loom/graph/store.py"},
        }
        tokens = normalise(task["title"])

        result = rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 1.0

    def test_match_from_context_notes(self) -> None:
        rule = FileExistenceRule()
        summary = _summary(("loom/graph/store.py", [], []))
        task = {
            "title": "Generic task name",
            "context": {"notes": "File: loom/graph/store.py needs changes"},
        }
        tokens = normalise(task["title"])

        result = rule.evaluate(tokens, task, summary)

        assert result is not None
        assert result.confidence == 1.0


# ── 16. TestSymbolRuleFuzzyFallback ──────────────────────────────────


class TestSymbolRuleFuzzyFallback:
    """Test fuzzy matching fallback in SymbolExistenceRule."""

    def test_fuzzy_match_returns_scaled_confidence(self) -> None:
        """A close-but-not-exact symbol should match with scaled confidence."""
        rule = SymbolExistenceRule()
        # Symbol name is very similar to what the task describes
        summary = _summary(("store.py", ["create_user_profile"], []))
        # "create user profiling" is close to "create_user_profile"
        task = {"title": "create user profiling handler", "context": {}}
        tokens = normalise(task["title"])

        result = rule.evaluate(tokens, task, summary)

        # Should match — could be exact or fuzzy
        if result is not None and "Fuzzy" in result.rationale:
            # Fuzzy confidence is ratio * 0.85
            assert result.confidence < 0.90
            assert result.confidence > 0


# ── 17. TestCycleDetectionDirectly ───────────────────────────────────


class TestCycleDetectionDirectly:
    """Direct tests for the _detect_cycles helper."""

    def test_no_cycle_passes(self) -> None:
        from loom.validator import _detect_cycles

        tasks = [
            {"id": "A", "depends_on": []},
            {"id": "B", "depends_on": ["A"]},
            {"id": "C", "depends_on": ["A", "B"]},
        ]
        # Should not raise
        _detect_cycles(tasks)

    def test_simple_cycle_raises(self) -> None:
        from loom.validator import _detect_cycles

        tasks = [
            {"id": "A", "depends_on": ["B"]},
            {"id": "B", "depends_on": ["A"]},
        ]
        with pytest.raises(_CycleError):
            _detect_cycles(tasks)

    def test_self_loop_raises(self) -> None:
        from loom.validator import _detect_cycles

        tasks = [{"id": "A", "depends_on": ["A"]}]
        with pytest.raises(_CycleError):
            _detect_cycles(tasks)

    def test_deps_to_unknown_ids_ignored(self) -> None:
        from loom.validator import _detect_cycles

        tasks = [
            {"id": "A", "depends_on": ["unknown"]},
            {"id": "B", "depends_on": ["A"]},
        ]
        # Dependencies to IDs not in the graph should be silently ignored
        _detect_cycles(tasks)

    def test_empty_graph_passes(self) -> None:
        from loom.validator import _detect_cycles

        _detect_cycles([])


# ── 18. TestTaskComplexityHelper ─────────────────────────────────────


class TestTaskComplexityHelper:
    """Tests for _task_complexity() internal helper."""

    def test_empty_task_returns_zero(self) -> None:
        from loom.validator import _task_complexity

        t = {"id": "t1", "title": "", "context": {}}
        score = _task_complexity(t)
        assert score == 0.0

    def test_short_title_low_complexity(self) -> None:
        from loom.validator import _task_complexity

        t = {"id": "t1", "title": "Fix", "context": {}}
        score = _task_complexity(t)
        assert score < 0.2

    def test_long_title_higher_complexity(self) -> None:
        from loom.validator import _task_complexity

        t = {
            "id": "t1",
            "title": "Implement comprehensive caching layer with Redis fallback",
            "context": {
                "description": "Build a caching module with circuit breaker pattern, retry logic, and fallback"
            },
        }
        score = _task_complexity(t)
        assert score > 0.1

    def test_result_in_0_to_1_range(self) -> None:
        from loom.validator import _task_complexity

        t = {"id": "t1", "title": "A " * 100, "context": {}}
        score = _task_complexity(t)
        assert 0.0 <= score <= 1.0


# ── 19. TestFindCycleParticipants ────────────────────────────────────


class TestFindCycleParticipants:
    """Tests for _find_cycle_participants used by merge_trivial_tasks."""

    def test_no_cycles(self) -> None:
        from loom.validator import _find_cycle_participants

        tasks = [
            {"id": "A", "dependencies": []},
            {"id": "B", "dependencies": ["A"]},
        ]
        assert _find_cycle_participants(tasks) == set()

    def test_simple_cycle(self) -> None:
        from loom.validator import _find_cycle_participants

        tasks = [
            {"id": "A", "dependencies": ["B"]},
            {"id": "B", "dependencies": ["A"]},
        ]
        result = _find_cycle_participants(tasks)
        assert "A" in result
        assert "B" in result

    def test_partial_cycle(self) -> None:
        from loom.validator import _find_cycle_participants

        tasks = [
            {"id": "A", "dependencies": []},
            {"id": "B", "dependencies": ["A", "C"]},
            {"id": "C", "dependencies": ["B"]},
        ]
        result = _find_cycle_participants(tasks)
        assert "B" in result
        assert "C" in result
        assert "A" not in result

    def test_empty_graph(self) -> None:
        from loom.validator import _find_cycle_participants

        assert _find_cycle_participants([]) == set()


# ── 20. TestReverseTopoOrder ─────────────────────────────────────────


class TestReverseTopoOrder:
    """Tests for _reverse_topo_order used by merge_trivial_tasks."""

    def test_simple_chain(self) -> None:
        from loom.validator import _reverse_topo_order

        tasks = [
            {"id": "A", "dependencies": []},
            {"id": "B", "dependencies": ["A"]},
            {"id": "C", "dependencies": ["B"]},
        ]
        order = _reverse_topo_order(tasks, set())
        # Reverse topo: leaves first — C before B before A
        assert order.index("C") < order.index("B")
        assert order.index("B") < order.index("A")

    def test_skip_ids_excluded(self) -> None:
        from loom.validator import _reverse_topo_order

        tasks = [
            {"id": "A", "dependencies": []},
            {"id": "B", "dependencies": ["A"]},
            {"id": "C", "dependencies": ["B"]},
        ]
        order = _reverse_topo_order(tasks, {"B"})
        assert "B" not in order
        assert "A" in order
        assert "C" in order

    def test_empty_tasks(self) -> None:
        from loom.validator import _reverse_topo_order

        assert _reverse_topo_order([], set()) == []
