# -*- coding: utf-8 -*-
# pylint: disable=unused-argument
"""The tool protocol in agentscope."""
import inspect
import os
from abc import abstractmethod, ABC
from pathlib import Path
from typing import AsyncGenerator, Any, Callable, List

from pydantic import BaseModel

from ._constants import DEFAULT_DANGEROUS_FILES, DEFAULT_DANGEROUS_DIRECTORIES
from ..permission import (
    PermissionContext,
    PermissionDecision,
    PermissionRule,
    PermissionBehavior,
)
from ._response import ToolChunk
from ._utils import _remove_title_field


class ParamsBase(BaseModel):
    """A base class for tool parameters that remove the title field from the
    exported JSON schema.
    """

    @classmethod
    def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict:
        """An override implementation to remove the title field from the
        exported schema.
        """
        return _remove_title_field(super().model_json_schema(*args, **kwargs))


class ToolMiddlewareBase(ABC):
    """Base class for tool middlewares.

    A tool middleware wraps the execution of a tool in an onion fashion: the
    first registered middleware is the outermost layer and runs its pre-logic
    before any inner layer, then its post-logic after all inner layers have
    completed. Subclass this and implement :meth:`on_tool_call` — the signature
    is already spelled out, so second-party developers only need to fill in the
    body without reasoning about the wrapping protocol.

    Streaming and non-streaming tools are unified: ``next_handler`` always
    returns an async generator, so a middleware never needs to know whether the
    underlying tool yields a stream of chunks or returns a single chunk.

    Example:
        ```python
        class LoggingMiddleware(ToolMiddlewareBase):
            async def on_tool_call(self, tool, input_kwargs, next_handler):
                print(f"Calling {tool.name} with {input_kwargs}")
                async for chunk in next_handler(**input_kwargs):
                    yield chunk
                print(f"Finished {tool.name}")

        tool = MyTool(middlewares=[LoggingMiddleware()])
        ```
    """

    @abstractmethod
    async def on_tool_call(
        self,
        tool: "ToolBase",
        input_kwargs: dict[str, Any],
        next_handler: Callable[..., AsyncGenerator[ToolChunk, None]],
    ) -> AsyncGenerator[ToolChunk, None]:
        """Intercept a single tool invocation.

        Add pre-/post-logic around ``next_handler``, rewrite the tool inputs by
        passing modified keyword arguments to ``next_handler``, or transform
        the yielded chunks.

        Args:
            tool (`ToolBase`):
                The tool instance being invoked.
            input_kwargs (`dict[str, Any]`):
                The tool's input arguments for this invocation. Pass them on
                via ``next_handler(**input_kwargs)``; mutate or replace them to
                change what the inner layers and the tool itself receive.
            next_handler (`Callable[..., AsyncGenerator[ToolChunk, None]]`):
                Call it as ``next_handler(**input_kwargs)`` to run the next
                layer. It always returns an async generator, regardless of
                whether the underlying tool is streaming or not.

        Yields:
            `ToolChunk`:
                The chunks produced by this tool invocation.
        """


class ToolBase(ABC):
    """The tool protocol."""

    name: str
    """The name presented to the agent."""
    description: str
    """The agent-oriented tool description."""
    input_schema: dict[str, Any]
    """The input schema of the tool, following JSON schema format."""
    is_concurrency_safe: bool
    """If this tool is concurrency safe."""
    is_read_only: bool
    """If this tool is read-only, which will be used in the permission
    checking."""
    is_external_tool: bool = False
    """If this tool is an external tool, which doesn't need to implement the
    __call__ method and the agent will yield the external tool call event."""
    is_state_injected: bool = False
    """If this tool requires agent state to be injected when called. If `True`,
    the state will be injected by an argument named `_agent_state`. Note your
    tool should be able to accept such argument.
    """
    is_mcp: bool = False
    """If this tool is an MCP tool, which will be used in the permission"""
    mcp_name: str | None = None
    """The name of the MCP server this tool belongs to, which is required if
    this tool is an MCP tool."""

    # Class attributes for dangerous path checking
    dangerous_files: list[str] = DEFAULT_DANGEROUS_FILES
    """List of dangerous files that should be protected from auto-editing."""
    dangerous_directories: list[str] = DEFAULT_DANGEROUS_DIRECTORIES
    """List of dangerous directories that should be protected from
    auto-editing."""

    def __init__(
        self,
        middlewares: List["ToolMiddlewareBase"] | None = None,
    ) -> None:
        """Initialize the tool with optional middlewares.

        Args:
            middlewares (`List[ToolMiddlewareBase] | None`, optional):
                A list of :class:`ToolMiddlewareBase` instances wrapping the
                tool execution in an onion fashion. Defaults to an empty list.
        """
        self._middlewares: List["ToolMiddlewareBase"] = (
            middlewares if middlewares is not None else []
        )

    async def call(
        self,
        *args: Any,
        **kwargs: Any,
    ) -> ToolChunk | AsyncGenerator[ToolChunk, None]:
        """Execute the tool logic.

        This is the new override point for tool implementations.
        Subclasses should override this method instead of
        :meth:`__call__`.  The base implementation raises
        :exc:`NotImplementedError` for non-external tools and
        :exc:`RuntimeError` for external tools.

        Args:
            **kwargs: Tool input arguments.

        Returns:
            `ToolChunk | AsyncGenerator[ToolChunk, None]`:
                A single :class:`~agentscope.tool.ToolChunk` or an
                async generator that yields them.
        """
        if not self.is_external_tool:
            raise NotImplementedError(
                f"{self.__class__.__name__} does not implement call",
            )

        raise RuntimeError(
            f"{self.__class__.__name__} is an external tool and should not "
            f"be called directly",
        )

    async def __call__(
        self,
        *args: Any,
        **kwargs: Any,
    ) -> ToolChunk | AsyncGenerator[ToolChunk, None]:
        """Invoke the tool, layering any registered middlewares around
        :meth:`call`.

        Tools are always invoked with keyword arguments only. ``*args`` is
        accepted in the signature solely to stay Liskov-compatible with
        subclasses that override ``__call__`` with their own positional
        parameters; any positional argument actually passed here is rejected
        (raising :exc:`TypeError`) so it fails loudly instead of being silently
        dropped.

        Middlewares are applied in an onion fashion: the first registered
        middleware is the outermost layer and runs its pre-logic before
        any inner layers, then its post-logic after all inner layers
        have completed.
        """
        if args:
            raise TypeError(
                f"{type(self).__name__} must be called with keyword arguments "
                f"only, but got {len(args)} positional argument(s).",
            )
        # ``getattr`` with a default so the no-middleware path keeps working
        # even if a subclass overrides ``__init__`` without calling
        # ``super().__init__()``.
        middlewares = getattr(self, "_middlewares", [])
        if not middlewares:
            if inspect.isasyncgenfunction(self.call):
                return self.call(**kwargs)
            return await self.call(**kwargs)

        async def execute_chain(
            index: int = 0,
            **chain_kwargs: Any,
        ) -> AsyncGenerator[ToolChunk, None]:
            """Execute the tool middleware chain."""
            if index >= len(middlewares):
                # Innermost layer: run the tool's own ``call``. ``call`` is
                # always async but comes in two shapes — an async generator
                # function (e.g. ``Bash``) or a coroutine returning a single
                # ``ToolChunk`` / an async generator (e.g. ``FunctionTool``).
                # Normalize both into a single stream so middlewares never have
                # to distinguish them.
                if inspect.isasyncgenfunction(self.call):
                    async for chunk in self.call(**chain_kwargs):
                        yield chunk
                else:
                    result = await self.call(**chain_kwargs)
                    if isinstance(result, AsyncGenerator):
                        async for chunk in result:
                            yield chunk
                    else:
                        yield result
            else:
                mw = middlewares[index]
                input_kwargs = dict(chain_kwargs)

                async def next_handler(
                    **kw: Any,
                ) -> AsyncGenerator[ToolChunk, None]:
                    async for chunk in execute_chain(index + 1, **kw):
                        yield chunk

                async for chunk in mw.on_tool_call(
                    tool=self,
                    input_kwargs=input_kwargs,
                    next_handler=next_handler,
                ):
                    yield chunk

        return execute_chain(**kwargs)

    @abstractmethod
    async def check_permissions(
        self,
        tool_input: dict[str, Any],
        context: PermissionContext,
    ) -> PermissionDecision:
        """Check permissions for the tool usage."""

    async def check_read_only(
        self,
        tool_input: dict[str, Any],
    ) -> bool:
        """Decide whether this specific invocation is read-only.

        Returns the static :attr:`is_read_only` attribute by default.
        Subclasses with input-dependent semantics (e.g. ``Bash``) should
        override this to inspect ``tool_input`` — for example, ``Bash`` is
        statically marked as not read-only but ``ls -a`` is in fact read-only.

        Should be cheap — the permission engine may call this before the
        full :meth:`check_permissions` flow.

        Args:
            tool_input (`dict[str, Any]`):
                The tool input data for this invocation.

        Returns:
            `bool`:
                ``True`` if this invocation is read-only, ``False`` otherwise.
        """
        return self.is_read_only

    async def match_rule(
        self,
        rule_content: str | None,
        tool_input: dict[str, Any],
    ) -> bool:
        """Check if a permission rule matches the tool input.

        .. note:: This is an optional method. A rule with no content (``None``)
        is a tool-name-level rule that matches every invocation; a rule
        with content requires the tool to override this method with its
        own matching logic, otherwise it returns ``False``.

        This means:
        - ``_FunctionTool`` and ``MCPTool`` (which do not override this)
          can still be controlled at the tool-name level via rules like
          ``{"tool_name": "my_tool", "rule_content": None}``.
        - Specific tools (Bash, Read, Write, Edit, Glob, Grep) override
          this method to support fine-grained pattern matching.

        Args:
            rule_content (`str | None`):
                The rule pattern to match. ``None`` means "match all
                invocations of this tool" (tool-name-level rule).
            tool_input (`dict[str, Any]`):
                The tool input data

        Returns:
            `bool`:
                True if the rule matches, False otherwise
        """
        # None rule_content = tool-name-level rule, matches everything
        return rule_content is None

    async def generate_suggestions(
        self,
        tool_input: dict[str, Any],
    ) -> List[PermissionRule]:
        """Generate suggested permission rules for the tool input.

        .. note:: Suggest a single tool-name-level rule (``rule_content=None``)
        that allows all invocations of this tool. Tools can override this to
        provide finer-grained suggestions.

        For example:
        - File tools (Read/Write/Edit): suggest a glob pattern covering the
          parent directory (e.g., "src/main.py" -> "src/**")
        - Bash: suggest command prefix patterns (e.g., "git commit -m 'xxx'"
          -> "git commit:*")
        - Grep/Glob: suggest patterns based on search paths

        Args:
            tool_input (`dict[str, Any]`):
                The tool input data

        Returns:
            `List[PermissionRule]`:
                List of suggested permission rules (usually 1, max 5 for
                compound operations)
        """
        return [
            PermissionRule(
                tool_name=self.name,
                rule_content=None,
                behavior=PermissionBehavior.ALLOW,
                source="suggested",
            ),
        ]

    def _path_in_allowed_working_path(
        self,
        file_path: str,
        context: PermissionContext,
    ) -> bool:
        """Check if a file path is within any allowed working directory.

        A "working directory" is the process's current directory plus any
        entries in :attr:`PermissionContext.working_directories`. Paths
        are compared via :func:`os.path.realpath` so that aliases like
        macOS's ``/tmp`` → ``/private/tmp`` and symlinked working
        directories compare equal on both sides.

        Used by tools that conditionally auto-allow file operations in
        :attr:`PermissionMode.ACCEPT_EDITS` (e.g. Write, Edit, and the
        filesystem-command branch of Bash).

        Args:
            file_path (`str`):
                The file path to check.
            context (`PermissionContext`):
                The permission context containing the working directories.

        Returns:
            `bool`:
                True if ``file_path`` is within any allowed working
                directory.
        """
        current_dir = os.getcwd()
        additional_dirs = list(context.working_directories.keys())
        all_working_dirs = [current_dir] + additional_dirs

        abs_file_path = os.path.realpath(os.path.expanduser(file_path))

        for working_dir in all_working_dirs:
            abs_working_dir = os.path.realpath(
                os.path.expanduser(working_dir),
            )
            try:
                os.path.relpath(abs_file_path, abs_working_dir)
                if (
                    abs_file_path.startswith(abs_working_dir + os.sep)
                    or abs_file_path == abs_working_dir
                ):
                    return True
            except ValueError:
                # On Windows, relpath raises ValueError if paths are on
                # different drives.
                continue

        return False

    def _is_dangerous_path(self, file_path: str) -> bool:
        """Check if a file path is dangerous (sensitive file or directory).

        A path is considered dangerous if:
        1. The filename matches a dangerous file (e.g., .bashrc, .gitconfig)
        2. Any path segment matches a dangerous directory (e.g., .git, .ssh)

        Case-insensitive matching is used to prevent bypasses on
        case-insensitive filesystems (macOS, Windows).

        Args:
            file_path (`str`):
                The file path to check

        Returns:
            `bool`:
                True if the path is dangerous and should require explicit
                permission

        Example:
            >>> self._is_dangerous_path("/home/user/.bashrc")
            True
            >>> self._is_dangerous_path("/home/user/.git/config")
            True
            >>> self._is_dangerous_path("/home/user/project/main.py")
            False
        """

        # Normalize path
        abs_path = os.path.abspath(os.path.expanduser(file_path))

        # Split path into segments
        path_parts = Path(abs_path).parts
        path_parts_lower = [p.lower() for p in path_parts]

        # Check if filename matches dangerous files (case-insensitive)
        filename = os.path.basename(abs_path)
        filename_lower = filename.lower()
        for dangerous_file in self.dangerous_files:
            if filename_lower == dangerous_file.lower():
                return True

        # Check if any path segment matches dangerous directories
        # (case-insensitive)
        for dangerous_dir in self.dangerous_directories:
            dangerous_dir_lower = dangerous_dir.lower()
            if dangerous_dir_lower in path_parts_lower:
                return True

        return False
