"""Unit tests for the #187 LLM-backed assumption verifier.

The verifier core lives in
``active_claude_github_mcp.validators.llm``; the Anthropic SDK is
injected via the `client_factory` parameter so these tests run
without `anthropic` in their runfiles.

Coverage:
  - Graceful skip paths (no API key / no frontmatter / no assumptions).
  - Fresh-read enforcement (each declared file is read from disk via
    the injected reader; path-traversal candidates are silently dropped).
  - Prompt composition: assumptions block first, files next, diff last
    with budget-driven truncation.
  - JSON parse: clean payload, fenced payload, malformed → graceful
    `parse_error`.
  - End-to-end: structured `drift_findings` returned with model + token
    telemetry.
"""
from __future__ import annotations

import asyncio
from pathlib import Path
from types import SimpleNamespace

import pytest

from active_claude_github_mcp.validators.llm import (
    _build_user_message,
    _parse_response,
    _resolve_touched_files,
    verify_existing_code_assumptions,
)


_PLAN_WITH_ASSUMPTIONS = """\
---
plan_id: "test-plan"
touched_files:
  - src/handlers/foo.py
  - src/handlers/bar.py
acceptance_criteria:
  - id: AC1
    description: "x"
assumptions:
  - "All handlers in src/handlers/ are async def"
  - "ServerContext owns the AsyncClient"
---

# Plan body
"""

_PLAN_NO_ASSUMPTIONS = """\
---
plan_id: "no-assumptions"
touched_files:
  - src/x.py
acceptance_criteria:
  - id: AC1
    description: "x"
---
"""

_PLAN_NO_FRONTMATTER = "# Just a body, no YAML."

_DIFF = """\
diff --git a/src/handlers/foo.py b/src/handlers/foo.py
--- a/src/handlers/foo.py
+++ b/src/handlers/foo.py
@@ -1,2 +1,3 @@
 async def existing(): ...
+def newly_added_sync(): ...
"""


# -- helpers -----------------------------------------------------------------


def _seed_files(tmp_path: Path, layout: dict[str, str]) -> Path:
    for relpath, content in layout.items():
        full = tmp_path / relpath
        full.parent.mkdir(parents=True, exist_ok=True)
        full.write_text(content, encoding="utf-8")
    return tmp_path


class _FakeUsage:
    def __init__(self, input_tokens: int, output_tokens: int) -> None:
        self.input_tokens = input_tokens
        self.output_tokens = output_tokens


class _FakeContent:
    def __init__(self, text: str) -> None:
        self.text = text


class _FakeResponse:
    def __init__(self, text: str, in_tok: int = 100, out_tok: int = 50) -> None:
        self.content = [_FakeContent(text)]
        self.usage = _FakeUsage(in_tok, out_tok)


class _FakeClient:
    """Minimal AsyncAnthropic-like fake — captures call args, returns
    a queued response."""

    def __init__(self, response: _FakeResponse | Exception):
        self._response = response
        self.calls: list[dict] = []
        self.messages = self  # so `client.messages.create(...)` resolves

    async def create(self, **kwargs):  # noqa: D401 — mirrors SDK signature
        self.calls.append(kwargs)
        if isinstance(self._response, Exception):
            raise self._response
        return self._response


def _factory(response):
    captured = {}

    def make(api_key: str):
        captured["api_key"] = api_key
        client = _FakeClient(response)
        captured["client"] = client
        return client

    make.captured = captured  # type: ignore[attr-defined]
    return make


# -- skip paths --------------------------------------------------------------


class TestSkipPaths:
    @pytest.mark.asyncio
    async def test_no_api_key_returns_skipped(self, tmp_path):
        result = await verify_existing_code_assumptions(
            plan_body=_PLAN_WITH_ASSUMPTIONS,
            pr_diff=_DIFF,
            repo_root=tmp_path,
            api_key=None,
        )
        assert result == {
            "verifier_ran": False,
            "skipped_reason": "ANTHROPIC_API_KEY not set",
        }

    @pytest.mark.asyncio
    async def test_empty_api_key_treated_as_unset(self, tmp_path):
        result = await verify_existing_code_assumptions(
            plan_body=_PLAN_WITH_ASSUMPTIONS,
            pr_diff=_DIFF,
            repo_root=tmp_path,
            api_key="   ",
        )
        assert result["verifier_ran"] is False
        assert "ANTHROPIC_API_KEY" in result["skipped_reason"]

    @pytest.mark.asyncio
    async def test_no_frontmatter_skips(self, tmp_path):
        result = await verify_existing_code_assumptions(
            plan_body=_PLAN_NO_FRONTMATTER,
            pr_diff=_DIFF,
            repo_root=tmp_path,
            api_key="sk-ant-test",
        )
        assert result["verifier_ran"] is False
        assert "no YAML frontmatter" in result["skipped_reason"]

    @pytest.mark.asyncio
    async def test_no_assumptions_skips(self, tmp_path):
        result = await verify_existing_code_assumptions(
            plan_body=_PLAN_NO_ASSUMPTIONS,
            pr_diff=_DIFF,
            repo_root=tmp_path,
            api_key="sk-ant-test",
        )
        assert result["verifier_ran"] is False
        assert "no `assumptions`" in result["skipped_reason"]


# -- fresh-read enforcement -------------------------------------------------


class TestFreshReadEnforcement:
    @pytest.mark.asyncio
    async def test_each_declared_file_is_read_fresh(self, tmp_path):
        _seed_files(
            tmp_path,
            {
                "src/handlers/foo.py": "async def existing(): ...\n",
                "src/handlers/bar.py": "async def other(): ...\n",
            },
        )
        reader_paths: list[Path] = []

        def fake_reader(p: Path) -> str:
            reader_paths.append(p)
            return p.read_text(encoding="utf-8")

        factory = _factory(
            _FakeResponse('{"drift_findings": []}', in_tok=10, out_tok=5)
        )
        await verify_existing_code_assumptions(
            plan_body=_PLAN_WITH_ASSUMPTIONS,
            pr_diff=_DIFF,
            repo_root=tmp_path,
            api_key="sk-ant-test",
            client_factory=factory,
            file_reader=fake_reader,
        )

        # AC3 — reader fired once per declared touched_file.
        names = {p.name for p in reader_paths}
        assert names == {"foo.py", "bar.py"}

    def test_path_traversal_silently_dropped(self, tmp_path):
        # `..` candidates can't escape repo_root via _resolve_touched_files.
        _seed_files(tmp_path, {"src/x.py": "x"})
        # Build a candidate that resolves outside tmp_path.
        outside = tmp_path.parent / "outside.py"
        outside.write_text("escaped", encoding="utf-8")

        reads: list[Path] = []

        def reader(p: Path) -> str:
            reads.append(p)
            return p.read_text(encoding="utf-8")

        results = _resolve_touched_files(
            tmp_path, ["../outside.py", "src/x.py"], reader
        )

        # Only the in-tree file should be returned.
        assert [p for p, _ in results] == ["src/x.py"]
        assert all(tmp_path in p.parents or p == tmp_path / "src" / "x.py" for p in reads)

    def test_missing_file_silently_dropped(self, tmp_path):
        _seed_files(tmp_path, {"src/x.py": "x"})
        results = _resolve_touched_files(
            tmp_path,
            ["src/x.py", "src/does-not-exist.py"],
            lambda p: p.read_text(encoding="utf-8"),
        )
        assert [p for p, _ in results] == ["src/x.py"]


# -- prompt composition -----------------------------------------------------


class TestBuildUserMessage:
    def test_assumptions_block_present_when_files_empty(self):
        msg, dropped = _build_user_message(
            ["A1", "A2"], "diff text", [], char_budget=10_000
        )
        assert "## Assumptions to verify" in msg
        assert "- A1" in msg
        assert "- A2" in msg
        assert "no files resolved" in msg
        assert dropped == []

    def test_diff_truncated_when_budget_tight(self):
        long_diff = "diff line\n" * 5_000  # ~50 KB
        msg, dropped = _build_user_message(
            ["A1"], long_diff, [], char_budget=2_000
        )
        # Either the diff is truncated or omitted; in this scenario
        # the budget is tight enough that truncation kicks in.
        assert "diff" in msg.lower()
        assert len(msg) <= 2_500  # respects the budget with small margin

    def test_files_dropped_when_budget_exhausted(self):
        big = "x" * 5_000
        files = [
            ("a.py", big),
            ("b.py", big),
            ("c.py", big),
        ]
        msg, dropped = _build_user_message(
            ["A1"], "", files, char_budget=8_000
        )
        # First file fits; later ones get reported as dropped.
        assert "### a.py" in msg
        assert dropped, "expected at least one dropped file under tight budget"


# -- response parsing -------------------------------------------------------


class TestParseResponse:
    def test_clean_json(self):
        raw = '{"drift_findings": [{"assumption": "x", "verdict": "consistent", "evidence": "ok", "severity": "info"}]}'
        findings, err = _parse_response(raw)
        assert err is None
        assert len(findings) == 1
        assert findings[0]["verdict"] == "consistent"

    def test_fenced_json_unwrapped(self):
        raw = '```json\n{"drift_findings": []}\n```'
        findings, err = _parse_response(raw)
        assert err is None
        assert findings == []

    def test_malformed_returns_parse_error(self):
        findings, err = _parse_response("not JSON at all")
        assert findings == []
        assert err and "JSON parse error" in err

    def test_missing_drift_findings_returns_error(self):
        findings, err = _parse_response('{"other_key": []}')
        assert findings == []
        assert err and "drift_findings" in err

    def test_non_dict_entries_filtered(self):
        raw = '{"drift_findings": [{"assumption": "x"}, "not a dict", 42]}'
        findings, err = _parse_response(raw)
        assert err is None
        assert len(findings) == 1
        assert findings[0]["assumption"] == "x"
        # Defaults applied for missing fields.
        assert findings[0]["verdict"] == "unclear"


# -- end-to-end -------------------------------------------------------------


class TestEndToEnd:
    @pytest.mark.asyncio
    async def test_returns_structured_findings(self, tmp_path):
        _seed_files(
            tmp_path,
            {
                "src/handlers/foo.py": "async def existing(): ...\n",
                "src/handlers/bar.py": "async def other(): ...\n",
            },
        )
        factory = _factory(
            _FakeResponse(
                '{"drift_findings": ['
                '{"assumption": "All handlers in src/handlers/ are async def", '
                '"verdict": "violated", '
                '"evidence": "src/handlers/foo.py:2 newly_added_sync is sync", '
                '"severity": "blocker"}]}',
                in_tok=2_400,
                out_tok=180,
            )
        )

        result = await verify_existing_code_assumptions(
            plan_body=_PLAN_WITH_ASSUMPTIONS,
            pr_diff=_DIFF,
            repo_root=tmp_path,
            api_key="sk-ant-test",
            client_factory=factory,
        )

        assert result["verifier_ran"] is True
        assert result["model"] == "claude-sonnet-4-6"
        assert result["input_tokens"] == 2_400
        assert result["output_tokens"] == 180
        assert len(result["drift_findings"]) == 1
        assert result["drift_findings"][0]["verdict"] == "violated"
        assert result["drift_findings"][0]["severity"] == "blocker"
        # Token telemetry recorded for AC10.
        assert "input_tokens" in result and "output_tokens" in result
        # Prompt was stripped — no conversation history (AC4).
        client = factory.captured["client"]
        assert len(client.calls) == 1
        call = client.calls[0]
        # System prompt is the verifier's contract; user message is a
        # single message block. No prior assistant turns.
        assert call["system"]
        assert len(call["messages"]) == 1
        assert call["messages"][0]["role"] == "user"

    @pytest.mark.asyncio
    async def test_anthropic_api_error_surfaces_softly(self, tmp_path):
        _seed_files(tmp_path, {"src/handlers/foo.py": "x"})
        factory = _factory(RuntimeError("rate limit"))

        result = await verify_existing_code_assumptions(
            plan_body=_PLAN_WITH_ASSUMPTIONS,
            pr_diff=_DIFF,
            repo_root=tmp_path,
            api_key="sk-ant-test",
            client_factory=factory,
        )

        assert result["verifier_ran"] is True
        assert result["drift_findings"] == []
        assert "rate limit" in result["parse_error"]


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