"""Canonical error-message templates for persisted evaluation output.

Every error string written into a JSON/CSV/HTML output file MUST be produced
by a builder in this module. The builders accept only string arguments — never
exception objects — which keeps ``repr(exc)``, ``traceback.format_exc()``, and
SDK class names out of persisted output by construction.

Two flavours:

* **Turn/item-level `ErrorObject` builders** return a structured ``{code, message}``
  dict for the top-level ``error`` field on a turn or single-turn item. Used
  when ``status == "error"`` (cause) or ``status == "partial"`` with at least
  one errored evaluator (summary).

* **Per-evaluator string builders** return a flat string formatted as
  ``"<category prefix>: <detail>"`` for the ``error`` field inside an
  ``ErroredScore`` entry. Evaluator identity is encoded by the ``scores`` map's
  parent property key, so no ``code`` is needed at this level.
"""

from __future__ import annotations

from typing import TypedDict


class ErrorObject(TypedDict):
    """Turn/item-level top-level error shape — `{code, message}` per ErrorObject $def."""
    code: str
    message: str


class ErroredScore(TypedDict, total=False):
    """In-memory per-evaluator errored entry — `{result, code, error}` (+ transient threshold)."""
    result: str
    code: str
    error: str
    threshold: float


# ── Error code constants ──────────────────────────────────────────────
# These are the canonical machine-readable codes emitted in the top-level
# `error.code` field. They appear in persisted output and consumer-facing
# documentation; treat the string values as part of the public contract
# (do not rename without a schema-version bump).

ERROR_CODE_AGENT_REQUEST_FAILED = "agentRequestFailed"
ERROR_CODE_AGENT_AUTH = "agentAuthenticationError"
ERROR_CODE_AGENT_RATE_LIMITED = "agentRateLimited"
ERROR_CODE_AGENT_TIMEOUT = "agentTimeout"
ERROR_CODE_AGENT_RESPONSE_UNPARSEABLE = "agentResponseUnparseable"
ERROR_CODE_TURN_SKIPPED = "turnSkipped"
ERROR_CODE_EVALUATORS_FAILED = "evaluatorsFailed"
ERROR_CODE_EVALUATION_CRASHED = "evaluationCrashed"

# `AGENT_REQUEST_ERROR_CODES`, `EVAL_ERROR_CODES`, and `TURN_ERROR_CODES` are
# derived below from the single ERROR_CODE_REGISTRY (see "Error-code registry").


# ── Per-evaluator error codes ─────────────────────────────────────────
# Machine-readable categories attached to an individual ErroredScore's `code`
# field so callers can distinguish *why* an evaluator produced no result.
# Applied uniformly across native + custom, LLM + non-LLM evaluators. Treat the
# string values as part of the public contract (see the ErroredScore $def).

EVAL_ERROR_CODE_EMPTY_AGENT_RESPONSE = "emptyAgentResponse"
EVAL_ERROR_CODE_JUDGE_UNPARSEABLE = "judgeOutputUnparseable"
EVAL_ERROR_CODE_JUDGE_AUTH = "judgeAuthenticationError"
EVAL_ERROR_CODE_JUDGE_RATE_LIMITED = "judgeRateLimited"
EVAL_ERROR_CODE_JUDGE_TIMEOUT = "judgeTimeout"
EVAL_ERROR_CODE_EVALUATOR_CONFIG = "evaluatorConfigurationError"
EVAL_ERROR_CODE_EVALUATOR_LOAD = "evaluatorLoadError"
EVAL_ERROR_CODE_INVALID_RESULT = "invalidEvaluatorResult"
EVAL_ERROR_CODE_EVALUATOR_ERROR = "evaluatorError"

# ── Error-code registry (single source of truth) ─────────────────────
# Every error code is declared exactly once here with its scope, originating
# subsystem, and description. The frozensets below, the exception classes, the
# schema enums, and the docs tables all derive from (or are kept in sync with)
# this registry — so a code cannot exist in one place and be forgotten in
# another.
#
#   scope     — "turn"      → carried in a turn/item-level ``ErrorObject.code``
#               "evaluator" → carried in a per-evaluator ``ErroredScore.code``
#   subsystem — "agent"     → the WorkIQ agent produced/failed the response
#               "judge"     → the LLM judge / model backend (auth, throttling,
#                             timeouts, output)
#               "evaluator" → the evaluator framework / custom-evaluator code
#               "framework" → the pipeline itself (skip/summary bookkeeping)

_TURN = "turn"
_EVALUATOR = "evaluator"

ERROR_CODE_REGISTRY: "dict[str, tuple[str, str, str]]" = {
    # Turn/item-level ErrorObject.code — agent-request failures.
    ERROR_CODE_AGENT_REQUEST_FAILED: (
        _TURN, "agent", "The agent request failed (generic catch-all)."),
    ERROR_CODE_AGENT_AUTH: (
        _TURN, "agent", "Authentication to the agent failed (e.g. HTTP 401)."),
    ERROR_CODE_AGENT_RATE_LIMITED: (
        _TURN, "agent", "The agent throttled the request (HTTP 429)."),
    ERROR_CODE_AGENT_TIMEOUT: (
        _TURN, "agent", "The agent request timed out."),
    ERROR_CODE_AGENT_RESPONSE_UNPARSEABLE: (
        _TURN, "agent",
        "The agent returned a malformed / non-JSON / protocol-invalid response."),
    # Turn/item-level ErrorObject.code — pipeline bookkeeping.
    ERROR_CODE_TURN_SKIPPED: (
        _TURN, "framework", "A preceding turn failed, so this turn was not attempted."),
    ERROR_CODE_EVALUATORS_FAILED: (
        _TURN, "framework", "At least one evaluator errored (partial result)."),
    ERROR_CODE_EVALUATION_CRASHED: (
        _TURN, "framework",
        "The evaluation of this item crashed unexpectedly before any result "
        "could be produced (as distinct from evaluatorsFailed, where a "
        "response was scored and only some evaluators individually errored)."),
    # Per-evaluator ErroredScore.code.
    EVAL_ERROR_CODE_EMPTY_AGENT_RESPONSE: (
        _EVALUATOR, "agent",
        "Agent response was empty / None / whitespace; evaluator not run."),
    EVAL_ERROR_CODE_JUDGE_UNPARSEABLE: (
        _EVALUATOR, "judge", "LLM judge returned empty / non-JSON output."),
    EVAL_ERROR_CODE_JUDGE_AUTH: (
        _EVALUATOR, "judge",
        "Authentication to the Azure AI / OpenAI judge backend failed."),
    EVAL_ERROR_CODE_JUDGE_RATE_LIMITED: (
        _EVALUATOR, "judge", "The LLM judge backend was throttled / rate-limited."),
    EVAL_ERROR_CODE_JUDGE_TIMEOUT: (
        _EVALUATOR, "judge", "The LLM judge call or Foundry evaluation timed out."),
    EVAL_ERROR_CODE_EVALUATOR_CONFIG: (
        _EVALUATOR, "evaluator", "The evaluator was misconfigured for the run."),
    EVAL_ERROR_CODE_EVALUATOR_LOAD: (
        _EVALUATOR, "evaluator", "A custom evaluator failed to import / resolve."),
    EVAL_ERROR_CODE_INVALID_RESULT: (
        _EVALUATOR, "evaluator", "A custom evaluator returned a malformed result."),
    EVAL_ERROR_CODE_EVALUATOR_ERROR: (
        _EVALUATOR, "evaluator", "Any other uncaught evaluator failure."),
}


def _codes_for(scope: str, subsystem: "str | None" = None) -> "frozenset[str]":
    """Derive the set of codes for a scope (optionally a specific subsystem)."""
    return frozenset(
        code
        for code, (sc, sub, _desc) in ERROR_CODE_REGISTRY.items()
        if sc == scope and (subsystem is None or sub == subsystem)
    )


# All valid turn/item-level ErrorObject.code values (mirrored by the
# ErrorObject.code enum in schema/v1/eval-document.schema.json).
TURN_ERROR_CODES = _codes_for(_TURN)

# The agent-request failure subset of the turn-level codes. `agentRequestFailed`
# is the catch-all; the others are more specific, actionable variants. Names use
# the `agent*` prefix to mark the WorkIQ agent as the originating subsystem.
AGENT_REQUEST_ERROR_CODES = _codes_for(_TURN, "agent")

# The full set of valid per-evaluator error codes, mirrored by the
# ErroredScore.code enum in schema/v1/eval-document.schema.json.
EVAL_ERROR_CODES = _codes_for(_EVALUATOR)


# ── Typed exceptions (structured code at the raise site) ──────────────
# Raising these instead of a bare ``RuntimeError`` lets a subsystem attach the
# precise failure category *where it is known* (it has the HTTP status, the
# exception type, the JSON-RPC error), so downstream classification is a direct
# ``exc.code`` read rather than fragile substring-matching of ``str(exc)``.
# Both subclass ``RuntimeError`` so existing ``except RuntimeError`` handlers
# keep working, and both pass the message straight through to ``super()`` so
# ``str(exc)`` is byte-for-byte identical to the previous ``RuntimeError``.


class AgentRequestError(RuntimeError):
    """Agent (WorkIQ) request failure carrying a turn-level ``ErrorObject.code``.

    ``code`` MUST be one of :data:`AGENT_REQUEST_ERROR_CODES`; unknown values
    fall back to ``agentRequestFailed`` so a mis-wired raise can never surface an
    unknown code.
    """

    def __init__(self, message: str, code: str = ERROR_CODE_AGENT_REQUEST_FAILED) -> None:
        super().__init__(message)
        self.code = (
            code if code in AGENT_REQUEST_ERROR_CODES else ERROR_CODE_AGENT_REQUEST_FAILED
        )


class JudgeError(RuntimeError):
    """LLM-judge/backend failure carrying a per-evaluator ``ErroredScore.code``.

    Raised by the judge backend for hard failures it can positively identify
    (e.g. rate-limiting) so the caller records the precise code instead of the
    generic ``judgeOutputUnparseable`` it would otherwise infer from a bare
    ``None`` return. ``code`` MUST be one of :data:`EVAL_ERROR_CODES`; unknown
    values fall back to ``evaluatorError``.
    """

    def __init__(self, message: str, code: str = EVAL_ERROR_CODE_EVALUATOR_ERROR) -> None:
        super().__init__(message)
        self.code = code if code in EVAL_ERROR_CODES else EVAL_ERROR_CODE_EVALUATOR_ERROR


# ── Turn/item-level ErrorObject builders ──────────────────────────────


def agent_request_failed(
    exc_message: str, code: str = ERROR_CODE_AGENT_REQUEST_FAILED
) -> ErrorObject:
    """`status == "error"` cause when the agent client raised — no response obtained.

    ``code`` categorizes *why* the agent call failed (auth / rate-limit / timeout /
    unparseable response / generic). It MUST be one of
    :data:`AGENT_REQUEST_ERROR_CODES`; unknown values fall back to
    ``agentRequestFailed`` so a mis-wired call can never emit an unknown code.
    """
    safe_code = code if code in AGENT_REQUEST_ERROR_CODES else ERROR_CODE_AGENT_REQUEST_FAILED
    return {
        "code": safe_code,
        "message": f"Agent request failed: {exc_message}",
    }


def turn_skipped() -> ErrorObject:
    """`status == "error"` cause for downstream turns after a preceding turn failed.

    Synthesized cause — no exception text appended (FR-013).
    """
    return {
        "code": ERROR_CODE_TURN_SKIPPED,
        "message": "Turn not attempted: preceding turn failed",
    }


def evaluators_failed_summary(error_count: int, total: int) -> ErrorObject:
    """`status == "partial"` summary when at least one evaluator returned `result: "error"`.

    Unified template regardless of error_count vs total — per-evaluator detail
    (crash vs missing-prereq, with optional exception text) lives in `scores`.
    """
    return {
        "code": ERROR_CODE_EVALUATORS_FAILED,
        "message": f"Agent response obtained. {error_count} of {total} evaluators failed to run.",
    }


def evaluation_crashed(exc_message: str) -> ErrorObject:
    """`status == "error"` cause when evaluation of an item crashed unexpectedly,
    before any evaluator produced a result (no response invocation involved).

    Currently only used by the evaluate-only pipeline; FULL mode's equivalent
    fallback still uses :func:`agent_request_failed` for backward compatibility.
    """
    return {
        "code": ERROR_CODE_EVALUATION_CRASHED,
        "message": f"Evaluation crashed: {exc_message}",
    }


# ── Per-evaluator string builders (inside `scores`) ──────────────────


def evaluator_failed(exc_message: str) -> str:
    """Per-evaluator `error` string when the evaluator raised during run."""
    return f"Evaluator failed: {exc_message}"


def evaluator_error(code: str, message: str) -> ErroredScore:
    """Build a structured per-evaluator errored entry.

    Returns the in-memory errored-score shape carried through the pipeline:
    ``{"result": "error", "code": <code>, "error": <message>}``. The ``message``
    is used verbatim — callers that want the ``"Evaluator failed: "`` prefix
    (for a raw exception fragment) wrap the detail with :func:`evaluator_failed`
    first, e.g. ``evaluator_error(code, evaluator_failed(str(exc)))``. Messages
    that are already complete sentences — framework contract-violation
    diagnostics (``invalidEvaluatorResult``) and author-supplied
    ``{"error": ...}`` signals (``evaluatorError``) — are passed straight
    through. The ``threshold`` is added by callers (it is stripped from
    persisted output).

    ``code`` MUST be one of :data:`EVAL_ERROR_CODES`; unknown values fall back to
    ``evaluatorError`` so a mis-wired call can never emit a code the schema
    rejects.
    """
    safe_code = code if code in EVAL_ERROR_CODES else EVAL_ERROR_CODE_EVALUATOR_ERROR
    return {
        "result": "error",
        "code": safe_code,
        "error": message,
    }


# Per-evaluator prerequisite-miss builders are not present today because no
# reachable prereq-fail exists: `validate_environment()` exits the process if
# Azure OpenAI config is missing, and no registered evaluator has a
# data-dependent prereq. When that changes, add builders following the
# convention `"Evaluator missing prerequisites: <description>"` and wire a
# prereq check in evaluation_runner. See specs/236-unified-error-output for
# the deferred sub-cases.
