#
# Copyright 2026 The Dapr Authors
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#     http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

from __future__ import annotations

import inspect
import logging
from contextlib import AsyncExitStack
from typing import TYPE_CHECKING, Callable, Type, Optional, Any, Dict
from inspect import signature, Parameter
from pydantic import (
    BaseModel,
    Field,
    ValidationError,
    model_validator,
    PrivateAttr,
    create_model,
)
from mcp.types import CallToolResult, TextContent
from dapr_agents.tool.utils.tool import ToolHelper
from dapr_agents.tool.utils.function_calling import (
    sanitize_openai_tool_name,
    to_function_call_definition,
)
from dapr_agents.types import ToolError

if TYPE_CHECKING:
    from mcp.types import Tool as MCPTool
    from mcp import ClientSession
    from toolbox_core.sync_tool import ToolboxSyncTool

logger = logging.getLogger(__name__)


class AgentTool(BaseModel):
    """
    Base class for agent tools, supporting both synchronous and asynchronous execution.
    This class can be used by the Agent and DurableAgent types to define tools that can be executed

    Attributes:
        name (str): The tool's name.
        description (str): A brief description of the tool's purpose.
        args_model (Optional[Type[BaseModel]]): Model for validating tool arguments.
        func (Optional[Callable]): Function defining tool behavior.
    """

    name: str = Field(
        ...,
        description="The name of the tool, formatted with capitalization and no spaces.",
    )
    description: str = Field(
        ..., description="A brief description of the tool's functionality."
    )
    args_model: Optional[Type[BaseModel]] = Field(
        None, description="Pydantic model for validating tool arguments."
    )
    func: Optional[Callable] = Field(
        None, description="Optional function implementing the tool's behavior."
    )

    _is_async: bool = PrivateAttr(default=False)

    @model_validator(mode="before")
    @classmethod
    def set_name_and_description(cls, values: dict) -> dict:
        """
        Validator to dynamically set `name` and `description` before validation.
        """
        func = values.get("func")
        if func:
            values.setdefault("name", func.__name__)
            values.setdefault("description", func.__doc__ or "")
        return values

    @classmethod
    def from_func(cls, func: Callable) -> "AgentTool":
        """
        Creates an instance of `AgentTool` from a raw Python function.

        Args:
            func (Callable): The function to wrap in the tool.

        Returns:
            AgentTool: An instance of `AgentTool`.
        """
        ToolHelper.check_docstring(func)
        return cls(func=func)

    @classmethod
    def from_mcp(
        cls,
        mcp_tool: MCPTool,
        session: Optional[ClientSession] = None,
        connection: Any = None,
    ) -> AgentTool:
        """
        Create an AgentTool from an MCPTool and a session or connection.

        Args:
            mcp_tool: The MCPTool object to wrap.
            session: An active MCP ClientSession (preferred).
            connection: Optional connection config (if no session provided).

        Returns:
            AgentTool: A ready-to-use AgentTool instance.
        """
        if session is None and connection is None:
            raise ValueError("Either a session or a connection config must be provided")

        tool_name = mcp_tool.name
        tool_docs = mcp_tool.description or ""

        async def executor(**kwargs: Any) -> Any:
            try:
                logger.debug(f"Calling MCP tool '{tool_name}' with args: {kwargs}")
                # Unwrap MCP kwargs wrapper so server receives flat arguments
                tool_args = kwargs
                if (
                    len(kwargs) == 1
                    and "kwargs" in kwargs
                    and isinstance(kwargs.get("kwargs"), dict)
                ):
                    tool_args = kwargs["kwargs"]
                if session is not None:
                    result = await session.call_tool(tool_name, tool_args)
                else:
                    logger.debug(f"Starting transport session for tool '{tool_name}'")
                    from dapr_agents.tool.mcp.transport import start_transport_session

                    async with AsyncExitStack() as stack:
                        tool_session = await start_transport_session(
                            connection["transport"], connection["params"], stack
                        )
                        await tool_session.initialize()
                        result = await tool_session.call_tool(tool_name, tool_args)
                tool_result = CallToolResult(
                    isError=False,
                    content=[TextContent(type="text", text=str(result))],
                    structuredContent={},
                )
                return tool_result
            except (ValidationError, ToolError, Exception) as e:
                err_type = type(e).__name__
                logger.error(f"{err_type} running tool: {str(e)}")
                return CallToolResult(
                    isError=True,
                    content=[
                        TextContent(
                            type="text",
                            text=f"{err_type} during Tool Call. Arguments sent to Tool: {str(kwargs)}.\nError: {str(e)}",
                        )
                    ],
                )

        # Optionally generate args model from input schema
        tool_args_model = None
        if getattr(mcp_tool, "inputSchema", None):
            try:
                from dapr_agents.tool.mcp.schema import (
                    create_pydantic_model_from_schema,
                )

                tool_args_model = create_pydantic_model_from_schema(
                    mcp_tool.inputSchema, f"{tool_name}Args"
                )
            except Exception as e:
                logger.warning(f"Failed to create schema for tool '{tool_name}': {e}")
                pass

        return cls(
            name=tool_name,
            description=tool_docs,
            func=executor,
            args_model=tool_args_model,
        )

    @classmethod
    def from_mcp_many(
        cls,
        mcp_tools: list[MCPTool],
        session: Optional[ClientSession] = None,
        connection: Any = None,
    ) -> list[AgentTool]:
        """
        Batch-create AgentTool objects from a list of MCPTool objects.

        Args:
            mcp_tools (List[MCPTool]): List of MCP tool objects to convert.
            session: An active MCP ClientSession (preferred).
            connection: Optional connection config (if no session provided).

        Returns:
            List[AgentTool]: List of ready-to-use AgentTool objects.
        """
        return [
            cls.from_mcp(
                tool,
                session=session,
                connection=connection,
            )
            for tool in mcp_tools
        ]

    @classmethod
    async def from_mcp_session(cls, session: ClientSession) -> list[AgentTool]:
        """
        Fetch all tools and wrap them as AgentTool objects.

        Args:
            session: An active MCP ClientSession.

        Returns:
            List[AgentTool]: List of ready-to-use AgentTool objects.
        """
        mcp_tools_response = await session.list_tools()
        return cls.from_mcp_many(
            mcp_tools_response.tools,
            session=session,
        )

    @classmethod
    def from_toolbox(
        cls,
        toolbox_tool: ToolboxSyncTool,
    ) -> AgentTool:
        """
        Create an AgentTool from a ToolboxSyncTool.

        Args:
            toolbox_tool: A ToolboxSyncTool instance from toolbox-core.

        Returns:
            AgentTool: A ready-to-use AgentTool instance.
        """
        tool_name = toolbox_tool._name
        tool_description = toolbox_tool._description

        def executor(**kwargs: Any) -> Any:
            try:
                logger.debug(f"Calling Toolbox tool '{tool_name}' with args: {kwargs}")
                result = toolbox_tool(**kwargs)
                tool_result = CallToolResult(
                    isError=False,
                    content=[TextContent(type="text", text=str(result))],
                    structuredContent={},
                )
                return tool_result
            except (ValidationError, ToolError, Exception) as e:
                err_type = type(e).__name__
                logger.error(f"{err_type} running tool: {str(e)}")
                return CallToolResult(
                    isError=True,
                    content=[
                        TextContent(
                            type="text",
                            text=f"{err_type} during Tool Call. Arguments sent to Tool: {str(kwargs)}.\nError: {str(e)}",
                        )
                    ],
                )

        tool_args_model = None
        try:
            params = toolbox_tool._params
            if params and len(params) > 0:
                field_definitions = {}
                for param in params:
                    annotation = (
                        param.annotation if hasattr(param, "annotation") else Any
                    )
                    if param.required:
                        field_definitions[param.name] = (annotation, ...)
                    else:
                        field_definitions[param.name] = (Optional[annotation], None)
                if field_definitions:
                    tool_args_model = create_model(
                        f"{tool_name}Args", **field_definitions
                    )
            else:
                # Create empty model to avoid infering kwargs onto model
                tool_args_model = create_model(f"{tool_name}Args")
        except Exception as e:
            logger.warning(
                f"Failed to create args model for Toolbox tool '{tool_name}': {e}"
            )
            # Failed to create model from params, fallback to empty model
            tool_args_model = create_model(f"{tool_name}Args")

        return cls(
            name=tool_name,
            description=tool_description,
            func=executor,
            args_model=tool_args_model,
        )

    @classmethod
    def from_toolbox_many(
        cls,
        toolbox_tools: list[ToolboxSyncTool],
    ) -> list[AgentTool]:
        """
        Batch-create AgentTool objects from a list of ToolboxSyncTool objects.

        Args:
            toolbox_tools: List of ToolboxSyncTool objects to convert.

        Returns:
            List[AgentTool]: List of ready-to-use AgentTool objects.
        """
        return [cls.from_toolbox(tool) for tool in toolbox_tools]

    def model_post_init(self, __context: Any) -> None:
        """
        Handles post-initialization logic for both class-based and function-based tools.
        """
        # Sanitize tool name to comply with OpenAI requirements: ^[^\s<|\\/>]+$
        # This ensures the name matches what will be sent to OpenAI API
        # The sanitization is idempotent, so it's safe to call even if already sanitized
        self.name = sanitize_openai_tool_name(self.name)

        if self.func:
            self._is_async = inspect.iscoroutinefunction(self.func)
            self._initialize_from_func(self.func)
        else:
            self._initialize_from_run()
        return super().model_post_init(__context)

    def _initialize_from_func(self, func: Callable) -> None:
        """Initialize Tool fields from a provided function."""
        if self.args_model is None:
            self.args_model = ToolHelper.infer_func_schema(func)

    def _initialize_from_run(self) -> None:
        """Initialize Tool fields based on the abstract `_run` method."""
        if self.args_model is None:
            self.args_model = ToolHelper.infer_func_schema(self._run)

    def _validate_and_prepare_args(
        self, func: Callable, *args, **kwargs
    ) -> Dict[str, Any]:
        """
        Normalize and validate arguments for the given function.

        Args:
            func (Callable): The function whose signature is used.
            *args: Positional arguments.
            **kwargs: Keyword arguments.

        Returns:
            Dict[str, Any]: Validated and prepared arguments.

        Raises:
            ToolError: If argument validation fails.
        """
        sig = signature(func)
        if args:
            arg_names = list(sig.parameters.keys())
            kwargs.update(dict(zip(arg_names, args)))

        if self.args_model:
            try:
                # 1. If LLM returned wrapped args but model expects flat, unwrap
                # 2. If model expects wrapped but we have flat, wrap
                # 3. After validation, always return flat args for executors
                schema_props = (
                    self.args_model.model_json_schema().get("properties") or {}
                )
                model_expects_kwargs = list(schema_props.keys()) == ["kwargs"]

                # Unwrap if LLM returned wrapped args but model expects flat
                if (
                    not model_expects_kwargs
                    and len(kwargs) == 1
                    and "kwargs" in kwargs
                    and isinstance(kwargs.get("kwargs"), dict)
                ):
                    kwargs = kwargs["kwargs"]
                # Wrap if model expects kwargs but we have flat args
                elif model_expects_kwargs and "kwargs" not in kwargs and kwargs:
                    kwargs = {"kwargs": kwargs}

                validated_args = self.args_model(**kwargs)
                out = validated_args.model_dump(exclude_none=True)

                # Always return flat args for executors (unwrap if model was kwargs-wrapped)
                if (
                    isinstance(out, dict)
                    and list(out.keys()) == ["kwargs"]
                    and isinstance(out.get("kwargs"), dict)
                ):
                    return out["kwargs"]
                return out
            except ValidationError as ve:
                logger.debug(f"Validation failed for tool '{self.name}': {ve}")
                raise ToolError(f"Validation error in tool '{self.name}': {ve}") from ve

        return kwargs

    def run(self, *args, **kwargs) -> Any:
        """
        Execute the tool synchronously.

        Raises:
            ToolError if the tool is async or execution fails.
        """
        if self._is_async:
            raise ToolError(
                f"Tool '{self.name}' is async and must be awaited. Use `await tool.arun(...)` instead."
            )
        try:
            func = self.func or self._run
            kwargs = self._validate_and_prepare_args(func, *args, **kwargs)
            return func(**kwargs)
        except Exception as e:
            self._log_and_raise_error(e)

    async def arun(self, *args, **kwargs) -> Any:
        """
        Execute the tool asynchronously (whether it's sync or async under the hood).
        """
        try:
            func = self.func or self._run
            kwargs = self._validate_and_prepare_args(func, *args, **kwargs)
            return await func(**kwargs) if self._is_async else func(**kwargs)
        except Exception as e:
            self._log_and_raise_error(e)

    def _run(self, *args, **kwargs) -> Any:
        """Fallback default run logic if no `func` is set."""
        if self.func:
            return self.func(*args, **kwargs)
        raise NotImplementedError("No function or _run method defined for this tool.")

    def _log_and_raise_error(self, error: Exception) -> None:
        """Log the error and raise a ToolError."""
        logger.error(f"Error executing tool '{self.name}': {str(error)}")
        raise ToolError(
            f"An error occurred during the execution of tool '{self.name}': {str(error)}"
        )

    def __call__(self, *args, **kwargs) -> Any:
        """
        Enables `tool(...)` syntax.

        Raises:
            ToolError: if async tool is called without `await`.
        """
        if self._is_async:
            raise ToolError(
                f"Tool '{self.name}' is async and must be awaited. Use `await tool.arun(...)`."
            )
        return self.run(*args, **kwargs)

    def to_function_call(
        self, format_type: str = "openai", use_deprecated: bool = False
    ) -> Dict:
        """
        Converts the tool to a specified function call format.

        Args:
            format_type (str): The format type (e.g., 'openai').
            use_deprecated (bool): Whether to use deprecated format.

        Returns:
            Dict: The function call representation.
        """
        if self.args_model is None:
            raise ToolError(
                f"Cannot format tool '{self.name}' for LLM: args_model is None. "
                f"This indicates a bug in tool creation - validation will be bypassed."
            )
        return to_function_call_definition(
            self.name, self.description, self.args_model, format_type, use_deprecated
        )

    def __repr__(self) -> str:
        """Returns a string representation of the AgentTool."""
        return f"AgentTool(name={self.name}, description={self.description})"

    @property
    def args_schema(self) -> dict:
        """Returns a JSON-serializable dictionary of the tool's function args_model."""
        if self.args_model:
            schema = self.args_model.model_json_schema()
            for prop in schema.get("properties", {}).values():
                prop.pop("title", None)
            return schema.get("properties", {})
        return {}

    @property
    def signature(self) -> str:
        """Provides a dynamic and detailed string representation of the tool's function signature."""
        func_to_inspect = self.func if self.func else self._run
        params = signature(func_to_inspect).parameters
        args = [
            f"{name}: {param.annotation.__name__ if param.annotation != Parameter.empty else 'Any'}"
            f"{' = ' + repr(param.default) if param.default != Parameter.empty else ''}"
            for name, param in params.items()
        ]
        return f"{self.name}({', '.join(args)})"


def tool(
    func: Optional[Callable] = None, *, args_model: Optional[Type[BaseModel]] = None
) -> AgentTool:
    """
    A decorator to wrap a function with an `AgentTool` for validation and metadata.

    Args:
        func (Optional[Callable]): The function to wrap.
        args_model (Optional[Type[BaseModel]]): Optional Pydantic model for argument validation.

    Returns:
        AgentTool: The wrapped function as an `AgentTool`.
    """

    def decorator(f: Callable) -> AgentTool:
        ToolHelper.check_docstring(f)
        return AgentTool(func=f, args_model=args_model)

    return decorator(func) if func else decorator
