"""Anthropic provider for Claude models."""

from __future__ import annotations

import logging
from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Any, Final

import anthropic
import httpx
from ember._internal.exceptions import (
    ProviderAPIError,
    ProviderAuthenticationError,
    ProviderRateLimitError,
)
from ember.models.discovery.provider_api import DiscoveryProvider
from ember.models.discovery.registry import register_provider as register_discovery_provider
from ember.models.discovery.types import DiscoveredModel
from ember.models.providers.anthropic._defaults import (
    ANTHROPIC_MAX_TOKENS_DEFAULT,
    max_output_for_model,
)
from ember.models.providers.anthropic.responses import _convert_tool_choice, _convert_tools
from ember.models.providers.base import BaseProvider
from ember.models.providers.utils import (
    ResponsesMessage,
    ResponsesRequest,
    parse_responses_request,
    text_turns,
)
from ember.models.schemas import ChatResponse, UsageStats

ContextMapping = Mapping[str, object]
SystemBlock = Mapping[str, str]
AnthropicMessage = Mapping[str, object]


def _normalize_context(
    raw: Sequence[ContextMapping] | ContextMapping | None,
) -> tuple[ContextMapping, ...]:
    if raw is None:
        return ()
    if isinstance(raw, Mapping):
        return (raw,)
    if isinstance(raw, Sequence) and not isinstance(raw, (str, bytes)):
        return tuple(raw)
    raise ProviderAPIError(
        "Anthropic context must be a mapping or sequence of mappings",
        context={"type": type(raw).__name__},
    )


def _anthropic_messages(prompt: str, context: Sequence[ContextMapping]) -> list[AnthropicMessage]:
    prompt_text = prompt.strip()
    if not prompt_text:
        raise ProviderAPIError("Anthropic prompt must be non-empty")

    messages: list[AnthropicMessage] = []
    for index, entry in enumerate(context):
        try:
            role_raw = entry["role"]
        except KeyError as exc:
            raise ProviderAPIError(
                "Anthropic context entries must include a role",
                context={"index": index},
            ) from exc
        role = str(role_raw).strip()
        if not role:
            raise ProviderAPIError(
                "Anthropic context roles must be non-empty",
                context={"index": index},
            )
        content_block = _text_block(entry.get("content"), field=f"context[{index}]")
        messages.append({"role": role, "content": [content_block]})

    messages.append({"role": "user", "content": [_text_block(prompt_text, field="prompt")]})
    return messages


def _normalize_system(
    raw: Sequence[str | Mapping[str, str]] | str | Mapping[str, str] | None,
) -> list[SystemBlock] | None:
    if raw is None:
        return None
    if isinstance(raw, Sequence) and not isinstance(raw, (str, bytes)):
        blocks = [_text_block(part, field=f"system[{index}]") for index, part in enumerate(raw)]
        if not blocks:
            raise ProviderAPIError("Anthropic system prompt sequences must contain text")
        return blocks
    return [_text_block(raw, field="system")]


def _text_block(raw: str | Mapping[str, str], *, field: str) -> SystemBlock:
    if isinstance(raw, Mapping):
        text = str(raw.get("text", "")).strip()
        block_type = str(raw.get("type", "text") or "text")
    else:
        text = str(raw).strip()
        block_type = "text"
    if not text:
        raise ProviderAPIError(
            "Anthropic messages require non-empty text",
            context={"field": field},
        )
    return {"type": block_type, "text": text}


logger = logging.getLogger(__name__)


def _convert_messages_to_anthropic(
    messages: Sequence[ResponsesMessage],
    instructions: str | None,
) -> tuple[list[AnthropicMessage], str | None]:
    """Convert Responses API messages to Anthropic message format.

    Handles conversion of:
    - system messages -> merged into instructions
    - user/assistant messages -> standard Anthropic messages
    - tool messages -> tool_result blocks in user messages
    - assistant tool_use -> preserved as tool_use blocks

    Args:
        messages: Sequence of ResponsesMessage objects.
        instructions: Optional system instructions.

    Returns:
        Tuple of (anthropic_messages, merged_instructions).
    """
    system_parts: list[str] = []
    anthropic_messages: list[AnthropicMessage] = []

    for msg in messages:
        role_lower = msg.role.lower()

        if role_lower == "system":
            # Extract text from system messages
            text = _extract_message_text(msg)
            if text:
                system_parts.append(text)

        elif role_lower == "tool":
            # Convert tool result to Anthropic tool_result block
            # Tool results must be in a user message
            tool_result_block = _convert_tool_message_to_result(msg)
            if tool_result_block:
                # Append to last user message or create new one
                if anthropic_messages and anthropic_messages[-1].get("role") == "user":
                    # Add to existing user message
                    content = anthropic_messages[-1].get("content", [])
                    if isinstance(content, list):
                        content.append(tool_result_block)
                else:
                    # Create new user message with tool result
                    anthropic_messages.append(
                        {
                            "role": "user",
                            "content": [tool_result_block],
                        }
                    )

        elif role_lower == "assistant":
            # Handle assistant messages - may have tool_use blocks
            content_blocks = _convert_assistant_content(msg)
            if content_blocks:
                anthropic_messages.append(
                    {
                        "role": "assistant",
                        "content": content_blocks,
                    }
                )

        elif role_lower == "user":
            # Standard user message
            text = _extract_message_text(msg)
            if text:
                anthropic_messages.append(
                    {
                        "role": "user",
                        "content": [{"type": "text", "text": text}],
                    }
                )

    # Merge system parts with instructions
    merged_instructions = instructions or ""
    if system_parts:
        system_text = "\n\n".join(system_parts)
        if merged_instructions:
            merged_instructions = f"{system_text}\n\n{merged_instructions}"
        else:
            merged_instructions = system_text

    return anthropic_messages, merged_instructions if merged_instructions else None


def _extract_message_text(msg: ResponsesMessage) -> str:
    """Extract text content from a message."""
    text_types = {"input_text", "output_text", "text", "reasoning", "thinking"}
    fragments: list[str] = []
    for part in msg.content:
        if part.type in text_types:
            text_value = part.fields.get("text")
            if isinstance(text_value, str):
                fragments.append(text_value)
    return "".join(fragments)


def _convert_tool_message_to_result(msg: ResponsesMessage) -> dict[str, Any] | None:
    """Convert a tool message to Anthropic tool_result block."""
    import json

    # Look for tool_call_id in the message content or fields
    tool_use_id = None
    content_text = None

    for part in msg.content:
        # Check for tool_call_id in fields
        if "tool_call_id" in part.fields:
            tool_use_id = part.fields["tool_call_id"]
        # Extract content/text
        if "text" in part.fields:
            content_text = part.fields["text"]
        elif "content" in part.fields:
            content_text = part.fields["content"]

    # Also check message-level attributes if available (OpenAI format)
    # In OpenAI format, tool_call_id might be at message level
    if tool_use_id is None:
        # Try to find it in the first content part's fields
        for part in msg.content:
            if part.type == "tool_result":
                tool_use_id = part.fields.get("tool_use_id") or part.fields.get("tool_call_id")
                content_text = part.fields.get("content") or part.fields.get("text")
                break

    if tool_use_id is None:
        # Cannot convert without tool_use_id
        logger.warning("Tool message missing tool_call_id, skipping")
        return None

    # Ensure content is a string
    if content_text is None:
        content_text = ""
    elif not isinstance(content_text, str):
        content_text = json.dumps(content_text) if content_text else ""

    return {
        "type": "tool_result",
        "tool_use_id": str(tool_use_id),
        "content": content_text,
    }


def _convert_assistant_content(msg: ResponsesMessage) -> list[dict[str, Any]]:
    """Convert assistant message content to Anthropic format."""
    import json

    blocks: list[dict[str, Any]] = []

    for part in msg.content:
        if part.type in {"output_text", "text", "input_text"}:
            text = part.fields.get("text")
            if text:
                blocks.append({"type": "text", "text": str(text)})

        elif part.type == "tool_use":
            # Anthropic tool_use block
            blocks.append(
                {
                    "type": "tool_use",
                    "id": part.fields.get("id", ""),
                    "name": part.fields.get("name", ""),
                    "input": part.fields.get("input", {}),
                }
            )

        elif part.type == "function_call":
            # OpenAI function_call format - convert to Anthropic tool_use
            arguments = part.fields.get("arguments", "{}")
            if isinstance(arguments, str):
                try:
                    input_dict = json.loads(arguments)
                except json.JSONDecodeError:
                    input_dict = {"raw": arguments}
            else:
                input_dict = arguments if arguments else {}

            blocks.append(
                {
                    "type": "tool_use",
                    "id": part.fields.get("call_id", part.fields.get("id", "")),
                    "name": part.fields.get("name", ""),
                    "input": input_dict,
                }
            )

        elif part.type == "thinking":
            # Extended thinking block
            thinking = part.fields.get("thinking") or part.fields.get("text")
            if thinking:
                blocks.append({"type": "thinking", "thinking": str(thinking)})

    return blocks


# ---------------------------------------------------------------------------
# Responses API tool-use → Anthropic message conversion
# ---------------------------------------------------------------------------

_TOOL_ITEM_TYPES: Final[frozenset[str]] = frozenset(
    {"function_call", "function_call_output"}
)


def _has_tool_items(items: Sequence[Any]) -> bool:
    """Return True if the raw input list contains tool-use items."""
    return any(
        isinstance(item, Mapping) and item.get("type") in _TOOL_ITEM_TYPES
        for item in items
    )


def _responses_input_to_anthropic(
    input_items: Sequence[Mapping[str, Any]],
    instructions: str | None,
) -> tuple[list[dict[str, Any]], str | None]:
    """Convert Responses API flat input items to Anthropic messages.

    The Responses API uses a flat list where tool interactions are top-level
    items (``function_call``, ``function_call_output``) rather than nested
    inside message content.  This function converts them to Anthropic's
    strict user/assistant alternation with ``tool_use`` and ``tool_result``
    content blocks.

    Consecutive items with the same role are merged into a single message
    to satisfy Anthropic's alternation constraint.
    """
    import json as _json

    system_parts: list[str] = []
    messages: list[dict[str, Any]] = []

    def _append_blocks(role: str, blocks: list[dict[str, Any]]) -> None:
        """Append content blocks, merging with the last message if same role."""
        if messages and messages[-1].get("role") == role:
            existing = messages[-1]["content"]
            existing.extend(blocks)
        else:
            messages.append({"role": role, "content": list(blocks)})

    for item in input_items:
        if not isinstance(item, Mapping):
            continue

        item_type = item.get("type", "message")

        if item_type == "function_call":
            arguments = item.get("arguments", "{}")
            if isinstance(arguments, str):
                try:
                    input_dict = _json.loads(arguments)
                except (ValueError, _json.JSONDecodeError):
                    input_dict = {"raw": arguments}
            else:
                input_dict = arguments or {}

            _append_blocks("assistant", [{
                "type": "tool_use",
                "id": str(item.get("call_id", "")),
                "name": str(item.get("name", "")),
                "input": input_dict,
            }])

        elif item_type == "function_call_output":
            output = item.get("output", "")
            _append_blocks("user", [{
                "type": "tool_result",
                "tool_use_id": str(item.get("call_id", "")),
                "content": str(output) if output else "",
            }])

        elif item_type == "message":
            role = str(item.get("role", "user")).lower()
            content = item.get("content", [])

            if role == "system":
                text = _extract_text_from_content(content)
                if text:
                    system_parts.append(text)
            else:
                blocks = _content_to_anthropic_blocks(content)
                if blocks:
                    _append_blocks(role, blocks)

    merged = instructions or ""
    if system_parts:
        prefix = "\n\n".join(system_parts)
        merged = f"{prefix}\n\n{merged}" if merged else prefix

    return messages, merged if merged else None


def _extract_text_from_content(content: Any) -> str:
    """Extract concatenated text from a Responses API content list."""
    if isinstance(content, str):
        return content
    if not isinstance(content, list):
        return ""
    return "".join(
        str(p.get("text", ""))
        for p in content
        if isinstance(p, dict)
        and p.get("type") in {"input_text", "output_text", "text"}
    )


def _content_to_anthropic_blocks(content: Any) -> list[dict[str, Any]]:
    """Convert Responses API content parts to Anthropic text blocks."""
    if isinstance(content, str):
        return [{"type": "text", "text": content}] if content else []
    if not isinstance(content, list):
        return []
    blocks: list[dict[str, Any]] = []
    for part in content:
        if not isinstance(part, dict):
            continue
        part_type = part.get("type", "")
        if part_type in {"input_text", "output_text", "text"}:
            text = part.get("text")
            if text:
                blocks.append({"type": "text", "text": str(text)})
    return blocks


def _has_assistant_without_thinking(messages: list[AnthropicMessage]) -> bool:
    """Check if conversation contains assistant messages lacking thinking blocks.

    When extended thinking is enabled, Anthropic requires all prior assistant
    messages to start with thinking/redacted_thinking blocks. If any assistant
    message lacks these, extended thinking must be disabled to avoid API errors.

    Args:
        messages: Anthropic-formatted messages list.

    Returns:
        True if any assistant message exists without a leading thinking block.
    """
    for msg in messages:
        if msg.get("role") != "assistant":
            continue
        content = msg.get("content")
        if not content or not isinstance(content, list):
            return True  # Empty assistant message - no thinking block
        first_block = content[0] if content else {}
        block_type = first_block.get("type", "") if isinstance(first_block, dict) else ""
        if block_type not in ("thinking", "redacted_thinking"):
            return True
    return False


def _resolve_anthropic_api_key() -> str:
    """Resolve Anthropic API key from the active Ember context."""
    from ember._internal.context.runtime import EmberContext

    return EmberContext.current().get_credential("anthropic")


class AnthropicProvider(BaseProvider):
    """Anthropic Claude provider with retry-aware completions.

    Handles API invocation, retries, and normalization into ``ChatResponse``.
    """

    supports_responses_api = True

    def __init__(self, api_key: str | None = None):
        """Initialize Anthropic provider.

        Args:
            api_key: Anthropic API key. When omitted, resolves from configuration.
        """
        super().__init__(api_key)
        self.client = anthropic.Anthropic(
            api_key=self.api_key,
            timeout=httpx.Timeout(120.0, connect=5.0),
        )

    def _resolve_model_name(self, model: str) -> str:
        """Resolve user-friendly model names to actual API model IDs.

        Maps short names like 'claude-3-haiku' to full API names like
        'claude-3-haiku-20240307'. This provides a better user experience
        while maintaining API compatibility.

        Args:
            model: User-provided model name.

        Returns:
            Resolved model name for API calls.
        """
        # Model name mappings from friendly names to API names. This deliberately
        # enumerates only the aliases that Anthropic documents publicly so that
        # resolution stays explicit.
        return _MODEL_NAME_ALIASES.get(model, model)

    def complete(self, prompt: str, model: str, **kwargs: Any) -> ChatResponse:
        """Complete a prompt using Anthropic's API.

        Args:
            prompt: The prompt text.
            model: Model name (e.g., "claude-3-opus", "claude-3-sonnet").
            **kwargs: Additional parameters like temperature, max_tokens, etc.

        Returns:
            ChatResponse with completion and usage information.

        Raises:
            ProviderAPIError: For API errors.
            AuthenticationError: For invalid API key.
            RateLimitError: When rate limited.
        """
        resolved_model = self._resolve_model_name(model)
        params = self._build_completion_params(
            prompt=prompt,
            requested_model=model,
            resolved_model=resolved_model,
            raw_kwargs=kwargs,
        )
        return self._execute_completion(params, model, resolved_model)

    def _execute_completion(
        self,
        params: dict[str, Any],
        requested_model: str,
        resolved_model: str,
    ) -> ChatResponse:
        """Execute an Anthropic API call with fully-built params.

        Handles streaming, fast-mode beta routing, and error translation.
        Both ``complete()`` and ``_complete_tool_use_payload()`` delegate
        here after building their params dicts.
        """
        message_count = len(params.get("messages", ()))
        logger.debug(
            "Anthropic API call (streaming): model=%s (requested: %s), messages=%d",
            resolved_model,
            requested_model,
            message_count,
        )

        # Pop fast-mode params before the API call.  When betas is present
        # we route through the beta endpoint; speed is not a named SDK param
        # so it travels via extra_body.
        speed = params.pop("speed", None)
        betas = params.pop("betas", None)

        try:
            if betas:
                extra_body: dict[str, object] = {}
                if speed:
                    extra_body["speed"] = speed
                with self.client.beta.messages.stream(
                    **params,
                    betas=list(betas),
                    extra_body=extra_body or None,
                ) as stream:
                    response = stream.get_final_message()
            else:
                with self.client.messages.stream(**params) as stream:
                    response = stream.get_final_message()

            return self._build_chat_response(response, requested_model)

        except anthropic.AuthenticationError as e:
            logger.error(f"Anthropic authentication error: {e}")
            raise ProviderAuthenticationError(
                f"Invalid Anthropic API key: {e}",
                context={
                    "model": requested_model,
                    "resolved_model": resolved_model,
                    "provider": "anthropic",
                },
            ) from e

        except anthropic.RateLimitError as e:
            logger.warning(f"Anthropic rate limit: {e}")
            raise ProviderRateLimitError(
                f"Anthropic rate limit exceeded: {e}",
                retry_after_seconds=60,
                context={
                    "model": requested_model,
                    "resolved_model": resolved_model,
                    "provider": "anthropic",
                },
            ) from e

        except anthropic.APIError as e:
            logger.error(f"Anthropic API error: {e}")
            raise ProviderAPIError(
                f"Anthropic API error: {str(e)}",
                context={
                    "model": requested_model,
                    "resolved_model": resolved_model,
                },
            ) from e

        except Exception as e:
            logger.exception("Unexpected error calling Anthropic API")
            raise ProviderAPIError(
                f"Unexpected error: {str(e)}",
                context={
                    "model": requested_model,
                    "resolved_model": resolved_model,
                },
            ) from e

    def complete_responses_payload(
        self,
        payload: Mapping[str, Any],
        **overrides: Any,
    ) -> ChatResponse:
        request = self._parse_responses_payload(payload)
        if overrides:
            unsupported = ", ".join(sorted(overrides))
            raise ProviderAPIError(
                f"Unsupported Anthropic Responses overrides: {unsupported}",
                context={"model": request.model},
            )

        self._validate_responses_payload(request)

        # Detect tool-use conversations.  When the raw input contains
        # function_call / function_call_output items we must convert them
        # to Anthropic's tool_use / tool_result blocks instead of using
        # the text-only path (which silently drops tool history).
        raw_input = payload.get("input")
        if isinstance(raw_input, list) and _has_tool_items(raw_input):
            return self._complete_tool_use_payload(request, raw_input)

        # --- Text-only path (no tool-use items) ---
        turns = text_turns(request)
        if not turns:
            raise ProviderAPIError(
                "Responses payload must include at least one user message",
                context={"model": request.model},
            )

        system_parts: list[str] = []
        conversation_turns: list[tuple[str, str]] = []
        for role, text in turns:
            role_lower = role.lower()
            if role_lower == "system":
                system_parts.append(text)
            elif role_lower in {"user", "assistant"}:
                conversation_turns.append((role, text))
            else:
                raise ProviderAPIError(
                    f"Anthropic Responses adapter does not support role: {role}",
                    context={"model": request.model},
                )

        has_user_turn = any(role.lower() == "user" for role, _ in conversation_turns)
        if not conversation_turns or not has_user_turn:
            raise ProviderAPIError(
                "Anthropic Responses payload requires a user turn",
                context={"model": request.model},
            )

        merged_instructions = request.instructions or ""
        if system_parts:
            system_text = "\n\n".join(system_parts)
            if merged_instructions:
                merged_instructions = f"{system_text}\n\n{merged_instructions}"
            else:
                merged_instructions = system_text

        prompt = conversation_turns[-1][1]
        context_messages = [
            {"role": role, "content": text}
            for role, text in conversation_turns[:-1]
        ]

        params: dict[str, Any] = {}
        if request.max_output_tokens is not None:
            params["max_tokens"] = request.max_output_tokens
        if request.temperature is not None:
            params["temperature"] = request.temperature
        if request.top_p is not None:
            params["top_p"] = request.top_p
        if request.stop_sequences:
            params["stop"] = list(request.stop_sequences)
        if request.tools:
            params["tools"] = list(request.tools)
        if request.tool_choice is not None:
            params["tool_choice"] = request.tool_choice
        if request.parallel_tool_calls is not None:
            params["parallel_tool_calls"] = request.parallel_tool_calls
        if request.reasoning is not None:
            params["reasoning"] = dict(request.reasoning)

        return self.complete(
            prompt,
            request.model,
            system=merged_instructions if merged_instructions else None,
            context=context_messages,
            **params,
        )

    def _complete_tool_use_payload(
        self,
        request: ResponsesRequest,
        raw_input: Sequence[Mapping[str, Any]],
    ) -> ChatResponse:
        """Handle Responses payloads with tool-use conversation history.

        Converts Responses API flat input items (``function_call``,
        ``function_call_output``) to Anthropic's ``tool_use`` /
        ``tool_result`` content blocks, then calls the API with the
        properly-structured messages.
        """
        anthropic_messages, merged_instructions = _responses_input_to_anthropic(
            raw_input, request.instructions,
        )

        if not anthropic_messages:
            raise ProviderAPIError(
                "Tool-use payload produced no messages",
                context={"model": request.model},
            )

        resolved_model = self._resolve_model_name(request.model)

        # Build kwargs for _build_completion_params.  The messages are
        # pre-built so we pass them via messages_override.
        kwargs: dict[str, Any] = {}
        if request.max_output_tokens is not None:
            kwargs["max_tokens"] = request.max_output_tokens
        if request.temperature is not None:
            kwargs["temperature"] = request.temperature
        if request.top_p is not None:
            kwargs["top_p"] = request.top_p
        if request.stop_sequences:
            kwargs["stop"] = list(request.stop_sequences)
        if request.tools:
            kwargs["tools"] = list(request.tools)
        if request.tool_choice is not None:
            kwargs["tool_choice"] = request.tool_choice
        if request.parallel_tool_calls is not None:
            kwargs["parallel_tool_calls"] = request.parallel_tool_calls
        if request.reasoning is not None:
            kwargs["reasoning"] = dict(request.reasoning)
        if merged_instructions:
            kwargs["system"] = merged_instructions

        params = self._build_completion_params(
            prompt="",  # Unused; messages are pre-built
            requested_model=request.model,
            resolved_model=resolved_model,
            raw_kwargs=kwargs,
            messages_override=anthropic_messages,
        )
        return self._execute_completion(params, request.model, resolved_model)

    def stream_responses_payload(
        self,
        payload: Mapping[str, Any],
        **_: Any,
    ) -> Iterable[str]:
        raise ProviderAPIError(
            "Streaming is not implemented for Anthropic Responses payloads",
            context={"model": payload.get("model")},
        )

    def _build_chat_response(self, response: Any, requested_model: str) -> ChatResponse:
        """Build ChatResponse from Anthropic API response."""
        import json

        content = getattr(response, "content", ())

        # Separate text, thinking, and tool_use blocks
        text_parts: list[str] = []
        thinking_parts: list[str] = []
        tool_calls: list[dict[str, Any]] = []

        for chunk in content:
            chunk_type = getattr(chunk, "type", None)
            if chunk_type is None and isinstance(chunk, Mapping):
                chunk_type = chunk.get("type")

            if chunk_type == "thinking":
                # Extract thinking trace from thinking blocks
                thinking = getattr(chunk, "thinking", None)
                if thinking is None and isinstance(chunk, Mapping):
                    thinking = chunk.get("thinking")
                if thinking:
                    thinking_parts.append(str(thinking))
            elif chunk_type == "tool_use":
                # Extract tool_use and convert to OpenAI tool_calls format
                tool_id = getattr(chunk, "id", None)
                tool_name = getattr(chunk, "name", None)
                tool_input = getattr(chunk, "input", None)
                if tool_id is None and isinstance(chunk, Mapping):
                    tool_id = chunk.get("id")
                    tool_name = chunk.get("name")
                    tool_input = chunk.get("input")

                if tool_id and tool_name:
                    # Convert Anthropic input dict to JSON string for OpenAI format
                    arguments = json.dumps(tool_input) if tool_input else "{}"
                    tool_calls.append(
                        {
                            "id": tool_id,
                            "type": "function",
                            "function": {
                                "name": tool_name,
                                "arguments": arguments,
                            },
                        }
                    )
            else:
                # Extract text from text blocks (default behavior)
                text_parts.append(_content_piece_text(chunk))

        text = "".join(text_parts)
        thinking_trace = "\n".join(thinking_parts) if thinking_parts else None

        usage_payload = getattr(response, "usage", None)
        usage = None
        if usage_payload is not None:
            usage = UsageStats(
                prompt_tokens=usage_payload.input_tokens,
                completion_tokens=usage_payload.output_tokens,
                total_tokens=usage_payload.input_tokens + usage_payload.output_tokens,
            )

        return ChatResponse(
            data=text,
            usage=usage,
            model_id=requested_model,
            raw_output=response,
            thinking_trace=thinking_trace,
            tool_calls=tool_calls if tool_calls else None,
        )

    def _build_completion_params(
        self,
        *,
        prompt: str,
        requested_model: str,
        resolved_model: str,
        raw_kwargs: Mapping[str, Any],
        messages_override: list[dict[str, Any]] | None = None,
    ) -> dict[str, Any]:
        """Assemble Anthropic SDK arguments from high-level options.

        Args:
            messages_override: When provided, use these pre-built Anthropic
                messages instead of constructing them from ``prompt`` and
                ``context``.  Used by the tool-use path where messages have
                already been converted from Responses API format.
        """
        from ember.models.providers.reasoning_config import translate_reasoning_for_provider

        options = dict(raw_kwargs)

        if messages_override is not None:
            messages = list(messages_override)
            options.pop("context", None)
        else:
            context_entries = _normalize_context(options.pop("context", None))
            messages = _anthropic_messages(prompt, context_entries)
        system_blocks = _normalize_system(options.pop("system", None))

        # Translate reasoning config via centralized reasoning_config module.
        # Result shape determines routing:
        #   {"effort": "..."} → output_config (native effort, no thinking constraint)
        #   {"type": "enabled", "budget_tokens": N} → thinking (explicit budget)
        reasoning = options.pop("reasoning", None)
        if reasoning is not None:
            translated = translate_reasoning_for_provider(reasoning, "anthropic")
            if translated is not None:
                if "effort" in translated:
                    # Native effort — no assistant-thinking constraint
                    options["output_config"] = translated
                elif "budget_tokens" in translated:
                    # Explicit thinking budget — check assistant messages
                    if not _has_assistant_without_thinking(messages):
                        options["thinking"] = translated
                        logger.debug(
                            "Using extended thinking: %d tokens",
                            translated["budget_tokens"],
                        )
                    else:
                        logger.warning(
                            "Disabling thinking: prior assistant messages "
                            "lack thinking blocks"
                        )

        # Extract thinking budget for max_tokens auto-adjustment.
        # Only explicit thinking needs this; output_config.effort lets the
        # API handle allocation internally.
        thinking_budget: int | None = None
        thinking_config = options.get("thinking")
        if isinstance(thinking_config, Mapping):
            budget_value = thinking_config.get("budget_tokens")
            if budget_value is not None:
                try:
                    thinking_budget = int(budget_value)
                except (TypeError, ValueError):
                    logger.warning(
                        "Invalid budget_tokens value %r; ignoring",
                        budget_value,
                    )

        max_tokens = _resolve_max_tokens(
            explicit=options.pop("max_tokens", None),
            override=options.pop("max_output_tokens", None),
            model=requested_model,
            resolved_model=resolved_model,
            thinking_budget=thinking_budget,
        )

        params: dict[str, Any] = {
            "model": resolved_model,
            "messages": messages,
            "max_tokens": max_tokens,
        }
        if system_blocks:
            params["system"] = system_blocks

        # Anthropic constraint: temperature and top_p cannot both be specified.
        # If both are present, prefer temperature (more commonly used) and drop top_p.
        has_temperature = "temperature" in options
        has_top_p = "top_p" in options
        if has_temperature and has_top_p:
            logger.debug(
                "Dropping top_p=%s: Anthropic models do not accept both "
                "temperature and top_p simultaneously",
                options.get("top_p"),
            )
            options.pop("top_p")

        passthrough_keys = {
            "temperature": "temperature",
            "top_p": "top_p",
            "stop": "stop_sequences",
        }
        for source, target in passthrough_keys.items():
            if source in options:
                params[target] = options.pop(source)

        # Convert tools from OpenAI function format to Anthropic custom tool format
        raw_tools = options.pop("tools", None)
        if raw_tools:
            converted_tools = _convert_tools(list(raw_tools))
            if converted_tools:
                params["tools"] = converted_tools

        # Convert tool_choice from OpenAI to Anthropic format
        # Anthropic now supports all tool_choice modes directly including "none"
        raw_tool_choice = options.pop("tool_choice", None)
        parallel_tool_calls = options.pop("parallel_tool_calls", None)
        if raw_tool_choice is not None or parallel_tool_calls is False:
            converted_choice = _convert_tool_choice(
                raw_tool_choice,
                disable_parallel=parallel_tool_calls is False,
            )
            if converted_choice is not None:
                params["tool_choice"] = converted_choice
                # Anthropic constraint: extended thinking is incompatible with
                # forced tool use. When tool_choice forces the model to use tools
                # ("any" or "tool"), we must disable thinking to avoid API errors.
                choice_type = converted_choice.get("type")
                if choice_type in ("any", "tool") and "thinking" in options:
                    logger.warning(
                        "Disabling extended thinking: incompatible with "
                        "forced tool_choice=%s",
                        choice_type,
                    )
                    options.pop("thinking")

        params.update(options)
        return params

    @staticmethod
    def _parse_responses_payload(payload: Mapping[str, Any]) -> ResponsesRequest:
        if not isinstance(payload, Mapping):
            raise ProviderAPIError("Responses payload must be a JSON object")
        try:
            return parse_responses_request(dict(payload))
        except ValueError as exc:
            raise ProviderAPIError(str(exc)) from exc

    @staticmethod
    def _validate_responses_payload(request: ResponsesRequest) -> None:
        # Note: tools, tool_choice, and reasoning are supported via native Anthropic API.
        # Reasoning is translated from OpenAI's effort levels to Claude's thinking/effort.
        if request.response_format:
            raise ProviderAPIError(
                "Anthropic Responses adapter does not support response_format",
                context={"model": request.model},
            )

    def _get_api_key_from_env(self) -> str:
        """Get Anthropic API key from configuration."""

        return _resolve_anthropic_api_key()

    def validate_model(self, model: str) -> bool:
        """Check if this provider supports the given model.

        Args:
            model: Model name to validate.

        Returns:
            True if model is supported.
        """
        from ember.models.catalog import list_available_models

        available = set(list_available_models(provider="anthropic"))
        if model in available:
            return True

        resolved = self._resolve_model_name(model)
        return resolved in available

    def get_model_info(self, model: str) -> dict[str, Any]:
        """Get information about a specific model.

        Args:
            model: Model name.

        Returns:
            Dictionary with model information.
        """
        from ember.models.catalog import get_model_info

        info = super().get_model_info(model)

        resolved_model = self._resolve_model_name(model)
        lookup_order = (resolved_model, model)
        for candidate in lookup_order:
            try:
                catalog_entry = get_model_info(candidate)
            except KeyError:
                continue
            info.update(
                {
                    "context_window": catalog_entry.context_window,
                    "status": getattr(catalog_entry, "status", "stable"),
                    "description": catalog_entry.description,
                }
            )
            break

        return info


class AnthropicDiscoveryAdapter(DiscoveryProvider):
    """Discovery adapter leveraging Anthropic's model listing endpoint."""

    name = "anthropic"

    def __init__(
        self,
        *,
        api_key_resolver: Callable[[], str] = _resolve_anthropic_api_key,
        client_factory: Callable[[str], anthropic.Anthropic] | None = None,
    ) -> None:
        self._resolve_api_key: Callable[[], str] = api_key_resolver
        if client_factory is None:

            def _default_factory(key: str) -> anthropic.Anthropic:
                return anthropic.Anthropic(
                    api_key=key,
                    timeout=httpx.Timeout(15.0, connect=5.0),
                )

            client_factory = _default_factory
        self._client_factory: Callable[[str], anthropic.Anthropic] = client_factory

    def list_models(
        self,
        *,
        region: str | None = None,
        project_hint: str | None = None,
    ) -> Iterable[DiscoveredModel]:
        """List available models from Anthropic API.

        Args:
            region: Region hint (ignored for Anthropic).
            project_hint: Project hint (ignored for Anthropic).

        Returns:
            List of discovered models.
        """
        api_key = self._resolve_api_key()

        client = self._client_factory(api_key)

        if region:
            logger.debug("Anthropic discovery region hint '%s' ignored", region)
        if project_hint:
            logger.debug("Anthropic discovery project hint '%s' ignored", project_hint)

        paginator = client.models.list(limit=_DISCOVERY_PAGE_LIMIT)  # type: ignore[no-untyped-call]
        iterator = getattr(paginator, "iter_pages", None)
        pages = iterator() if callable(iterator) else (paginator,)

        models: list[DiscoveredModel] = []
        for page in pages:
            entries = getattr(page, "data", None) or []
            for entry in entries:
                discovered = self._to_discovered(entry)
                if discovered is not None:
                    models.append(discovered)
        return models

    def _to_discovered(self, entry: Any) -> DiscoveredModel | None:
        """Convert API entry to DiscoveredModel."""
        model_id = getattr(entry, "id", None)
        if model_id is None and isinstance(entry, dict):
            model_id = entry.get("id")
        if not model_id:
            return None

        display = getattr(entry, "display_name", None) or getattr(entry, "displayName", None)
        description = getattr(entry, "description", None)
        raw_payload = _extract_payload(entry)

        return DiscoveredModel(
            provider="anthropic",
            id=model_id,
            display_name=display,
            description=description,
            raw_payload=raw_payload,
        )


def _extract_payload(entry: Any) -> dict[str, Any]:
    """Extract dictionary payload from an API entry object.

    Args:
        entry: API response entry object.

    Returns:
        Dictionary representation of the entry.
    """
    if isinstance(entry, dict):
        return dict(entry)

    # Try standard serialization methods
    if hasattr(entry, "model_dump") and callable(entry.model_dump):
        return entry.model_dump()

    if hasattr(entry, "to_dict") and callable(entry.to_dict):
        return entry.to_dict()

    # If no serialization method found, return empty dict
    return {}


register_discovery_provider(AnthropicDiscoveryAdapter())
_MODEL_NAME_ALIASES: Final[dict[str, str]] = {
    "claude-3-opus": "claude-3-opus-20240229",
    "claude-3-sonnet": "claude-3-sonnet-20240229",
    "claude-3-haiku": "claude-3-haiku-20240307",
    "claude-3.5-sonnet": "claude-3-5-sonnet-20241022",
    "claude-3.5-haiku": "claude-3-5-haiku-20241022",
    "claude-3.7-sonnet": "claude-3-7-sonnet-20250219",
    "claude-opus-4": "claude-opus-4-20250514",
    "claude-4-opus": "claude-opus-4-20250514",
    "claude-opus-4.1": "claude-opus-4-1-20250805",
    "claude-4.1-opus": "claude-opus-4-1-20250805",
    "claude-4-sonnet": "claude-4-sonnet-20250514",
    "claude-4.5-sonnet": "claude-sonnet-4-5-20250929",
    "claude-4-5-sonnet": "claude-sonnet-4-5-20250929",
    "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
    "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
    "claude-opus-4.6": "claude-opus-4-6",
    "claude-4.6-opus": "claude-opus-4-6",
    "claude-sonnet-4": "claude-4-sonnet-20250514",
    # Legacy models
    "claude-2.1": "claude-2.1",
    "claude-2.0": "claude-2.0",
    "claude-instant-1.2": "claude-instant-1.2",
}


_DISCOVERY_PAGE_LIMIT: Final[int] = 100


def _content_piece_text(chunk: Any) -> str:
    if hasattr(chunk, "text"):
        return str(chunk.text)
    if isinstance(chunk, str):
        return chunk
    if isinstance(chunk, Mapping):
        text = chunk.get("text")
        return str(text) if text is not None else ""
    return ""


def _resolve_max_tokens(
    *,
    explicit: Any,
    override: Any,
    model: str,
    resolved_model: str,
    thinking_budget: int | None = None,
) -> int:
    if explicit is not None and override is not None:
        raise ProviderAPIError(
            "Specify only one of 'max_tokens' or 'max_output_tokens' for Anthropic requests",
            context={"model": model, "resolved_model": resolved_model},
        )

    raw_value = override if override is not None else explicit
    model_limit = max_output_for_model(resolved_model)
    value = raw_value if raw_value is not None else model_limit

    try:
        max_tokens = int(value)
    except (TypeError, ValueError) as exc:
        raise ProviderAPIError(
            "Anthropic 'max_tokens' must be an integer",
            context={"model": model, "resolved_model": resolved_model},
        ) from exc

    if max_tokens <= 0:
        raise ProviderAPIError(
            "Anthropic 'max_tokens' must be positive",
            context={"model": model, "resolved_model": resolved_model},
        )

    # Auto-adjust max_tokens when thinking is enabled.
    # Anthropic requires max_tokens > budget_tokens for extended thinking.
    if thinking_budget is not None and max_tokens <= thinking_budget:
        max_tokens = thinking_budget + model_limit
        logger.debug(
            "Auto-adjusted max_tokens to %d (thinking budget: %d, model limit: %d)",
            max_tokens,
            thinking_budget,
            model_limit,
        )

    return max_tokens
