"""
evaluators.py — Pre-built LLM output evaluators (zero extra deps).

Evaluators are stateless functions: evaluate(text, context) → EvalResult.

Built-in evaluators:
  PII detector        — regex patterns for emails, phones, SSNs, credit cards, IBANs
  Toxicity classifier — keyword + pattern list (English, extendable)
  Hallucination scorer — TF-IDF overlap between claim and source context
  Off-topic classifier — cosine similarity between output and expected topic

Usage:
    from qppg_service.evaluators import run_evaluator, EVALUATOR_REGISTRY

    result = run_evaluator("pii_detector", text="call me at 555-123-4567")
    # EvalResult(passed=False, score=1.0, label="pii_detected", details={...})
"""

from __future__ import annotations

import math
import re
from dataclasses import dataclass, field
from typing import Callable


@dataclass
class EvalResult:
    passed:  bool
    score:   float             # 0-1; higher = more risky / more of the thing being detected
    label:   str               # human-readable verdict
    details: dict = field(default_factory=dict)

    def to_dict(self) -> dict:
        return {"passed": self.passed, "score": self.score,
                "label": self.label, "details": self.details}


# ── PII Detector ──────────────────────────────────────────────────────────────

_PII_PATTERNS: list[tuple[str, re.Pattern]] = [
    ("email",        re.compile(r'\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b')),
    ("phone_us",     re.compile(r'\b(\+?1[\s\-.]?)?\(?\d{3}\)?[\s\-.]?\d{3}[\s\-.]?\d{4}\b')),
    ("ssn",          re.compile(r'\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b')),
    ("credit_card",  re.compile(r'\b(?:\d{4}[\s\-]?){3}\d{4}\b')),
    ("iban",         re.compile(r'\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}(?:[A-Z0-9]?){0,16}\b')),
    ("ipv4",         re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b')),
    ("passport",     re.compile(r'\b[A-Z]{1,2}\d{6,9}\b')),
    ("dob",          re.compile(r'\b(?:0?[1-9]|1[0-2])[/\-](?:0?[1-9]|[12]\d|3[01])[/\-](?:19|20)\d{2}\b')),
]


def pii_detector(text: str, context: str = "", config: dict | None = None) -> EvalResult:
    """Detect PII in output text. Returns passed=False if any PII found."""
    findings: dict[str, list[str]] = {}
    for name, pattern in _PII_PATTERNS:
        matches = pattern.findall(text)
        if matches:
            findings[name] = [str(m) for m in matches[:5]]  # cap at 5 samples

    if findings:
        # Score = fraction of pattern types triggered (normalised to 0-1)
        score = min(1.0, len(findings) / len(_PII_PATTERNS) * 2)
        return EvalResult(
            passed=False, score=round(score, 3),
            label=f"pii_detected:{','.join(findings.keys())}",
            details={"found": findings},
        )
    return EvalResult(passed=True, score=0.0, label="no_pii", details={})


# ── Toxicity Classifier ───────────────────────────────────────────────────────

_TOXICITY_KEYWORDS: list[re.Pattern] = [
    # Hate speech / slurs (censored stems)
    re.compile(r'\b(kil+\w*|murd\w*|assassin\w*)\b', re.I),
    re.compile(r'\b(racist|sexist|homophob\w*|transphob\w*)\b', re.I),
    re.compile(r'\bhet[e]?\s*(it|them|you|him|her)\b', re.I),
    # Explicit violence
    re.compile(r'\b(bomb\w*|explos\w*|detona\w*|shoot\w*)\b', re.I),
    # Profanity (common English; filtered to avoid false positives)
    re.compile(r'\b(f+u+c+k+\w*|sh[i1]+t+\w*|b[i1]tch\w*|a+s+h[o0]le\w*)\b', re.I),
    # Harassment
    re.compile(r'\b(threaten\w*|stalk\w*|harass\w*|intimidat\w*)\b', re.I),
    # Self-harm (flagged for content safety, not penalised as "toxic" per se)
    re.compile(r'\b(suicid\w*|self.harm\w*|cut\s+myself)\b', re.I),
]


def toxicity_classifier(text: str, context: str = "", config: dict | None = None) -> EvalResult:
    """Classify toxicity based on keyword matching. Score = fraction of categories hit."""
    hits: list[str] = []
    for i, pattern in enumerate(_TOXICITY_KEYWORDS):
        if pattern.search(text):
            hits.append(f"cat_{i+1}")

    if hits:
        score = min(1.0, len(hits) / len(_TOXICITY_KEYWORDS) * 2.5)
        return EvalResult(
            passed=False, score=round(score, 3),
            label=f"toxic:{len(hits)}_categories",
            details={"categories_hit": hits},
        )
    return EvalResult(passed=True, score=0.0, label="not_toxic", details={})


# ── Hallucination Scorer ──────────────────────────────────────────────────────

def _tokenize(text: str) -> dict[str, int]:
    """Simple word-frequency map (lowercased, alphanumeric only)."""
    tokens = re.findall(r'\b[a-z0-9]+\b', text.lower())
    freq: dict[str, int] = {}
    for t in tokens:
        freq[t] = freq.get(t, 0) + 1
    return freq


def _tfidf_cosine(query_freq: dict, doc_freq: dict) -> float:
    """Cosine similarity using raw term frequencies as a TF-IDF proxy."""
    if not query_freq or not doc_freq:
        return 0.0
    common = set(query_freq) & set(doc_freq)
    if not common:
        return 0.0
    dot = sum(query_freq[t] * doc_freq[t] for t in common)
    mag_q = math.sqrt(sum(v * v for v in query_freq.values()))
    mag_d = math.sqrt(sum(v * v for v in doc_freq.values()))
    return dot / (mag_q * mag_d + 1e-9)


def hallucination_scorer(text: str, context: str = "", config: dict | None = None) -> EvalResult:
    """
    Score hallucination risk by measuring overlap between the LLM output and the
    source context (observations / retrieved docs).

    High overlap → grounded → low hallucination risk.
    Low overlap  → possibly hallucinated → high risk.

    Requires context to be non-empty; if no context, returns score=0.5 (uncertain).
    """
    if not context or not context.strip():
        return EvalResult(passed=True, score=0.5, label="no_context_to_verify",
                          details={"note": "Provide context for hallucination checking"})

    cfg    = config or {}
    thresh = float(cfg.get("threshold", 0.10))

    # Try semantic similarity first (sentence-transformers); fall back to TF-IDF
    overlap: float
    method: str
    sem = _semantic_similarity(text, context)
    if sem is not None:
        overlap = sem
        method  = "semantic"
    else:
        out_freq = _tokenize(text)
        ctx_freq = _tokenize(context)
        overlap  = _tfidf_cosine(out_freq, ctx_freq)
        method   = "tfidf"

    # Semantic thresholds are higher than lexical thresholds
    grounded_thresh     = 0.55 if method == "semantic" else 0.30
    uncertain_thresh    = thresh if method != "semantic" else 0.35

    if overlap >= grounded_thresh:
        return EvalResult(passed=True, score=round(1 - overlap, 3),
                          label="grounded",
                          details={"overlap": round(overlap, 3), "method": method})
    elif overlap >= uncertain_thresh:
        return EvalResult(passed=True, score=round(1 - overlap, 3),
                          label="possibly_hallucinated",
                          details={"overlap": round(overlap, 3), "method": method})
    else:
        return EvalResult(passed=False, score=round(1 - overlap, 3),
                          label="likely_hallucinated",
                          details={"overlap": round(overlap, 3), "method": method})


# ── Off-Topic Classifier ──────────────────────────────────────────────────────

def off_topic_classifier(text: str, context: str = "", config: dict | None = None) -> EvalResult:
    """
    Classify whether LLM output is on-topic relative to the original query (context).

    Uses cosine similarity between output tokens and query tokens.
    config["threshold"] = minimum similarity to be considered on-topic (default 0.15).
    config["expected_topics"] = comma-separated topic words to check against.
    """
    cfg = config or {}
    threshold = float(cfg.get("threshold", 0.15))
    expected  = cfg.get("expected_topics", "")

    # Build reference text: query + expected topics
    reference = context
    if expected:
        reference = reference + " " + expected.replace(",", " ")

    if not reference.strip():
        return EvalResult(passed=True, score=0.5, label="no_reference",
                          details={"note": "Set expected_topics in config"})

    out_freq = _tokenize(text)
    ref_freq = _tokenize(reference)
    similarity = _tfidf_cosine(out_freq, ref_freq)

    if similarity >= threshold:
        return EvalResult(passed=True, score=round(1 - similarity, 3),
                          label="on_topic", details={"similarity": round(similarity, 3)})
    else:
        return EvalResult(passed=False, score=round(1 - similarity, 3),
                          label="off_topic", details={"similarity": round(similarity, 3)})


# ── Prompt Injection Detector ──────────────────────────────────────────────────

_INJECTION_PATTERNS: list[tuple[str, re.Pattern]] = [
    ("ignore_instructions", re.compile(
        r'\b(ignore|disregard|forget|override|bypass|skip)\s+(all\s+)?(previous|prior|above|earlier|system|initial)\s+(instructions?|prompt|rules?|context|guidelines?)',
        re.I,
    )),
    ("jailbreak_role", re.compile(
        r'\b(you\s+are\s+now|pretend\s+(you\s+are|to\s+be)|act\s+as|roleplay\s+as|simulate\s+a|imagine\s+you\s+are)\s+\w',
        re.I,
    )),
    ("dan_pattern", re.compile(
        r'\bDAN\b|\bdo\s+anything\s+now\b|\bjailbreak\b|\bunfiltered\s+(AI|mode|version)',
        re.I,
    )),
    ("prompt_leak", re.compile(
        r'(repeat|print|show|output|reveal|tell\s+me|display|write\s+out)\s+(your\s+)?(system\s+prompt|instructions?|context|full\s+prompt)',
        re.I,
    )),
    ("delimiter_injection", re.compile(
        r'(<\|im_start\|>|<\|im_end\|>|<\|endoftext\|>|<\|system\|>|\[INST\]|\[/INST\]|###\s*System\b)',
    )),
    ("indirect_injection", re.compile(
        r'\b(new\s+instruction|revised\s+instruction|updated\s+instruction|secret\s+instruction|hidden\s+instruction)\b',
        re.I,
    )),
    ("token_smuggling", re.compile(
        r'(base64|hex\s+encoded|rot13|unicode\s+escape)\s+(decode|encoded|payload)',
        re.I,
    )),
]


def prompt_injection_detector(text: str, context: str = "", config: dict | None = None) -> EvalResult:
    """
    Detect prompt injection and jailbreak attempts in user input text.

    Checks for:
    - Instruction override attempts ("ignore previous instructions")
    - Role-play jailbreaks ("pretend you are", "act as DAN")
    - Prompt leak attempts ("show me your system prompt")
    - Delimiter injection (special tokens like <|im_start|>)
    - Indirect injection ("new instruction:")
    - Token smuggling (base64/hex encoded payloads)
    """
    findings: dict[str, list[str]] = {}
    for name, pattern in _INJECTION_PATTERNS:
        matches = [m.group(0) for m in pattern.finditer(text)]
        if matches:
            findings[name] = matches[:3]

    if findings:
        score = min(1.0, len(findings) / len(_INJECTION_PATTERNS) * 2)
        return EvalResult(
            passed=False, score=round(score, 3),
            label=f"injection_detected:{','.join(findings.keys())}",
            details={"found": findings},
        )
    return EvalResult(passed=True, score=0.0, label="no_injection", details={})


# ── Hallucination scorer: semantic upgrade ─────────────────────────────────────

def _semantic_similarity(text1: str, text2: str) -> float | None:
    """Sentence-transformer cosine similarity if available, else None."""
    try:
        from sentence_transformers import SentenceTransformer, util
        _model = SentenceTransformer("all-MiniLM-L6-v2")
        embeddings = _model.encode([text1, text2], convert_to_tensor=True)
        return float(util.cos_sim(embeddings[0], embeddings[1]))
    except Exception:
        return None


# ── Registry ──────────────────────────────────────────────────────────────────

EVALUATOR_REGISTRY: dict[str, Callable] = {
    "pii_detector":             pii_detector,
    "toxicity_classifier":      toxicity_classifier,
    "hallucination_scorer":     hallucination_scorer,
    "off_topic_classifier":     off_topic_classifier,
    "prompt_injection_detector": prompt_injection_detector,
}


def run_evaluator(name: str, text: str, context: str = "", config: dict | None = None) -> EvalResult:
    """
    Run a named evaluator. Raises ValueError for unknown names.

    Parameters
    ----------
    name    : one of EVALUATOR_REGISTRY keys
    text    : LLM output to evaluate
    context : source context (query / retrieved docs); used by hallucination + off-topic
    config  : evaluator-specific config dict
    """
    fn = EVALUATOR_REGISTRY.get(name)
    if not fn:
        raise ValueError(f"Unknown evaluator '{name}'. Available: {list(EVALUATOR_REGISTRY)}")
    return fn(text, context, config)


def run_all(text: str, context: str = "", config: dict | None = None) -> dict[str, EvalResult]:
    """Run all evaluators and return {name: EvalResult}."""
    return {name: fn(text, context, config) for name, fn in EVALUATOR_REGISTRY.items()}
