import json
from typing import Any, AsyncGenerator, AsyncIterable

from mlflow.exceptions import MlflowException
from mlflow.gateway.config import RouteConfig, TogetherAIConfig
from mlflow.gateway.exceptions import AIGatewayException
from mlflow.gateway.providers.base import BaseProvider, ProviderAdapter
from mlflow.gateway.providers.utils import rename_payload_keys, send_request, send_stream_request
from mlflow.gateway.schemas import chat as chat_schema
from mlflow.gateway.schemas import completions as completions_schema
from mlflow.gateway.schemas import embeddings as embeddings_schema
from mlflow.gateway.utils import strip_sse_prefix


class TogetherAIAdapter(ProviderAdapter):
    @classmethod
    def model_to_embeddings(cls, resp, config):
        # Response example: (https://docs.together.ai/docs/embeddings-rest)
        # ```
        # {
        #  "object": "list",
        #  "data": [
        #    {
        #      "object": "embedding",
        #      "embedding": [
        #        0.44990748,
        #        -0.2521129,
        #        ...
        #        -0.43091708,
        #        0.214978
        #      ],
        #      "index": 0
        #    }
        #  ],
        #  "model": "togethercomputer/m2-bert-80M-8k-retrieval",
        #  "request_id": "840fc1b5bb2830cb-SEA"
        # }
        # ```
        return embeddings_schema.ResponsePayload(
            data=[
                embeddings_schema.EmbeddingObject(
                    embedding=item["embedding"],
                    index=item["index"],
                )
                for item in resp["data"]
            ],
            model=config.model.name,
            usage=embeddings_schema.EmbeddingsUsage(prompt_tokens=None, total_tokens=None),
        )

    @classmethod
    def model_to_completions(cls, resp, config):
        # Example response (https://docs.together.ai/reference/completions):
        # {
        #  "id": "8447f286bbdb67b3-SJC",
        #  "choices": [
        #    {
        #      "text": "Example text."
        #    }
        #  ],
        #  "usage": {
        #    "prompt_tokens": 16,
        #    "completion_tokens": 78,
        #    "total_tokens": 94
        #  },
        #  "created": 1705089226,
        #  "model": "mistralai/Mixtral-8x7B-Instruct-v0.1",
        #  "object": "text_completion"
        # }

        return completions_schema.ResponsePayload(
            id=resp["id"],
            created=resp["created"],
            model=config.model.name,
            choices=[
                completions_schema.Choice(
                    index=idx,
                    text=c["text"],
                    finish_reason=None,
                )
                for idx, c in enumerate(resp["choices"])
            ],
            usage=completions_schema.CompletionsUsage(
                prompt_tokens=resp["usage"]["prompt_tokens"],
                completion_tokens=resp["usage"]["completion_tokens"],
                total_tokens=resp["usage"]["total_tokens"],
            ),
        )

    @classmethod
    def model_to_completions_streaming(cls, resp, config):
        # Response example (after manually calling API):
        #
        # {'id': '86d8d6e06df86f61-ATH', 'object': 'completion.chunk',
        # 'created': 1711977238, 'choices': [{'index': 0, 'text': '   ',
        # 'logprobs': None, 'finish_reason': None, 'delta': {'token_id': 2287, 'content': '   '}}],
        # 'model': 'mistralai/Mixtral-8x7B-v0.1', 'usage': None}
        #
        # {'id': '86d8d6e06df86f61-ATH', 'object': 'completion.chunk',
        # 'created': 1711977238, 'choices': [{'index': 0, 'text': ' "', 'logprobs': None,
        # 'finish_reason': None, 'delta': {'token_id': 345, 'content': ' "'}}],
        # 'model': 'mistralai/Mixtral-8x7B-v0.1', 'usage': None}
        #
        # "{'id': '86d8d6e06df86f61-ATH', 'object': 'completion.chunk',
        # 'created': 1711977238, 'choices': [{'index': 0, 'text': 'name', 'logprobs': None,
        # 'finish_reason': None, 'delta': {'token_id': 861, 'content': 'name'}}],
        # 'model': 'mistralai/Mixtral-8x7B-v0.1', 'usage': None}
        #
        # LAST CHUNK
        # {'id': '86d8d6e06df86f61-ATH', 'object': 'completion.chunk',
        # 'created': 1711977238, 'choices': [{'index': 0, 'text': '":', 'logprobs': None,
        # 'finish_reason': 'length', 'delta': {'token_id': 1264, 'content': '":'}}],
        # 'model': 'mistralai/Mixtral-8x7B-v0.1',
        # 'usage': {'prompt_tokens': 17, 'completion_tokens': 200, 'total_tokens': 217}}
        # ":[DONE]

        return completions_schema.StreamResponsePayload(
            id=resp.get("id"),
            created=resp.get("created"),
            model=config.model.name,
            choices=[
                completions_schema.StreamChoice(
                    index=idx,
                    # TODO this is questionable since the finish reason comes from togetherai api
                    finish_reason=choice.get("finish_reason"),
                    text=choice.get("text"),
                )
                for idx, choice in enumerate(resp.get("choices", []))
            ],
            # usage is not included in OpenAI StreamResponsePayload
        )

    @classmethod
    def completions_to_model(cls, payload, config):
        key_mapping = {
            # TogetherAI uses logprobs
            # OpenAI uses top_logprobs
            "top_logprobs": "logprobs"
        }

        # in openAI API the logprobs parameter
        # is a boolean flag.
        # Insert this here to prevent the user from mixing up the APIs
        logprobs_in_payload_condition = "logprobs" in payload and not isinstance(
            payload["logprobs"], int
        )

        if logprobs_in_payload_condition:
            raise AIGatewayException(
                status_code=422,
                detail="Wrong type for logprobs. It should be an 32bit integer.",
            )

        openai_top_logprobs_in_payload_condition = "top_logprobs" in payload and not isinstance(
            payload["top_logprobs"], int
        )

        if openai_top_logprobs_in_payload_condition:
            raise AIGatewayException(
                status_code=422,
                detail="Wrong type for top_logprobs. It should a 32bit integer.",
            )

        payload = rename_payload_keys(payload, key_mapping)
        return {"model": config.model.name, **payload}

    @classmethod
    def completions_streaming_to_model(cls, payload, config):
        # parameters for streaming completions are the same as the standard completions
        return TogetherAIAdapter.completions_to_model(payload, config)

    @classmethod
    def model_to_chat(cls, resp, config):
        # Example response (https://docs.together.ai/reference/chat-completions):
        # {
        #   "id": "8448080b880415ea-SJC",
        #   "choices": [
        #    {
        #        "message": {
        #           "role": "assistant",
        #           "content": "example"
        #         }
        #     }
        #   ],
        #   "usage": {
        #     "prompt_tokens": 31,
        #     "completion_tokens": 455,
        #     "total_tokens": 486
        #   },
        #   "created": 1705090115,
        #   "model": "mistralai/Mixtral-8x7B-Instruct-v0.1",
        #   "object": "chat.completion"
        # }
        return chat_schema.ResponsePayload(
            id=resp["id"],
            object="chat.completion",
            created=resp["created"],
            model=config.model.name,
            choices=[
                chat_schema.Choice(
                    index=idx,
                    message=chat_schema.ResponseMessage(
                        role="assistant",
                        content=c["message"]["content"],
                    ),
                    finish_reason=None,
                )
                for idx, c in enumerate(resp["choices"])
            ],
            usage=chat_schema.ChatUsage(
                prompt_tokens=resp["usage"]["prompt_tokens"],
                completion_tokens=resp["usage"]["completion_tokens"],
                total_tokens=resp["usage"]["total_tokens"],
            ),
        )

    @classmethod
    def model_to_chat_streaming(cls, resp, config):
        # Response example (after running API manually):
        #
        # {'id': '86f2cfd18f6b38ca-ATH', 'object': 'chat.completion.chunk',
        # 'created': 1712249578, 'choices': [{'index': 0, 'text': ' The', 'logprobs': None,
        # 'finish_reason': None, 'delta': {'token_id': 415, 'content': ' The'}}],
        # 'model': 'mistralai/Mixtral-8x7B-v0.1', 'usage': None}
        #
        # {'id': '86f2cfd18f6b38ca-ATH', 'object': 'chat.completion.chunk',
        # 'created': 1712249578, 'choices': [{'index': 0, 'text': ' City', 'logprobs': None,
        # 'finish_reason': None, 'delta': {'token_id': 3805, 'content': ' City'}}],
        # 'model': 'mistralai/Mixtral-8x7B-v0.1', 'usage': None}
        #
        # {'id': '86f2cfd18f6b38ca-ATH', 'object': 'chat.completion.chunk',
        # 'created': 1712249578, 'choices': [{'index': 0, 'text': ' of', 'logprobs': None,
        # 'finish_reason': None, 'delta': {'token_id': 302, 'content': ' of'}}],
        # 'model': 'mistralai/Mixtral-8x7B-v0.1', 'usage': None}
        #
        # LAST CHUNK
        # {'id': '86f2cfd18f6b38ca-ATH', 'object': 'chat.completion.chunk',
        # 'created': 1712249578, 'choices': [{'index': 0, 'text': ' Paris', 'logprobs': None,
        # 'finish_reason': 'length', 'delta': {'token_id': 5465, 'content': ' Paris'}}],
        # 'model': 'mistralai/Mixtral-8x7B-v0.1',
        # 'usage': {'prompt_tokens': 93, 'completion_tokens': 100, 'total_tokens': 193}}

        return chat_schema.StreamResponsePayload(
            id=resp["id"],
            model=config.model.name,
            object="chat.completion.chunk",
            created=resp["created"],
            choices=[
                chat_schema.StreamChoice(
                    index=idx,
                    finish_reason=choice.get("finish_reason"),
                    delta=chat_schema.StreamDelta(
                        role=None,
                        content=choice.get("text"),
                    ),
                )
                # Added enumerate and a default empty list
                for idx, choice in enumerate(resp.get("choices", []))
            ],
            usage=resp.get("usage"),
        )

    @classmethod
    def chat_to_model(cls, payload, config):
        # completions and chat endpoint contain the same parameters
        return TogetherAIAdapter.completions_to_model(payload, config)

    @classmethod
    def chat_streaming_to_model(cls, payload, config):
        # streaming and standard chat contain the same parameters
        return TogetherAIAdapter.chat_to_model(payload, config)

    @classmethod
    def embeddings_to_model(cls, payload, config):
        # Example request (https://docs.together.ai/reference/embeddings):
        # curl --request POST \
        #   --url https://api.together.xyz/v1/embeddings \
        #   --header 'accept: application/json' \
        #   --header 'content-type: application/json' \
        #   --data '
        #   {
        #     "model": "togethercomputer/m2-bert-80M-8k-retrieval",
        #     "input": "Our solar system orbits the Milky Way galaxy at about 515,000 mph"
        #   }

        # This is just to keep the interface consistent the adapter
        # class is not needed here as the togetherai request similar
        # to the openAI one.

        return payload


class TogetherAIProvider(BaseProvider):
    NAME = "TogetherAI"
    CONFIG_TYPE = TogetherAIConfig

    def __init__(self, config: RouteConfig) -> None:
        super().__init__(config)
        if config.model.config is None or not isinstance(config.model.config, TogetherAIConfig):
            # Should be unreachable
            raise MlflowException.invalid_parameter_value(
                f"Invalid config type {config.model.config}"
            )
        self.togetherai_config: TogetherAIConfig = config.model.config

    @property
    def base_url(self):
        # togetherai seems to support only this url
        return "https://api.together.xyz/v1"

    @property
    def headers(self):
        return {"Authorization": f"Bearer {self.togetherai_config.togetherai_api_key}"}

    @property
    def adapter_class(self) -> type[ProviderAdapter]:
        return TogetherAIAdapter

    def get_endpoint_url(self, route_type: str) -> str:
        if route_type == "llm/v1/chat":
            return f"{self.base_url}/chat/completions"
        elif route_type == "llm/v1/completions":
            return f"{self.base_url}/completions"
        elif route_type == "llm/v1/embeddings":
            return f"{self.base_url}/embeddings"
        else:
            raise ValueError(f"Invalid route type {route_type}")

    async def _request(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
        return await send_request(
            headers=self.headers,
            base_url=self.base_url,
            path=path,
            payload=payload,
        )

    async def _stream_request(
        self, path: str, payload: dict[str, Any]
    ) -> AsyncGenerator[bytes, None]:
        return send_stream_request(
            headers=self.headers,
            base_url=self.base_url,
            path=path,
            payload=payload,
        )

    async def embeddings(
        self, payload: embeddings_schema.RequestPayload
    ) -> embeddings_schema.ResponsePayload:
        from fastapi.encoders import jsonable_encoder

        payload = jsonable_encoder(payload, exclude_none=True)

        resp = await self._request(
            path="embeddings",
            payload=TogetherAIAdapter.embeddings_to_model(payload, self.config),
        )

        return TogetherAIAdapter.model_to_embeddings(resp, self.config)

    async def completions_stream(
        self, payload: completions_schema.RequestPayload
    ) -> AsyncIterable[completions_schema.StreamResponsePayload]:
        from fastapi.encoders import jsonable_encoder

        payload = jsonable_encoder(payload, exclude_none=True)

        if not payload.get("max_tokens"):
            raise AIGatewayException(
                status_code=422,
                detail=(
                    "max_tokens is not present in payload."
                    "It is a required parameter for TogetherAI completions."
                ),
            )

        stream = await self._stream_request(
            path="completions",
            payload=TogetherAIAdapter.completions_streaming_to_model(payload, self.config),
        )

        async for chunk in stream:
            chunk = chunk.strip()
            if not chunk:
                continue

            chunk = strip_sse_prefix(chunk.decode("utf-8"))
            if chunk == "[DONE]":
                return

            resp = json.loads(chunk)
            yield TogetherAIAdapter.model_to_completions_streaming(resp, self.config)

    async def completions(
        self, payload: completions_schema.RequestPayload
    ) -> completions_schema.ResponsePayload:
        from fastapi.encoders import jsonable_encoder

        payload = jsonable_encoder(payload, exclude_none=True)

        if not payload.get("max_tokens"):
            raise AIGatewayException(
                status_code=422,
                detail=(
                    "max_tokens is not present in payload."
                    "It is a required parameter for TogetherAI completions."
                ),
            )

        resp = await self._request(
            path="completions", payload=TogetherAIAdapter.completions_to_model(payload, self.config)
        )

        return TogetherAIAdapter.model_to_completions(resp, self.config)

    async def chat_stream(self, payload: chat_schema.RequestPayload) -> chat_schema.ResponsePayload:
        from fastapi.encoders import jsonable_encoder

        payload = jsonable_encoder(payload, exclude_none=True)

        stream = await self._stream_request(
            path="chat/completions",
            payload=TogetherAIAdapter.chat_streaming_to_model(payload, self.config),
        )

        async for chunk in stream:
            chunk = chunk.strip()
            if not chunk:
                continue

            chunk = strip_sse_prefix(chunk.decode("utf-8"))
            if chunk == "[DONE]":
                return

            resp = json.loads(chunk)
            yield TogetherAIAdapter.model_to_chat_streaming(resp, self.config)

    async def chat(self, payload: chat_schema.RequestPayload) -> chat_schema.ResponsePayload:
        from fastapi.encoders import jsonable_encoder

        payload = jsonable_encoder(payload, exclude_none=True)

        resp = await self._request(
            path="chat/completions",
            payload=TogetherAIAdapter.chat_to_model(payload, self.config),
        )

        return TogetherAIAdapter.model_to_chat(resp, self.config)
