"""OpenAI sampling handler for FastMCP."""

import json
from collections.abc import Iterator, Sequence
from typing import Any, Literal, get_args

from mcp import ClientSession, ServerSession
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.types import (
    AudioContent,
    CreateMessageResult,
    CreateMessageResultWithTools,
    ImageContent,
    ModelPreferences,
    SamplingMessage,
    StopReason,
    TextContent,
    Tool,
    ToolChoice,
    ToolResultContent,
    ToolUseContent,
)
from mcp.types import CreateMessageRequestParams as SamplingParams

try:
    from openai import AsyncOpenAI
    from openai.types.chat import (
        ChatCompletion,
        ChatCompletionAssistantMessageParam,
        ChatCompletionContentPartImageParam,
        ChatCompletionContentPartInputAudioParam,
        ChatCompletionContentPartParam,
        ChatCompletionContentPartTextParam,
        ChatCompletionMessageParam,
        ChatCompletionMessageToolCallParam,
        ChatCompletionSystemMessageParam,
        ChatCompletionToolChoiceOptionParam,
        ChatCompletionToolMessageParam,
        ChatCompletionToolParam,
        ChatCompletionUserMessageParam,
    )
    from openai.types.shared.chat_model import ChatModel
    from openai.types.shared_params import FunctionDefinition
except ImportError as e:
    raise ImportError(
        "The `openai` package is not installed. "
        "Please install `fastmcp-slim[openai]` or add `openai` to your dependencies manually."
    ) from e

# OpenAI only supports wav and mp3 for input audio
_OPENAI_AUDIO_FORMATS: dict[str, Literal["wav", "mp3"]] = {
    "audio/wav": "wav",
    "audio/x-wav": "wav",
    "audio/mp3": "mp3",
    "audio/mpeg": "mp3",
}

_OPENAI_IMAGE_MEDIA_TYPES: frozenset[str] = frozenset(
    {"image/jpeg", "image/png", "image/gif", "image/webp"}
)


def _image_content_to_openai_part(
    content: ImageContent,
) -> ChatCompletionContentPartImageParam:
    """Convert MCP ImageContent to OpenAI image_url content part."""
    if content.mimeType not in _OPENAI_IMAGE_MEDIA_TYPES:
        raise ValueError(
            f"Unsupported image MIME type for OpenAI: {content.mimeType!r}. "
            f"Supported types: {', '.join(sorted(_OPENAI_IMAGE_MEDIA_TYPES))}"
        )
    data_url = f"data:{content.mimeType};base64,{content.data}"
    return ChatCompletionContentPartImageParam(
        type="image_url",
        image_url={"url": data_url},
    )


def _audio_content_to_openai_part(
    content: AudioContent,
) -> ChatCompletionContentPartInputAudioParam:
    """Convert MCP AudioContent to OpenAI input_audio content part."""
    audio_format = _OPENAI_AUDIO_FORMATS.get(content.mimeType)
    if audio_format is None:
        raise ValueError(
            f"Unsupported audio MIME type for OpenAI: {content.mimeType!r}. "
            f"Supported types: {', '.join(sorted(_OPENAI_AUDIO_FORMATS))}"
        )
    return ChatCompletionContentPartInputAudioParam(
        type="input_audio",
        input_audio={"data": content.data, "format": audio_format},
    )


class OpenAISamplingHandler:
    """Sampling handler that uses the OpenAI API."""

    def __init__(
        self,
        default_model: ChatModel,
        client: AsyncOpenAI | None = None,
    ) -> None:
        self.client: AsyncOpenAI = client or AsyncOpenAI()
        self.default_model: ChatModel = default_model

    async def __call__(
        self,
        messages: list[SamplingMessage],
        params: SamplingParams,
        context: RequestContext[ServerSession, LifespanContextT]
        | RequestContext[ClientSession, LifespanContextT],
    ) -> CreateMessageResult | CreateMessageResultWithTools:
        openai_messages: list[ChatCompletionMessageParam] = (
            self._convert_to_openai_messages(
                system_prompt=params.systemPrompt,
                messages=messages,
            )
        )

        model: ChatModel = self._select_model_from_preferences(params.modelPreferences)

        # Convert MCP tools to OpenAI format
        openai_tools: list[ChatCompletionToolParam] | None = None
        if params.tools:
            openai_tools = self._convert_tools_to_openai(params.tools)

        # Convert tool_choice to OpenAI format
        openai_tool_choice: ChatCompletionToolChoiceOptionParam | None = None
        if params.toolChoice:
            openai_tool_choice = self._convert_tool_choice_to_openai(params.toolChoice)

        # Build kwargs to avoid sentinel type compatibility issues across
        # openai SDK versions (NotGiven vs Omit)
        kwargs: dict[str, Any] = {
            "model": model,
            "messages": openai_messages,
        }
        if params.maxTokens is not None:
            kwargs["max_completion_tokens"] = params.maxTokens
        if params.temperature is not None:
            kwargs["temperature"] = params.temperature
        if params.stopSequences:
            kwargs["stop"] = params.stopSequences
        if openai_tools is not None:
            kwargs["tools"] = openai_tools
        if openai_tool_choice is not None:
            kwargs["tool_choice"] = openai_tool_choice

        response = await self.client.chat.completions.create(**kwargs)

        # Return appropriate result type based on whether tools were provided
        if params.tools:
            return self._chat_completion_to_result_with_tools(response)
        return self._chat_completion_to_create_message_result(response)

    @staticmethod
    def _iter_models_from_preferences(
        model_preferences: ModelPreferences | str | list[str] | None,
    ) -> Iterator[str]:
        if model_preferences is None:
            return

        if isinstance(model_preferences, str) and model_preferences in get_args(
            ChatModel
        ):
            yield model_preferences

        elif isinstance(model_preferences, list):
            yield from model_preferences

        elif isinstance(model_preferences, ModelPreferences):
            if not (hints := model_preferences.hints):
                return

            for hint in hints:
                if not (name := hint.name):
                    continue

                yield name

    @staticmethod
    def _convert_to_openai_messages(
        system_prompt: str | None, messages: Sequence[SamplingMessage]
    ) -> list[ChatCompletionMessageParam]:
        openai_messages: list[ChatCompletionMessageParam] = []

        if system_prompt:
            openai_messages.append(
                ChatCompletionSystemMessageParam(
                    role="system",
                    content=system_prompt,
                )
            )

        for message in messages:
            content = message.content

            # Handle list content (from CreateMessageResultWithTools)
            if isinstance(content, list):
                # Collect tool calls, content parts, and text from the list
                tool_calls: list[ChatCompletionMessageToolCallParam] = []
                content_parts: list[ChatCompletionContentPartParam] = []
                text_parts: list[str] = []
                # Collect tool results separately to maintain correct ordering
                tool_messages: list[ChatCompletionToolMessageParam] = []

                for item in content:
                    if isinstance(item, ToolUseContent):
                        tool_calls.append(
                            ChatCompletionMessageToolCallParam(
                                id=item.id,
                                type="function",
                                function={
                                    "name": item.name,
                                    "arguments": json.dumps(item.input),
                                },
                            )
                        )
                    elif isinstance(item, TextContent):
                        text_parts.append(item.text)
                        content_parts.append(
                            ChatCompletionContentPartTextParam(
                                type="text", text=item.text
                            )
                        )
                    elif isinstance(item, ImageContent):
                        content_parts.append(_image_content_to_openai_part(item))
                    elif isinstance(item, AudioContent):
                        content_parts.append(_audio_content_to_openai_part(item))
                    elif isinstance(item, ToolResultContent):
                        # Collect tool results (added after assistant message)
                        content_text = ""
                        if item.content:
                            result_texts = [
                                sub_item.text
                                for sub_item in item.content
                                if isinstance(sub_item, TextContent)
                            ]
                            content_text = "\n".join(result_texts)
                        tool_messages.append(
                            ChatCompletionToolMessageParam(
                                role="tool",
                                tool_call_id=item.toolUseId,
                                content=content_text,
                            )
                        )
                    else:
                        raise ValueError(
                            f"Unsupported content type for OpenAI: {type(item).__name__}"
                        )

                # Add assistant message with tool calls if present
                # OpenAI requires: assistant (with tool_calls) -> tool messages
                if tool_calls or content_parts:
                    if tool_calls:
                        has_multimodal = len(content_parts) > len(text_parts)
                        if has_multimodal:
                            raise ValueError(
                                "ImageContent/AudioContent is only supported "
                                "in user messages for OpenAI"
                            )
                        text_str = "\n".join(text_parts) or None
                        openai_messages.append(
                            ChatCompletionAssistantMessageParam(
                                role="assistant",
                                content=text_str,
                                tool_calls=tool_calls,
                            )
                        )
                        # Add tool messages AFTER assistant message
                        openai_messages.extend(tool_messages)
                    elif content_parts:
                        if message.role == "user":
                            openai_messages.append(
                                ChatCompletionUserMessageParam(
                                    role="user",
                                    content=content_parts,
                                )
                            )
                        else:
                            has_multimodal = len(content_parts) > len(text_parts)
                            if has_multimodal:
                                raise ValueError(
                                    "ImageContent/AudioContent is only supported "
                                    "in user messages for OpenAI"
                                )
                            assistant_text = "\n".join(text_parts)
                            if assistant_text:
                                openai_messages.append(
                                    ChatCompletionAssistantMessageParam(
                                        role="assistant",
                                        content=assistant_text,
                                    )
                                )
                elif tool_messages:
                    # Tool results only (assistant message was in previous message)
                    openai_messages.extend(tool_messages)
                continue

            # Handle ToolUseContent (assistant's tool calls)
            if isinstance(content, ToolUseContent):
                openai_messages.append(
                    ChatCompletionAssistantMessageParam(
                        role="assistant",
                        tool_calls=[
                            ChatCompletionMessageToolCallParam(
                                id=content.id,
                                type="function",
                                function={
                                    "name": content.name,
                                    "arguments": json.dumps(content.input),
                                },
                            )
                        ],
                    )
                )
                continue

            # Handle ToolResultContent (user's tool results)
            if isinstance(content, ToolResultContent):
                # Extract text parts from the content list
                result_texts: list[str] = []
                if content.content:
                    for item in content.content:
                        if isinstance(item, TextContent):
                            result_texts.append(item.text)
                openai_messages.append(
                    ChatCompletionToolMessageParam(
                        role="tool",
                        tool_call_id=content.toolUseId,
                        content="\n".join(result_texts),
                    )
                )
                continue

            # Handle TextContent
            if isinstance(content, TextContent):
                if message.role == "user":
                    openai_messages.append(
                        ChatCompletionUserMessageParam(
                            role="user",
                            content=content.text,
                        )
                    )
                else:
                    openai_messages.append(
                        ChatCompletionAssistantMessageParam(
                            role="assistant",
                            content=content.text,
                        )
                    )
                continue

            # Handle ImageContent
            if isinstance(content, ImageContent):
                if message.role != "user":
                    raise ValueError(
                        "ImageContent is only supported in user messages for OpenAI"
                    )
                openai_messages.append(
                    ChatCompletionUserMessageParam(
                        role="user",
                        content=[_image_content_to_openai_part(content)],
                    )
                )
                continue

            # Handle AudioContent
            if isinstance(content, AudioContent):
                if message.role != "user":
                    raise ValueError(
                        "AudioContent is only supported in user messages for OpenAI"
                    )
                openai_messages.append(
                    ChatCompletionUserMessageParam(
                        role="user",
                        content=[_audio_content_to_openai_part(content)],
                    )
                )
                continue

            raise ValueError(f"Unsupported content type: {type(content)}")

        return openai_messages

    @staticmethod
    def _chat_completion_to_create_message_result(
        chat_completion: ChatCompletion,
    ) -> CreateMessageResult:
        if len(chat_completion.choices) == 0:
            raise ValueError("No response for completion")

        first_choice = chat_completion.choices[0]

        if content := first_choice.message.content:
            return CreateMessageResult(
                content=TextContent(type="text", text=content),
                role="assistant",
                model=chat_completion.model,
            )

        raise ValueError("No content in response from completion")

    def _select_model_from_preferences(
        self, model_preferences: ModelPreferences | str | list[str] | None
    ) -> ChatModel:
        for model_option in self._iter_models_from_preferences(model_preferences):
            if model_option in get_args(ChatModel):
                chosen_model: ChatModel = model_option  # type: ignore[assignment]  # ty:ignore[invalid-assignment]
                return chosen_model

        return self.default_model

    @staticmethod
    def _convert_tools_to_openai(tools: list[Tool]) -> list[ChatCompletionToolParam]:
        """Convert MCP tools to OpenAI tool format."""
        openai_tools: list[ChatCompletionToolParam] = []
        for tool in tools:
            # Build parameters dict, ensuring required fields
            parameters: dict[str, Any] = dict(tool.inputSchema)
            if "type" not in parameters:
                parameters["type"] = "object"

            openai_tools.append(
                ChatCompletionToolParam(
                    type="function",
                    function=FunctionDefinition(
                        name=tool.name,
                        description=tool.description or "",
                        parameters=parameters,
                    ),
                )
            )
        return openai_tools

    @staticmethod
    def _convert_tool_choice_to_openai(
        tool_choice: ToolChoice,
    ) -> ChatCompletionToolChoiceOptionParam:
        """Convert MCP tool_choice to OpenAI format."""
        if tool_choice.mode == "auto":
            return "auto"
        elif tool_choice.mode == "required":
            return "required"
        elif tool_choice.mode == "none":
            return "none"
        else:
            raise ValueError(f"Unsupported tool_choice mode: {tool_choice.mode!r}")

    @staticmethod
    def _chat_completion_to_result_with_tools(
        chat_completion: ChatCompletion,
    ) -> CreateMessageResultWithTools:
        """Convert OpenAI response to CreateMessageResultWithTools."""
        if len(chat_completion.choices) == 0:
            raise ValueError("No response for completion")

        first_choice = chat_completion.choices[0]
        message = first_choice.message

        # Determine stop reason
        stop_reason: StopReason
        if first_choice.finish_reason == "tool_calls":
            stop_reason = "toolUse"
        elif first_choice.finish_reason == "stop":
            stop_reason = "endTurn"
        elif first_choice.finish_reason == "length":
            stop_reason = "maxTokens"
        else:
            stop_reason = "endTurn"

        # Build content list
        content: list[TextContent | ToolUseContent] = []

        # Add text content if present
        if message.content:
            content.append(TextContent(type="text", text=message.content))

        # Add tool calls if present
        if message.tool_calls:
            for tool_call in message.tool_calls:
                # Skip non-function tool calls
                if not hasattr(tool_call, "function"):
                    continue
                func = tool_call.function
                # Parse the arguments JSON string
                try:
                    arguments = json.loads(func.arguments)  # type: ignore[union-attr]  # ty:ignore[unresolved-attribute]
                except json.JSONDecodeError as e:
                    raise ValueError(
                        f"Invalid JSON in tool arguments for "
                        f"'{func.name}': {func.arguments}"  # type: ignore[union-attr]  # ty:ignore[unresolved-attribute]
                    ) from e

                content.append(
                    ToolUseContent(
                        type="tool_use",
                        id=tool_call.id,
                        name=func.name,  # type: ignore[union-attr]  # ty:ignore[unresolved-attribute]
                        input=arguments,
                    )
                )

        # Must have at least some content
        if not content:
            raise ValueError("No content in response from completion")

        return CreateMessageResultWithTools(
            content=content,  # type: ignore[arg-type]  # ty:ignore[invalid-argument-type]
            role="assistant",
            model=chat_completion.model,
            stopReason=stop_reason,
        )
