# 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 the PluginManager."""

from __future__ import annotations

import asyncio
from unittest.mock import AsyncMock
from unittest.mock import Mock

from google.adk.models.llm_response import LlmResponse
from google.adk.plugins.base_plugin import BasePlugin
# Assume the following path to your modules
# You might need to adjust this based on your project structure.
from google.adk.plugins.plugin_manager import PluginCallbackName
from google.adk.plugins.plugin_manager import PluginManager
import pytest


# A helper class to use in tests instead of mocks.
# This makes tests more explicit and easier to debug.
class TestPlugin(BasePlugin):
  __test__ = False
  """
  A test plugin that can be configured to return specific values or raise
  exceptions for any callback, and it logs which callbacks were invoked.
  """

  def __init__(self, name: str):
    super().__init__(name)
    # A log to track the names of callbacks that have been called.
    self.call_log: list[PluginCallbackName] = []
    # A map to configure return values for specific callbacks.
    self.return_values: dict[PluginCallbackName, any] = {}
    # A map to configure exceptions to be raised by specific callbacks.
    self.exceptions_to_raise: dict[PluginCallbackName, Exception] = {}

  async def _handle_callback(self, name: PluginCallbackName):
    """Generic handler for all callback methods."""
    self.call_log.append(name)
    if name in self.exceptions_to_raise:
      raise self.exceptions_to_raise[name]
    return self.return_values.get(name)

  # Implement all callback methods from the BasePlugin interface.
  async def on_user_message_callback(self, **kwargs):
    return await self._handle_callback("on_user_message_callback")

  async def before_run_callback(self, **kwargs):
    return await self._handle_callback("before_run_callback")

  async def after_run_callback(self, **kwargs):
    return await self._handle_callback("after_run_callback")

  async def on_event_callback(self, **kwargs):
    return await self._handle_callback("on_event_callback")

  async def before_agent_callback(self, **kwargs):
    return await self._handle_callback("before_agent_callback")

  async def after_agent_callback(self, **kwargs):
    return await self._handle_callback("after_agent_callback")

  async def before_tool_callback(self, **kwargs):
    return await self._handle_callback("before_tool_callback")

  async def after_tool_callback(self, **kwargs):
    return await self._handle_callback("after_tool_callback")

  async def on_tool_error_callback(self, **kwargs):
    return await self._handle_callback("on_tool_error_callback")

  async def before_model_callback(self, **kwargs):
    return await self._handle_callback("before_model_callback")

  async def after_model_callback(self, **kwargs):
    return await self._handle_callback("after_model_callback")

  async def on_model_error_callback(self, **kwargs):
    return await self._handle_callback("on_model_error_callback")

  async def on_agent_error_callback(self, **kwargs):
    return await self._handle_callback("on_agent_error_callback")

  async def on_run_error_callback(self, **kwargs):
    return await self._handle_callback("on_run_error_callback")


@pytest.fixture
def service() -> PluginManager:
  """Provides a clean PluginManager instance for each test."""
  return PluginManager()


@pytest.fixture
def plugin1() -> TestPlugin:
  """Provides a clean instance of our test plugin named 'plugin1'."""
  return TestPlugin(name="plugin1")


@pytest.fixture
def plugin2() -> TestPlugin:
  """Provides a clean instance of our test plugin named 'plugin2'."""
  return TestPlugin(name="plugin2")


def test_register_and_get_plugin(service: PluginManager, plugin1: TestPlugin):
  """Tests successful registration and retrieval of a plugin."""
  service.register_plugin(plugin1)

  assert len(service.plugins) == 1
  assert service.plugins[0] is plugin1
  assert service.get_plugin("plugin1") is plugin1


def test_register_duplicate_plugin_name_raises_value_error(
    service: PluginManager, plugin1: TestPlugin
):
  """Tests that registering a plugin with a duplicate name raises an error."""
  plugin1_duplicate = TestPlugin(name="plugin1")
  service.register_plugin(plugin1)

  with pytest.raises(
      ValueError, match="Plugin with name 'plugin1' already registered."
  ):
    service.register_plugin(plugin1_duplicate)


@pytest.mark.asyncio
async def test_early_exit_stops_subsequent_plugins(
    service: PluginManager, plugin1: TestPlugin, plugin2: TestPlugin
):
  """Tests the core "early exit" logic: if a plugin returns a value,

  subsequent plugins for that callback should not be executed.
  """
  # Configure plugin1 to return a value, simulating a cache hit.
  mock_response = Mock(spec=LlmResponse)
  plugin1.return_values["before_run_callback"] = mock_response

  service.register_plugin(plugin1)
  service.register_plugin(plugin2)

  # Execute the callback chain.
  result = await service.run_before_run_callback(invocation_context=Mock())

  # Assert that the final result is the one returned by the first plugin.
  assert result is mock_response
  # Assert that the first plugin was called.
  assert "before_run_callback" in plugin1.call_log
  # CRITICAL: Assert that the second plugin was never called.
  assert "before_run_callback" not in plugin2.call_log


@pytest.mark.asyncio
async def test_normal_flow_all_plugins_are_called(
    service: PluginManager, plugin1: TestPlugin, plugin2: TestPlugin
):
  """Tests that if no plugin returns a value, all plugins in the chain

  are executed in order.
  """
  # By default, plugins are configured to return None.
  service.register_plugin(plugin1)
  service.register_plugin(plugin2)

  result = await service.run_before_run_callback(invocation_context=Mock())

  # The final result should be None as no plugin interrupted the flow.
  assert result is None
  # Both plugins must have been called.
  assert "before_run_callback" in plugin1.call_log
  assert "before_run_callback" in plugin2.call_log


@pytest.mark.asyncio
async def test_plugin_exception_is_wrapped_in_runtime_error(
    service: PluginManager, plugin1: TestPlugin
):
  """Tests that if a plugin callback raises an exception, the PluginManager

  catches it and raises a descriptive RuntimeError.
  """
  # Configure the plugin to raise an error during a specific callback.
  original_exception = ValueError("Something went wrong inside the plugin!")
  plugin1.exceptions_to_raise["before_run_callback"] = original_exception
  service.register_plugin(plugin1)

  with pytest.raises(RuntimeError) as excinfo:
    await service.run_before_run_callback(invocation_context=Mock())

  # Check that the error message is informative.
  assert "Error in plugin 'plugin1'" in str(excinfo.value)
  assert "before_run_callback" in str(excinfo.value)
  # Check that the original exception is chained for better tracebacks.
  assert excinfo.value.__cause__ is original_exception


@pytest.mark.asyncio
async def test_all_callbacks_are_supported(
    service: PluginManager, plugin1: TestPlugin
):
  """Tests that all callbacks defined in the BasePlugin interface are supported

  by the PluginManager.
  """
  service.register_plugin(plugin1)
  mock_context = Mock()
  mock_user_message = Mock()

  # Test all callbacks
  await service.run_on_user_message_callback(
      user_message=mock_user_message, invocation_context=mock_context
  )
  await service.run_before_run_callback(invocation_context=mock_context)
  await service.run_after_run_callback(invocation_context=mock_context)
  await service.run_on_event_callback(
      invocation_context=mock_context, event=mock_context
  )
  await service.run_before_agent_callback(
      agent=mock_context, callback_context=mock_context
  )
  await service.run_after_agent_callback(
      agent=mock_context, callback_context=mock_context
  )
  await service.run_before_tool_callback(
      tool=mock_context, tool_args={}, tool_context=mock_context
  )
  await service.run_after_tool_callback(
      tool=mock_context, tool_args={}, tool_context=mock_context, result={}
  )
  await service.run_on_tool_error_callback(
      tool=mock_context,
      tool_args={},
      tool_context=mock_context,
      error=mock_context,
  )
  await service.run_before_model_callback(
      callback_context=mock_context, llm_request=mock_context
  )
  await service.run_after_model_callback(
      callback_context=mock_context, llm_response=mock_context
  )
  await service.run_on_model_error_callback(
      callback_context=mock_context,
      llm_request=mock_context,
      error=mock_context,
  )
  await service.run_on_agent_error_callback(
      agent=mock_context,
      callback_context=mock_context,
      error=mock_context,
  )
  await service.run_on_run_error_callback(
      invocation_context=mock_context,
      error=mock_context,
  )

  # Verify all callbacks were logged
  expected_callbacks = [
      "on_user_message_callback",
      "before_run_callback",
      "after_run_callback",
      "on_event_callback",
      "before_agent_callback",
      "after_agent_callback",
      "before_tool_callback",
      "after_tool_callback",
      "on_tool_error_callback",
      "before_model_callback",
      "after_model_callback",
      "on_model_error_callback",
      "on_agent_error_callback",
      "on_run_error_callback",
  ]
  assert set(plugin1.call_log) == set(expected_callbacks)


@pytest.mark.asyncio
async def test_close_calls_plugin_close(
    service: PluginManager, plugin1: TestPlugin
):
  """Tests that close calls the close method on registered plugins."""
  plugin1.close = AsyncMock()
  service.register_plugin(plugin1)

  await service.close()

  plugin1.close.assert_awaited_once()


@pytest.mark.asyncio
async def test_close_raises_runtime_error_on_plugin_exception(
    service: PluginManager, plugin1: TestPlugin
):
  """Tests that close raises a RuntimeError if a plugin's close fails."""
  plugin1.close = AsyncMock(side_effect=ValueError("Shutdown error"))
  service.register_plugin(plugin1)

  with pytest.raises(
      RuntimeError, match="Failed to close plugins: 'plugin1': ValueError"
  ):
    await service.close()

  plugin1.close.assert_awaited_once()


@pytest.mark.asyncio
async def test_close_with_timeout(plugin1: TestPlugin):
  """Tests that close respects the timeout and raises on failure."""
  service = PluginManager(close_timeout=0.1)

  async def slow_close():
    await asyncio.sleep(0.2)

  plugin1.close = slow_close
  service.register_plugin(plugin1)

  with pytest.raises(RuntimeError) as excinfo:
    await service.close()

  assert "Failed to close plugins: 'plugin1': TimeoutError" in str(
      excinfo.value
  )


@pytest.mark.asyncio
async def test_close_is_noop_after_set_skip_closing_plugins(
    plugin1: TestPlugin,
):
  """Tests that close is a no-op after set_skip_closing_plugins(True)."""
  plugin1.close = AsyncMock()
  service = PluginManager(plugins=[plugin1])
  service.set_skip_closing_plugins(True)

  await service.close()

  plugin1.close.assert_not_awaited()


@pytest.mark.asyncio
async def test_set_skip_closing_plugins_bypasses_close_timeout(
    plugin1: TestPlugin,
):
  """Tests that set_skip_closing_plugins(True) bypasses close timeout."""

  async def slow_close():
    await asyncio.sleep(10)  # Would otherwise time out.

  plugin1.close = slow_close
  service = PluginManager(plugins=[plugin1], close_timeout=0.01)
  service.set_skip_closing_plugins(True)

  # Should return immediately without raising.
  await service.close()


@pytest.mark.asyncio
async def test_set_skip_closing_plugins_false_reverts_to_closing(
    plugin1: TestPlugin,
):
  """Tests that set_skip_closing_plugins(False) re-enables closing."""
  plugin1.close = AsyncMock()
  service = PluginManager(plugins=[plugin1])
  service.set_skip_closing_plugins(True)
  service.set_skip_closing_plugins(False)

  await service.close()

  plugin1.close.assert_awaited_once()
