# Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
#
# SPDX-License-Identifier: Apache-2.0


import warnings
from collections.abc import Callable
from functools import wraps
from inspect import signature
from typing import Any

from ...doc_utils import export_module
from ...import_utils import optional_import_block, require_optional_import
from ...tools import Tool
from ..registry import register_interoperable_class

__all__ = ["PydanticAIInteroperability"]

with optional_import_block():
    from pydantic_ai import RunContext
    from pydantic_ai.tools import Tool as PydanticAITool
    from pydantic_ai.usage import Usage


@register_interoperable_class("pydanticai")
@export_module("autogen.interop")
class PydanticAIInteroperability:
    """A class implementing the `Interoperable` protocol for converting Pydantic AI tools
    into a general `Tool` format.

    This class takes a `PydanticAITool` and converts it into a standard `Tool` object,
    ensuring compatibility between Pydantic AI tools and other systems that expect
    the `Tool` format. It also provides a mechanism for injecting context parameters
    into the tool's function.
    """

    @staticmethod
    @require_optional_import("pydantic_ai", "interop-pydantic-ai")
    def inject_params(
        ctx: Any,
        tool: Any,
    ) -> Callable[..., Any]:
        """Wraps the tool's function to inject context parameters and handle retries.

        This method ensures that context parameters are properly passed to the tool
        when invoked and that retries are managed according to the tool's settings.

        Args:
            ctx (Optional[RunContext[Any]]): The run context, which may include dependencies and retry information.
            tool (PydanticAITool): The Pydantic AI tool whose function is to be wrapped.

        Returns:
            Callable[..., Any]: A wrapped function that includes context injection and retry handling.

        Raises:
            ValueError: If the tool fails after the maximum number of retries.
        """
        ctx_typed: RunContext[Any] | None = ctx  # type: ignore[no-any-unimported]
        tool_typed: PydanticAITool[Any] = tool  # type: ignore[no-any-unimported]

        max_retries = tool_typed.max_retries if tool_typed.max_retries is not None else 1
        f = tool_typed.function

        @wraps(f)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            current_retry = 0 if ctx_typed is None else ctx_typed.retries.get(tool_typed.name, 0)  # type: ignore[attr-defined]

            if current_retry >= max_retries:
                raise ValueError(f"{tool_typed.name} failed after {max_retries} retries")

            try:
                if ctx_typed is not None:
                    kwargs.pop("ctx", None)
                    ctx_typed.retry = current_retry
                    result = f(**kwargs, ctx=ctx_typed)  # type: ignore[call-arg]
                    ctx_typed.retries[tool_typed.name] = 0  # type: ignore[attr-defined]
                else:
                    result = f(**kwargs)  # type: ignore[call-arg]
            except Exception as e:
                if ctx_typed is not None:
                    ctx_typed.retries[tool_typed.name] = ctx_typed.retries.get(tool_typed.name, 0) + 1  # type: ignore[attr-defined]
                raise e

            return result

        sig = signature(f)
        if ctx_typed is not None:
            new_params = [param for name, param in sig.parameters.items() if name != "ctx"]
        else:
            new_params = list(sig.parameters.values())

        wrapper.__signature__ = sig.replace(parameters=new_params)  # type: ignore[attr-defined]

        return wrapper

    @classmethod
    @require_optional_import("pydantic_ai", "interop-pydantic-ai")
    def convert_tool(cls, tool: Any, deps: Any = None, **kwargs: Any) -> Tool:
        """Converts a given Pydantic AI tool into a general `Tool` format.

        This method verifies that the provided tool is a valid `PydanticAITool`,
        handles context dependencies if necessary, and returns a standardized `Tool` object.

        Args:
            tool (Any): The tool to convert, expected to be an instance of `PydanticAITool`.
            deps (Any, optional): The dependencies to inject into the context, required if
                                   the tool takes a context. Defaults to None.
            **kwargs (Any): Additional arguments that are not used in this method.

        Returns:
            Tool: A standardized `Tool` object converted from the Pydantic AI tool.

        Raises:
            ValueError: If the provided tool is not an instance of `PydanticAITool`, or if
                        dependencies are missing for tools that require a context.
            UserWarning: If the `deps` argument is provided for a tool that does not take a context.
        """
        if not isinstance(tool, PydanticAITool):
            raise ValueError(f"Expected an instance of `pydantic_ai.tools.Tool`, got {type(tool)}")

        # needed for type checking
        pydantic_ai_tool: PydanticAITool[Any] = tool  # type: ignore[no-any-unimported]

        if tool.takes_ctx and deps is None:
            raise ValueError("If the tool takes a context, the `deps` argument must be provided")
        if not tool.takes_ctx and deps is not None:
            warnings.warn(
                "The `deps` argument is provided but will be ignored because the tool does not take a context.",
                UserWarning,
            )

        ctx = (
            RunContext(
                model=None,  # type: ignore [arg-type]
                usage=Usage(),
                prompt="",
                deps=deps,
                retry=0,
                # All messages send to or returned by a model.
                # This is mostly used on pydantic_ai Agent level.
                messages=[],  # TODO: check in the future if this is needed on Tool level
                tool_name=pydantic_ai_tool.name,
            )
            if tool.takes_ctx
            else None
        )

        # Initialize retries dict for tracking retry counts per tool
        if ctx is not None:
            ctx.retries = {}  # type: ignore[attr-defined]

        func = PydanticAIInteroperability.inject_params(
            ctx=ctx,
            tool=pydantic_ai_tool,
        )

        return Tool(
            name=pydantic_ai_tool.name,
            description=pydantic_ai_tool.description,
            func_or_tool=func,
            parameters_json_schema=pydantic_ai_tool.function_schema.json_schema,  # type: ignore[attr-defined]
        )

    @classmethod
    def get_unsupported_reason(cls) -> str | None:
        with optional_import_block() as result:
            import pydantic_ai.tools  # noqa: F401

        if not result.is_successful:
            return "Please install `interop-pydantic-ai` extra to use this module:\n\n\tpip install ag2[interop-pydantic-ai]"

        return None
