"""Typed SDK client for the evalkit server."""

from __future__ import annotations

import json
import subprocess
import time
import urllib.error
import urllib.request
from typing import Any

from pydantic import BaseModel

from evalkit.models import EvaluationReport, Suite, TestCase


class Project(BaseModel):
    id: int
    name: str
    description: str


class ServerConfig(BaseModel):
    judge_provider: str
    judge_model: str | None
    suite: str
    available_suites: list[Suite]
    available_providers: list[str]


class Evaluation(BaseModel):
    id: int
    status: str
    suite_name: str
    average_score: float | None = None
    created_at: str | None = None
    completed_at: str | None = None
    tags: dict[str, str] = {}


class Trace(BaseModel):
    id: int
    project_id: int
    timestamp: str
    test_case: dict[str, Any] = {}
    question: str = ""
    answer: str = ""
    context: list[str] = []
    expected_answer: str | None = None
    scores: list[dict[str, Any]] = []
    metadata: dict[str, str] = {}
    avg_score: float | None = None


class EvalKitAPIError(Exception):
    def __init__(self, status_code: int, detail: str) -> None:
        self.status_code = status_code
        self.detail = detail
        super().__init__(f"HTTP {status_code}: {detail}")


def _collect_auto_tags() -> dict[str, str]:
    tags: dict[str, str] = {}
    try:
        commit = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if commit.returncode == 0 and commit.stdout.strip():
            tags["git_commit"] = commit.stdout.strip()

        branch = subprocess.run(
            ["git", "rev-parse", "--abbrev-ref", "HEAD"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if branch.returncode == 0 and branch.stdout.strip():
            tags["git_branch"] = branch.stdout.strip()
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        pass
    return tags


class EvalKitClient:
    """Typed client for the evalkit REST API."""

    def __init__(self, base_url: str = "http://localhost:8000") -> None:
        self.base_url = base_url.rstrip("/")

    def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
        url = f"{self.base_url}/api/v1{path}"
        data = json.dumps(body).encode() if body else None
        req = urllib.request.Request(
            url,
            data=data,
            method=method,
            headers={"Content-Type": "application/json"} if data else {},
        )
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                return json.loads(resp.read().decode())
        except urllib.error.HTTPError as exc:
            body_text = exc.read().decode() if exc.fp else ""
            try:
                detail = json.loads(body_text).get("detail", body_text)
            except (json.JSONDecodeError, AttributeError):
                detail = body_text
            raise EvalKitAPIError(exc.code, detail) from exc
        except urllib.error.URLError as exc:
            raise ConnectionError(
                f"Cannot connect to evalkit server at {self.base_url}. "
                "Is the server running? Start it with: evalkit serve"
            ) from exc

    def get_config(self) -> ServerConfig:
        return ServerConfig(**self._request("GET", "/config"))

    def list_projects(self) -> list[Project]:
        return [Project(**p) for p in self._request("GET", "/projects")]

    def create_project(self, name: str, description: str = "") -> Project:
        try:
            data = self._request("POST", "/projects", {"name": name, "description": description})
            return Project(**data)
        except EvalKitAPIError as exc:
            if exc.status_code == 409:
                return next(p for p in self.list_projects() if p.name == name)
            raise

    def evaluate(
        self,
        project_id: int,
        test_cases: list[TestCase | dict],
        *,
        suite: Suite | None = None,
        judge_provider: str | None = None,
        judge_model: str | None = None,
        api_key: str | None = None,
        tags: dict[str, str] | None = None,
    ) -> Evaluation:
        cases = [tc.model_dump() if isinstance(tc, TestCase) else tc for tc in test_cases]
        body: dict[str, Any] = {"project_id": project_id, "test_cases": cases}
        if suite is not None:
            body["suite"] = suite
        if judge_provider is not None:
            body["judge_provider"] = judge_provider
        if judge_model is not None:
            body["judge_model"] = judge_model
        if api_key is not None:
            body["api_key"] = api_key
        merged_tags = _collect_auto_tags()
        if tags:
            merged_tags.update(tags)
        body["tags"] = merged_tags

        data = self._request("POST", "/evaluate", body)
        return Evaluation(
            id=data["evaluation_id"],
            status=data["status"],
            suite_name=suite or "",
        )

    def get_evaluation(self, evaluation_id: int) -> Evaluation:
        return Evaluation(**self._request("GET", f"/evaluations/{evaluation_id}"))

    def wait_for_evaluation(
        self,
        evaluation_id: int,
        *,
        poll_interval: float = 2.0,
        timeout: float = 300.0,
    ) -> Evaluation:
        start = time.monotonic()
        while True:
            evaluation = self.get_evaluation(evaluation_id)
            if evaluation.status in ("complete", "failed"):
                return evaluation
            if time.monotonic() - start > timeout:
                raise TimeoutError(f"Evaluation {evaluation_id} did not complete within {timeout}s")
            time.sleep(poll_interval)

    def upload_results(
        self, project_id: int, report: EvaluationReport, *, tags: dict[str, str] | None = None
    ) -> Evaluation:
        results = []
        for r in report.results:
            results.append(
                {
                    "test_case": r.test_case.model_dump(),
                    "scores": [
                        {
                            "metric_name": s.metric_name,
                            "score": s.score,
                            "passed": s.passed,
                            "reason": s.reason,
                        }
                        for s in r.scores
                    ],
                    "overall_score": r.overall_score,
                }
            )
        body = {
            "project_id": project_id,
            "suite_name": report.suite_name,
            "results": results,
            "average_score": report.summary.average_score,
        }
        merged_tags = _collect_auto_tags()
        if tags:
            merged_tags.update(tags)
        body["tags"] = merged_tags
        data = self._request("POST", "/results", body)
        return Evaluation(
            id=data["evaluation_id"],
            status=data["status"],
            suite_name=report.suite_name,
            average_score=report.summary.average_score,
        )

    def upload_report(
        self,
        project_name: str,
        report: EvaluationReport,
        *,
        description: str = "",
        tags: dict[str, str] | None = None,
    ) -> Evaluation:
        """Create or reuse a project by name, then upload a report to it."""
        project = self.create_project(project_name, description)
        return self.upload_results(project.id, report, tags=tags)

    def update_tags(self, evaluation_id: int, tags: dict[str, str | None]) -> dict[str, str]:
        data = self._request("PUT", f"/evaluations/{evaluation_id}/tags", {"tags": tags})
        return data["tags"]

    def compare(self, project_id: int, evaluation_ids: list[int], *, baseline: int | None = None) -> dict[str, Any]:
        ids_str = ",".join(str(i) for i in evaluation_ids)
        params = f"?ids={ids_str}"
        if baseline is not None:
            params += f"&baseline={baseline}"
        return self._request("GET", f"/projects/{project_id}/compare{params}")

    # -- Traces (online monitoring) ------------------------------------------

    def log_trace(
        self,
        project_id: int,
        test_case: TestCase,
        *,
        suite: Suite | None = None,
        metadata: dict[str, str] | None = None,
        timestamp: str | None = None,
    ) -> int:
        """Log a single production trace. Returns the trace ID.

        The server evaluates the trace using the specified suite (or the
        server's default suite). Scores appear asynchronously.
        """
        trace: dict[str, Any] = {"test_case": test_case.model_dump()}
        if metadata is not None:
            trace["metadata"] = metadata
        if timestamp is not None:
            trace["timestamp"] = timestamp
        body: dict[str, Any] = {"project_id": project_id, "traces": [trace]}
        if suite is not None:
            body["suite"] = suite
        data = self._request("POST", "/traces", body)
        return data["trace_ids"][0]

    def log_traces(
        self,
        project_id: int,
        traces: list[dict[str, Any]],
        *,
        suite: Suite | None = None,
    ) -> list[int]:
        """Log multiple traces in one call. Returns list of trace IDs.

        Each trace dict should have ``test_case`` (TestCase dict), and
        optionally ``timestamp`` and ``metadata``.
        """
        body: dict[str, Any] = {"project_id": project_id, "traces": traces}
        if suite is not None:
            body["suite"] = suite
        data = self._request("POST", "/traces", body)
        return data["trace_ids"]

    def get_traces(
        self,
        project_id: int,
        *,
        start: str | None = None,
        end: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> tuple[list[Trace], int]:
        """List traces with optional filters. Returns (traces, total_count)."""
        params = f"?project_id={project_id}&limit={limit}&offset={offset}"
        if start:
            params += f"&start={start}"
        if end:
            params += f"&end={end}"
        data = self._request("GET", f"/traces{params}")
        return [Trace(**t) for t in data["traces"]], data["total"]

    def get_trace(self, trace_id: int) -> Trace:
        """Get a single trace by ID."""
        return Trace(**self._request("GET", f"/traces/{trace_id}"))
