"""verify_existing_code_assumptions — async LLM-backed drift check (#187).

Closes the validation strategy from Discussion #182: where the
deterministic verifiers in 2.1 (#184 / #185) catch concept-loss and
plan-completeness drift, this verifier targets *error class 4* —
plans that builds on stale assumptions about the current codebase.

The LLM is structurally necessary here. Re-feeding the main-thread
Claude more context only reinforces the analysis bias the plan was
written under; only an independent reader with **fresh file
evidence** breaks it. The verifier therefore:

1. **Forces fresh reads.** For every path declared in the plan's
   `touched_files`, the verifier reads the current head version of
   the file from `repo_root`. It does not trust prior context.
2. **Strips conversation history.** The Anthropic call carries only
   the system prompt and a single structured user message — no
   prior turns, no summary of prior thinking.
3. **Returns structured JSON.** The model is instructed to emit a
   JSON object with the `drift_findings` schema; a parse failure
   surfaces as a single warning rather than crashing the verifier.

Handler in `server/handlers/verify.py` wraps this module as the MCP
tool. Tests pass a `client_factory` to inject a fake client (Anthropic
SDK is not exercised in unit tests).
"""
from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, Awaitable, Callable, Protocol

from .plan import parse_plan_frontmatter, FrontmatterParseError


# -- prompt budget heuristics -----------------------------------------------

# Rough tokens-per-character estimate for English source / prose.
# 4 chars/token is the conventional first-cut number; over-counting
# inputs is safer than under-counting. Real cost is reported via the
# Anthropic response's `usage` field, not this estimate.
_CHARS_PER_TOKEN = 4
# Reserve for system prompt + JSON scaffolding + format instructions.
_SCAFFOLD_TOKENS = 2_000
# Reserve for the model's JSON response (drift_findings list).
_OUTPUT_TOKENS = 4_000
# Cap per-file content; avoids one giant file consuming the whole budget.
_PER_FILE_MAX_CHARS = 16_000


_SYSTEM_PROMPT = """\
You are a code-drift verifier for the active-claude-github skill.

Your job: given a plan/RFC's declared `assumptions` about the codebase,
the PR's diff, and the **fresh contents** of the files the plan claims
to touch, decide whether each assumption still holds.

For each assumption, assign one of:
- `consistent` — the fresh code matches the assumption.
- `violated`   — the fresh code contradicts the assumption.
- `unclear`    — the fresh code does not give enough signal either way.

Return ONLY a JSON object on the form:

{"drift_findings": [
    {"assumption": "<verbatim from input>",
     "verdict": "consistent" | "violated" | "unclear",
     "evidence": "<≤200-char file:line-or-snippet excerpt>",
     "severity": "blocker" | "warning" | "info"}
]}

`severity` rule of thumb: `blocker` for `violated` assumptions whose
falseness directly invalidates the plan's premise; `warning` for
violations of nice-to-have invariants; `info` for `consistent` and
`unclear` verdicts.

No commentary outside the JSON object. No markdown fences."""


# Sentinel used when an assumption can't be checked locally because no
# fresh file evidence reached the prompt (budget cut, or the plan
# named no `touched_files`). The verifier surfaces it explicitly so
# the operator can decide whether to re-run with a higher budget.
_NO_EVIDENCE_VERDICT = "unclear"


class _ClientLike(Protocol):
    """Structural type for the subset of `AsyncAnthropic` we use.

    Matching only the call signature lets tests inject a fake without
    pulling the anthropic SDK into the unit-test runfiles."""

    async def messages_create(
        self,
        *,
        model: str,
        max_tokens: int,
        system: str,
        messages: list[dict[str, str]],
    ) -> Any: ...


# Type alias for the file reader — defaults to disk read, but tests
# inject a deterministic dict-backed reader.
FileReader = Callable[[Path], str]
ClientFactory = Callable[[str], Any]  # api_key → client


def _read_file_default(path: Path) -> str:
    """Default file reader. UTF-8 with errors='replace' for binaries."""
    return path.read_text(encoding="utf-8", errors="replace")


def _resolve_touched_files(
    repo_root: Path,
    declared: list[str],
    file_reader: FileReader,
) -> list[tuple[str, str]]:
    """Read each declared path fresh from `repo_root`.

    Returns ``[(repo_relative_path, content), ...]``. Paths that
    don't resolve under repo_root (path traversal), don't exist, or
    error on read are silently dropped — the verifier surfaces them
    as missing evidence in the prompt header.

    Per-file content is capped at `_PER_FILE_MAX_CHARS` so a single
    enormous file doesn't consume the whole token budget.
    """
    results: list[tuple[str, str]] = []
    repo_root_resolved = repo_root.resolve()
    for declared_path in declared:
        if not isinstance(declared_path, str) or not declared_path.strip():
            continue
        candidate = (repo_root / declared_path).resolve()
        try:
            candidate.relative_to(repo_root_resolved)
        except ValueError:
            # Path escaped repo_root; refuse to read.
            continue
        if not candidate.is_file():
            continue
        try:
            content = file_reader(candidate)
        except OSError:
            continue
        if len(content) > _PER_FILE_MAX_CHARS:
            content = (
                content[:_PER_FILE_MAX_CHARS]
                + f"\n\n[... truncated; file exceeds {_PER_FILE_MAX_CHARS} chars ...]"
            )
        results.append((declared_path, content))
    return results


def _build_user_message(
    assumptions: list[str],
    pr_diff: str,
    fresh_files: list[tuple[str, str]],
    char_budget: int,
) -> tuple[str, list[str]]:
    """Compose the user-message body within `char_budget`.

    Layout:
      1. Assumptions block (always included; small).
      2. Fresh file contents (priority by declared order; truncated
         per-file already by `_resolve_touched_files`; whole files
         dropped from the tail if budget exceeded).
      3. PR diff (last; truncated to whatever budget remains).

    Returns ``(message_text, dropped_files)`` so callers can record
    which files didn't reach the prompt.
    """
    parts: list[str] = []
    dropped: list[str] = []

    parts.append("## Assumptions to verify\n")
    if assumptions:
        for a in assumptions:
            parts.append(f"- {a}\n")
    else:
        parts.append("(plan declared no assumptions)\n")
    parts.append("\n")

    # Track running length to enforce char_budget. The diff is added
    # at the end so files (which are the freshness signal) take
    # priority.
    used = sum(len(p) for p in parts)

    files_block: list[str] = ["## Fresh file contents (read from current head)\n\n"]
    for path, content in fresh_files:
        block = f"### {path}\n\n```\n{content}\n```\n\n"
        if used + sum(len(p) for p in files_block) + len(block) > char_budget:
            dropped.append(path)
            continue
        files_block.append(block)
    if not fresh_files:
        files_block.append("(no files resolved from `touched_files`)\n\n")
    parts.extend(files_block)
    used = sum(len(p) for p in parts)

    diff_header = "## PR diff (`git diff main...HEAD`)\n\n```diff\n"
    diff_footer = "\n```\n"
    diff_overhead = len(diff_header) + len(diff_footer)
    diff_room = max(0, char_budget - used - diff_overhead)
    if pr_diff and diff_room > 0:
        diff_text = pr_diff
        if len(diff_text) > diff_room:
            diff_text = (
                diff_text[: diff_room - 64]
                + "\n[... diff truncated to fit token budget ...]"
            )
        parts.append(diff_header + diff_text + diff_footer)
    elif pr_diff:
        parts.append("## PR diff\n\n[diff omitted — token budget exhausted]\n")

    return "".join(parts), dropped


_JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*\n(.*?)\n```\s*$", re.DOTALL)


def _parse_response(raw: str) -> tuple[list[dict], str | None]:
    """Parse the Anthropic response into ``(drift_findings, parse_error)``.

    On any parse failure returns ``([], <reason>)`` so the verifier
    can degrade gracefully — the caller surfaces the parse_error as
    a single ``verifier_ran=true`` warning rather than crashing.
    """
    text = raw.strip()
    fence = _JSON_FENCE_RE.match(text)
    if fence:
        text = fence.group(1).strip()
    try:
        parsed = json.loads(text)
    except json.JSONDecodeError as exc:
        return [], f"JSON parse error: {exc}"
    if not isinstance(parsed, dict):
        return [], "response is not a JSON object"
    findings = parsed.get("drift_findings")
    if not isinstance(findings, list):
        return [], "response missing `drift_findings` array"
    cleaned: list[dict] = []
    for entry in findings:
        if not isinstance(entry, dict):
            continue
        cleaned.append(
            {
                "assumption": str(entry.get("assumption", "")),
                "verdict": str(entry.get("verdict", "unclear")),
                "evidence": str(entry.get("evidence", "")),
                "severity": str(entry.get("severity", "info")),
            }
        )
    return cleaned, None


async def verify_existing_code_assumptions(
    plan_body: str,
    pr_diff: str,
    repo_root: Path,
    api_key: str | None,
    model: str = "claude-sonnet-4-6",
    token_budget: int = 100_000,
    *,
    client_factory: ClientFactory | None = None,
    file_reader: FileReader | None = None,
) -> dict:
    """Run the LLM verifier and return the structured report.

    Returns the dict shape from #187 — ``verifier_ran`` plus, on a
    successful call, model / token telemetry and ``drift_findings``;
    on a graceful skip, ``skipped_reason``.

    Skips (returns ``verifier_ran=False``) when:
      - ``api_key`` is None or empty (#187 AC6).
      - ``plan_body`` has no YAML frontmatter (matches the
        deterministic verifiers' graceful-degradation policy).
      - The plan declares no ``assumptions`` field (nothing to
        verify; surface verbatim so the operator sees why nothing ran).

    Hard errors (parse failures, API errors) surface as
    ``verifier_ran=True`` with a ``parse_error`` field — the
    verifier never crashes the caller.
    """
    if not api_key or not api_key.strip():
        return {
            "verifier_ran": False,
            "skipped_reason": "ANTHROPIC_API_KEY not set",
        }

    try:
        frontmatter = parse_plan_frontmatter(plan_body)
    except FrontmatterParseError as exc:
        return {
            "verifier_ran": False,
            "skipped_reason": f"plan frontmatter parse error: {exc}",
        }
    if frontmatter is None:
        return {
            "verifier_ran": False,
            "skipped_reason": (
                "plan body has no YAML frontmatter; nothing to verify"
            ),
        }

    raw_assumptions = frontmatter.get("assumptions") or []
    assumptions = [a for a in raw_assumptions if isinstance(a, str) and a.strip()]
    if not assumptions:
        return {
            "verifier_ran": False,
            "skipped_reason": (
                "plan frontmatter declares no `assumptions`; nothing to verify"
            ),
        }

    declared_files = frontmatter.get("touched_files") or []
    if not isinstance(declared_files, list):
        declared_files = []

    reader = file_reader or _read_file_default
    fresh_files = _resolve_touched_files(repo_root, declared_files, reader)

    char_budget = max(
        1_000,
        (token_budget - _SCAFFOLD_TOKENS - _OUTPUT_TOKENS) * _CHARS_PER_TOKEN,
    )
    user_message, dropped = _build_user_message(
        assumptions, pr_diff, fresh_files, char_budget
    )

    if client_factory is None:
        # Lazy import — keeps anthropic out of the dep graph for
        # tests that pass a `client_factory` explicitly.
        import anthropic
        client = anthropic.AsyncAnthropic(api_key=api_key)
    else:
        client = client_factory(api_key)

    try:
        response = await client.messages.create(
            model=model,
            max_tokens=_OUTPUT_TOKENS,
            system=_SYSTEM_PROMPT,
            messages=[{"role": "user", "content": user_message}],
        )
    except Exception as exc:  # anthropic.APIError or transport errors
        return {
            "verifier_ran": True,
            "model": model,
            "input_tokens": 0,
            "output_tokens": 0,
            "drift_findings": [],
            "parse_error": f"anthropic API error: {exc}",
            "files_dropped_for_budget": dropped,
        }

    raw_text = ""
    if response.content:
        # The SDK returns a list of content blocks; take the first
        # text block. Tool-use response shapes are out of scope for
        # this verifier — we ask the model for plain JSON.
        first = response.content[0]
        raw_text = getattr(first, "text", "") or ""

    findings, parse_error = _parse_response(raw_text)

    usage = getattr(response, "usage", None)
    input_tokens = getattr(usage, "input_tokens", 0) if usage else 0
    output_tokens = getattr(usage, "output_tokens", 0) if usage else 0

    result = {
        "verifier_ran": True,
        "model": model,
        "input_tokens": input_tokens,
        "output_tokens": output_tokens,
        "drift_findings": findings,
        "files_dropped_for_budget": dropped,
    }
    if parse_error is not None:
        result["parse_error"] = parse_error
    return result
