# 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 SandboxClient class."""

import base64
import json
import unittest
from unittest.mock import MagicMock
from unittest.mock import patch

from google.adk.integrations.vmaas.sandbox_client import SandboxClient


def _make_response(data: dict) -> MagicMock:
  """Create a mock HttpResponse with a JSON body."""
  response = MagicMock()
  response.body = json.dumps(data)
  return response


class TestSandboxClient(unittest.IsolatedAsyncioTestCase):
  """Tests for SandboxClient."""

  def setUp(self):
    """Set up test fixtures."""
    self.mock_vertexai_client = MagicMock()
    self.mock_sandbox = MagicMock()
    self.access_token = "test_token_12345"
    self.client = SandboxClient(
        vertexai_client=self.mock_vertexai_client,
        sandbox=self.mock_sandbox,
        access_token=self.access_token,
    )

  def test_init(self):
    """Test client initialization."""
    self.assertEqual(self.client._client, self.mock_vertexai_client)
    self.assertEqual(self.client._sandbox, self.mock_sandbox)
    self.assertEqual(self.client._access_token, self.access_token)

  def test_update_access_token(self):
    """Test updating access token."""
    new_token = "new_token_67890"
    self.client.update_access_token(new_token)
    self.assertEqual(self.client._access_token, new_token)

  @patch("asyncio.to_thread")
  async def test_make_cdp_request(self, mock_to_thread):
    """Test making a single CDP request."""
    mock_to_thread.return_value = _make_response({"result": "success"})

    result = await self.client.make_cdp_request(
        "Page.navigate", {"url": "https://example.com"}
    )

    self.assertEqual(result, {"result": "success"})
    mock_to_thread.assert_called_once()
    call_args = mock_to_thread.call_args
    # First positional arg is the send_command method
    self.assertEqual(
        call_args[0][0],
        self.mock_vertexai_client.agent_engines.sandboxes.send_command,
    )
    # Check keyword args
    self.assertEqual(call_args[1]["http_method"], "POST")
    self.assertEqual(call_args[1]["path"], "cdp")
    self.assertEqual(call_args[1]["access_token"], self.access_token)
    self.assertEqual(call_args[1]["sandbox_environment"], self.mock_sandbox)
    self.assertEqual(
        call_args[1]["request_dict"],
        {"command": "Page.navigate", "params": {"url": "https://example.com"}},
    )

  @patch("asyncio.to_thread")
  async def test_make_cdp_batch_request_with_batch_endpoint(
      self, mock_to_thread
  ):
    """Test making a batch CDP request using batch endpoint."""
    mock_to_thread.return_value = _make_response(
        {"results": [{"status": "success"}, {"status": "success"}]}
    )

    commands = [
        {
            "command": "Input.dispatchMouseEvent",
            "params": {"type": "mousePressed"},
        },
        {
            "command": "Input.dispatchMouseEvent",
            "params": {"type": "mouseReleased"},
        },
    ]
    result = await self.client.make_cdp_batch_request(commands)

    # Should have 2 results from batch
    self.assertEqual(len(result), 2)
    # Should have made 1 call to /cdps
    self.assertEqual(mock_to_thread.call_count, 1)
    call_args = mock_to_thread.call_args
    self.assertEqual(call_args[1]["path"], "cdps")

  @patch("asyncio.to_thread")
  async def test_make_cdp_batch_request_fallback_sequential(
      self, mock_to_thread
  ):
    """Test batch CDP falls back to sequential when batch endpoint fails."""

    # First call (to /cdps) fails with 404
    # Subsequent calls (to /cdp) succeed
    def side_effect(*args, **kwargs):
      if kwargs.get("path") == "cdps":
        raise Exception("404 Not Found")
      return _make_response({"result": "ok"})

    mock_to_thread.side_effect = side_effect

    commands = [
        {"command": "Command1", "params": {}},
        {"command": "Command2", "params": {}},
    ]
    result = await self.client.make_cdp_batch_request(commands)

    # Should have 2 results (sequential fallback)
    self.assertEqual(len(result), 2)
    self.assertEqual(result[0]["status"], "success")
    self.assertEqual(result[1]["status"], "success")
    # Should have made 3 calls (1 failed /cdps + 2 sequential /cdp)
    self.assertEqual(mock_to_thread.call_count, 3)

  @patch("asyncio.to_thread")
  async def test_get_screenshot(self, mock_to_thread):
    """Test capturing a screenshot."""
    # Create a simple PNG-like base64 data
    png_data = b"\x89PNG\r\n\x1a\n"
    base64_data = base64.b64encode(png_data).decode()

    mock_to_thread.return_value = _make_response({"data": base64_data})

    result = await self.client.get_screenshot()

    self.assertEqual(result, png_data)
    call_args = mock_to_thread.call_args
    self.assertEqual(
        call_args[1]["request_dict"]["command"], "Page.captureScreenshot"
    )

  @patch("asyncio.to_thread")
  async def test_get_current_url(self, mock_to_thread):
    """Test getting the current URL."""
    mock_to_thread.return_value = _make_response({
        "active_tab_id": "tab1",
        "all_tabs": [
            {"id": "tab1", "url": "https://example.com", "title": "Example"},
        ],
    })

    result = await self.client.get_current_url()

    self.assertEqual(result, "https://example.com")
    call_args = mock_to_thread.call_args
    self.assertEqual(call_args[1]["path"], "tabs")
    self.assertEqual(call_args[1]["http_method"], "GET")

  @patch("asyncio.to_thread")
  async def test_get_current_url_no_active_tab(self, mock_to_thread):
    """Test getting URL when no tab is active."""
    mock_to_thread.return_value = _make_response({
        "active_tab_id": None,
        "all_tabs": [],
    })

    result = await self.client.get_current_url()

    self.assertIsNone(result)

  @patch("asyncio.to_thread")
  async def test_navigate(self, mock_to_thread):
    """Test navigating to a URL."""
    mock_to_thread.return_value = _make_response({"frameId": "frame123"})

    result = await self.client.navigate("https://example.com")

    self.assertEqual(result, {"frameId": "frame123"})
    call_args = mock_to_thread.call_args
    self.assertEqual(
        call_args[1]["request_dict"],
        {"command": "Page.navigate", "params": {"url": "https://example.com"}},
    )

  @patch("asyncio.to_thread")
  async def test_click_at(self, mock_to_thread):
    """Test clicking at coordinates."""
    # Return success for batch endpoint
    mock_to_thread.return_value = _make_response({"results": [{}, {}]})

    await self.client.click_at(100, 200)

    # Should call batch endpoint
    call_args = mock_to_thread.call_args
    self.assertEqual(call_args[1]["path"], "cdps")
    commands = call_args[1]["request_dict"]["commands"]
    self.assertEqual(len(commands), 2)
    self.assertEqual(commands[0]["params"]["type"], "mousePressed")
    self.assertEqual(commands[1]["params"]["type"], "mouseReleased")

  @patch("asyncio.to_thread")
  async def test_hover_at(self, mock_to_thread):
    """Test hovering at coordinates."""
    mock_to_thread.return_value = _make_response({})

    await self.client.hover_at(150, 250)

    call_args = mock_to_thread.call_args
    self.assertEqual(
        call_args[1]["request_dict"]["params"],
        {"type": "mouseMoved", "x": 150, "y": 250},
    )

  @patch("asyncio.to_thread")
  async def test_scroll_at_down(self, mock_to_thread):
    """Test scrolling down."""
    mock_to_thread.return_value = _make_response({})

    await self.client.scroll_at(100, 200, "down", 300)

    call_args = mock_to_thread.call_args
    params = call_args[1]["request_dict"]["params"]
    self.assertEqual(params["type"], "mouseWheel")
    self.assertEqual(params["x"], 100)
    self.assertEqual(params["y"], 200)
    self.assertEqual(params["deltaX"], 0)
    self.assertEqual(params["deltaY"], 300)  # Positive for down

  @patch("asyncio.to_thread")
  async def test_scroll_at_up(self, mock_to_thread):
    """Test scrolling up."""
    mock_to_thread.return_value = _make_response({})

    await self.client.scroll_at(100, 200, "up", 300)

    call_args = mock_to_thread.call_args
    params = call_args[1]["request_dict"]["params"]
    self.assertEqual(params["deltaY"], -300)  # Negative for up

  @patch("asyncio.to_thread")
  async def test_go_back(self, mock_to_thread):
    """Test navigating back."""
    # First call returns navigation history, second navigates
    mock_to_thread.side_effect = [
        _make_response({
            "currentIndex": 1,
            "entries": [
                {"id": 1, "url": "https://first.com"},
                {"id": 2, "url": "https://second.com"},
            ],
        }),
        _make_response({}),  # Navigation response
    ]

    result = await self.client.go_back()

    self.assertTrue(result)
    self.assertEqual(mock_to_thread.call_count, 2)

  @patch("asyncio.to_thread")
  async def test_go_back_at_beginning(self, mock_to_thread):
    """Test navigating back when at beginning of history."""
    mock_to_thread.return_value = _make_response({
        "currentIndex": 0,
        "entries": [{"id": 1, "url": "https://first.com"}],
    })

    result = await self.client.go_back()

    self.assertFalse(result)
    # Should only call once (to get history)
    self.assertEqual(mock_to_thread.call_count, 1)

  @patch("asyncio.to_thread")
  async def test_type_text_with_clear_and_enter(self, mock_to_thread):
    """Test typing text with clear and enter options."""
    # Return success for batch endpoint
    mock_to_thread.return_value = _make_response({"results": [{}] * 7})

    await self.client.type_text(
        "hello", press_enter=True, clear_before_typing=True
    )

    # Should have: Ctrl+A down, Ctrl+A up, Delete down, Delete up,
    # insertText, Enter down, Enter up = 7 commands in batch
    call_args = mock_to_thread.call_args
    commands = call_args[1]["request_dict"]["commands"]
    self.assertEqual(len(commands), 7)

  @patch("asyncio.to_thread")
  async def test_key_combination(self, mock_to_thread):
    """Test pressing key combinations."""
    mock_to_thread.return_value = _make_response({"results": [{}] * 4})

    await self.client.key_combination(["control", "c"])

    # Should have: Control down, c down, c up, Control up = 4 commands
    call_args = mock_to_thread.call_args
    commands = call_args[1]["request_dict"]["commands"]
    self.assertEqual(len(commands), 4)

  @patch("asyncio.to_thread")
  async def test_drag_and_drop(self, mock_to_thread):
    """Test drag and drop operation."""
    mock_to_thread.return_value = _make_response({"results": [{}] * 4})

    await self.client.drag_and_drop(10, 20, 100, 200)

    # Should have: mouseMoved (start), mousePressed, mouseMoved (end),
    # mouseReleased = 4 commands
    call_args = mock_to_thread.call_args
    commands = call_args[1]["request_dict"]["commands"]
    self.assertEqual(len(commands), 4)

  @patch("asyncio.to_thread")
  async def test_health_check_healthy(self, mock_to_thread):
    """Test health check when sandbox is healthy."""
    mock_to_thread.return_value = _make_response({"status": "healthy"})

    result = await self.client.health_check()

    self.assertTrue(result)
    call_args = mock_to_thread.call_args
    self.assertEqual(call_args[1]["http_method"], "GET")
    self.assertEqual(call_args[1]["path"], "")

  @patch("asyncio.to_thread")
  async def test_health_check_unhealthy(self, mock_to_thread):
    """Test health check when sandbox is unhealthy."""
    mock_to_thread.return_value = _make_response({"status": "unhealthy"})

    result = await self.client.health_check()

    self.assertFalse(result)

  @patch("asyncio.to_thread")
  async def test_health_check_exception(self, mock_to_thread):
    """Test health check when request fails."""
    mock_to_thread.side_effect = Exception("Connection failed")

    result = await self.client.health_check()

    self.assertFalse(result)


if __name__ == "__main__":
  unittest.main()
