"""
Cascade SDK - Evaluation Client

Programmatic access to Cascade's evaluation system from Python.
Create scorers, run evaluations, and manage batch/scheduled tasks
directly from your code — the same capabilities available in the dashboard.

Minimal usage (Tier 2 — one extra line)::

    from cascade import init_tracing, trace_run, evaluate

    init_tracing(project="my_agent")

    with trace_run("MyAgent") as run:
        # ... agent logic ...

    results = evaluate(run, ["helpfulness", "hallucination"])

Full control (Tier 3)::

    from cascade import CascadeEval

    evals = CascadeEval()
    scorers = evals.list_scorers()
    task = evals.create_task(...)
    evals.run_task(task["task_id"])
"""

import os
import json
import time
import logging
import threading
from typing import Optional, List, Dict, Any, Union

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Module-level state for the convenience API
# ---------------------------------------------------------------------------

# Singleton client, lazily created by evaluate() / _setup_evals()
_default_client: Optional["CascadeEval"] = None

# Cached mapping: short scorer name -> scorer_id  (populated on first resolve)
_scorer_name_cache: Dict[str, str] = {}

# Scorer IDs configured via init_tracing(evals=[...])
_init_eval_scorer_ids: Optional[List[str]] = None

# Session-scoped scorer IDs configured via init_tracing(session_evals=[...])
_init_session_eval_scorer_ids: Optional[List[str]] = None

# Try to import requests; fall back to urllib if not available
try:
    import requests as _requests
    _HAS_REQUESTS = True
except ImportError:
    _requests = None
    _HAS_REQUESTS = False
    import urllib.request
    import urllib.error


class CascadeEvalError(Exception):
    """Raised when a Cascade evaluation API call fails."""

    def __init__(self, message: str, status_code: int = 0, detail: str = ""):
        self.status_code = status_code
        self.detail = detail
        super().__init__(message)


class CascadeEval:
    """
    Client for the Cascade Evaluation API.

    Provides methods to manage scorers, run evaluations, and create
    batch/scheduled tasks — the same operations available in the dashboard.
    """

    def __init__(
        self,
        endpoint: Optional[str] = None,
        api_key: Optional[str] = None,
    ):
        """
        Initialize the evaluation client.

        If endpoint/api_key are not provided, they are read from:
          1. The values set by ``init_tracing()``
          2. Environment variables ``CASCADE_ENDPOINT`` and ``CASCADE_API_KEY``

        Args:
            endpoint: Base URL of the Cascade backend (e.g. ``http://localhost:8000``).
                      If the URL contains ``/v1/traces``, that suffix is stripped automatically.
            api_key:  Organisation API key (``csk_live_...``).
        """
        # Resolve endpoint
        if endpoint is None:
            # Try globals set by init_tracing
            try:
                from cascade.tracing import _cascade_endpoint
                endpoint = _cascade_endpoint
            except (ImportError, AttributeError):
                pass
        if endpoint is None:
            endpoint = os.getenv("CASCADE_ENDPOINT", "http://localhost:8000/v1/traces")

        # Strip /v1/traces suffix to get the base URL
        if endpoint and endpoint.endswith("/v1/traces"):
            endpoint = endpoint[: -len("/v1/traces")]

        self._base_url = endpoint.rstrip("/")
        self._api_url = f"{self._base_url}/api"

        # Resolve API key
        if api_key is None:
            try:
                from cascade.tracing import _cascade_api_key
                api_key = _cascade_api_key
            except (ImportError, AttributeError):
                pass
        if api_key is None:
            api_key = os.getenv("CASCADE_API_KEY")

        if not api_key:
            raise CascadeEvalError(
                "CASCADE_API_KEY is required. Pass it to CascadeEval() or set the environment variable."
            )

        self._api_key = api_key

    # ========================================================================
    # Internal HTTP helpers
    # ========================================================================

    def _headers(self) -> Dict[str, str]:
        return {
            "Content-Type": "application/json",
            "X-API-Key": self._api_key,
        }

    def _request(
        self,
        method: str,
        path: str,
        body: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
    ) -> Any:
        """Make an HTTP request and return parsed JSON."""
        url = f"{self._api_url}{path}"

        # Build query string
        if params:
            filtered = {k: v for k, v in params.items() if v is not None}
            if filtered:
                qs = "&".join(f"{k}={v}" for k, v in filtered.items())
                url = f"{url}?{qs}"

        if _HAS_REQUESTS:
            return self._request_with_requests(method, url, body)
        return self._request_with_urllib(method, url, body)

    def _request_with_requests(self, method: str, url: str, body: Optional[dict]) -> Any:
        try:
            resp = _requests.request(
                method,
                url,
                headers=self._headers(),
                json=body,
            timeout=60,
        )
        except Exception as e:
            raise CascadeEvalError(f"Network error: {e}") from e
        if resp.status_code >= 400:
            detail = ""
            try:
                detail = resp.json().get("detail", resp.text)
            except Exception:
                detail = resp.text
            raise CascadeEvalError(
                f"API error {resp.status_code}: {detail}",
                status_code=resp.status_code,
                detail=str(detail),
            )
        if resp.status_code == 204 or not resp.content:
            return {}
        return resp.json()

    def _request_with_urllib(self, method: str, url: str, body: Optional[dict]) -> Any:
        data = json.dumps(body).encode() if body else None
        req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                raw = resp.read().decode()
                return json.loads(raw) if raw else {}
        except urllib.error.HTTPError as e:
            detail = ""
            try:
                detail = json.loads(e.read().decode()).get("detail", "")
            except Exception:
                pass
            raise CascadeEvalError(
                f"API error {e.code}: {detail}",
                status_code=e.code,
                detail=str(detail),
            )
        except Exception as e:
            raise CascadeEvalError(f"Network error: {e}") from e

    # ========================================================================
    # Scorers
    # ========================================================================

    def list_scorers(
        self,
        scorer_type: Optional[str] = None,
        scope: Optional[str] = None,
        tags: Optional[List[str]] = None,
        project_filter: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> List[Dict[str, Any]]:
        """
        List all scorers for your organisation.

        Args:
            scorer_type: Filter by type (``'llm_judge'`` or ``'code'``).
            scope: Filter by scope (``'trace'``, ``'span'``, ``'trajectory'``).
            tags: Filter by tags.
            project_filter: Filter to scorers scoped to this project (+ org-wide).
                            E.g. ``"travel_agent"`` returns project-specific + global scorers.
            limit: Max results to return.
            offset: Pagination offset.

        Returns:
            List of scorer dicts.
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if scorer_type:
            params["scorer_type"] = scorer_type
        if scope:
            params["scope"] = scope
        if tags:
            params["tags"] = ",".join(tags)
        if project_filter:
            params["project_filter"] = project_filter
        return self._request("GET", "/evaluations/scorers", params=params)

    def get_scorer(self, scorer_id: str) -> Dict[str, Any]:
        """Get a scorer by ID."""
        return self._request("GET", f"/evaluations/scorers/{scorer_id}")

    def create_scorer(
        self,
        name: str,
        scorer_type: str = "llm_judge",
        threshold: float = 0.5,
        *,
        scope: Optional[str] = None,
        description: Optional[str] = None,
        project_filter: Optional[str] = None,
        llm_model: Optional[str] = None,
        llm_provider: Optional[str] = None,
        llm_template: Optional[str] = None,
        llm_system_prompt: Optional[str] = None,
        llm_temperature: Optional[float] = None,
        output_type: Optional[str] = None,
        choices: Optional[List[Dict[str, Any]]] = None,
        min_score: Optional[float] = None,
        max_score: Optional[float] = None,
        code_language: Optional[str] = None,
        code_definition: Optional[str] = None,
        variable_mappings: Optional[Dict[str, str]] = None,
        is_higher_better: bool = True,
        tags: Optional[List[str]] = None,
    ) -> Dict[str, Any]:
        """
        Create a new scorer.

        Args:
            name: Human-readable name (e.g. ``"Hallucination Detector"``).
            scorer_type: ``'llm_judge'`` or ``'code'``.
            threshold: Pass/fail threshold (0-1 for normalized scores).
            scope: ``'trace'``, ``'span'``, or ``'both'``.
            description: Optional description.
            project_filter: Scope scorer to a specific project (e.g. ``"travel_agent"``).
                            If ``None``, the scorer applies org-wide.
            llm_model: LLM model name (for llm_judge scorers).
            llm_provider: ``'openai'`` or ``'anthropic'``.
            llm_template: Prompt template with ``{input}``, ``{output}`` placeholders.
            llm_system_prompt: System prompt for the judge LLM.
            llm_temperature: Temperature for the judge LLM.
            output_type: ``'binary'``, ``'scale'``, or ``'classification'``.
            choices: For classification scorers, list of ``{label, score, description}``.
            min_score: Minimum score value (for scale output).
            max_score: Maximum score value (for scale output).
            code_language: ``'python'`` (for code scorers).
            code_definition: Python code string (for code scorers).
            variable_mappings: Map scorer variables to span attributes.
            is_higher_better: Whether higher scores are better.
            tags: List of tags.

        Returns:
            The created scorer dict.
        """
        body: Dict[str, Any] = {
            "name": name,
            "scorer_type": scorer_type,
            "threshold": threshold,
            "is_higher_better": is_higher_better,
        }
        # Add optional fields only if provided
        optional = {
            "scope": scope,
            "description": description,
            "project_filter": project_filter,
            "llm_model": llm_model,
            "llm_provider": llm_provider,
            "llm_template": llm_template,
            "llm_system_prompt": llm_system_prompt,
            "llm_temperature": llm_temperature,
            "output_type": output_type,
            "choices": choices,
            "min_score": min_score,
            "max_score": max_score,
            "code_language": code_language,
            "code_definition": code_definition,
            "variable_mappings": variable_mappings,
            "tags": tags,
        }
        for k, v in optional.items():
            if v is not None:
                body[k] = v
        return self._request("POST", "/evaluations/scorers", body=body)

    def update_scorer(self, scorer_id: str, **kwargs) -> Dict[str, Any]:
        """
        Update a scorer's fields.

        Pass any scorer fields as keyword arguments (e.g. ``name="New Name"``).

        Returns:
            The updated scorer dict.
        """
        body = {k: v for k, v in kwargs.items() if v is not None}
        return self._request("PUT", f"/evaluations/scorers/{scorer_id}", body=body)

    def delete_scorer(self, scorer_id: str) -> Dict[str, Any]:
        """Delete a scorer by ID."""
        return self._request("DELETE", f"/evaluations/scorers/{scorer_id}")

    def list_builtin_scorers(self, scope: Optional[str] = None) -> Dict[str, Any]:
        """
        List all built-in scorer templates.

        Args:
            scope: Optional filter by scope (``'trace'``, ``'span'``, ``'trajectory'``).

        Returns:
            Dict mapping scorer keys to their info.
        """
        params: Dict[str, Any] = {}
        if scope:
            params["scope"] = scope
        return self._request("GET", "/evaluations/builtin-scorers", params=params)

    # ========================================================================
    # Evaluation execution
    # ========================================================================

    def evaluate(
        self,
        trace_id: str,
        scorer_ids: List[str],
        *,
        span_id: Optional[str] = None,
        agent_filter: Optional[List[str]] = None,
    ) -> Dict[str, Any]:
        """
        Run scorers against a trace (or a specific span).

        Args:
            trace_id: The trace to evaluate.
            scorer_ids: List of scorer UUIDs to run.
            span_id: Optional span to evaluate (instead of whole trace).
            agent_filter: Filter trace-level evals to specific agents.

        Returns:
            Dict with ``success``, ``results`` list, and optional ``errors``.
        """
        body: Dict[str, Any] = {
            "trace_id": trace_id,
            "scorer_ids": scorer_ids,
        }
        if span_id:
            body["span_id"] = span_id
        if agent_filter:
            body["agent_filter"] = agent_filter
        return self._request("POST", "/evaluations/evaluate", body=body)

    def evaluate_spans(
        self,
        trace_id: str,
        scorer_ids: List[str],
        *,
        span_type: Optional[str] = None,
        span_name_pattern: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Run span-level scorers on matching spans within a trace.

        Args:
            trace_id: The trace containing the spans.
            scorer_ids: List of scorer UUIDs to run.
            span_type: Filter spans by type (e.g. ``'llm'``, ``'tool'``).
            span_name_pattern: Substring filter for span names.

        Returns:
            Dict with ``success``, ``results`` list, and optional ``errors``.
        """
        body: Dict[str, Any] = {
            "trace_id": trace_id,
            "scorer_ids": scorer_ids,
        }
        if span_type:
            body["span_type"] = span_type
        if span_name_pattern:
            body["span_name_pattern"] = span_name_pattern
        return self._request("POST", "/evaluations/evaluate-spans", body=body)

    # ========================================================================
    # Evaluation results
    # ========================================================================

    def list_results(
        self,
        trace_id: Optional[str] = None,
        span_id: Optional[str] = None,
        scorer_id: Optional[str] = None,
        passed: Optional[bool] = None,
        project: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> List[Dict[str, Any]]:
        """
        List evaluation results with optional filters.

        Args:
            trace_id: Filter to results for this trace.
            span_id: Filter to results for this span.
            scorer_id: Filter to results from this scorer.
            passed: Filter by pass/fail status.
            project: Filter to results for traces in this project
                     (e.g. ``"travel_agent"``). Filtered at SQL level.
            limit: Max results to return.
            offset: Pagination offset.

        Returns:
            List of evaluation result dicts.
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if trace_id:
            params["trace_id"] = trace_id
        if span_id:
            params["span_id"] = span_id
        if scorer_id:
            params["scorer_id"] = scorer_id
        if passed is not None:
            params["passed"] = str(passed).lower()
        if project:
            params["project"] = project
        return self._request("GET", "/evaluations/results", params=params)

    def get_result(self, result_id: str) -> Dict[str, Any]:
        """Get an evaluation result by ID."""
        return self._request("GET", f"/evaluations/results/{result_id}")

    def delete_result(self, result_id: str) -> Dict[str, Any]:
        """Delete an evaluation result."""
        return self._request("DELETE", f"/evaluations/results/{result_id}")

    # ========================================================================
    # Evaluation tasks (batch & scheduled)
    # ========================================================================

    def create_task(
        self,
        name: str,
        mode: str,
        scorer_ids: List[str],
        *,
        description: Optional[str] = None,
        project_filter: Optional[str] = None,
        trace_ids: Optional[List[str]] = None,
        trace_filter: Optional[Dict[str, Any]] = None,
        span_filter: Optional[Dict[str, Any]] = None,
        agent_filter: Optional[List[str]] = None,
        config: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Create an evaluation task.

        Args:
            name: Human-readable task name.
            mode: ``'batch'`` (run on historical traces) or ``'scheduled'``
                  (auto-evaluate new traces as they arrive).
            scorer_ids: List of scorer UUIDs to run.
            description: Optional task description.
            project_filter: Scope to a specific project (e.g. ``"travel_agent"``).
            trace_ids: *Batch only* — explicit list of trace IDs to evaluate.
            trace_filter: *Batch only* — filter to select traces dynamically.
            span_filter: Optional span-level filter for span-scoped scorers.
            agent_filter: Scope evaluation to specific agent(s) by name
                          (e.g. ``["PlannerAgent"]``). Filters the trajectory
                          for trace-level scorers and restricts which spans are
                          evaluated for span-level scorers.
            config: Additional configuration dict.

        Returns:
            The created task dict.

        Example::

            # Batch: evaluate specific traces
            task = evals.create_task(
                name="Nightly quality check",
                mode="batch",
                scorer_ids=["scorer-1", "scorer-2"],
                trace_ids=["trace-a", "trace-b", "trace-c"],
            )
            evals.run_task(task["task_id"])

            # Scheduled: auto-evaluate new traces
            task = evals.create_task(
                name="Production monitor",
                mode="scheduled",
                scorer_ids=["scorer-1"],
                project_filter="travel_agent",
            )
        """
        if mode not in ("batch", "scheduled"):
            raise ValueError(f"mode must be 'batch' or 'scheduled', got '{mode}'")

        body: Dict[str, Any] = {
            "name": name,
            "mode": mode,
            "scorer_ids": scorer_ids,
        }
        optional = {
            "description": description,
            "project_filter": project_filter,
            "trace_ids": trace_ids,
            "trace_filter": trace_filter,
            "span_filter": span_filter,
            "agent_filter": agent_filter,
            "config": config,
        }
        for k, v in optional.items():
            if v is not None:
                body[k] = v
        return self._request("POST", "/evaluations/tasks", body=body)

    def list_tasks(
        self,
        mode: Optional[str] = None,
        status: Optional[str] = None,
        project_filter: Optional[str] = None,
        limit: int = 50,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """
        List evaluation tasks.

        Args:
            mode: Filter by ``'batch'`` or ``'scheduled'``.
            status: Filter by status (e.g. ``'pending'``, ``'active'``, ``'completed'``).
            project_filter: Filter tasks scoped to this project (e.g. ``"travel_agent"``).
            limit: Max results.
            offset: Pagination offset.

        Returns:
            Dict with ``tasks`` list and ``total`` count.
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if mode:
            params["mode"] = mode
        if status:
            params["status"] = status
        if project_filter:
            params["project_filter"] = project_filter
        return self._request("GET", "/evaluations/tasks", params=params)

    def get_task(self, task_id: str) -> Dict[str, Any]:
        """Get a task by ID."""
        return self._request("GET", f"/evaluations/tasks/{task_id}")

    def update_task(self, task_id: str, **kwargs) -> Dict[str, Any]:
        """
        Update a task's fields.

        Pass any task fields as keyword arguments
        (e.g. ``name="New Name"``, ``status="completed"``).

        Returns:
            The updated task dict.
        """
        body = {k: v for k, v in kwargs.items() if v is not None}
        return self._request("PUT", f"/evaluations/tasks/{task_id}", body=body)

    def delete_task(self, task_id: str) -> Dict[str, Any]:
        """Delete a task by ID."""
        return self._request("DELETE", f"/evaluations/tasks/{task_id}")

    def run_task(self, task_id: str) -> Dict[str, Any]:
        """
        Trigger execution of a batch task.

        Only works for tasks with ``mode='batch'``. Scheduled tasks
        execute automatically when new traces arrive.

        Returns:
            Dict with ``status``, ``message``, and ``total_traces``.
        """
        return self._request("POST", f"/evaluations/tasks/{task_id}/run")

    def pause_task(self, task_id: str) -> Dict[str, Any]:
        """
        Pause a scheduled task (stops auto-evaluating new traces).

        Returns:
            The updated task dict.
        """
        return self._request("POST", f"/evaluations/tasks/{task_id}/pause")

    def resume_task(self, task_id: str) -> Dict[str, Any]:
        """
        Resume a paused scheduled task.

        Returns:
            The updated task dict.
        """
        return self._request("POST", f"/evaluations/tasks/{task_id}/resume")

    def get_task_results(
        self,
        task_id: str,
        limit: int = 200,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """
        Get results for a specific task with aggregate stats.

        Returns:
            Dict with ``results``, ``average_scores``, ``pass_rates``,
            and progress counters.
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        return self._request("GET", f"/evaluations/tasks/{task_id}/results", params=params)

    # ========================================================================
    # Failures
    # ========================================================================

    def list_failures(
        self,
        project: str,
        *,
        scorer_id: Optional[str] = None,
        mode: Optional[str] = None,
        date_from: Optional[str] = None,
        date_to: Optional[str] = None,
        limit: int = 50,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """
        List evaluation failures for a project.

        Returns traces/spans where a scorer returned a failing result.

        Args:
            project: Project name (e.g. ``"travel_agent"``).
            scorer_id: Optional — filter to a specific scorer.
            mode: Optional — ``'scheduled'``, ``'batch'``, or ``'adhoc'``.
            date_from: Start date filter (``YYYY-MM-DD``).
            date_to: End date filter (``YYYY-MM-DD``).
            limit: Max results per page.
            offset: Pagination offset.

        Returns:
            Dict with ``failures`` list and ``total`` count.
            Each failure contains ``result_id``, ``scorer_name``,
            ``trace_id``, ``score``, ``reasoning``, ``created_at``, etc.

        Example::

            resp = evals.list_failures("travel_agent", date_from="2025-01-01")
            for f in resp["failures"]:
                print(f"{f['scorer_name']}: {f['reasoning'][:80]}")
        """
        params: Dict[str, Any] = {
            "project": project,
            "limit": limit,
            "offset": offset,
        }
        if scorer_id:
            params["scorer_id"] = scorer_id
        if mode:
            params["mode"] = mode
        if date_from:
            params["date_from"] = date_from
        if date_to:
            params["date_to"] = date_to
        return self._request("GET", "/evaluations/failures", params=params)

    def get_failure_stats(
        self,
        project: str,
        *,
        mode: Optional[str] = None,
        date_from: Optional[str] = None,
        date_to: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Get aggregate failure statistics for a project.

        Returns summary counts and trend data — the same numbers and
        charts shown on the Failures dashboard page.

        Args:
            project: Project name (e.g. ``"travel_agent"``).
            mode: Optional — ``'scheduled'``, ``'batch'``, or ``'adhoc'``.
            date_from: Start date filter (``YYYY-MM-DD``).
            date_to: End date filter (``YYYY-MM-DD``).

        Returns:
            Dict with:

            - ``total_failures`` — total count of failing evaluations
            - ``failures_today`` — failures in the last 24 hours
            - ``unique_scorers_failed`` — how many distinct scorers produced failures
            - ``failures_per_day`` — list of ``{date, count}`` for trend charts
            - ``failures_by_scorer`` — list of ``{scorer_name, scorer_id, count}``

        Example::

            stats = evals.get_failure_stats("travel_agent", date_from="2025-01-01")
            print(f"Total failures: {stats['total_failures']}")
            print(f"Failures today: {stats['failures_today']}")
            for s in stats["failures_by_scorer"]:
                print(f"  {s['scorer_name']}: {s['count']} failures")
        """
        params: Dict[str, Any] = {"project": project}
        if mode:
            params["mode"] = mode
        if date_from:
            params["date_from"] = date_from
        if date_to:
            params["date_to"] = date_to
        return self._request("GET", "/evaluations/failures/stats", params=params)

    # ========================================================================
    # Projects & Traces
    # ========================================================================

    def list_projects(self) -> List[str]:
        """
        List all projects (agents/services) in your organisation.

        Returns:
            List of project name strings.

        Example::

            projects = evals.list_projects()
            for p in projects:
                print(p)  # e.g. "travel_agent", "support_bot"
        """
        resp = self._request("GET", "/projects")
        return resp.get("projects", [])

    def list_traces(
        self,
        project: Optional[str] = None,
        *,
        metadata: Optional[Dict[str, str]] = None,
        session_id: Optional[str] = None,
        name_prefix: Optional[str] = None,
        names: Optional[List[str]] = None,
        start_time_after: Optional[int] = None,
        start_time_before: Optional[int] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """
        List traces with optional filtering.

        Args:
            project: Filter by project/agent name.
            metadata: Filter by metadata key-value pairs.
            session_id: Filter by explicit session ID.
            name_prefix: Filter by root span name prefix.
            names: Filter by exact trace names (comma-separated).
            start_time_after: Only traces started after this time (nanoseconds).
            start_time_before: Only traces started before this time (nanoseconds).
            limit: Max results (default 100).
            offset: Pagination offset.

        Returns:
            Dict with ``traces`` list, ``total`` count, ``limit``, ``offset``.

        Example::

            # Recent traces for a project
            resp = evals.list_traces("travel_agent", limit=20)
            for t in resp["traces"]:
                print(f"{t['trace_id'][:12]}  {t['root_span_name']}  {t['status']}")
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if project:
            params["project_filter"] = project
        if metadata:
            import json as _json
            params["metadata"] = _json.dumps(metadata)
        if session_id:
            params["session_id"] = session_id
        if name_prefix:
            params["name_prefix"] = name_prefix
        if names:
            params["names"] = ",".join(names)
        if start_time_after is not None:
            params["start_time_after"] = start_time_after
        if start_time_before is not None:
            params["start_time_before"] = start_time_before
        return self._request("GET", "/traces", params=params)

    def list_sessions(
        self,
        project: Optional[str] = None,
        *,
        start_time_after: Optional[int] = None,
        start_time_before: Optional[int] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """
        List grouped sessions for a project/org scope.

        Args:
            project: Optional project name to filter.
            start_time_after: Only sessions with traces after this timestamp (ns).
            start_time_before: Only sessions with traces before this timestamp (ns).
            limit: Max results (default 100).
            offset: Pagination offset.

        Returns:
            Dict with ``sessions`` list, ``total`` count, ``limit``, ``offset``.
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if project:
            params["project_filter"] = project
        if start_time_after is not None:
            params["start_time_after"] = start_time_after
        if start_time_before is not None:
            params["start_time_before"] = start_time_before
        return self._request("GET", "/sessions", params=params)

    def get_session(
        self,
        session_id: str,
        *,
        project: Optional[str] = None,
        limit: int = 200,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """
        Get a session and traces within that session.

        Args:
            session_id: Session identifier.
            project: Optional project filter.
            limit: Max traces to return.
            offset: Pagination offset.

        Returns:
            Dict with ``session`` summary and ``traces`` list.
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if project:
            params["project_filter"] = project
        return self._request("GET", f"/sessions/{session_id}", params=params)

    def evaluate_session(
        self,
        session_id: str,
        scorer_ids: List[str],
        *,
        max_traces: Optional[int] = None,
    ) -> Dict[str, Any]:
        """
        Run session-level evaluation across all traces in a session.

        Combines spans from every trace in the session into a single
        trajectory and evaluates it with the given scorers.  Scorers
        must have ``scope='session'`` (or ``'both'``).

        Args:
            session_id: The session to evaluate.
            scorer_ids: List of scorer UUIDs to run.
            max_traces: Optional cap on traces included (default: all).

        Returns:
            Dict with ``success``, ``results``, and optional ``errors``.
        """
        body: Dict[str, Any] = {
            "session_id": session_id,
            "scorer_ids": scorer_ids,
        }
        if max_traces is not None:
            body["max_traces"] = max_traces
        return self._request("POST", "/evaluations/evaluate-session", body=body)

    def signal_session_end(self, session_id: str) -> Dict[str, Any]:
        """
        Signal that a session has ended.

        Triggers any active scheduled tasks with session-scoped scorers
        to evaluate the completed session.  The backend runs evaluations
        asynchronously; this call returns immediately.

        Args:
            session_id: The session that has ended.

        Returns:
            Dict with ``status`` (``'accepted'``) and ``session_id``.
        """
        params: Dict[str, Any] = {}
        try:
            from cascade.tracing import _cascade_project
            if _cascade_project:
                params["project"] = _cascade_project
        except (ImportError, AttributeError):
            pass
        return self._request(
            "POST",
            f"/evaluations/sessions/{session_id}/end",
            params=params,
        )

    def get_trace(self, trace_id: str) -> Dict[str, Any]:
        """
        Get all spans for a specific trace.

        Args:
            trace_id: The trace ID.

        Returns:
            Dict with ``trace_id`` and ``spans`` list.
        """
        return self._request("GET", f"/traces/{trace_id}")

    def get_trace_tree(self, trace_id: str) -> Dict[str, Any]:
        """
        Get a trace as a hierarchical tree structure.

        Returns the same tree view shown in the dashboard sidebar.

        Args:
            trace_id: The trace ID.

        Returns:
            Dict with ``trace_id`` and ``root`` (nested tree node).
        """
        return self._request("GET", f"/traces/{trace_id}/tree")

    def get_span(self, span_id: str) -> Dict[str, Any]:
        """
        Get a single span by span ID.

        Args:
            span_id: The span ID.

        Returns:
            Span dict with attributes, events, timing, etc.
        """
        return self._request("GET", f"/spans/{span_id}")

    def get_spans_since(
        self,
        trace_id: str,
        since_time: int,
        *,
        include_content: bool = False,
    ) -> Dict[str, Any]:
        """
        Get spans added to a trace after a given timestamp.

        Useful for polling/live updates on a running trace.

        Args:
            trace_id: The trace to poll.
            since_time: Timestamp in nanoseconds — only spans after this.
            include_content: Include full LLM prompt/completion and tool I/O.

        Returns:
            Dict with ``spans`` list, ``count``, ``server_time``,
            and ``trace_completed`` boolean.
        """
        params: Dict[str, Any] = {
            "since_time": since_time,
            "include_content": str(include_content).lower(),
        }
        return self._request("GET", f"/traces/{trace_id}/spans/since", params=params)

    # ========================================================================
    # Built-in scorer details & auto-generation
    # ========================================================================

    def get_builtin_scorer(self, key: str) -> Dict[str, Any]:
        """
        Get a specific built-in scorer template by key.

        Args:
            key: Scorer key (e.g. ``"trace_helpfulness"``).

        Returns:
            Full scorer template dict with name, description, template, etc.
        """
        return self._request("GET", f"/evaluations/builtin-scorers/{key}")

    def generate_scorer_from_comment(
        self,
        comment: str,
        span_name: str,
        trace_id: str,
        span_id: str,
        domain: str = "Trace",
        *,
        child_spans: Optional[List[Dict[str, Any]]] = None,
        span_data: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Auto-generate a scorer from a natural-language comment.

        Uses an LLM to create a precise scorer that addresses the
        developer's feedback. Same as "Comment & Create Scorer" in the dashboard.

        Args:
            comment: The developer's feedback (e.g. ``"The agent calls
                     the weather API too many times"``).
            span_name: Name of the span being commented on.
            trace_id: Trace ID for context.
            span_id: Span ID being commented on.
            domain: Evaluation domain:
                    ``"Trajectory"`` — full execution across all agents,
                    ``"Trace"`` — single agent's children,
                    ``"Span"`` — single tool/LLM call.
            child_spans: Child span summaries (for Trajectory/Trace domain).
            span_data: Span details (for Span domain).

        Returns:
            Dict with ``scorer`` (the created scorer) and ``message``.

        Example::

            result = evals.generate_scorer_from_comment(
                comment="The agent should not call the same tool twice in a row",
                span_name="TravelAgent",
                trace_id="abc123...",
                span_id="def456...",
                domain="Trace",
            )
            print(f"Created scorer: {result['scorer']['name']}")
        """
        body: Dict[str, Any] = {
            "comment": comment,
            "span_name": span_name,
            "trace_id": trace_id,
            "span_id": span_id,
            "domain": domain,
        }
        if child_spans is not None:
            body["child_spans"] = child_spans
        if span_data is not None:
            body["span_data"] = span_data
        return self._request("POST", "/evaluations/generate-scorer-from-comment", body=body)

    # ========================================================================
    # Scorer name resolution (used by the top-level evaluate() helper)
    # ========================================================================

    def resolve_scorer_names(self, names: List[str]) -> List[str]:
        """
        Resolve human-readable scorer names to scorer UUIDs.

        Resolution order for each name:

        1. If it already looks like a UUID, return it as-is.
        2. Match against built-in scorer keys (e.g. ``"helpfulness"``
           matches ``"trace_helpfulness"``).
        3. Match against custom scorer names (exact, case-insensitive).

        Results are cached for the lifetime of this client instance.

        Args:
            names: List of scorer short-names, full keys, or UUIDs.

        Returns:
            List of scorer UUID strings (same order as *names*).

        Raises:
            CascadeEvalError: If a name cannot be resolved.
        """
        resolved: List[str] = []

        # Names we still need to look up
        unknown: List[str] = []
        for name in names:
            # Already cached?
            if name in _scorer_name_cache:
                resolved.append(_scorer_name_cache[name])
                continue
            # Looks like a UUID already?
            if len(name) >= 32 and "-" in name:
                resolved.append(name)
                continue
            unknown.append(name)

        if not unknown:
            return resolved

        # ---------- Fetch existing scorers first ---------------------------
        # Check custom/previously-created scorers BEFORE creating new ones.
        # This prevents duplicate scorers across runs.
        # Only reuse scorers that have a valid template (skip broken ones).
        try:
            custom_scorers = self.list_scorers(limit=500)
        except Exception:
            custom_scorers = []

        # Build lookup by lowercase name for exact match.
        # Only include scorers that have the required fields populated
        # (skips broken scorers created before the template-fetch fix).
        custom_by_name: Dict[str, str] = {}
        for s in custom_scorers:
            is_valid = True
            if s.get("scorer_type") == "llm_judge":
                # LLM judge scorers MUST have a template to be usable
                if not s.get("llm_template"):
                    is_valid = False
            if is_valid:
                custom_by_name[s["name"].lower()] = s["scorer_id"]

        # ---------- Fetch built-in templates (for fallback creation) ------
        try:
            builtins = self.list_builtin_scorers()
        except Exception:
            builtins = {}

        # Build lookup: short suffix -> key  (e.g. "helpfulness" -> "trace_helpfulness")
        builtin_by_suffix: Dict[str, str] = {}
        for key in builtins:
            for prefix in ("trace_", "span_", "trajectory_"):
                if key.startswith(prefix):
                    builtin_by_suffix[key[len(prefix):]] = key
                    break
            builtin_by_suffix[key] = key  # also allow full key

        # Map built-in key -> expected scorer name (for matching existing scorers)
        builtin_expected_name: Dict[str, str] = {}
        for key, template in builtins.items():
            builtin_expected_name[key] = template.get("name", key).lower()

        # ---------- Resolve each unknown name ----------------------------
        still_missing: List[str] = []
        for name in unknown:
            lower = name.lower().strip()

            # 1) Try exact match against existing valid scorer names
            if lower in custom_by_name:
                sid = custom_by_name[lower]
                _scorer_name_cache[name] = sid
                _scorer_name_cache[lower] = sid
                resolved.append(sid)
                continue

            # 2) If name matches a built-in key/suffix, check if a scorer
            #    with the built-in template's name already exists
            if lower in builtin_by_suffix:
                full_key = builtin_by_suffix[lower]
                summary = builtins[full_key]
                template_name = summary.get("name", full_key).lower()

                # Already have a scorer with this template's name?
                if template_name in custom_by_name:
                    sid = custom_by_name[template_name]
                    _scorer_name_cache[name] = sid
                    _scorer_name_cache[lower] = sid
                    _scorer_name_cache[full_key] = sid
                    logger.debug(f"Reusing existing scorer '{template_name}' ({sid[:8]}...)")
                    resolved.append(sid)
                    continue

                # No existing scorer — fetch the FULL template (list only
                # returns a summary without llm_template, choices, etc.)
                # and create a scorer from it.
                try:
                    template = self.get_builtin_scorer(full_key)
                    scorer = self.create_scorer(
                        name=template.get("name", full_key),
                        scorer_type=template.get("scorer_type", "llm_judge"),
                        threshold=template.get("threshold", 0.5),
                        description=template.get("description"),
                        llm_model=template.get("llm_model"),
                        llm_provider=template.get("llm_provider"),
                        llm_template=template.get("llm_template"),
                        llm_system_prompt=template.get("llm_system_prompt"),
                        llm_temperature=template.get("llm_temperature"),
                        output_type=template.get("output_type"),
                        choices=template.get("choices"),
                        variable_mappings=template.get("variable_mappings"),
                        is_higher_better=template.get("is_higher_better", True),
                        tags=template.get("tags", []) + ["auto-created"],
                    )
                    sid = scorer["scorer_id"]
                    _scorer_name_cache[name] = sid
                    _scorer_name_cache[lower] = sid
                    _scorer_name_cache[full_key] = sid
                    resolved.append(sid)
                    continue
                except Exception as e:
                    logger.warning(f"Failed to instantiate built-in scorer '{full_key}': {e}")

            still_missing.append(name)

        if still_missing:
            available_builtins = list(builtin_by_suffix.keys())
            available_custom = [s["name"] for s in custom_scorers]
            raise CascadeEvalError(
                f"Could not resolve scorer name(s): {still_missing}. "
                f"Available built-ins: {available_builtins}. "
                f"Custom scorers: {available_custom}."
            )

        return resolved


# ============================================================================
# Top-level convenience API (Tier 1 & 2)
# ============================================================================

def _get_default_client() -> CascadeEval:
    """Get or create the module-level singleton CascadeEval client."""
    global _default_client
    if _default_client is None:
        _default_client = CascadeEval()
    return _default_client


def evaluate(
    run,
    scorers: Optional[List[str]] = None,
    *,
    wait: float = 3,
) -> List[Dict[str, Any]]:
    """
    Evaluate a completed trace run.  **This is the simplest way to add evals.**

    Usage::

        from cascade import init_tracing, trace_run, evaluate

        init_tracing(project="my_agent")

        with trace_run("MyAgent") as run:
            # ... your agent code ...
            pass

        results = evaluate(run, ["helpfulness", "hallucination"])
        for r in results:
            print(f"{r['scorer_name']}: {'PASS' if r['passed'] else 'FAIL'}")

    Args:
        run: The span object yielded by ``trace_run()``  **or** a trace-ID string.
        scorers: List of scorer names (e.g. ``["helpfulness"]``), full built-in
                 keys (``"trace_helpfulness"``), custom scorer names, or UUIDs.
                 If *None*, uses the scorers configured via
                 ``init_tracing(evals=[...])``.
        wait: Seconds to wait for trace ingestion before evaluating (default 3).

    Returns:
        List of result dicts, each containing at least
        ``scorer_name``, ``score``, ``passed``, and ``reasoning``.

    Raises:
        CascadeEvalError: If no scorers are specified (and none were set in
            ``init_tracing``), or if evaluation fails.
    """
    client = _get_default_client()

    # --- Extract trace ID -------------------------------------------------
    if isinstance(run, str):
        trace_id = run
    else:
        # Assume it's an OpenTelemetry Span object
        try:
            ctx = run.get_span_context()
            trace_id = format(ctx.trace_id, "032x")
        except Exception:
            raise CascadeEvalError(
                "Could not extract trace ID from the provided run object. "
                "Pass the span from `with trace_run(...) as run` or a trace-ID string."
            )

    # --- Resolve scorer names to IDs -------------------------------------
    if scorers is None:
        if _init_eval_scorer_ids:
            scorer_ids = _init_eval_scorer_ids
        else:
            raise CascadeEvalError(
                "No scorers specified. Pass a list like "
                'evaluate(run, ["helpfulness", "hallucination"]) '
                "or configure them via init_tracing(evals=[...])."
            )
    else:
        scorer_ids = client.resolve_scorer_names(scorers)

    # --- Wait for ingestion ----------------------------------------------
    if wait > 0:
        time.sleep(wait)

    # --- Run evaluation --------------------------------------------------
    response = client.evaluate(trace_id=trace_id, scorer_ids=scorer_ids)

    return response.get("results", [])


def get_failures(
    project: Optional[str] = None,
    *,
    scorer: Optional[str] = None,
    mode: Optional[str] = None,
    days: int = 7,
    limit: int = 50,
) -> Dict[str, Any]:
    """
    Get evaluation failures for a project.  **Simple, one-call interface.**

    Returns both the failure list and aggregate stats in a single call,
    combining the data shown on the Failures dashboard page.

    Usage::

        from cascade import get_failures

        report = get_failures("my_agent", days=7)
        print(f"Total failures: {report['total_failures']}")
        print(f"Failures today: {report['failures_today']}")
        for f in report["failures"]:
            print(f"  [{f['scorer_name']}] trace {f['trace_id'][:12]}... — {f['reasoning'][:60]}")

    Args:
        project: Project name. If *None*, uses the project set in ``init_tracing()``.
        scorer: Optional scorer name or ID to filter by.
        mode: Optional — ``'scheduled'``, ``'batch'``, or ``'adhoc'``.
        days: Look-back window in days (default 7). Use ``0`` for all time.
        limit: Max failure entries to return.

    Returns:
        Dict combining stats and list data:

        - ``total_failures``, ``failures_today``, ``unique_scorers_failed``
        - ``failures_per_day`` — daily trend ``[{date, count}, ...]``
        - ``failures_by_scorer`` — breakdown ``[{scorer_name, count}, ...]``
        - ``failures`` — list of individual failure entries
        - ``total`` — total matching failures (for pagination)
    """
    client = _get_default_client()

    # Resolve project
    if project is None:
        try:
            from cascade.tracing import _cascade_project
            project = _cascade_project
        except (ImportError, AttributeError):
            pass
    if not project:
        raise CascadeEvalError(
            "Project name is required. Pass it to get_failures() "
            "or set it via init_tracing(project=...)."
        )

    # Build date filter
    date_from = None
    if days > 0:
        from datetime import datetime, timedelta
        date_from = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")

    # Resolve scorer name to ID if it doesn't look like a UUID
    scorer_id = None
    if scorer:
        if len(scorer) >= 32 and "-" in scorer:
            scorer_id = scorer
        else:
            try:
                resolved = client.resolve_scorer_names([scorer])
                scorer_id = resolved[0] if resolved else None
            except Exception:
                scorer_id = None  # Fall through — API will just return unfiltered

    # Fetch stats and list in parallel-ish (sequential but combined result)
    stats = client.get_failure_stats(
        project, mode=mode, date_from=date_from,
    )
    failures_resp = client.list_failures(
        project, scorer_id=scorer_id, mode=mode,
        date_from=date_from, limit=limit,
    )

    # Merge into a single response
    return {
        **stats,
        "failures": failures_resp.get("failures", []),
        "total": failures_resp.get("total", 0),
    }


def list_projects() -> List[str]:
    """
    List all projects in your organisation.  **One-call convenience.**

    Usage::

        from cascade import list_projects
        for p in list_projects():
            print(p)

    Returns:
        List of project name strings.
    """
    return _get_default_client().list_projects()


def list_traces(
    project: Optional[str] = None,
    *,
    days: int = 0,
    session_id: Optional[str] = None,
    limit: int = 100,
    offset: int = 0,
) -> Dict[str, Any]:
    """
    List traces for a project.  **One-call convenience.**

    Usage::

        from cascade import list_traces

        resp = list_traces("travel_agent", days=7, limit=20)
        for t in resp["traces"]:
            print(f"{t['trace_id'][:12]}  {t['root_span_name']}")

    Args:
        project: Project name. If *None*, uses the project from ``init_tracing()``.
        days: Look-back window in days. ``0`` = all time.
        limit: Max results.
        offset: Pagination offset.

    Returns:
        Dict with ``traces`` list, ``total``, ``limit``, ``offset``.
    """
    client = _get_default_client()

    if project is None:
        try:
            from cascade.tracing import _cascade_project
            project = _cascade_project
        except (ImportError, AttributeError):
            pass

    start_time_after = None
    if days > 0:
        from datetime import datetime, timedelta
        epoch_ns = int((datetime.utcnow() - timedelta(days=days)).timestamp() * 1e9)
        start_time_after = epoch_ns

    return client.list_traces(
        project=project,
        session_id=session_id,
        start_time_after=start_time_after,
        limit=limit,
        offset=offset,
    )


def get_trace(trace_id: str) -> Dict[str, Any]:
    """
    Get all spans for a trace.  **One-call convenience.**

    Usage::

        from cascade import get_trace
        data = get_trace("abc123...")
        for span in data["spans"]:
            print(f"  {span['name']}  {span['span_id']}")
    """
    return _get_default_client().get_trace(trace_id)


def get_trace_tree(trace_id: str) -> Dict[str, Any]:
    """
    Get a trace as a hierarchical tree.  **One-call convenience.**

    Returns the same tree structure shown in the dashboard sidebar.
    """
    return _get_default_client().get_trace_tree(trace_id)


def list_sessions(
    project: Optional[str] = None,
    *,
    days: int = 0,
    limit: int = 100,
    offset: int = 0,
) -> Dict[str, Any]:
    """
    List grouped sessions.  **One-call convenience.**

    Args:
        project: Optional project name. If ``None``, uses project from init_tracing().
        days: Look-back window in days. ``0`` = all time.
        limit: Max results.
        offset: Pagination offset.
    """
    client = _get_default_client()

    if project is None:
        try:
            from cascade.tracing import _cascade_project
            project = _cascade_project
        except (ImportError, AttributeError):
            pass

    start_time_after = None
    if days > 0:
        from datetime import datetime, timedelta
        start_time_after = int((datetime.utcnow() - timedelta(days=days)).timestamp() * 1e9)

    return client.list_sessions(
        project=project,
        start_time_after=start_time_after,
        limit=limit,
        offset=offset,
    )


def get_session(
    session_id: str,
    *,
    project: Optional[str] = None,
    limit: int = 200,
    offset: int = 0,
) -> Dict[str, Any]:
    """
    Get a session and traces.  **One-call convenience.**
    """
    client = _get_default_client()

    if project is None:
        try:
            from cascade.tracing import _cascade_project
            project = _cascade_project
        except (ImportError, AttributeError):
            pass

    return client.get_session(
        session_id=session_id,
        project=project,
        limit=limit,
        offset=offset,
    )


def _setup_scheduled_evals(project: str, scorer_names: List[str]) -> None:
    """
    Called by ``init_tracing(evals=[...])`` in a background thread.

    Resolves scorer names, stores the IDs for ``evaluate()`` to use,
    and creates a scheduled task so every future trace is auto-evaluated.
    """
    global _init_eval_scorer_ids

    try:
        client = _get_default_client()

        # Resolve names -> IDs (may create scorers from built-in templates)
        scorer_ids = client.resolve_scorer_names(scorer_names)
        _init_eval_scorer_ids = scorer_ids

        # Check if a scheduled task already exists for this project + scorers
        existing = client.list_tasks(mode="scheduled", status="active")
        for task in existing.get("tasks", []):
            pf = task.get("project_filter") or ""
            if pf.endswith(f"/{project}") or pf == project:
                # Task already exists for this project — update scorer list
                existing_ids = set(str(s) for s in task.get("scorer_ids", []))
                new_ids = set(scorer_ids)
                if new_ids.issubset(existing_ids):
                    logger.info(
                        f"Scheduled eval task already active for project "
                        f"'{project}' (task {task['task_id'][:8]}...)"
                    )
                    return

        # Create a new scheduled task
        task_name = f"Auto-eval: {project}"
        client.create_task(
            name=task_name,
            mode="scheduled",
            scorer_ids=scorer_ids,
            project_filter=project,
            description=(
                f"Auto-created by init_tracing(evals={scorer_names}). "
                f"Evaluates every new trace in project '{project}'."
            ),
        )
        logger.info(
            f"Created scheduled eval task '{task_name}' with "
            f"{len(scorer_ids)} scorer(s) for project '{project}'"
        )

    except Exception as e:
        logger.warning(f"Failed to set up scheduled evals: {e}")


def _setup_session_evals(project: str, scorer_names: List[str]) -> None:
    """
    Called by ``init_tracing(session_evals=[...])`` in a background thread.

    Resolves scorer names to IDs and stores them so that ``end_session()``
    can trigger session-level evaluation.
    """
    global _init_session_eval_scorer_ids

    try:
        client = _get_default_client()
        scorer_ids = client.resolve_scorer_names(scorer_names)
        _init_session_eval_scorer_ids = scorer_ids
        logger.info(
            f"Session evals configured: {len(scorer_ids)} scorer(s) "
            f"for project '{project}'"
        )
    except Exception as e:
        logger.warning(f"Failed to set up session evals: {e}")


def end_session(session_id: Optional[str] = None) -> None:
    """
    End a session and trigger session-level evaluations.

    Clears the session context variable and then, in a background thread:

    1. Signals the backend that the session has ended, which triggers any
       active scheduled tasks with session-scoped scorers (platform-managed
       evals — no code change needed).
    2. If ``session_evals`` were configured via ``init_tracing()``, also
       runs those scorers directly (SDK-managed evals).

    Args:
        session_id: Session to evaluate.  If ``None``, uses the current
                    session ID from context (set by ``set_session_id()``
                    or ``trace_session()``).

    Example::

        set_session_id("chat-123")
        # ... multi-turn conversation ...
        end_session("chat-123")  # clears context + triggers session evals
    """
    import threading
    from cascade.tracing import _current_session_id

    actual_id = session_id or _current_session_id.get()

    try:
        _current_session_id.set(None)
    except Exception:
        pass

    if not actual_id:
        return

    local_scorer_ids = list(_init_session_eval_scorer_ids) if _init_session_eval_scorer_ids else []

    def _signal_and_eval():
        try:
            client = _get_default_client()
        except Exception as e:
            logger.warning(f"Cannot create eval client for session end: {e}")
            return

        # Always signal the backend so platform-managed Active Evals run.
        try:
            client.signal_session_end(actual_id)
            logger.debug(f"Session end signal sent for '{actual_id}'")
        except Exception as e:
            logger.warning(f"Failed to signal session end for '{actual_id}': {e}")

        # Also run locally-configured session evals (existing behaviour).
        if local_scorer_ids:
            try:
                result = client.evaluate_session(actual_id, local_scorer_ids)
                n = len(result.get("results", []))
                logger.info(
                    f"Session eval complete for '{actual_id}': "
                    f"{n} result(s)"
                )
            except Exception as e:
                logger.warning(f"Session eval failed for '{actual_id}': {e}")

    threading.Thread(target=_signal_and_eval, daemon=True).start()
