# Copyright (c) Microsoft. All rights reserved.

import logging
import sys
from collections.abc import AsyncIterable
from typing import TYPE_CHECKING, Any, ClassVar

if sys.version_info >= (3, 12):
    from typing import override  # pragma: no cover
else:
    from typing_extensions import override  # pragma: no cover

from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
from semantic_kernel.utils.feature_stage_decorator import experimental

if TYPE_CHECKING:
    from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgentThread

logger = logging.getLogger(__name__)


@experimental
class BedrockAgentChannel(AgentChannel, ChatHistory):
    """An AgentChannel for a BedrockAgent that is based on a ChatHistory.

    The chat history will override the session state when invoking the agent.

    This channel allows Bedrock agents to interact with other types of agents in Semantic Kernel in an AgentGroupChat.
    However, since Bedrock agents require the chat history to alternate between user and agent messages, this channel
    will preprocess the chat history to ensure that it meets the requirements of the Bedrock agent. When an invalid
    pattern is detected, the channel will insert a placeholder user or assistant message to ensure that the chat history
    alternates between user and agent messages.
    """

    thread: "BedrockAgentThread"
    MESSAGE_PLACEHOLDER: ClassVar[str] = "[SILENCE]"

    @override
    async def invoke(self, agent: "Agent", **kwargs: Any) -> AsyncIterable[tuple[bool, ChatMessageContent]]:
        """Perform a discrete incremental interaction between a single Agent and AgentChat.

        Args:
            agent: The agent to interact with.
            kwargs: Additional keyword arguments.

        Returns:
            An async iterable of ChatMessageContent with a boolean indicating if the
            message should be visible external to the agent.
        """
        from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgent

        if not isinstance(agent, BedrockAgent):
            raise AgentChatException(f"Agent is not of the expected type {type(BedrockAgent)}.")
        if not self.messages:
            # This is not supposed to happen, as the channel won't get invoked
            # before it has received messages. This is just extra safety.
            raise AgentChatException("No chat history available.")

        # Preprocess chat history
        await self._ensure_history_alternates()
        await self._ensure_last_message_is_user()

        async for response in agent.invoke(
            messages=self.messages[-1].content,
            thread=self.thread,
            sessionState=await self._parse_chat_history_to_session_state(),
        ):
            # All messages from Bedrock agents are user facing, i.e., function calls are not returned as messages
            self.messages.append(response.message)
            yield True, response.message

    @override
    async def invoke_stream(
        self,
        agent: "Agent",
        messages: list[ChatMessageContent],
        **kwargs: Any,
    ) -> AsyncIterable[ChatMessageContent]:
        """Perform a streaming interaction between a single Agent and AgentChat.

        Args:
            agent: The agent to interact with.
            messages: The history of messages in the conversation.
            kwargs: Additional keyword arguments.

        Returns:
            An async iterable of ChatMessageContent.
        """
        from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgent

        if not isinstance(agent, BedrockAgent):
            raise AgentChatException(f"Agent is not of the expected type {type(BedrockAgent)}.")
        if not self.messages:
            raise AgentChatException("No chat history available.")

        # Preprocess chat history
        await self._ensure_history_alternates()
        await self._ensure_last_message_is_user()

        full_message: list[StreamingChatMessageContent] = []
        async for response_chunk in agent.invoke_stream(
            messages=self.messages[-1].content,
            thread=self.thread,
            sessionState=await self._parse_chat_history_to_session_state(),
        ):
            yield response_chunk.message
            full_message.append(response_chunk.message)

        messages.append(
            ChatMessageContent(
                role=AuthorRole.ASSISTANT,
                content="".join([message.content for message in full_message]),
                name=agent.name,
                inner_content=full_message,
                ai_model_id=agent.agent_model.foundation_model,
            )
        )

    @override
    async def receive(
        self,
        history: list[ChatMessageContent],
    ) -> None:
        """Receive the conversation messages.

        Bedrock requires the chat history to alternate between user and agent messages.
        Thus, when receiving the history, the message sequence will be mutated by inserting
        empty agent or user messages as needed.

        Args:
            history: The history of messages in the conversation.
        """
        for incoming_message in history:
            if not self.messages or self.messages[-1].role != incoming_message.role:
                self.messages.append(incoming_message)
            else:
                self.messages.append(
                    ChatMessageContent(
                        role=AuthorRole.ASSISTANT if incoming_message.role == AuthorRole.USER else AuthorRole.USER,
                        content=self.MESSAGE_PLACEHOLDER,
                    )
                )
                self.messages.append(incoming_message)

    @override
    async def get_history(  # type: ignore
        self,
    ) -> AsyncIterable[ChatMessageContent]:
        """Retrieve the message history specific to this channel.

        Returns:
            An async iterable of ChatMessageContent.
        """
        for message in reversed(self.messages):
            yield message

    @override
    async def reset(self) -> None:
        """Reset the channel state."""
        self.messages.clear()

    # region chat history preprocessing and parsing

    async def _ensure_history_alternates(self):
        """Ensure that the chat history alternates between user and agent messages."""
        if not self.messages or len(self.messages) == 1:
            return

        current_index = 1
        while current_index < len(self.messages):
            if self.messages[current_index].role == self.messages[current_index - 1].role:
                self.messages.insert(
                    current_index,
                    ChatMessageContent(
                        role=AuthorRole.ASSISTANT
                        if self.messages[current_index].role == AuthorRole.USER
                        else AuthorRole.USER,
                        content=self.MESSAGE_PLACEHOLDER,
                    ),
                )
                current_index += 2
            else:
                current_index += 1

    async def _ensure_last_message_is_user(self):
        """Ensure that the last message in the chat history is a user message."""
        if self.messages and self.messages[-1].role == AuthorRole.ASSISTANT:
            self.messages.append(
                ChatMessageContent(
                    role=AuthorRole.USER,
                    content=self.MESSAGE_PLACEHOLDER,
                )
            )

    async def _parse_chat_history_to_session_state(self) -> dict[str, Any]:
        """Parse the chat history to a session state."""
        session_state: dict[str, Any] = {"conversationHistory": {"messages": []}}
        if len(self.messages) > 1:
            # We don't take the last message as it needs to be sent separately in another parameter
            for message in self.messages[:-1]:
                if message.role not in [AuthorRole.USER, AuthorRole.ASSISTANT]:
                    logger.debug(f"Skipping message with unsupported role: {message}")
                    continue
                session_state["conversationHistory"]["messages"].append({
                    "content": [{"text": message.content}],
                    "role": message.role.value,
                })

        return session_state

    # endregion
