"""
OCI Generative AI — Generic-format chat transformation helpers.

Handles message building, tool definition adaptation, non-streaming response
parsing, and streaming chunk parsing for models served with
``apiFormat="GENERIC"`` (e.g. Meta Llama, xAI Grok, Google Gemini).
"""

import datetime
import hashlib
from typing import Any, Dict, List, Optional, Union

import httpx
from pydantic import ValidationError

from litellm.llms.oci.common_utils import (
    OCIError,
    resolve_oci_schema_anyof,
    resolve_oci_schema_refs,
    sanitize_oci_schema,
)
from litellm.types.llms.oci import (
    OCICompletionResponse,
    OCIContentPartUnion,
    OCIImageContentPart,
    OCIImageUrl,
    OCIMessage,
    OCIRoles,
    OCIStreamChunk,
    OCITextContentPart,
    OCIToolCall,
    OCIToolDefinition,
    OCIVendors,
)
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import (
    Delta,
    ModelResponse,
    ModelResponseStream,
    StreamingChoices,
)
from litellm.types.utils import ChatCompletionMessageToolCall, Usage

# Maps OpenAI role names to OCI GENERIC role names.
open_ai_to_generic_oci_role_map: Dict[str, OCIRoles] = {
    "system": "SYSTEM",
    "user": "USER",
    "assistant": "ASSISTANT",
    "tool": "TOOL",
}


# ---------------------------------------------------------------------------
# Message building
# ---------------------------------------------------------------------------


def adapt_messages_to_generic_oci_standard_content_message(
    role: str, content: Union[str, list]
) -> OCIMessage:
    """Convert a plain-text or multipart content message to OCI format."""
    new_content: List[OCIContentPartUnion] = []
    if isinstance(content, str):
        return OCIMessage(
            role=open_ai_to_generic_oci_role_map[role],
            content=[OCITextContentPart(text=content)],
            toolCalls=None,
            toolCallId=None,
        )

    for content_item in content:
        if not isinstance(content_item, dict):
            raise OCIError(
                status_code=400, message="Each content item must be a dictionary"
            )

        item_type = content_item.get("type")
        if not isinstance(item_type, str):
            raise OCIError(
                status_code=400,
                message="Each content item must have a string `type` field",
            )
        if item_type not in ["text", "image_url"]:
            raise OCIError(
                status_code=400,
                message=f"Content type `{item_type}` is not supported by OCI",
            )

        if item_type == "text":
            text = content_item.get("text")
            if not isinstance(text, str):
                raise OCIError(
                    status_code=400,
                    message="Content item of type `text` must have a string `text` field",
                )
            new_content.append(OCITextContentPart(text=text))

        elif item_type == "image_url":
            image_url = content_item.get("image_url")
            if isinstance(image_url, dict):
                image_url = image_url.get("url")
            if not isinstance(image_url, str):
                raise OCIError(
                    status_code=400,
                    message="Prop `image_url` must be a string or an object with a `url` property",
                )
            new_content.append(OCIImageContentPart(imageUrl=OCIImageUrl(url=image_url)))

    return OCIMessage(
        role=open_ai_to_generic_oci_role_map[role],
        content=new_content,
        toolCalls=None,
        toolCallId=None,
    )


def adapt_messages_to_generic_oci_standard_tool_call(
    role: str, tool_calls: list
) -> OCIMessage:
    """Convert an assistant tool-call message to OCI format."""
    tool_calls_formatted = []
    for tool_call in tool_calls:
        if not isinstance(tool_call, dict):
            raise OCIError(
                status_code=400, message="Each tool call must be a dictionary"
            )
        if tool_call.get("type") != "function":
            raise OCIError(
                status_code=400, message="OCI only supports function tool calls"
            )

        tool_call_id = tool_call.get("id")
        if not isinstance(tool_call_id, str):
            raise OCIError(status_code=400, message="Tool call `id` must be a string")

        tool_function = tool_call.get("function")
        if not isinstance(tool_function, dict):
            raise OCIError(
                status_code=400, message="Tool call `function` must be a dictionary"
            )

        function_name = tool_function.get("name")
        if not isinstance(function_name, str):
            raise OCIError(
                status_code=400, message="Tool call `function.name` must be a string"
            )

        arguments = tool_call["function"].get("arguments", "{}")
        if not isinstance(arguments, str):
            raise OCIError(
                status_code=400,
                message="Tool call `function.arguments` must be a JSON string",
            )

        tool_calls_formatted.append(
            OCIToolCall(
                id=tool_call_id,
                type="FUNCTION",
                name=function_name,
                arguments=arguments,
            )
        )

    return OCIMessage(
        role=open_ai_to_generic_oci_role_map[role],
        content=None,
        toolCalls=tool_calls_formatted,
        toolCallId=None,
    )


def adapt_messages_to_generic_oci_standard_tool_response(
    role: str, tool_call_id: str, content: str
) -> OCIMessage:
    """Convert a tool-result message to OCI format."""
    return OCIMessage(
        role=open_ai_to_generic_oci_role_map[role],
        content=[OCITextContentPart(text=content)],
        toolCalls=None,
        toolCallId=tool_call_id,
    )


def adapt_messages_to_generic_oci_standard(
    messages: List[AllMessageValues],
) -> List[OCIMessage]:
    """Convert an OpenAI-format message array to OCI GENERIC format."""
    new_messages = []
    for message in messages:
        role = message["role"]
        content = message.get("content")
        tool_calls = message.get("tool_calls")
        tool_call_id = message.get("tool_call_id")

        if role == "assistant" and tool_calls is not None:
            if not isinstance(tool_calls, list):
                raise OCIError(
                    status_code=400, message="Message `tool_calls` must be a list"
                )
            new_messages.append(
                adapt_messages_to_generic_oci_standard_tool_call(role, tool_calls)
            )

        elif role in ["system", "user", "assistant"] and content is not None:
            if not isinstance(content, (str, list)):
                raise OCIError(
                    status_code=400,
                    message="Message `content` must be a string or list of content parts",
                )
            new_messages.append(
                adapt_messages_to_generic_oci_standard_content_message(role, content)
            )

        elif role == "tool":
            if not isinstance(tool_call_id, str):
                raise OCIError(
                    status_code=400,
                    message="Tool result message must have a string `tool_call_id`",
                )
            if not isinstance(content, str):
                raise OCIError(
                    status_code=400,
                    message="Tool result message `content` must be a string",
                )
            new_messages.append(
                adapt_messages_to_generic_oci_standard_tool_response(
                    role, tool_call_id, content
                )
            )

    return new_messages


# ---------------------------------------------------------------------------
# Tool definition adaptation
# ---------------------------------------------------------------------------


def adapt_tool_definition_to_oci_standard(
    tools: List[Dict], vendor: OCIVendors
) -> List[OCIToolDefinition]:
    """Convert OpenAI-format tool definitions to OCI GENERIC format.

    Resolves ``$ref``/``$defs`` and ``anyOf`` that the OCI endpoint rejects.
    """
    new_tools = []
    for tool in tools:
        if tool["type"] != "function":
            raise OCIError(status_code=400, message="OCI only supports function tools")

        tool_function = tool.get("function")
        if not isinstance(tool_function, dict):
            raise OCIError(
                status_code=400, message="Tool `function` must be a dictionary"
            )

        raw_params = tool_function.get("parameters", {})
        resolved_params = sanitize_oci_schema(
            resolve_oci_schema_anyof(resolve_oci_schema_refs(raw_params))
        )

        new_tools.append(
            OCIToolDefinition(
                type="FUNCTION",
                name=tool_function.get("name"),
                description=tool_function.get("description", ""),
                parameters=resolved_params,
            )
        )

    return new_tools


def _normalize_oci_finish_reason(raw: Optional[str]) -> Optional[str]:
    """Map an OCI-specific finish reason to its OpenAI-standard equivalent.

    OCI emits ``COMPLETE`` / ``MAX_TOKENS`` / ``TOOL_CALL(S)`` plus a long tail
    of error/cancel reasons (``ERROR``, ``ERROR_TOXIC``, ``ERROR_LIMIT``,
    ``USER_CANCEL``, ``CONTENT_FILTERED``, ``CANCELLED``, ...). The OpenAI
    spec only defines ``stop`` / ``length`` / ``tool_calls`` / ... — anything
    else is collapsed to ``"stop"`` so downstream consumers switching on
    ``finish_reason`` keep working. A ``None`` input passes through unchanged.
    """
    if raw is None:
        return None
    if raw == "COMPLETE":
        return "stop"
    if raw == "MAX_TOKENS":
        return "length"
    if raw in ("TOOL_CALL", "TOOL_CALLS"):
        return "tool_calls"
    return "stop"


def _synthesize_oci_tool_call_id(position: int, name: str, arguments: str) -> str:
    """Deterministic synthetic tool-call id derived from chunk content.

    Used as a fallback when OCI omits ``id`` (always the case for the OCI
    Cohere protocol, occasionally the case for OCI GENERIC streaming chunks).
    A random ``uuid4`` per chunk would cause downstream stream-merging
    consumers — which key off the tool-call ``id`` — to treat re-emissions of
    the same logical call (e.g. terminal consolidation chunks, retries) as
    distinct calls. A content-derived digest stays stable across identical
    re-emissions while differing across truly distinct calls.
    """
    digest = hashlib.sha256(
        f"{position}|{name}|{arguments}".encode("utf-8"),
        usedforsecurity=False,
    ).hexdigest()[:24]
    return f"call_{digest}"


def adapt_tools_to_openai_standard(
    tools: List[OCIToolCall],
) -> List[ChatCompletionMessageToolCall]:
    """Convert OCI tool-call objects in a response to the OpenAI format."""
    return [
        ChatCompletionMessageToolCall(
            id=tool.id or _synthesize_oci_tool_call_id(i, tool.name, tool.arguments),
            type="function",
            function={"name": tool.name, "arguments": tool.arguments},
        )
        for i, tool in enumerate(tools)
    ]


# ---------------------------------------------------------------------------
# Response parsing
# ---------------------------------------------------------------------------


def handle_generic_response(
    json_data: dict,
    model: str,
    model_response: ModelResponse,
    raw_response: httpx.Response,
) -> ModelResponse:
    """Parse a non-streaming GENERIC OCI response into a LiteLLM ModelResponse."""
    try:
        completion_response = OCICompletionResponse(**json_data)
    except (TypeError, ValidationError) as e:
        raise OCIError(
            message=f"Response cannot be casted to OCICompletionResponse: {str(e)}",
            status_code=raw_response.status_code,
        )

    iso_str = completion_response.chatResponse.timeCreated
    dt = datetime.datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
    model_response.created = int(dt.timestamp())
    model_response.model = completion_response.modelId

    if not completion_response.chatResponse.choices:
        raise OCIError(
            message="OCI response contained no choices",
            status_code=raw_response.status_code,
        )

    response_choice = completion_response.chatResponse.choices[0]
    message = model_response.choices[0].message  # type: ignore
    response_message = response_choice.message
    if response_message is not None:
        if response_message.content:
            # Concatenate all text parts — matches the streaming handler, which
            # iterates the full content array. Skips non-text parts (e.g. image
            # parts) so a leading non-text part doesn't suppress trailing text.
            text: Optional[str] = None
            for item in response_message.content:
                if isinstance(item, OCITextContentPart):
                    text = (text or "") + item.text
            if text is not None:
                message.content = text
        if response_message.toolCalls:
            message.tool_calls = adapt_tools_to_openai_standard(
                response_message.toolCalls
            )

    model_response.choices[0].finish_reason = _normalize_oci_finish_reason(  # type: ignore[union-attr,assignment]
        response_choice.finishReason
    )

    oci_usage = completion_response.chatResponse.usage
    reasoning_tokens: Optional[int] = None
    if (
        oci_usage.completionTokensDetails
        and oci_usage.completionTokensDetails.reasoningTokens is not None
    ):
        reasoning_tokens = oci_usage.completionTokensDetails.reasoningTokens
    model_response.usage = Usage(  # type: ignore[attr-defined]
        prompt_tokens=oci_usage.promptTokens,
        completion_tokens=oci_usage.completionTokens or 0,
        total_tokens=oci_usage.totalTokens,
        reasoning_tokens=reasoning_tokens,
    )

    return model_response


def handle_generic_stream_chunk(dict_chunk: dict) -> ModelResponseStream:
    """Parse a single GENERIC SSE chunk into a LiteLLM ModelResponseStream."""
    # OCI streams tool calls progressively — early chunks may omit required fields.
    if dict_chunk.get("message") and dict_chunk["message"].get("toolCalls"):
        for tool_call in dict_chunk["message"]["toolCalls"]:
            tool_call.setdefault("arguments", "")
            tool_call.setdefault("id", "")
            tool_call.setdefault("name", "")

    try:
        typed_chunk = OCIStreamChunk(**dict_chunk)
    except (TypeError, ValidationError) as e:
        raise OCIError(
            status_code=500,
            message=f"Chunk cannot be parsed as OCIStreamChunk: {str(e)}",
        )

    if typed_chunk.index is None:
        typed_chunk.index = 0

    # Emit ``content=None`` rather than ``content=""`` on chunks with no text
    # parts (e.g. tool-call-only or keep-alive chunks) so downstream
    # stream-mergers that distinguish "no text in this delta" from "an
    # explicitly empty text delta" behave correctly.
    text: Optional[str] = None
    if typed_chunk.message and typed_chunk.message.content:
        for item in typed_chunk.message.content:
            if isinstance(item, OCITextContentPart):
                text = (text or "") + item.text
            elif isinstance(item, OCIImageContentPart):
                raise OCIError(
                    status_code=500,
                    message="OCI returned image content in a streaming response — not supported",
                )
            else:
                raise OCIError(
                    status_code=500,
                    message=f"Unsupported content type in OCI streaming response: {item.type}",
                )

    # Build plain tool-call dicts inline (matching the shape produced by
    # ``handle_cohere_stream_chunk``) rather than calling
    # ``adapt_tools_to_openai_standard`` and ``model_dump``-ing the typed
    # objects. Both code paths feed ``Delta.tool_calls``, so emitting the
    # same minimal ``{"id", "type", "function": {"name", "arguments"}}``
    # shape keeps downstream stream-mergers behaving identically across
    # GENERIC and Cohere chunks.
    tool_calls: Optional[List[Dict[str, Any]]] = None
    if typed_chunk.message and typed_chunk.message.toolCalls:
        tool_calls = [
            {
                "id": tc.id or _synthesize_oci_tool_call_id(i, tc.name, tc.arguments),
                "type": "function",
                "function": {
                    "name": tc.name,
                    "arguments": tc.arguments,
                },
            }
            for i, tc in enumerate(typed_chunk.message.toolCalls)
        ]

    finish_reason: Optional[str] = _normalize_oci_finish_reason(
        typed_chunk.finishReason
    )

    return ModelResponseStream(
        choices=[
            StreamingChoices(
                index=typed_chunk.index,
                delta=Delta(
                    content=text,
                    tool_calls=tool_calls,
                    provider_specific_fields=None,
                    thinking_blocks=None,
                    reasoning_content=None,
                ),
                finish_reason=finish_reason,
            )
        ]
    )
