"""Modular pattern detectors for trace analysis.

Each detector is a standalone function: (traces, stats, evaluations) -> Pattern | None.
"""

from __future__ import annotations

import json as _json
from typing import Any, Callable

from bandito.models import get_eval_score
from .models import AggregateStats, Pattern
from .utils import build_trace_model_map

Detector = Callable[
    [list[dict[str, Any]], AggregateStats, dict[str, dict[str, Any]]],
    Pattern | None,
]

_REGISTRY: list[Detector] = []


def detector(fn: Detector) -> Detector:
    _REGISTRY.append(fn)
    return fn


def run_all(
    traces: list[dict[str, Any]],
    stats: AggregateStats,
    evaluations: dict[str, dict[str, Any]] | None = None,
) -> list[Pattern]:
    evaluations = evaluations or {}
    patterns = []
    for fn in _REGISTRY:
        result = fn(traces, stats, evaluations)
        if result is not None:
            patterns.append(result)
    return patterns


# --- Bootstrap detectors (always run) ---


@detector
def detect_duplicate_tool_calls(
    traces: list[dict[str, Any]],
    stats: AggregateStats,
    evaluations: dict[str, dict[str, Any]],
) -> Pattern | None:
    evidence = []
    for trace in traces:
        spans = trace.get("spans", [])
        prev_name = None
        prev_input = None
        for span in spans:
            if span.get("kind") != "tool":
                continue
            name = span.get("name", "")
            raw_inp = span.get("input", "")
            inp = _json.dumps(raw_inp, sort_keys=True) if isinstance(raw_inp, (dict, list)) else str(raw_inp)
            if name == prev_name and inp == prev_input and name:
                evidence.append(
                    f"Trace {trace.get('trace_id', '?')}: "
                    f"duplicate '{name}' call"
                )
            prev_name = name
            prev_input = inp
    if not evidence:
        return None
    return Pattern(
        name="duplicate_tool_calls",
        severity="warning",
        description=(
            f"Found {len(evidence)} sequential duplicate tool calls "
            f"(same name + input). These may be redundant retries."
        ),
        evidence=evidence[:5],
    )


@detector
def detect_cost_concentration(
    traces: list[dict[str, Any]],
    stats: AggregateStats,
    evaluations: dict[str, dict[str, Any]],
) -> Pattern | None:
    if stats.trace_count < 10 or stats.cost.total == 0:
        return None
    costs = []
    for trace in traces:
        cost = sum(s.get("cost") or 0.0 for s in trace.get("spans", []))
        costs.append((trace.get("trace_id", "?"), cost))
    costs.sort(key=lambda x: x[1], reverse=True)
    top_n = max(1, len(costs) // 10)
    top_cost = sum(c for _, c in costs[:top_n])
    ratio = top_cost / stats.cost.total
    if ratio < 0.5:
        return None
    return Pattern(
        name="cost_concentration",
        severity="warning",
        description=(
            f"Top {top_n} traces ({top_n}/{stats.trace_count}) account for "
            f"{ratio:.0%} of total cost (${top_cost:.4f} of ${stats.cost.total:.4f})."
        ),
        evidence=[f"{tid}: ${c:.4f}" for tid, c in costs[:3]],
    )


@detector
def detect_latency_concentration(
    traces: list[dict[str, Any]],
    stats: AggregateStats,
    evaluations: dict[str, dict[str, Any]],
) -> Pattern | None:
    if not stats.span_kinds:
        return None
    for sk in stats.span_kinds:
        if sk.pct_of_total > 60:
            return Pattern(
                name="latency_concentration",
                severity="info",
                description=(
                    f"'{sk.kind}' spans account for {sk.pct_of_total:.0f}% "
                    f"of total latency ({sk.count} spans, {sk.total_latency:.2f}s total)."
                ),
                evidence=[
                    f"{sk.kind}: {sk.avg_latency:.3f}s avg, {sk.count} calls"
                ],
            )
    return None


@detector
def detect_error_patterns(
    traces: list[dict[str, Any]],
    stats: AggregateStats,
    evaluations: dict[str, dict[str, Any]],
) -> Pattern | None:
    if stats.error_count == 0:
        return None
    clusters: dict[str, int] = {}
    for trace in traces:
        for span in trace.get("spans", []):
            if span.get("status") != "error":
                continue
            key = f"{span.get('name', '?')}: {span.get('error', 'unknown')}"
            clusters[key] = clusters.get(key, 0) + 1
    if not clusters:
        return None
    sorted_clusters = sorted(clusters.items(), key=lambda x: x[1], reverse=True)
    return Pattern(
        name="error_patterns",
        severity="critical" if stats.error_count > stats.span_count * 0.1 else "warning",
        description=(
            f"{stats.error_count} error spans across {len(clusters)} distinct patterns."
        ),
        evidence=[f"{k} ({v}x)" for k, v in sorted_clusters[:5]],
    )


@detector
def detect_context_size_variance(
    traces: list[dict[str, Any]],
    stats: AggregateStats,
    evaluations: dict[str, dict[str, Any]],
) -> Pattern | None:
    input_tokens_list = []
    for trace in traces:
        for span in trace.get("spans", []):
            if span.get("kind") == "llm" and span.get("input_tokens"):
                input_tokens_list.append(span["input_tokens"])
    if len(input_tokens_list) < 10:
        return None
    input_tokens_list.sort()
    n = len(input_tokens_list)
    top_10_start = int(n * 0.9)
    bottom_10_end = max(1, int(n * 0.1))
    top_avg = sum(input_tokens_list[top_10_start:]) / max(1, n - top_10_start)
    bottom_avg = sum(input_tokens_list[:bottom_10_end]) / bottom_10_end
    if bottom_avg == 0 or top_avg / bottom_avg < 5:
        return None
    return Pattern(
        name="context_size_variance",
        severity="info",
        description=(
            f"High variance in LLM input sizes: top 10% avg {top_avg:.0f} tokens "
            f"vs bottom 10% avg {bottom_avg:.0f} tokens ({top_avg / bottom_avg:.1f}x)."
        ),
        evidence=[
            f"Min: {input_tokens_list[0]}, Max: {input_tokens_list[-1]}, "
            f"Median: {input_tokens_list[n // 2]}"
        ],
    )


@detector
def detect_model_diversity(
    traces: list[dict[str, Any]],
    stats: AggregateStats,
    evaluations: dict[str, dict[str, Any]],
) -> Pattern | None:
    if len(stats.models) != 1:
        return None
    m = stats.models[0]
    return Pattern(
        name="model_diversity",
        severity="info",
        description=(
            f"Only one model used: {m.model} ({m.provider or 'unknown provider'}). "
            f"Adding alternatives enables optimization."
        ),
        evidence=[
            f"{m.model}: {m.count} calls" + (f", ${m.total_cost:.4f} total cost" if m.has_cost else "")
        ],
    )


# --- Enhanced detectors (when evaluations exist) ---


@detector
def detect_quality_cost_correlation(
    traces: list[dict[str, Any]],
    stats: AggregateStats,
    evaluations: dict[str, dict[str, Any]],
) -> Pattern | None:
    if not evaluations:
        return None
    graded: list[tuple[float, float]] = []  # (cost, score)
    for trace in traces:
        trace_id = trace.get("trace_id", "")
        ev = evaluations.get(trace_id)
        if not ev or ev.get("human_score") is None:
            continue
        cost = sum(s.get("cost") or 0.0 for s in trace.get("spans", []))
        graded.append((cost, ev["human_score"]))
    if len(graded) < 10:
        return None
    graded.sort(key=lambda x: x[0])
    mid = len(graded) // 2
    cheap_avg = sum(s for _, s in graded[:mid]) / mid
    expensive_avg = sum(s for _, s in graded[mid:]) / (len(graded) - mid)
    # Only report if cheap traces score within 20% of expensive ones
    if expensive_avg == 0 or cheap_avg / expensive_avg < 0.8:
        return None
    return Pattern(
        name="quality_cost_correlation",
        severity="info",
        description=(
            f"Cheaper traces score comparably to expensive ones "
            f"(cheap avg: {cheap_avg:.2f}, expensive avg: {expensive_avg:.2f}). "
            f"Cost optimization may be possible."
        ),
        evidence=[
            f"Bottom half avg cost: ${graded[0][0]:.4f}-${graded[mid - 1][0]:.4f}, "
            f"score: {cheap_avg:.2f}",
            f"Top half avg cost: ${graded[mid][0]:.4f}-${graded[-1][0]:.4f}, "
            f"score: {expensive_avg:.2f}",
        ],
    )


@detector
def detect_quality_by_model(
    traces: list[dict[str, Any]],
    stats: AggregateStats,
    evaluations: dict[str, dict[str, Any]],
) -> Pattern | None:
    if not evaluations or len(stats.models) < 2:
        return None
    trace_model = build_trace_model_map(traces)

    model_scores: dict[str, list[float]] = {}
    for trace_id, ev in evaluations.items():
        model = trace_model.get(trace_id)
        if not model:
            continue
        if ev.get("human_score") is not None:
            model_scores.setdefault(model, []).append(ev["human_score"])

    if len(model_scores) < 2:
        return None

    evidence = []
    for model, scores in sorted(model_scores.items()):
        avg = sum(scores) / len(scores)
        evidence.append(f"{model}: {avg:.2f} avg ({len(scores)} graded)")

    return Pattern(
        name="quality_by_model",
        severity="info",
        description="Grade breakdown by model (when multiple models used).",
        evidence=evidence,
    )
