"""LLM-as-judge backend for AI-text detection.

Reuses the existing provider plumbing (``resolve_api_key`` / the same client
init the hallucination verifier uses) and the per-check usage tracker
(``flow="ai_detection"``).  The rubric is adapted from the MIT-licensed
``harshaneel/humanize`` ``ai-check`` skill, hardened with anti-false-positive
instructions for academic / non-native-English / AI-polished text.

Important honesty constraint: an LLM judge produces an **uncalibrated** score.
Run standalone (the only way it runs today), its band is hard-capped at
``medium`` — it may never raise a "high" accusation on its own
(``base.clamp_not_above`` enforces this).  If it is ever combined with the
calibrated local detector, the same clamp keeps it from raising the calibrated
band.  It remains bound by the shared abstention rules and disclaimer.
"""

from __future__ import annotations

import json
import logging
import re
from typing import Any, List, Optional, Tuple

from refchecker.config.settings import resolve_api_key, resolve_endpoint, DEFAULT_HALLUCINATION_MODELS
from refchecker.llm.google_retry import call_google_with_retry, extract_google_response_text

from .base import (
    AIDetectionResult,
    DetectionBackend,
    SuspectSpan,
    BAND_HIGH,
    BAND_MEDIUM,
    BAND_LOW,
    OPERATING_POINT,
    DISCLAIMER,
    band_rank,
    clamp_not_above,
    make_inconclusive,
    make_unavailable,
    prepared_text,
    record_detection_usage,
    should_abstain,
    truncate_quote,
)

logger = logging.getLogger(__name__)

#: Cap the body sent to the LLM (mirrors the 60k-char ceiling used elsewhere).
_MAX_CHARS = 60_000


_SYSTEM_PROMPT = """\
You are a careful writing-forensics assistant. You assess whether the prose of \
an academic manuscript shows indicators of being generated by a large language \
model. You are advising the AUTHOR for their own self-review — you are NOT an \
accuser and your output is NOT proof of anything.

CRITICAL ANTI-FALSE-POSITIVE RULES (academic text is the hardest domain):
- Fluent, well-structured, formulaic, or hedge-heavy academic prose is NOT \
evidence of AI authorship. Scientific writing is templated by convention.
- Non-native English authors produce text that superficially resembles AI \
output. Do NOT treat ESL features as AI indicators.
- Light AI editing/polishing of human-written text IS human authorship. Do \
NOT flag polished-but-human prose as AI-generated.
- Default to "low" / "inconclusive". Reserve "high" for passages with \
MULTIPLE, SPECIFIC, CONVERGING indicators — and even then state it is not proof.
- If uncertain, abstain. A false accusation is far worse than a missed detection.
- NEVER claim a specific tool/person wrote the text. NEVER output a probability \
of guilt. NEVER assert a sentence is AI-written as fact.

Return ONLY a single JSON object, no prose around it, matching exactly:
{
  "abstain": true|false,
  "abstain_reason": "too_short"|"technical_section"|"insufficient_signal"|null,
  "band": "low"|"medium"|"high",
  "score": 0.0-1.0,                 // your subjective likelihood, uncalibrated
  "rationale": "2-3 sentences, register-level reasoning, no accusation",
  "non_native_or_polished_possible": true|false,
  "suspect_spans": [
    {"quote": "verbatim excerpt", "reason": "specific stylistic indicator",
     "confidence": "low"|"medium"|"high"}
  ]
}
"""

_USER_TEMPLATE = """\
Assess the following manuscript body text. Title: {title}

--- BEGIN MANUSCRIPT ---
{body}
--- END MANUSCRIPT ---

Apply the rules. Return only the JSON object."""


class LLMJudgeBackend(DetectionBackend):
    name = "llm-judge"

    _DEFAULT_MODELS = DEFAULT_HALLUCINATION_MODELS

    def __init__(
        self,
        provider: Optional[str] = None,
        api_key: Optional[str] = None,
        endpoint: Optional[str] = None,
        model: Optional[str] = None,
        check_id=None,
    ):
        self.check_id = check_id
        self.provider = (provider or "anthropic").lower()
        self.api_key = api_key or resolve_api_key(self.provider)
        self.endpoint = endpoint or resolve_endpoint(self.provider)
        self.model = model or self._DEFAULT_MODELS.get(
            self.provider, self._DEFAULT_MODELS["openai"]
        )
        self.client = None
        if not self.api_key:
            logger.debug("LLM-judge backend: no API key (provider=%s)", self.provider)
            return
        try:
            if self.provider == "anthropic":
                import anthropic
                self.client = anthropic.Anthropic(api_key=self.api_key, timeout=120.0)
            elif self.provider == "google":
                from google import genai
                self.client = genai.Client(api_key=self.api_key)
            else:
                import openai
                kwargs: dict = {"api_key": self.api_key}
                if self.endpoint:
                    kwargs["base_url"] = self.endpoint
                self.client = openai.OpenAI(**kwargs)
        except ImportError as exc:
            logger.warning("LLM-judge backend: provider package missing: %s", exc)
        except Exception as exc:  # noqa: BLE001
            logger.warning("LLM-judge backend init failed: %s", exc)

    @property
    def available(self) -> bool:
        return self.client is not None

    # -- LLM dispatch (mirrors hallucination_verifier chat paths) -----------

    def _call(self, system_prompt: str, user_prompt: str) -> str:
        if self.provider == "anthropic":
            resp = self.client.messages.create(
                model=self.model,
                max_tokens=1500,
                system=[{"type": "text", "text": system_prompt}],
                messages=[{"role": "user", "content": user_prompt}],
            )
            self._track(resp)
            return "".join(
                getattr(b, "text", "") for b in resp.content
                if getattr(b, "type", "") == "text"
            ).strip()
        if self.provider == "google":
            resp = call_google_with_retry(
                lambda: self.client.models.generate_content(
                    model=self.model,
                    contents=user_prompt,
                    config={"system_instruction": system_prompt},
                ),
                purpose="ai-detection judge",
            )
            self._track(resp)
            return extract_google_response_text(resp).strip()
        # openai / azure / vllm
        from refchecker.llm.providers import _openai_token_kwargs, _is_openai_reasoning_model
        kwargs = dict(
            model=self.model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
            **_openai_token_kwargs(self.model, 1500),
        )
        if not _is_openai_reasoning_model(self.model):
            kwargs["temperature"] = 0.0
        resp = self.client.chat.completions.create(**kwargs)
        self._track(resp)
        return (resp.choices[0].message.content or "").strip()

    def _track(self, response: Any) -> None:
        """Token attribution under flow='ai_detection'.

        Records to BOTH the process-wide tracker (global totals) AND the
        per-check tracker (the $ badge's per-flow breakdown), mirroring the
        hallucination verifier. Token cost is auto-estimated from the model.
        """
        try:
            from backend import usage_tracker as ut
            if self.provider == "anthropic":
                u = ut.extract_anthropic_usage(response)
            elif self.provider in ("google", "gemini"):
                u = ut.extract_gemini_usage(response)
            else:
                u = ut.extract_openai_usage(response)
            ut.record_usage(
                "google" if self.provider == "gemini" else self.provider,
                self.model, u["input_tokens"], u["output_tokens"], "ai_detection",
            )
        except Exception as exc:  # noqa: BLE001
            logger.debug("ai_detection global usage tracking skipped: %s", exc)
            u = None
        # Per-check meter (drives the on-screen token/$ badge + batch usage).
        try:
            if u is not None:
                record_detection_usage(
                    self.check_id, self.model,
                    input_tokens=u.get("input_tokens", 0),
                    output_tokens=u.get("output_tokens", 0),
                )
        except Exception as exc:  # noqa: BLE001
            logger.debug("ai_detection per-check usage tracking skipped: %s", exc)

    # -- Detection ----------------------------------------------------------

    def detect(self, text: str, *, title: Optional[str] = None) -> AIDetectionResult:
        body, wc = prepared_text(text)
        if not self.available:
            return make_unavailable("llm_not_configured", self.name, wc)
        reason = should_abstain(body)
        if reason:
            return make_inconclusive(reason, self.name, wc)

        truncated = len(body) > _MAX_CHARS
        prompt_body = body[:_MAX_CHARS]
        user = _USER_TEMPLATE.format(title=title or "(untitled)", body=prompt_body)
        try:
            raw = self._call(_SYSTEM_PROMPT, user)
        except Exception as exc:  # noqa: BLE001
            logger.warning("LLM-judge call failed: %s", exc)
            return make_unavailable("llm_call_failed", self.name, wc)

        parsed = _parse_json(raw)
        if parsed is None:
            logger.debug("LLM-judge returned unparseable output; abstaining")
            return make_inconclusive("insufficient_signal", self.name, wc)

        if parsed.get("abstain"):
            return make_inconclusive(
                parsed.get("abstain_reason") or "insufficient_signal", self.name, wc
            )

        band = str(parsed.get("band", BAND_LOW)).lower()
        if band not in (BAND_LOW, BAND_MEDIUM, BAND_HIGH):
            band = BAND_LOW
        # The model's asserted band, captured BEFORE any honesty adjustment so
        # the score-suppression below covers every path that lowers the band.
        model_band = band
        # Guardrail: if the model itself flags possible non-native/polished
        # authorship, do not allow a "high" verdict.
        if parsed.get("non_native_or_polished_possible") and band == BAND_HIGH:
            band = BAND_MEDIUM
        # Hard honesty cap: an uncalibrated LLM judge must never raise a
        # standalone "high" accusation. Cap its band at medium (clamp can
        # only lower). This makes the documented "only lower severity"
        # guarantee real rather than aspirational.
        band = clamp_not_above(BAND_MEDIUM, band)

        score = parsed.get("score")
        try:
            score = round(float(score), 3) if score is not None else None
        except (TypeError, ValueError):
            score = None
        # LLMs frequently ignore the "0.0-1.0" rubric and return a 0-100 scale
        # (e.g. 95). Reject out-of-range values rather than render "score 9500" —
        # same defensive stance as the API backend's _coerce_score.
        if score is not None and not (0.0 <= score <= 1.0):
            score = None
        # If ANY honesty step (non-native demotion or the medium cap) lowered
        # the band below what the model asserted, suppress the raw score so the
        # UI never shows e.g. "Medium · score 95" — the score is uncalibrated
        # and reading higher than the (lowered) band is confusing dissonance.
        if score is not None and band_rank(band) < band_rank(model_band):
            score = None

        spans: List[SuspectSpan] = []
        if band_rank(band) >= band_rank(BAND_MEDIUM):
            for sp in (parsed.get("suspect_spans") or [])[:8]:
                quote = (sp.get("quote") or "").strip()
                if not quote:
                    continue
                spans.append(SuspectSpan(
                    quote=truncate_quote(quote),
                    reason=(sp.get("reason") or "").strip(),
                    confidence=str(sp.get("confidence", "medium")).lower(),
                ))

        summary = (parsed.get("rationale") or "").strip() or _band_summary(band)
        if truncated:
            summary += " (Assessed the first ~60k characters of the manuscript.)"

        return AIDetectionResult(
            band=band,
            overall_score=score,
            confidence="low",  # an LLM judge is never high-confidence here
            summary=summary,
            spans=spans,
            backend_used=self.name,
            model_version=f"{self.provider}:{self.model}",
            operating_point=OPERATING_POINT,
            word_count=wc,
            disclaimer=DISCLAIMER,
        )


def _band_summary(band: str) -> str:
    return {
        BAND_LOW: "No strong indicators of AI-generated prose.",
        BAND_MEDIUM: "Some indicators present; not conclusive.",
        BAND_HIGH: "Multiple indicators present — this is NOT proof of AI authorship.",
    }.get(band, "")


_JSON_RE = re.compile(r"\{.*\}", re.DOTALL)


def _parse_json(raw: str) -> Optional[dict]:
    """Tolerant single-object JSON extraction."""
    if not raw:
        return None
    # Strip code fences if present.
    raw = raw.strip()
    if raw.startswith("```"):
        raw = raw.strip("`")
        raw = raw.split("\n", 1)[1] if "\n" in raw else raw
    try:
        return json.loads(raw)
    except Exception:  # noqa: BLE001
        pass
    m = _JSON_RE.search(raw)
    if not m:
        return None
    try:
        return json.loads(m.group(0))
    except Exception:  # noqa: BLE001
        return None
