# Copyright 2026 Google LLC
#
# 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.

"""Unit tests for BaseToolset."""

from typing import Optional

from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.readonly_context import ReadonlyContext
from google.adk.agents.sequential_agent import SequentialAgent
from google.adk.models.llm_request import LlmRequest
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.base_toolset import BaseToolset
from google.adk.tools.function_tool import FunctionTool
from google.adk.tools.tool_context import ToolContext
import pytest


class _TestingTool(BaseTool):
  """A test implementation of BaseTool."""

  async def run_async(self, *, args, tool_context):
    return 'test result'


class _TestingToolset(BaseToolset):
  """A test implementation of BaseToolset."""

  def __init__(self, *args, tools: Optional[list[BaseTool]] = None, **kwargs):
    super().__init__(*args, **kwargs)
    self._tools = tools or []

  async def get_tools(
      self, readonly_context: Optional[ReadonlyContext] = None
  ) -> list[BaseTool]:
    return self._tools

  async def close(self) -> None:
    pass


@pytest.mark.asyncio
async def test_process_llm_request_default_implementation():
  """Test that the default process_llm_request implementation does nothing."""
  toolset = _TestingToolset()

  # Create test objects
  session_service = InMemorySessionService()
  session = await session_service.create_session(
      app_name='test_app', user_id='test_user'
  )
  agent = SequentialAgent(name='test_agent')
  invocation_context = InvocationContext(
      invocation_id='test_id',
      agent=agent,
      session=session,
      session_service=session_service,
  )
  tool_context = ToolContext(invocation_context)
  llm_request = LlmRequest()

  # The default implementation should not modify the request
  original_request = LlmRequest.model_validate(llm_request.model_dump())

  await toolset.process_llm_request(
      tool_context=tool_context, llm_request=llm_request
  )

  # Verify the request was not modified
  assert llm_request.model_dump() == original_request.model_dump()


@pytest.mark.asyncio
async def test_process_llm_request_can_be_overridden():
  """Test that process_llm_request can be overridden by subclasses."""

  class _CustomToolset(_TestingToolset):

    async def process_llm_request(
        self, *, tool_context: ToolContext, llm_request: LlmRequest
    ) -> None:
      # Add some custom processing
      if not llm_request.contents:
        llm_request.contents = []
      llm_request.contents.append('Custom processing applied')

  toolset = _CustomToolset()

  # Create test objects
  session_service = InMemorySessionService()
  session = await session_service.create_session(
      app_name='test_app', user_id='test_user'
  )
  agent = SequentialAgent(name='test_agent')
  invocation_context = InvocationContext(
      invocation_id='test_id',
      agent=agent,
      session=session,
      session_service=session_service,
  )
  tool_context = ToolContext(invocation_context)
  llm_request = LlmRequest()

  await toolset.process_llm_request(
      tool_context=tool_context, llm_request=llm_request
  )

  # Verify the custom processing was applied
  assert llm_request.contents == ['Custom processing applied']


@pytest.mark.asyncio
async def test_prefix_functionality_disabled_by_default():
  """Test that prefix functionality is disabled by default."""
  tool1 = _TestingTool(name='tool1', description='Test tool 1')
  tool2 = _TestingTool(name='tool2', description='Test tool 2')
  toolset = _TestingToolset(tools=[tool1, tool2])

  # When tool_name_prefix is None (default), get_tools_with_prefix should return original tools
  prefixed_tools = await toolset.get_tools_with_prefix()

  assert len(prefixed_tools) == 2
  assert prefixed_tools[0].name == 'tool1'
  assert prefixed_tools[1].name == 'tool2'
  assert toolset.tool_name_prefix is None


@pytest.mark.asyncio
async def test_prefix_functionality_with_custom_prefix():
  """Test prefix functionality with custom prefix."""
  tool1 = _TestingTool(name='tool1', description='Test tool 1')
  tool2 = _TestingTool(name='tool2', description='Test tool 2')
  toolset = _TestingToolset(tools=[tool1, tool2], tool_name_prefix='custom')

  # Should use the provided prefix
  prefixed_tools = await toolset.get_tools_with_prefix()

  assert len(prefixed_tools) == 2
  assert prefixed_tools[0].name == 'custom_tool1'
  assert prefixed_tools[1].name == 'custom_tool2'
  assert toolset.tool_name_prefix == 'custom'


@pytest.mark.asyncio
async def test_prefix_with_none_has_no_effect():
  """Test that when prefix is None, tools are returned unchanged."""
  tool1 = _TestingTool(name='tool1', description='Test tool 1')
  tool2 = _TestingTool(name='tool2', description='Test tool 2')
  toolset = _TestingToolset(tools=[tool1, tool2], tool_name_prefix=None)

  prefixed_tools = await toolset.get_tools_with_prefix()

  assert len(prefixed_tools) == 2
  assert prefixed_tools[0].name == 'tool1'
  assert prefixed_tools[1].name == 'tool2'
  assert toolset.tool_name_prefix is None


@pytest.mark.asyncio
async def test_prefix_with_empty_string():
  """Test prefix functionality with empty string prefix."""
  tool1 = _TestingTool(name='tool1', description='Test tool 1')
  toolset = _TestingToolset(tools=[tool1], tool_name_prefix='')

  prefixed_tools = await toolset.get_tools_with_prefix()

  # Empty prefix should be treated as no prefix
  assert len(prefixed_tools) == 1
  assert prefixed_tools[0].name == 'tool1'
  assert toolset.tool_name_prefix == ''


@pytest.mark.asyncio
async def test_prefix_assignment():
  """Test that prefix is properly assigned."""
  toolset = _TestingToolset(tool_name_prefix='explicit')
  assert toolset.tool_name_prefix == 'explicit'

  # Test None assignment
  toolset_none = _TestingToolset(tool_name_prefix=None)
  assert toolset_none.tool_name_prefix is None


@pytest.mark.asyncio
async def test_prefix_creates_tool_copies():
  """Test that prefixing creates copies and preserves original tools."""
  original_tool = _TestingTool(
      name='original', description='Original description'
  )
  original_tool.is_long_running = True
  original_tool.custom_attribute = 'custom_value'

  toolset = _TestingToolset(tools=[original_tool], tool_name_prefix='test')
  prefixed_tools = await toolset.get_tools_with_prefix()

  prefixed_tool = prefixed_tools[0]

  # Name should be prefixed in the copy
  assert prefixed_tool.name == 'test_original'

  # Other attributes should be preserved
  assert prefixed_tool.description == 'Original description'
  assert prefixed_tool.is_long_running == True
  assert prefixed_tool.custom_attribute == 'custom_value'

  # Original tool should remain unchanged
  assert original_tool.name == 'original'
  assert original_tool is not prefixed_tool


@pytest.mark.asyncio
async def test_get_tools_vs_get_tools_with_prefix():
  """Test that get_tools returns tools without prefixing."""
  tool1 = _TestingTool(name='test_tool1', description='Test tool 1')
  tool2 = _TestingTool(name='test_tool2', description='Test tool 2')
  toolset = _TestingToolset(tools=[tool1, tool2], tool_name_prefix='prefix')

  # get_tools should return original tools (unmodified)
  original_tools = await toolset.get_tools()
  assert len(original_tools) == 2
  assert original_tools[0].name == 'test_tool1'
  assert original_tools[1].name == 'test_tool2'

  # Now calling get_tools_with_prefix should return prefixed copies
  prefixed_tools = await toolset.get_tools_with_prefix()
  assert len(prefixed_tools) == 2
  assert prefixed_tools[0].name == 'prefix_test_tool1'
  assert prefixed_tools[1].name == 'prefix_test_tool2'

  # Original tools should remain unchanged
  assert original_tools[0].name == 'test_tool1'
  assert original_tools[1].name == 'test_tool2'

  # The prefixed tools should be different instances
  assert prefixed_tools[0] is not original_tools[0]
  assert prefixed_tools[1] is not original_tools[1]


@pytest.mark.asyncio
async def test_empty_toolset_with_prefix():
  """Test prefix functionality with empty toolset."""
  toolset = _TestingToolset(tools=[], tool_name_prefix='test')

  prefixed_tools = await toolset.get_tools_with_prefix()
  assert len(prefixed_tools) == 0


@pytest.mark.asyncio
async def test_function_declarations_are_prefixed():
  """Test that function declarations have prefixed names."""

  def test_function(param1: str, param2: int) -> str:
    """A test function for checking prefixes."""
    return f'{param1}_{param2}'

  function_tool = FunctionTool(test_function)
  toolset = _TestingToolset(
      tools=[function_tool],
      tool_name_prefix='prefix',
  )

  prefixed_tools = await toolset.get_tools_with_prefix()
  prefixed_tool = prefixed_tools[0]

  # Tool name should be prefixed
  assert prefixed_tool.name == 'prefix_test_function'

  # Function declaration should also have prefixed name
  declaration = prefixed_tool._get_declaration()
  assert declaration is not None
  assert declaration.name == 'prefix_test_function'

  # Description should remain unchanged
  assert 'A test function for checking prefixes.' in declaration.description


@pytest.mark.asyncio
async def test_prefixed_tools_in_llm_request():
  """Test that prefixed tools are properly added to LLM request."""

  def test_function(param: str) -> str:
    """A test function."""
    return f'result: {param}'

  function_tool = FunctionTool(test_function)
  toolset = _TestingToolset(tools=[function_tool], tool_name_prefix='test')

  prefixed_tools = await toolset.get_tools_with_prefix()
  prefixed_tool = prefixed_tools[0]

  # Create LLM request and tool context
  session_service = InMemorySessionService()
  session = await session_service.create_session(
      app_name='test_app', user_id='test_user'
  )
  agent = SequentialAgent(name='test_agent')
  invocation_context = InvocationContext(
      invocation_id='test_id',
      agent=agent,
      session=session,
      session_service=session_service,
  )
  tool_context = ToolContext(invocation_context)
  llm_request = LlmRequest()

  # Process the LLM request with the prefixed tool
  await prefixed_tool.process_llm_request(
      tool_context=tool_context, llm_request=llm_request
  )

  # Verify the tool is registered with prefixed name in tools_dict
  assert 'test_test_function' in llm_request.tools_dict
  assert llm_request.tools_dict['test_test_function'] == prefixed_tool

  # Verify the function declaration has prefixed name
  assert llm_request.config is not None
  assert llm_request.config.tools is not None
  assert len(llm_request.config.tools) == 1
  tool_config = llm_request.config.tools[0]
  assert len(tool_config.function_declarations) == 1
  func_decl = tool_config.function_declarations[0]
  assert func_decl.name == 'test_test_function'


@pytest.mark.asyncio
async def test_multiple_tools_have_correct_declarations():
  """Test that each tool maintains its own function declaration after prefixing."""

  def tool_one(param: str) -> str:
    """Function one."""
    return f'one: {param}'

  def tool_two(param: int) -> str:
    """Function two."""
    return f'two: {param}'

  tool1 = FunctionTool(tool_one)
  tool2 = FunctionTool(tool_two)
  toolset = _TestingToolset(tools=[tool1, tool2], tool_name_prefix='test')

  prefixed_tools = await toolset.get_tools_with_prefix()

  # Verify each tool has its own correct declaration
  decl1 = prefixed_tools[0]._get_declaration()
  decl2 = prefixed_tools[1]._get_declaration()

  assert decl1.name == 'test_tool_one'
  assert decl2.name == 'test_tool_two'

  assert 'Function one.' in decl1.description
  assert 'Function two.' in decl2.description


@pytest.mark.asyncio
async def test_no_duplicate_prefixing():
  """Test that multiple calls to get_tools_with_prefix don't cause duplicate prefixing."""
  original_tool = _TestingTool(name='original', description='Original tool')
  toolset = _TestingToolset(tools=[original_tool], tool_name_prefix='test')

  # First call
  prefixed_tools_1 = await toolset.get_tools_with_prefix()
  assert len(prefixed_tools_1) == 1
  assert prefixed_tools_1[0].name == 'test_original'

  # Second call - should not double-prefix
  prefixed_tools_2 = await toolset.get_tools_with_prefix()
  assert len(prefixed_tools_2) == 1
  assert prefixed_tools_2[0].name == 'test_original'  # Not 'test_test_original'

  # Original tool should remain unchanged
  original_tools = await toolset.get_tools()
  assert original_tools[0].name == 'original'

  # The prefixed tools should be the same instance when cached
  assert prefixed_tools_1[0] is prefixed_tools_2[0]
  assert prefixed_tools_1[0] is not original_tools[0]


@pytest.mark.asyncio
async def test_get_tools_with_prefix_caching():
  """Test that get_tools_with_prefix caches results within the same invocation."""
  tool1 = _TestingTool(name='tool1', description='Test tool 1')
  toolset = _TestingToolset(tools=[tool1], tool_name_prefix='test')

  session_service = InMemorySessionService()
  session = await session_service.create_session(
      app_name='test_app', user_id='test_user'
  )
  agent = SequentialAgent(name='test_agent')
  invocation_context1 = InvocationContext(
      invocation_id='inv-1',
      agent=agent,
      session=session,
      session_service=session_service,
  )
  readonly_context1 = ReadonlyContext(invocation_context1)

  # First call
  tools1 = await toolset.get_tools_with_prefix(
      readonly_context=readonly_context1
  )
  assert len(tools1) == 1
  assert tools1[0].name == 'test_tool1'

  # Second call with same context/invocation_id
  tools2 = await toolset.get_tools_with_prefix(
      readonly_context=readonly_context1
  )
  assert len(tools2) == 1
  assert (
      tools2 is tools1
  )  # Should return the exact same list instance (from cache)

  # Third call with different invocation_id
  invocation_context2 = InvocationContext(
      invocation_id='inv-2',
      agent=agent,
      session=session,
      session_service=session_service,
  )
  readonly_context2 = ReadonlyContext(invocation_context2)

  tools3 = await toolset.get_tools_with_prefix(
      readonly_context=readonly_context2
  )
  assert len(tools3) == 1
  assert tools3 is not tools1  # Should be a new list instance
  assert tools3[0].name == 'test_tool1'

  # Test disabling caching
  toolset._use_invocation_cache = False
  tools4 = await toolset.get_tools_with_prefix(
      readonly_context=readonly_context2
  )
  tools5 = await toolset.get_tools_with_prefix(
      readonly_context=readonly_context2
  )
  assert tools4 is not tools5
