"""Tool call router for LLM provider format translation.

Central router that translates between different LLM provider tool call
formats and the internal ``ToolRunner``.  Supports OpenAI, Anthropic,
and LangChain formats with normalized ``ToolResult`` output.

## Supported Provider Formats

| Provider   | Tool Call Shape                                        |
|------------|--------------------------------------------------------|
| OpenAI     | ``{"name": "...", "arguments": "..."}`` (JSON string)  |
| Anthropic  | ``{"name": "...", "input": {...}}`` (dict)              |
| LangChain  | ``{"name": "...", "args": {...}, "type": "tool_call"}`` |

## Features

- **Format detection** -- auto-detects provider format from payload shape
- **Stable ID generation** -- deterministic IDs from plan_id + sequence
- **Validation surfacing** -- validation tools include pass/fail metadata
- **Error mapping** -- provider-specific error details
- **Provider metadata capture** -- tracks which provider was used

Based on ``docs/specification.md`` and ``implementation_plan.md`` task C5.router.
"""

from __future__ import annotations

import hashlib
import json
import logging
import threading
import time
from collections.abc import Generator
from enum import StrEnum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field

from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolError, ToolResult, ToolSpec

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Provider format enum
# ---------------------------------------------------------------------------


class ProviderFormat(StrEnum):
    """Supported LLM provider tool call formats."""

    OPENAI = "openai"
    ANTHROPIC = "anthropic"
    LANGCHAIN = "langchain"
    UNKNOWN = "unknown"


# ---------------------------------------------------------------------------
# Error category enum
# ---------------------------------------------------------------------------


class ToolCallErrorCategory(StrEnum):
    """Structured error categories for tool call failures."""

    TIMEOUT = "timeout"
    PERMISSION = "permission"
    RESOURCE = "resource"
    SCHEMA = "schema"
    NOT_FOUND = "not_found"
    EXECUTION = "execution"
    PARSE = "parse"
    UNKNOWN = "unknown"


# ---------------------------------------------------------------------------
# Streaming status
# ---------------------------------------------------------------------------


class StreamingStatus(StrEnum):
    """Status of a streaming tool call."""

    PENDING = "pending"
    RUNNING = "running"
    COMPLETE = "complete"
    ERROR = "error"


# ---------------------------------------------------------------------------
# Normalized tool call request
# ---------------------------------------------------------------------------


class ToolCallRequest(BaseModel):
    """Normalized internal representation of a tool call from any provider.

    Created by the router during format detection and normalization.
    """

    tool_name: str = Field(..., min_length=1, description="Tool name to invoke")
    arguments: dict[str, Any] = Field(
        default_factory=dict, description="Tool arguments as a dict"
    )
    call_id: str = Field(default="", description="Provider-assigned call ID (if any)")
    provider_format: ProviderFormat = Field(
        ProviderFormat.UNKNOWN, description="Detected provider format"
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# Normalized tool call result
# ---------------------------------------------------------------------------


class NormalizedToolCallResult(BaseModel):
    """Normalized result from a routed tool call.

    Includes the tool name, arguments, result, duration, provider
    metadata, and any error information regardless of the original
    provider format.
    """

    tool_call_id: str = Field(..., description="Stable tool call ID")
    tool_name: str = Field(..., description="Name of the tool invoked")
    arguments: dict[str, Any] = Field(
        default_factory=dict, description="Arguments passed to the tool"
    )
    result: ToolResult = Field(..., description="Execution result from the runner")
    duration_ms: float = Field(0.0, ge=0.0, description="Wall-clock duration (ms)")
    provider_format: ProviderFormat = Field(
        ProviderFormat.UNKNOWN, description="Provider format used"
    )
    provider_metadata: dict[str, Any] = Field(
        default_factory=dict, description="Provider-specific metadata"
    )
    error_category: ToolCallErrorCategory | None = Field(
        default=None, description="Error category if failed"
    )
    error_details: str | None = Field(
        default=None, description="Detailed error message"
    )
    is_validation: bool = Field(False, description="Whether the tool is a validation")
    validation_passed: bool | None = Field(
        default=None,
        description="Validation pass/fail (None if not a validation)",
    )
    validation_mode: str | None = Field(
        default=None,
        description="Validation mode (required/informational) if applicable",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# Streaming update
# ---------------------------------------------------------------------------


class StreamingToolUpdate(BaseModel):
    """Intermediate status update for a streaming tool call."""

    tool_call_id: str = Field(..., description="Stable tool call ID")
    tool_name: str = Field(..., description="Name of the tool")
    status: StreamingStatus = Field(..., description="Current status")
    elapsed_ms: float = Field(0.0, ge=0.0, description="Time elapsed so far")
    partial_output: dict[str, Any] | None = Field(
        default=None, description="Partial output (if available)"
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# ID generation
# ---------------------------------------------------------------------------


def generate_tool_call_id(plan_id: str, sequence: int) -> str:
    """Generate a deterministic tool call ID from plan_id + sequence.

    Uses SHA-256 to produce a stable, unique identifier that is
    reproducible given the same inputs.

    Parameters
    ----------
    plan_id:
        The plan identifier.
    sequence:
        The sequence number within the plan.

    Returns
    -------
    str:
        A hex-encoded deterministic ID (first 26 characters).
    """
    if not plan_id:
        raise ValueError("plan_id must not be empty")
    if sequence < 0:
        raise ValueError("sequence must be non-negative")

    raw = f"{plan_id}:{sequence}"
    digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
    return f"tc_{digest[:24]}"


# ---------------------------------------------------------------------------
# Format detection
# ---------------------------------------------------------------------------


def detect_provider_format(payload: dict[str, Any]) -> ProviderFormat:
    """Detect the provider format from a tool call payload.

    Heuristics:
    - OpenAI: has ``"arguments"`` key with a string value (JSON-encoded)
    - Anthropic: has ``"input"`` key with a dict value
    - LangChain: has ``"type"`` key equal to ``"tool_call"`` or ``"args"`` key

    Parameters
    ----------
    payload:
        The raw tool call payload from the provider.

    Returns
    -------
    ProviderFormat:
        The detected provider format.
    """
    if not isinstance(payload, dict):
        return ProviderFormat.UNKNOWN

    # LangChain: has "type": "tool_call" or "args" key
    if payload.get("type") == "tool_call":
        return ProviderFormat.LANGCHAIN
    if "args" in payload and "name" in payload:
        return ProviderFormat.LANGCHAIN

    # OpenAI: "arguments" is a JSON string
    if "arguments" in payload and isinstance(payload.get("arguments"), str):
        return ProviderFormat.OPENAI

    # Anthropic: "input" is a dict
    if "input" in payload and isinstance(payload.get("input"), dict):
        return ProviderFormat.ANTHROPIC

    # OpenAI variant: "arguments" is already a dict (some wrappers pre-parse)
    if "arguments" in payload and isinstance(payload.get("arguments"), dict):
        return ProviderFormat.OPENAI

    return ProviderFormat.UNKNOWN


# ---------------------------------------------------------------------------
# Payload normalization
# ---------------------------------------------------------------------------


def normalize_tool_call(payload: dict[str, Any]) -> ToolCallRequest:
    """Normalize a provider-specific tool call payload into a ToolCallRequest.

    Parameters
    ----------
    payload:
        The raw tool call payload from the provider.

    Returns
    -------
    ToolCallRequest:
        Normalized request with extracted name and arguments.

    Raises
    ------
    ValueError:
        If the payload cannot be parsed or has no tool name.
    """
    if not isinstance(payload, dict):
        raise ValueError("Tool call payload must be a dict")

    provider = detect_provider_format(payload)
    name = payload.get("name")
    if not name or not isinstance(name, str):
        raise ValueError("Tool call payload must include a non-empty 'name' field")

    call_id = str(payload.get("id", ""))
    arguments: dict[str, Any] = {}

    if provider == ProviderFormat.OPENAI:
        raw_args = payload.get("arguments", "{}")
        if isinstance(raw_args, str):
            try:
                arguments = json.loads(raw_args)
            except json.JSONDecodeError as exc:
                raise ValueError(f"OpenAI arguments JSON parse error: {exc}") from exc
        elif isinstance(raw_args, dict):
            arguments = raw_args
        else:
            raise ValueError(
                f"OpenAI arguments must be str or dict, got {type(raw_args).__name__}"
            )

    elif provider == ProviderFormat.ANTHROPIC:
        raw_input = payload.get("input", {})
        if isinstance(raw_input, dict):
            arguments = raw_input
        else:
            raise ValueError(
                f"Anthropic input must be a dict, got {type(raw_input).__name__}"
            )

    elif provider == ProviderFormat.LANGCHAIN:
        raw_args = payload.get("args", {})
        if isinstance(raw_args, dict):
            arguments = raw_args
        else:
            raise ValueError(
                f"LangChain args must be a dict, got {type(raw_args).__name__}"
            )

    else:
        # Unknown: try to extract arguments from common keys
        for key in ("arguments", "input", "args", "parameters"):
            raw = payload.get(key)
            if isinstance(raw, dict):
                arguments = raw
                break
            if isinstance(raw, str):
                try:
                    parsed = json.loads(raw)
                    if isinstance(parsed, dict):
                        arguments = parsed
                        break
                except json.JSONDecodeError:
                    continue

    return ToolCallRequest(
        tool_name=name,
        arguments=arguments,
        call_id=call_id,
        provider_format=provider,
    )


# ---------------------------------------------------------------------------
# Error mapping
# ---------------------------------------------------------------------------


def classify_tool_error(error_msg: str) -> ToolCallErrorCategory:
    """Classify an error message into a structured category.

    Parameters
    ----------
    error_msg:
        The error message to classify.

    Returns
    -------
    ToolCallErrorCategory:
        The determined error category.
    """
    if not error_msg:
        return ToolCallErrorCategory.UNKNOWN

    lower = error_msg.lower()

    if "timeout" in lower or "timed out" in lower:
        return ToolCallErrorCategory.TIMEOUT
    if "permission" in lower or "access denied" in lower or "forbidden" in lower:
        return ToolCallErrorCategory.PERMISSION
    if "not found" in lower:
        return ToolCallErrorCategory.NOT_FOUND
    if "resource" in lower or "memory" in lower or "disk" in lower:
        return ToolCallErrorCategory.RESOURCE
    if "schema" in lower or "validation" in lower or "json" in lower:
        return ToolCallErrorCategory.SCHEMA
    if "parse" in lower or "decode" in lower or "deserializ" in lower:
        return ToolCallErrorCategory.PARSE

    return ToolCallErrorCategory.EXECUTION


# ---------------------------------------------------------------------------
# Schema normalization
# ---------------------------------------------------------------------------


_MAX_DESCRIPTION_LENGTH = 1024


def normalize_tool_schema_for_provider(
    spec: ToolSpec,
    provider: ProviderFormat,
    max_description_length: int = _MAX_DESCRIPTION_LENGTH,
) -> dict[str, Any]:
    """Normalize a ToolSpec's schema for a specific provider.

    Applies provider-specific field pruning and limits:
    - Truncates descriptions to ``max_description_length``
    - Produces provider-appropriate schema shape

    Parameters
    ----------
    spec:
        The tool specification.
    provider:
        Target provider format.
    max_description_length:
        Maximum description length (default 1024).

    Returns
    -------
    dict:
        Provider-normalized tool schema dict.
    """
    if max_description_length < 1:
        raise ValueError("max_description_length must be at least 1")

    description = spec.description
    truncated = False
    if len(description) > max_description_length:
        description = description[:max_description_length]
        truncated = True

    schema: dict[str, Any] = {
        "name": spec.name,
        "description": description,
    }

    if truncated:
        logger.warning(
            "Tool description truncated for provider",
            extra={
                "tool": spec.name,
                "provider": provider.value,
                "original_length": len(spec.description),
                "max_length": max_description_length,
            },
        )

    default: dict[str, Any] = {"type": "object", "properties": {}}
    if provider == ProviderFormat.OPENAI:
        schema["parameters"] = spec.input_schema or default
    elif provider == ProviderFormat.ANTHROPIC:
        schema["input_schema"] = spec.input_schema or default
    elif provider == ProviderFormat.LANGCHAIN:
        schema["args_schema"] = spec.input_schema or default
    else:
        schema["input_schema"] = spec.input_schema or default

    return schema


# ---------------------------------------------------------------------------
# ToolCallRouter
# ---------------------------------------------------------------------------


class ToolCallRouter:
    """Central router translating between LLM provider tool call formats.

    Accepts tool calls in OpenAI, Anthropic, or LangChain format, routes
    them through the ``ToolRunner``, and returns normalized
    ``NormalizedToolCallResult`` objects.

    Parameters
    ----------
    registry:
        The ``ToolRegistry`` to look up tools.
    runner:
        The ``ToolRunner`` for executing tools.
    plan_id:
        Plan ID for stable tool call ID generation.
    """

    def __init__(
        self,
        registry: ToolRegistry,
        runner: ToolRunner,
        plan_id: str,
        plan_env: str | None = None,
        plan_priority: str | None = None,
        project_env: str | None = None,
        project_priority: str | None = None,
    ) -> None:
        if not plan_id:
            raise ValueError("plan_id must not be empty")

        self._registry = registry
        self._runner = runner
        self._plan_id = plan_id
        self._plan_env = plan_env
        self._plan_priority = plan_priority
        self._project_env = project_env
        self._project_priority = project_priority
        self._sequence = 0
        self._lock = threading.RLock()

    @property
    def plan_id(self) -> str:
        """Return the plan ID for this router."""
        return self._plan_id

    @property
    def sequence(self) -> int:
        """Return the current sequence number."""
        with self._lock:
            return self._sequence

    def _next_sequence(self) -> int:
        """Get and increment the sequence number (thread-safe)."""
        with self._lock:
            seq = self._sequence
            self._sequence += 1
            return seq

    # -- Route tool call -------------------------------------------------------

    def route(
        self,
        payload: dict[str, Any],
        provider_metadata: dict[str, Any] | None = None,
    ) -> NormalizedToolCallResult:
        """Route a tool call from any supported provider format.

        Detects the provider format, normalizes the payload, executes
        the tool, and returns a normalized result.

        Parameters
        ----------
        payload:
            The raw tool call payload from the provider.
        provider_metadata:
            Optional provider-level metadata (model name, latency, etc.).

        Returns
        -------
        NormalizedToolCallResult:
            The normalized result from execution.
        """
        if not isinstance(payload, dict):
            raise ValueError("payload must be a dict")

        seq = self._next_sequence()
        call_id = generate_tool_call_id(self._plan_id, seq)

        # Normalize payload
        try:
            request = normalize_tool_call(payload)
        except ValueError as exc:
            return NormalizedToolCallResult(
                tool_call_id=call_id,
                tool_name=payload.get("name", "<unknown>"),
                arguments={},
                result=ToolResult(
                    success=False,
                    output={},
                    error=f"Payload parse error: {exc}",
                ),
                duration_ms=0.0,
                provider_format=ProviderFormat.UNKNOWN,
                error_category=ToolCallErrorCategory.PARSE,
                error_details=str(exc),
                provider_metadata=provider_metadata or {},
                is_validation=False,
            )

        # Execute the tool with execution environment context
        start = time.monotonic()
        try:
            result = self._runner.execute(
                request.tool_name,
                request.arguments,
                plan_env=self._plan_env,
                plan_priority=self._plan_priority,
                project_env=self._project_env,
                project_priority=self._project_priority,
            )
        except ToolError as exc:
            elapsed = (time.monotonic() - start) * 1000.0
            error_cat = classify_tool_error(str(exc))
            return NormalizedToolCallResult(
                tool_call_id=call_id,
                tool_name=request.tool_name,
                arguments=request.arguments,
                result=ToolResult(
                    success=False,
                    output={},
                    error=str(exc),
                    duration_ms=elapsed,
                ),
                duration_ms=elapsed,
                provider_format=request.provider_format,
                provider_metadata=provider_metadata or {},
                error_category=error_cat,
                error_details=str(exc),
                is_validation=False,
            )
        except Exception as exc:
            elapsed = (time.monotonic() - start) * 1000.0
            error_cat = classify_tool_error(str(exc))
            return NormalizedToolCallResult(
                tool_call_id=call_id,
                tool_name=request.tool_name,
                arguments=request.arguments,
                result=ToolResult(
                    success=False,
                    output={},
                    error=f"{type(exc).__name__}: {exc}",
                    duration_ms=elapsed,
                ),
                duration_ms=elapsed,
                provider_format=request.provider_format,
                provider_metadata=provider_metadata or {},
                error_category=error_cat,
                error_details=str(exc),
                is_validation=False,
            )

        elapsed = (time.monotonic() - start) * 1000.0

        # Check if tool is a validation and surface pass/fail
        is_validation = False
        validation_passed: bool | None = None
        validation_mode: str | None = None

        spec = self._registry.get(request.tool_name)
        if spec is not None:
            is_validation = self._check_is_validation(spec)
            if is_validation and result.success:
                validation_passed = result.output.get("passed", None)
                validation_mode = self._get_validation_mode(spec)

        # Classify error if failed
        error_cat: ToolCallErrorCategory | None = None
        error_details: str | None = None
        if not result.success and result.error:
            error_cat = classify_tool_error(result.error)
            error_details = result.error

        return NormalizedToolCallResult(
            tool_call_id=call_id,
            tool_name=request.tool_name,
            arguments=request.arguments,
            result=result,
            duration_ms=elapsed,
            provider_format=request.provider_format,
            provider_metadata=provider_metadata or {},
            error_category=error_cat,
            error_details=error_details,
            is_validation=is_validation,
            validation_passed=validation_passed,
            validation_mode=validation_mode,
        )

    # -- Batch routing ---------------------------------------------------------

    def route_batch(
        self,
        payloads: list[dict[str, Any]],
        provider_metadata: dict[str, Any] | None = None,
    ) -> list[NormalizedToolCallResult]:
        """Route multiple tool calls sequentially.

        Parameters
        ----------
        payloads:
            List of raw tool call payloads.
        provider_metadata:
            Optional provider-level metadata.

        Returns
        -------
        list[NormalizedToolCallResult]:
            Results for each tool call in order.
        """
        if not isinstance(payloads, list):
            raise ValueError("payloads must be a list")

        results: list[NormalizedToolCallResult] = []
        for payload in payloads:
            result = self.route(payload, provider_metadata=provider_metadata)
            results.append(result)
        return results

    # -- Streaming execution ---------------------------------------------------

    def route_streaming(
        self,
        payload: dict[str, Any],
        provider_metadata: dict[str, Any] | None = None,
    ) -> Generator[StreamingToolUpdate | NormalizedToolCallResult]:
        """Route a tool call with streaming status updates.

        Yields ``StreamingToolUpdate`` for intermediate progress and a
        final ``NormalizedToolCallResult`` when complete.

        Parameters
        ----------
        payload:
            The raw tool call payload.
        provider_metadata:
            Optional provider metadata.

        Yields
        ------
        StreamingToolUpdate | NormalizedToolCallResult:
            Intermediate updates followed by the final result.
        """
        if not isinstance(payload, dict):
            raise ValueError("payload must be a dict")

        seq = self._next_sequence()
        call_id = generate_tool_call_id(self._plan_id, seq)

        # Normalize
        try:
            request = normalize_tool_call(payload)
        except ValueError as exc:
            yield NormalizedToolCallResult(
                tool_call_id=call_id,
                tool_name=payload.get("name", "<unknown>"),
                arguments={},
                result=ToolResult(
                    success=False,
                    output={},
                    error=f"Payload parse error: {exc}",
                ),
                duration_ms=0.0,
                provider_format=ProviderFormat.UNKNOWN,
                error_category=ToolCallErrorCategory.PARSE,
                error_details=str(exc),
                provider_metadata=provider_metadata or {},
                is_validation=False,
            )
            return

        # Emit pending
        yield StreamingToolUpdate(
            tool_call_id=call_id,
            tool_name=request.tool_name,
            status=StreamingStatus.PENDING,
            elapsed_ms=0.0,
        )

        # Emit running
        start = time.monotonic()
        yield StreamingToolUpdate(
            tool_call_id=call_id,
            tool_name=request.tool_name,
            status=StreamingStatus.RUNNING,
            elapsed_ms=0.0,
        )

        # Execute with execution environment context
        try:
            result = self._runner.execute(
                request.tool_name,
                request.arguments,
                plan_env=self._plan_env,
                plan_priority=self._plan_priority,
                project_env=self._project_env,
                project_priority=self._project_priority,
            )
        except (ToolError, Exception) as exc:
            elapsed = (time.monotonic() - start) * 1000.0
            error_cat = classify_tool_error(str(exc))

            yield StreamingToolUpdate(
                tool_call_id=call_id,
                tool_name=request.tool_name,
                status=StreamingStatus.ERROR,
                elapsed_ms=elapsed,
            )

            yield NormalizedToolCallResult(
                tool_call_id=call_id,
                tool_name=request.tool_name,
                arguments=request.arguments,
                result=ToolResult(
                    success=False,
                    output={},
                    error=str(exc),
                    duration_ms=elapsed,
                ),
                duration_ms=elapsed,
                provider_format=request.provider_format,
                provider_metadata=provider_metadata or {},
                error_category=error_cat,
                error_details=str(exc),
                is_validation=False,
            )
            return

        elapsed = (time.monotonic() - start) * 1000.0

        # Emit complete
        yield StreamingToolUpdate(
            tool_call_id=call_id,
            tool_name=request.tool_name,
            status=StreamingStatus.COMPLETE,
            elapsed_ms=elapsed,
        )

        # Check validation
        is_validation = False
        validation_passed: bool | None = None
        validation_mode: str | None = None
        spec = self._registry.get(request.tool_name)
        if spec is not None:
            is_validation = self._check_is_validation(spec)
            if is_validation and result.success:
                validation_passed = result.output.get("passed", None)
                validation_mode = self._get_validation_mode(spec)

        error_cat_final: ToolCallErrorCategory | None = None
        error_details_final: str | None = None
        if not result.success and result.error:
            error_cat_final = classify_tool_error(result.error)
            error_details_final = result.error

        yield NormalizedToolCallResult(
            tool_call_id=call_id,
            tool_name=request.tool_name,
            arguments=request.arguments,
            result=result,
            duration_ms=elapsed,
            provider_format=request.provider_format,
            provider_metadata=provider_metadata or {},
            error_category=error_cat_final,
            error_details=error_details_final,
            is_validation=is_validation,
            validation_passed=validation_passed,
            validation_mode=validation_mode,
        )

    # -- Schema export ---------------------------------------------------------

    def export_schemas(
        self,
        provider: ProviderFormat,
        namespace: str | None = None,
        max_description_length: int = _MAX_DESCRIPTION_LENGTH,
    ) -> list[dict[str, Any]]:
        """Export tool schemas normalized for a specific provider.

        Parameters
        ----------
        provider:
            The target provider format.
        namespace:
            Optional namespace filter.
        max_description_length:
            Maximum description length (default 1024).

        Returns
        -------
        list[dict]:
            Provider-normalized tool schemas.
        """
        specs = self._registry.list_tools(namespace=namespace)
        schemas: list[dict[str, Any]] = []
        for spec in specs:
            schema = normalize_tool_schema_for_provider(
                spec, provider, max_description_length
            )
            # Annotate validation tools
            if self._check_is_validation(spec):
                schema["tool_type"] = "validation"
                mode = self._get_validation_mode(spec)
                if mode:
                    schema["validation_mode"] = mode
            else:
                schema["tool_type"] = "tool"
            schemas.append(schema)
        return schemas

    # -- Internal helpers ------------------------------------------------------

    @staticmethod
    def _check_is_validation(spec: ToolSpec) -> bool:
        """Check if a ToolSpec represents a validation tool.

        Uses the metadata dict to determine if tool_type is 'validation'.
        """
        # Check capabilities for validation pattern: read_only=True, writes=False
        # Also check if the name contains 'validation' pattern
        cap = spec.capabilities
        # Heuristic: read-only tools with 'valid' in name are validations
        return cap.read_only and not cap.writes and "valid" in spec.name.lower()

    @staticmethod
    def _get_validation_mode(spec: ToolSpec) -> str | None:
        """Extract the validation mode from a ToolSpec if applicable.

        Checks output_schema metadata for the validation mode field.
        """
        # Check output_schema for mode hint
        if spec.output_schema and "validation_mode" in spec.output_schema:
            raw_mode = spec.output_schema["validation_mode"]
            if isinstance(raw_mode, str):
                return raw_mode
        return None
