"""Unit tests for the #185 plan-vs-diff comparator.

Covers diff parsing helpers, entity-survival regex matching, AC
coverage scanning, and the orchestrator's report shape across the
four drift classes plus the clean case.
"""
from __future__ import annotations

import pytest

from active_claude_github_mcp.validators.diff import (
    check_project_rules,
    extract_added_lines,
    extract_changed_lines,
    parse_diff_files,
    verify_diff_covers_plan,
)


_PLAN_GOLDEN = """\
---
plan_id: "test-plan"
touched_files:
  - mcp-server/src/foo.py
  - tests/test_foo.py
entities_introduced:
  - "new_function"
  - "NewClass"
entities_modified:
  - "existing_function"
acceptance_criteria:
  - id: AC1
    description: "Adds new_function"
  - id: AC2
    description: "Adds NewClass"
---

## Acceptance criteria

- [ ] (AC1) Adds new_function
- [ ] (AC2) Adds NewClass
"""

_DIFF_GOLDEN = """\
diff --git a/mcp-server/src/foo.py b/mcp-server/src/foo.py
--- a/mcp-server/src/foo.py
+++ b/mcp-server/src/foo.py
@@ -1,5 +1,12 @@
 def existing_function():
-    return 1
+    return 2  # modified
+
+def new_function():
+    return "x"
+
+class NewClass:
+    pass
 # end
diff --git a/tests/test_foo.py b/tests/test_foo.py
--- a/tests/test_foo.py
+++ b/tests/test_foo.py
@@ -1,2 +1,4 @@
 import foo
+def test_new_function():
+    assert foo.new_function() == "x"
"""


class TestParseDiffFiles:
    def test_extracts_paths(self):
        assert parse_diff_files(_DIFF_GOLDEN) == [
            "mcp-server/src/foo.py",
            "tests/test_foo.py",
        ]

    def test_dedup_across_minus_plus_lines(self):
        diff = (
            "--- a/x.py\n"
            "+++ b/x.py\n"
            "@@ -1,1 +1,2 @@\n"
            " a\n+b\n"
        )
        assert parse_diff_files(diff) == ["x.py"]

    def test_drops_dev_null(self):
        diff = (
            "--- /dev/null\n"
            "+++ b/new.py\n"
            "@@ -0,0 +1,1 @@\n"
            "+content\n"
        )
        assert parse_diff_files(diff) == ["new.py"]

    def test_empty_diff(self):
        assert parse_diff_files("") == []


class TestExtractLines:
    def test_added_excludes_plus_plus_plus_header(self):
        diff = (
            "+++ b/foo.py\n"
            "@@ -1 +1,2 @@\n"
            "+added line\n"
            " context\n"
        )
        assert extract_added_lines(diff) == ["added line"]

    def test_changed_includes_both_directions(self):
        diff = (
            "+++ b/foo.py\n"
            "@@ -1,2 +1,2 @@\n"
            "-removed line\n"
            "+added line\n"
        )
        out = extract_changed_lines(diff)
        assert "added line" in out
        assert "removed line" in out


class TestVerifyDiffCoversPlanGolden:
    def test_clean_diff_covers_plan(self):
        result = verify_diff_covers_plan(
            _PLAN_GOLDEN, _DIFF_GOLDEN, evidence_text="(AC1) (AC2)"
        )
        assert result["covered"] is True, (
            f"missing_files={result['missing_files']} "
            f"unexpected_files={result['unexpected_files']} "
            f"missing_introduced={result['missing_entities_introduced']} "
            f"missing_modified={result['missing_entities_modified']} "
            f"unchecked_ac={result['unchecked_ac']}"
        )
        # Evidence captures the two AC mentions in evidence_text.
        assert result["ac_coverage_evidence"]["AC1"]
        assert result["ac_coverage_evidence"]["AC2"]


class TestVerifyDiffCoversPlanDriftClasses:
    def test_missing_file_flagged(self):
        plan = _PLAN_GOLDEN
        # Diff omits tests/test_foo.py entirely.
        diff = """\
diff --git a/mcp-server/src/foo.py b/mcp-server/src/foo.py
--- a/mcp-server/src/foo.py
+++ b/mcp-server/src/foo.py
@@ -1,1 +1,4 @@
 def existing_function():
+def new_function():
+    pass
+class NewClass: pass
"""
        result = verify_diff_covers_plan(plan, diff, evidence_text="(AC1) (AC2)")
        assert result["covered"] is False
        assert "tests/test_foo.py" in result["missing_files"]

    def test_unexpected_file_flagged(self):
        plan = _PLAN_GOLDEN
        # Diff touches an extra file not declared in touched_files.
        diff = _DIFF_GOLDEN + """\
diff --git a/orphan.py b/orphan.py
--- a/orphan.py
+++ b/orphan.py
@@ -1 +1,2 @@
 a
+b
"""
        result = verify_diff_covers_plan(plan, diff, evidence_text="(AC1) (AC2)")
        assert result["covered"] is False
        assert "orphan.py" in result["unexpected_files"]

    def test_missing_entity_introduced_flagged(self):
        plan = _PLAN_GOLDEN
        # Diff lacks NewClass.
        diff = """\
diff --git a/mcp-server/src/foo.py b/mcp-server/src/foo.py
--- a/mcp-server/src/foo.py
+++ b/mcp-server/src/foo.py
@@ -1,1 +1,3 @@
 def existing_function():
+def new_function():
+    pass
diff --git a/tests/test_foo.py b/tests/test_foo.py
--- a/tests/test_foo.py
+++ b/tests/test_foo.py
@@ -1 +1,2 @@
 a
+def test_new_function(): pass
"""
        result = verify_diff_covers_plan(plan, diff, evidence_text="(AC1) (AC2)")
        assert result["covered"] is False
        assert "NewClass" in result["missing_entities_introduced"]
        assert "new_function" not in result["missing_entities_introduced"]

    def test_missing_entity_modified_flagged(self):
        plan = _PLAN_GOLDEN
        # Diff doesn't touch existing_function.
        diff = """\
diff --git a/mcp-server/src/foo.py b/mcp-server/src/foo.py
--- a/mcp-server/src/foo.py
+++ b/mcp-server/src/foo.py
@@ -1,1 +1,5 @@
 unchanged
+def new_function():
+    pass
+class NewClass:
+    pass
diff --git a/tests/test_foo.py b/tests/test_foo.py
--- a/tests/test_foo.py
+++ b/tests/test_foo.py
@@ -1 +1,2 @@
 a
+b
"""
        result = verify_diff_covers_plan(plan, diff, evidence_text="(AC1) (AC2)")
        assert result["covered"] is False
        assert "existing_function" in result["missing_entities_modified"]

    def test_unchecked_ac_flagged(self):
        plan = _PLAN_GOLDEN
        # Diff is clean, but no evidence text mentions AC1/AC2.
        result = verify_diff_covers_plan(plan, _DIFF_GOLDEN, evidence_text="")
        assert result["covered"] is False
        assert set(result["unchecked_ac"]) == {"AC1", "AC2"}


class TestVerifyDiffCoversPlanGracefulDegradation:
    def test_no_frontmatter_returns_covered_with_warning(self):
        result = verify_diff_covers_plan(
            "# Plan with no frontmatter\n", _DIFF_GOLDEN
        )
        assert result["covered"] is True
        assert any(
            "no YAML frontmatter" in w["issue"] for w in result["warnings"]
        )

    def test_malformed_frontmatter_returns_uncovered_with_warning(self):
        plan = "---\nplan_id: x\nno close marker\n"
        result = verify_diff_covers_plan(plan, _DIFF_GOLDEN)
        assert result["covered"] is False
        assert any(
            "parse error" in w["issue"] for w in result["warnings"]
        )


class TestAcCoverageEvidence:
    def test_evidence_records_diff_and_evidence_sources(self):
        plan = _PLAN_GOLDEN
        diff = _DIFF_GOLDEN + "\n# (AC1) referenced in diff context\n"
        result = verify_diff_covers_plan(
            plan, diff, evidence_text="commit msg: (AC2) done\n"
        )
        # AC1 was found in the diff context line, AC2 in evidence_text.
        ac1_ev = result["ac_coverage_evidence"]["AC1"]
        ac2_ev = result["ac_coverage_evidence"]["AC2"]
        assert any(e.startswith("diff:") for e in ac1_ev)
        assert any(e.startswith("evidence:") for e in ac2_ev)


# ----- #192: project rule-hook surface -------------------------------------


_SKILL_PROSE_RULE = {
    "name": "skill_prose_requires_running_log_update",
    "if_diff_touches_pattern": r"\.claude/skills/[^/]+/[^/]+\.md$",
    "must_also_touch_discussion": 190,
    "severity": "warning",
    "message": "Discussion #190 must be edited alongside skill-prose changes.",
}

_SKILL_PROSE_DIFF = """\
diff --git a/.claude/skills/active-claude-github/recording.md b/.claude/skills/active-claude-github/recording.md
--- a/.claude/skills/active-claude-github/recording.md
+++ b/.claude/skills/active-claude-github/recording.md
@@ -1,3 +1,5 @@
 # Recording protocol
+
+Adding a new rule.
"""

_NON_PROSE_DIFF = """\
diff --git a/mcp-server/src/foo.py b/mcp-server/src/foo.py
--- a/mcp-server/src/foo.py
+++ b/mcp-server/src/foo.py
@@ -1,1 +1,2 @@
 def existing_function():
+    pass
"""


class TestCheckProjectRulesUnit:
    """Direct coverage of `check_project_rules` — verifier-isolated,
    no plan body involvement (#192 AC4)."""

    def test_no_rules_returns_empty(self):
        assert check_project_rules(None, ["any/path.md"], "") == []
        assert check_project_rules([], ["any/path.md"], "") == []

    def test_rule_not_triggered_when_no_file_matches(self):
        violations = check_project_rules(
            [_SKILL_PROSE_RULE], ["mcp-server/src/foo.py"], ""
        )
        assert violations == []

    def test_rule_fires_when_pattern_matches_and_no_signal(self):
        violations = check_project_rules(
            [_SKILL_PROSE_RULE],
            [".claude/skills/active-claude-github/recording.md"],
            "PR description without the magic phrase.",
        )
        assert len(violations) == 1
        v = violations[0]
        assert v["rule"] == "skill_prose_requires_running_log_update"
        assert v["severity"] == "warning"
        assert ".claude/skills" in v["evidence"][0]

    def test_edits_section_signal_passes_rule(self):
        evidence = (
            "PR body...\n\n"
            "## Edits to Discussion #190\n"
            "Added new row mapping the rule.\n"
        )
        violations = check_project_rules(
            [_SKILL_PROSE_RULE],
            [".claude/skills/active-claude-github/recording.md"],
            evidence,
        )
        assert violations == []

    def test_comment_form_signal_passes_rule(self):
        evidence = (
            "Some PR comment trail...\n"
            "Updated Discussion #190 with: new entry for X."
        )
        violations = check_project_rules(
            [_SKILL_PROSE_RULE],
            [".claude/skills/active-claude-github/recording.md"],
            evidence,
        )
        assert violations == []

    def test_signal_is_case_insensitive(self):
        evidence = "## EDITS TO DISCUSSION #190\nupdated."
        violations = check_project_rules(
            [_SKILL_PROSE_RULE],
            [".claude/skills/active-claude-github/recording.md"],
            evidence,
        )
        assert violations == []

    def test_malformed_rule_silently_skipped(self):
        bad_rules = [
            {"name": "no-pattern"},  # missing pattern
            {"if_diff_touches_pattern": "[invalid"},  # bad regex
            {"if_diff_touches_pattern": ".+", "must_also_touch_discussion": "not-int"},
            "not-a-dict",
        ]
        violations = check_project_rules(
            bad_rules, [".claude/skills/x/y.md"], ""
        )
        assert violations == []


class TestVerifyDiffCoversPlanRuleViolations:
    """End-to-end: project_rules surface in the orchestrator result."""

    def test_rule_violation_reported_alongside_entity_check(self):
        # Plan covers everything; rule fires because no signal.
        diff = _DIFF_GOLDEN + _SKILL_PROSE_DIFF
        # Plan body is the golden plan (which doesn't declare the
        # skill-prose file, so unexpected_files would also fire).
        # Use a frontmatter-less plan body so the entity comparator
        # short-circuits and only the rule violation surfaces.
        result = verify_diff_covers_plan(
            "Just a body, no YAML.",
            _SKILL_PROSE_DIFF,
            evidence_text="",
            project_rules=[_SKILL_PROSE_RULE],
        )
        assert result["covered"] is False
        assert len(result["rule_violations"]) == 1
        assert (
            result["rule_violations"][0]["rule"]
            == "skill_prose_requires_running_log_update"
        )

    def test_rule_passes_when_signal_present(self):
        result = verify_diff_covers_plan(
            "Body without frontmatter.",
            _SKILL_PROSE_DIFF,
            evidence_text="## Edits to Discussion #190\n+ new row",
            project_rules=[_SKILL_PROSE_RULE],
        )
        assert result["rule_violations"] == []
        # Frontmatter-less plan + rule passes → covered=True.
        assert result["covered"] is True

    def test_rule_doesnt_fire_when_pattern_not_matched(self):
        result = verify_diff_covers_plan(
            "Body without frontmatter.",
            _NON_PROSE_DIFF,
            evidence_text="",
            project_rules=[_SKILL_PROSE_RULE],
        )
        assert result["rule_violations"] == []
        assert result["covered"] is True

    def test_no_project_rules_yields_empty_list_field(self):
        # Backward-compat: callers that don't pass project_rules still
        # get an empty `rule_violations` field in the report (#192 AC3).
        result = verify_diff_covers_plan(_PLAN_GOLDEN, _DIFF_GOLDEN)
        assert "rule_violations" in result
        assert result["rule_violations"] == []

    def test_rule_violation_blocks_covered_even_when_plan_clean(self):
        # Golden plan + golden diff would normally be `covered=True`,
        # but a triggered rule violation flips covered → False.
        # The plan doesn't declare the skill-prose file in
        # touched_files, so combine with the prose diff and a
        # standalone rule check (not via the plan path).
        result = verify_diff_covers_plan(
            "Body without frontmatter — rule still fires.",
            _SKILL_PROSE_DIFF,
            evidence_text="No edit signal here.",
            project_rules=[_SKILL_PROSE_RULE],
        )
        assert result["rule_violations"]
        assert result["covered"] is False


if __name__ == "__main__":
    import sys
    sys.exit(pytest.main([__file__, "-v"]))
