# Copyright (c) Microsoft. All rights reserved.

import asyncio
import logging
from functools import partial
from typing import Any, ClassVar

import boto3
from botocore.exceptions import ClientError
from pydantic import Field, field_validator

from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.bedrock.action_group_utils import kernel_function_to_bedrock_function_schema
from semantic_kernel.agents.bedrock.models.bedrock_action_group_model import BedrockActionGroupModel
from semantic_kernel.agents.bedrock.models.bedrock_agent_model import BedrockAgentModel
from semantic_kernel.agents.bedrock.models.bedrock_agent_status import BedrockAgentStatus
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior, FunctionChoiceType
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.utils.async_utils import run_in_executor
from semantic_kernel.utils.feature_stage_decorator import experimental

logger = logging.getLogger(__name__)


@experimental
class BedrockAgentBase(Agent):
    """Bedrock Agent Base Class to provide common functionalities for Bedrock Agents."""

    # There is a default alias created by Bedrock for the working draft version of the agent.
    # https://docs.aws.amazon.com/bedrock/latest/userguide/agents-deploy.html
    WORKING_DRAFT_AGENT_ALIAS: ClassVar[str] = "TSTALIASID"

    # Amazon Bedrock Clients
    # Runtime Client: Use for inference
    bedrock_runtime_client: Any
    # Client: Use for model management
    bedrock_client: Any
    # Function Choice Behavior: this is primarily used to control the behavior of the kernel when
    # the agent requests functions, and to configure the kernel function action group (i.e. via filters).
    # When this is None, users won't be able to create a kernel function action groups.
    function_choice_behavior: FunctionChoiceBehavior = Field(default=FunctionChoiceBehavior.Auto())
    # Agent Model: stores the agent information
    agent_model: BedrockAgentModel

    def __init__(
        self,
        agent_model: BedrockAgentModel | dict[str, Any],
        *,
        function_choice_behavior: FunctionChoiceBehavior | None = None,
        bedrock_runtime_client: Any | None = None,
        bedrock_client: Any | None = None,
        **kwargs,
    ) -> None:
        """Initialize the Bedrock Agent Base.

        Args:
            agent_model: The Bedrock Agent Model.
            function_choice_behavior: The function choice behavior.
            bedrock_client: The Bedrock Client.
            bedrock_runtime_client: The Bedrock Runtime Client.
            kwargs: Additional keyword arguments.
        """
        agent_model = (
            agent_model if isinstance(agent_model, BedrockAgentModel) else BedrockAgentModel.model_validate(agent_model)
        )

        args = {
            "agent_model": agent_model,
            "id": agent_model.agent_id,
            "name": agent_model.agent_name,
            "bedrock_runtime_client": bedrock_runtime_client or boto3.client("bedrock-agent-runtime"),
            "bedrock_client": bedrock_client or boto3.client("bedrock-agent"),
            **kwargs,
        }
        if function_choice_behavior:
            args["function_choice_behavior"] = function_choice_behavior

        super().__init__(**args)

    @field_validator("function_choice_behavior", mode="after")
    @classmethod
    def validate_function_choice_behavior(
        cls, function_choice_behavior: FunctionChoiceBehavior | None
    ) -> FunctionChoiceBehavior | None:
        """Validate the function choice behavior."""
        if function_choice_behavior and function_choice_behavior.type_ != FunctionChoiceType.AUTO:
            # Users cannot specify REQUIRED or NONE for the Bedrock agents.
            # Please note that the function choice behavior only control if the kernel will automatically
            # execute the functions the agent requests. It does not control the behavior of the agent.
            raise ValueError("Only FunctionChoiceType.AUTO is supported.")
        return function_choice_behavior

    def __repr__(self):
        """Return the string representation of the Bedrock Agent."""
        return f"{self.agent_model}"

    # region Agent Management

    async def prepare_agent_and_wait_until_prepared(self) -> None:
        """Prepare the agent for use."""
        if not self.agent_model.agent_id:
            raise ValueError("Agent does not exist. Please create the agent before preparing it.")

        try:
            await run_in_executor(
                None,
                partial(
                    self.bedrock_client.prepare_agent,
                    agentId=self.agent_model.agent_id,
                ),
            )

            # The agent will take some time to enter the PREPARING status after the prepare operation is called.
            # We need to wait for the agent to reach the PREPARING status before we can proceed, otherwise we
            # will return immediately if the agent is already in PREPARED status.
            await self._wait_for_agent_status(BedrockAgentStatus.PREPARING)
            # The agent will enter the PREPARED status when the preparation is complete.
            await self._wait_for_agent_status(BedrockAgentStatus.PREPARED)
        except ClientError as e:
            logger.error(f"Failed to prepare agent {self.agent_model.agent_id}.")
            raise e

    async def delete_agent(self, **kwargs) -> None:
        """Delete an agent asynchronously."""
        if not self.agent_model.agent_id:
            raise ValueError("Agent does not exist. Please create the agent before deleting it.")

        try:
            await run_in_executor(
                None,
                partial(
                    self.bedrock_client.delete_agent,
                    agentId=self.agent_model.agent_id,
                    **kwargs,
                ),
            )

            self.agent_model.agent_id = None
        except ClientError as e:
            logger.error(f"Failed to delete agent {self.agent_model.agent_id}.")
            raise e

    async def _get_agent(self) -> None:
        """Get an agent."""
        if not self.agent_model.agent_id:
            raise ValueError("Agent does not exist. Please create the agent before getting it.")

        try:
            response = await run_in_executor(
                None,
                partial(
                    self.bedrock_client.get_agent,
                    agentId=self.agent_model.agent_id,
                ),
            )

            # Update the agent model
            self.agent_model = BedrockAgentModel(**response["agent"])
        except ClientError as e:
            logger.error(f"Failed to get agent {self.agent_model.agent_id}.")
            raise e

    async def _wait_for_agent_status(
        self,
        status: BedrockAgentStatus,
        interval: int = 2,
        max_attempts: int = 5,
    ) -> None:
        """Wait for the agent to reach a specific status."""
        for _ in range(max_attempts):
            await self._get_agent()
            if self.agent_model.agent_status == status:
                return

            await asyncio.sleep(interval)

        raise TimeoutError(
            f"Agent did not reach status {status} within the specified time."
            f" Current status: {self.agent_model.agent_status}"
        )

    # endregion Agent Management

    # region Action Group Management
    async def create_code_interpreter_action_group(self, **kwargs) -> BedrockActionGroupModel:
        """Create a code interpreter action group."""
        if not self.agent_model.agent_id:
            raise ValueError("Agent does not exist. Please create the agent before creating an action group for it.")

        try:
            response = await run_in_executor(
                None,
                partial(
                    self.bedrock_client.create_agent_action_group,
                    agentId=self.agent_model.agent_id,
                    agentVersion=self.agent_model.agent_version or "DRAFT",
                    actionGroupName=f"{self.agent_model.agent_name}_code_interpreter",
                    actionGroupState="ENABLED",
                    parentActionGroupSignature="AMAZON.CodeInterpreter",
                    **kwargs,
                ),
            )

            await self.prepare_agent_and_wait_until_prepared()

            return BedrockActionGroupModel(**response["agentActionGroup"])
        except ClientError as e:
            logger.error(f"Failed to create code interpreter action group for agent {self.agent_model.agent_id}.")
            raise e

    async def create_user_input_action_group(self, **kwargs) -> BedrockActionGroupModel:
        """Create a user input action group."""
        if not self.agent_model.agent_id:
            raise ValueError("Agent does not exist. Please create the agent before creating an action group for it.")

        try:
            response = await run_in_executor(
                None,
                partial(
                    self.bedrock_client.create_agent_action_group,
                    agentId=self.agent_model.agent_id,
                    agentVersion=self.agent_model.agent_version or "DRAFT",
                    actionGroupName=f"{self.agent_model.agent_name}_user_input",
                    actionGroupState="ENABLED",
                    parentActionGroupSignature="AMAZON.UserInput",
                    **kwargs,
                ),
            )

            await self.prepare_agent_and_wait_until_prepared()

            return BedrockActionGroupModel(**response["agentActionGroup"])
        except ClientError as e:
            logger.error(f"Failed to create user input action group for agent {self.agent_model.agent_id}.")
            raise e

    async def create_kernel_function_action_group(self, **kwargs) -> BedrockActionGroupModel | None:
        """Create a kernel function action group."""
        if not self.agent_model.agent_id:
            raise ValueError("Agent does not exist. Please create the agent before creating an action group for it.")

        function_call_choice_config = self.function_choice_behavior.get_config(self.kernel)
        if not function_call_choice_config.available_functions:
            logger.warning("No available functions. Skipping kernel function action group creation.")
            return None

        try:
            response = await run_in_executor(
                None,
                partial(
                    self.bedrock_client.create_agent_action_group,
                    agentId=self.agent_model.agent_id,
                    agentVersion=self.agent_model.agent_version or "DRAFT",
                    actionGroupName=f"{self.agent_model.agent_name}_kernel_function",
                    actionGroupState="ENABLED",
                    actionGroupExecutor={"customControl": "RETURN_CONTROL"},
                    functionSchema=kernel_function_to_bedrock_function_schema(function_call_choice_config),
                    **kwargs,
                ),
            )

            await self.prepare_agent_and_wait_until_prepared()

            return BedrockActionGroupModel(**response["agentActionGroup"])
        except ClientError as e:
            logger.error(f"Failed to create kernel function action group for agent {self.agent_model.agent_id}.")
            raise e

    # endregion Action Group Management

    # region Knowledge Base Management

    async def associate_agent_knowledge_base(self, knowledge_base_id: str, **kwargs) -> dict[str, Any]:
        """Associate an agent with a knowledge base."""
        if not self.agent_model.agent_id:
            raise ValueError(
                "Agent does not exist. Please create the agent before associating it with a knowledge base."
            )

        try:
            response = await run_in_executor(
                None,
                partial(
                    self.bedrock_client.associate_agent_knowledge_base,
                    agentId=self.agent_model.agent_id,
                    agentVersion=self.agent_model.agent_version,
                    knowledgeBaseId=knowledge_base_id,
                    **kwargs,
                ),
            )

            await self.prepare_agent_and_wait_until_prepared()

            return response
        except ClientError as e:
            logger.error(
                f"Failed to associate agent {self.agent_model.agent_id} with knowledge base {knowledge_base_id}."
            )
            raise e

    async def disassociate_agent_knowledge_base(self, knowledge_base_id: str, **kwargs) -> None:
        """Disassociate an agent with a knowledge base."""
        if not self.agent_model.agent_id:
            raise ValueError(
                "Agent does not exist. Please create the agent before disassociating it with a knowledge base."
            )

        try:
            response = await run_in_executor(
                None,
                partial(
                    self.bedrock_client.disassociate_agent_knowledge_base,
                    agentId=self.agent_model.agent_id,
                    agentVersion=self.agent_model.agent_version,
                    knowledgeBaseId=knowledge_base_id,
                    **kwargs,
                ),
            )

            await self.prepare_agent_and_wait_until_prepared()

            return response
        except ClientError as e:
            logger.error(
                f"Failed to disassociate agent {self.agent_model.agent_id} with knowledge base {knowledge_base_id}."
            )
            raise e

    async def list_associated_agent_knowledge_bases(self, **kwargs) -> dict[str, Any]:
        """List associated knowledge bases with an agent."""
        if not self.agent_model.agent_id:
            raise ValueError("Agent does not exist. Please create the agent before listing associated knowledge bases.")

        try:
            return await run_in_executor(
                None,
                partial(
                    self.bedrock_client.list_agent_knowledge_bases,
                    agentId=self.agent_model.agent_id,
                    agentVersion=self.agent_model.agent_version,
                    **kwargs,
                ),
            )
        except ClientError as e:
            logger.error(f"Failed to list associated knowledge bases for agent {self.agent_model.agent_id}.")
            raise e

    # endregion Knowledge Base Management

    async def _invoke_agent(
        self,
        thread_id: str,
        message: str | ChatMessageContent,
        agent_alias: str | None = None,
        **kwargs,
    ) -> dict[str, Any]:
        """Invoke an agent."""
        if not self.agent_model.agent_id:
            raise ValueError("Agent does not exist. Please create the agent before invoking it.")

        if isinstance(message, ChatMessageContent) and message.role != AuthorRole.USER:
            raise ValueError("Only user messages are supported for invoking a Bedrock agent.")

        agent_alias = agent_alias or self.WORKING_DRAFT_AGENT_ALIAS

        try:
            return await run_in_executor(
                None,
                partial(
                    self.bedrock_runtime_client.invoke_agent,
                    agentAliasId=agent_alias,
                    agentId=self.agent_model.agent_id,
                    sessionId=thread_id,
                    inputText=message if isinstance(message, str) else message.content,
                    **kwargs,
                ),
            )
        except ClientError as e:
            logger.error(f"Failed to invoke agent {self.agent_model.agent_id}.")
            raise e
