"""LLM-judge evaluator for the reviewer eval.

Pairwise matches each agent-emitted candidate against each golden comment using
claude-opus-4-5 (the judge model used by the martian benchmark). Returns
precision/recall/f1 per example, plus aggregate micro/macro metrics across
the experiment via a summary evaluator.

The judge prompt is kept verbatim from
withmartian/code-review-benchmark `step3_judge_comments.py` so scores are
directly comparable to martian's published numbers.
"""

from __future__ import annotations

import json
import os
import threading
from typing import Any
from uuid import UUID

from langchain_anthropic import ChatAnthropic
from langsmith.schemas import Example, Run

JUDGE_MODEL = "claude-opus-4-5"

# Call Anthropic directly. Without an explicit base_url the Anthropic SDK falls
# back to ANTHROPIC_BASE_URL, which in dev shells points at the LangSmith
# gateway and 403s for this model — silently nulling every judge score.
JUDGE_BASE_URL = os.environ.get("JUDGE_ANTHROPIC_BASE_URL", "https://api.anthropic.com")

JUDGE_SYSTEM = "You are a precise code review evaluator. Always respond with valid JSON."

JUDGE_PROMPT = """You are evaluating AI code review tools.
Determine if the candidate issue matches the golden (expected) comment.

Golden Comment (the issue we're looking for):
{golden_comment}

Candidate Issue (from the tool's review):
{candidate}

Instructions:
- Determine if the candidate identifies the SAME underlying issue as the golden comment
- Accept semantic matches - different wording is fine if it's the same problem
- Focus on whether they point to the same bug, concern, or code issue

Respond with ONLY a JSON object:
{{"reasoning": "brief explanation", "match": true/false, "confidence": 0.0-1.0}}"""


_judge: ChatAnthropic | None = None


def _get_judge() -> ChatAnthropic:
    global _judge
    if _judge is None:
        api_key = os.environ.get("JUDGE_ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY")
        if not api_key:
            raise RuntimeError(
                "No Anthropic API key for the judge. Set JUDGE_ANTHROPIC_API_KEY or "
                "ANTHROPIC_API_KEY (the judge calls Anthropic directly, not via a gateway)."
            )
        _judge = ChatAnthropic(
            model=JUDGE_MODEL,
            temperature=0.0,
            max_tokens=512,
            base_url=JUDGE_BASE_URL,
            api_key=api_key,
            max_retries=3,
        )
    return _judge


def _format_candidate(c: dict) -> str:
    parts = []
    if c.get("file"):
        loc = c["file"]
        if c.get("line") is not None:
            loc += f":{c['line']}"
        parts.append(f"Location: {loc}")
    if c.get("severity"):
        parts.append(f"Severity: {c['severity']}")
    parts.append(f"Comment: {c.get('body') or c.get('comment') or ''}")
    return "\n".join(parts)


def _format_golden(g: dict) -> str:
    parts = []
    if g.get("severity"):
        parts.append(f"Severity: {g['severity']}")
    parts.append(f"Comment: {g.get('comment', '')}")
    return "\n".join(parts)


def _judge_pair(golden: dict, candidate: dict) -> dict[str, Any]:
    prompt = JUDGE_PROMPT.format(
        golden_comment=_format_golden(golden),
        candidate=_format_candidate(candidate),
    )
    msg = _get_judge().invoke(
        [{"role": "system", "content": JUDGE_SYSTEM}, {"role": "user", "content": prompt}]
    )
    raw = msg.content if isinstance(msg.content, str) else str(msg.content)
    try:
        start, end = raw.find("{"), raw.rfind("}")
        return json.loads(raw[start : end + 1])
    except (ValueError, json.JSONDecodeError):
        return {"match": False, "confidence": 0.0, "reasoning": f"unparseable: {raw[:200]}"}


_PER_EXAMPLE_COUNTS: dict[UUID, dict[str, int | float]] = {}
_COUNTS_LOCK = threading.Lock()


def _record_counts(example_id: UUID, counts: dict[str, int | float]) -> None:
    with _COUNTS_LOCK:
        _PER_EXAMPLE_COUNTS[example_id] = counts


def _drain_counts() -> list[dict[str, int | float]]:
    with _COUNTS_LOCK:
        snapshot = list(_PER_EXAMPLE_COUNTS.values())
        _PER_EXAMPLE_COUNTS.clear()
    return snapshot


def judge_match(run: Run, example: Example) -> dict[str, Any]:
    """Per-example evaluator: compute precision/recall/f1/tp/fp/fn against goldens.

    Stashes the raw counts on a process-local cache keyed by ``example.id`` so
    ``aggregate_pr`` can compute micro-averages without re-judging.
    """
    candidates: list[dict] = list((run.outputs or {}).get("comments") or [])
    goldens: list[dict] = list((example.outputs or {}).get("golden_comments") or [])

    if not goldens:
        return {"results": [{"key": "f1", "score": None, "comment": "no goldens"}]}

    matched_goldens: set[int] = set()
    matched_candidates: set[int] = set()

    for ci, cand in enumerate(candidates):
        for gi, gold in enumerate(goldens):
            if gi in matched_goldens:
                continue
            res = _judge_pair(gold, cand)
            if res.get("match"):
                matched_goldens.add(gi)
                matched_candidates.add(ci)
                break

    tp = len(matched_goldens)
    fp = max(0, len(candidates) - len(matched_candidates))
    fn = max(0, len(goldens) - tp)
    precision = tp / (tp + fp) if (tp + fp) else 0.0
    recall = tp / (tp + fn) if (tp + fn) else 0.0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0

    _record_counts(
        example.id,
        {"tp": tp, "fp": fp, "fn": fn, "precision": precision, "recall": recall, "f1": f1},
    )

    return {
        "results": [
            {"key": "f1", "score": f1},
            {"key": "precision", "score": precision},
            {"key": "recall", "score": recall},
            {"key": "tp", "score": tp},
            {"key": "fp", "score": fp},
            {"key": "fn", "score": fn},
            {"key": "n_candidates", "score": len(candidates)},
            {"key": "n_goldens", "score": len(goldens)},
        ]
    }


def _f1(p: float, r: float) -> float:
    return 2 * p * r / (p + r) if (p + r) else 0.0


def aggregate_pr(runs: list[Run], examples: list[Example]) -> dict[str, Any]:
    """Summary evaluator: micro/macro precision-recall-F1 across the experiment.

    Reads the per-example counts that ``judge_match`` stashed in the
    process-local cache. Falls back to an empty result set if the cache
    is empty (e.g. summary evaluator ran in a different process).
    """
    counts = _drain_counts()
    if not counts:
        return {"results": []}

    micro_tp = sum(int(c["tp"]) for c in counts)
    micro_fp = sum(int(c["fp"]) for c in counts)
    micro_fn = sum(int(c["fn"]) for c in counts)

    micro_p = micro_tp / (micro_tp + micro_fp) if (micro_tp + micro_fp) else 0.0
    micro_r = micro_tp / (micro_tp + micro_fn) if (micro_tp + micro_fn) else 0.0
    micro_f1 = _f1(micro_p, micro_r)

    n = len(counts)
    macro_p = sum(float(c["precision"]) for c in counts) / n
    macro_r = sum(float(c["recall"]) for c in counts) / n
    macro_f1 = sum(float(c["f1"]) for c in counts) / n

    return {
        "results": [
            {"key": "micro_precision", "score": micro_p},
            {"key": "micro_recall", "score": micro_r},
            {"key": "micro_f1", "score": micro_f1},
            {"key": "macro_precision", "score": macro_p},
            {"key": "macro_recall", "score": macro_r},
            {"key": "macro_f1", "score": macro_f1},
            {"key": "total_tp", "score": micro_tp},
            {"key": "total_fp", "score": micro_fp},
            {"key": "total_fn", "score": micro_fn},
        ]
    }
