"""Tests for vm_mouse_click coordinate resolution: scale, mark_id, and raw x/y.

Tests: vm_mouse_click in vm_interaction.py — coordinate resolution priority,
       scale division math, mark_id resolution from cache, and ToolError paths.
How: Mock _load_vboxapi to avoid needing a live VirtualBox installation;
     populate _marks_cache directly to test mark resolution without vm_screenshot.
Why: Incorrect coordinate resolution causes clicks to land at the wrong pixel
     position, making the pilot unreliable for any GUI interaction workflow.
"""

from __future__ import annotations

from typing import TYPE_CHECKING
from unittest.mock import MagicMock

import pytest
from fastmcp.exceptions import ToolError

from mcp_vm_blackbox.models import MarkEntry, SoMMarksLegend

if TYPE_CHECKING:
    from pytest_mock import MockerFixture


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_legend(vm_name: str, marks: list[MarkEntry]) -> SoMMarksLegend:
    """Build a SoMMarksLegend for populating the marks cache.

    Args:
        vm_name: VM name to associate with the legend.
        marks: List of MarkEntry records for the legend.

    Returns:
        SoMMarksLegend instance suitable for inserting into _marks_cache.
    """
    return SoMMarksLegend(
        vm_name=vm_name, marks=marks, detection_method="uiautomation", timestamp="2026-01-01T00:00:00+00:00"
    )


def _make_mark(mark_id: int, native_x: int, native_y: int, name: str = "Btn") -> MarkEntry:
    """Build a MarkEntry for use in a SoMMarksLegend.

    Args:
        mark_id: 1-based sequential mark ID.
        native_x: Native VM X coordinate of the element centre.
        native_y: Native VM Y coordinate of the element centre.
        name: Element accessibility name.

    Returns:
        MarkEntry instance.
    """
    return MarkEntry(
        mark_id=mark_id,
        name=name,
        control_type="Button",
        native_x=native_x,
        native_y=native_y,
        bounding_rect=(native_x - 40, native_y - 10, native_x + 40, native_y + 10),
    )


def _mock_vboxapi(mocker: MockerFixture) -> MagicMock:
    """Patch _load_vboxapi so vm_mouse_click does not require VirtualBox.

    Returns a mock VBoxManager class whose instances expose the minimal API
    surface needed by vm_mouse_click: getVirtualBox, findMachine,
    openMachineSession, closeMachineSession, and console.mouse.

    Args:
        mocker: pytest-mock fixture.

    Returns:
        Mock for _load_vboxapi return value (the VBoxManager class).
    """
    mouse = MagicMock()
    mouse.putMouseEventAbsolute = MagicMock(return_value=None)
    mouse.putMouseEvent = MagicMock(return_value=None)

    console = MagicMock()
    console.mouse = mouse

    session = MagicMock()
    session.console = console

    machine = MagicMock()

    vbox = MagicMock()
    vbox.findMachine = MagicMock(return_value=machine)

    mgr_instance = MagicMock()
    mgr_instance.getVirtualBox = MagicMock(return_value=vbox)
    mgr_instance.openMachineSession = MagicMock(return_value=session)
    mgr_instance.closeMachineSession = MagicMock(return_value=None)

    mgr_cls = MagicMock(return_value=mgr_instance)

    mocker.patch("mcp_vm_blackbox.vm_interaction._load_vboxapi", return_value=mgr_cls)
    return mgr_cls


# ---------------------------------------------------------------------------
# Tests: scale parameter
# ---------------------------------------------------------------------------


class TestVmMouseClickScale:
    """Tests for vm_mouse_click coordinate resolution via the scale parameter.

    Scope: When scale is provided and mark_id is None, native coordinates are
    computed as int(x / scale) and int(y / scale).
    Strategy: Mock vboxapi, call vm_mouse_click with known x, y, scale values,
    and verify the result reports the mathematically correct native coordinates.
    """

    def test_scale_factor_converts_jpeg_coords_to_native(self, mocker: MockerFixture) -> None:
        """scale=0.5333 with x=512, y=384 yields correctly rounded native coordinates.

        Tests: vm_mouse_click scale division math.
        How: Call with x=512, y=384, scale=0.5333; assert native_x = int(512/0.5333)
             and native_y = int(384/0.5333) are stored in the result.
        Why: The pilot provides JPEG-space coordinates from the downscaled image;
             dividing by scale must recover the original native VM pixel position.
        """
        # Arrange
        _mock_vboxapi(mocker)
        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        scale = 0.5333
        x, y = 512, 384
        expected_native_x = int(x / scale)
        expected_native_y = int(y / scale)

        # Act
        result = vm_mouse_click(vm_name="my-vm", x=x, y=y, scale=scale)

        # Assert
        assert result.status == "ok"
        assert result.x == expected_native_x
        assert result.y == expected_native_y
        assert result.scale_applied == pytest.approx(scale)
        assert result.mark_id_resolved is None

    def test_scale_none_uses_raw_coordinates_without_transformation(self, mocker: MockerFixture) -> None:
        """scale=None passes x/y through as-is with no coordinate transformation.

        Tests: vm_mouse_click raw coordinate fallback (scale=None, mark_id=None).
        How: Call with x=300, y=200, scale=None; result.x and result.y must equal
             the raw input values unchanged.
        Why: Pilots operating on native-resolution screenshots (no downscale) pass
             coordinates directly; scale=None must be the identity transformation.
        """
        # Arrange
        _mock_vboxapi(mocker)
        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act
        result = vm_mouse_click(vm_name="my-vm", x=300, y=200)

        # Assert
        assert result.status == "ok"
        assert result.x == 300
        assert result.y == 200
        assert result.scale_applied is None
        assert result.mark_id_resolved is None

    def test_scale_with_zero_x_raises_tool_error(self, mocker: MockerFixture) -> None:
        """scale provided with x=0 raises ToolError rather than dividing by zero.

        Tests: vm_mouse_click validation when scale is set but x is zero.
        How: Call with x=0, y=100, scale=0.5; expect ToolError to be raised.
        Why: x=0 is invalid when scale is provided; silent acceptance would
             produce a click at native x=0, far from the intended target.
        """
        # Arrange
        _mock_vboxapi(mocker)
        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act & Assert
        with pytest.raises(ToolError, match="x and y must be > 0"):
            vm_mouse_click(vm_name="my-vm", x=0, y=100, scale=0.5)

    def test_scale_with_zero_y_raises_tool_error(self, mocker: MockerFixture) -> None:
        """scale provided with y=0 raises ToolError.

        Tests: vm_mouse_click validation when scale is set but y is zero.
        How: Call with x=100, y=0, scale=0.5; expect ToolError to be raised.
        Why: y=0 is an invalid coordinate when scale is provided; it must be
             rejected with a clear error, not silently mapped to native y=0.
        """
        # Arrange
        _mock_vboxapi(mocker)
        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act & Assert
        with pytest.raises(ToolError, match="x and y must be > 0"):
            vm_mouse_click(vm_name="my-vm", x=100, y=0, scale=0.5)

    def test_scale_applied_recorded_in_result(self, mocker: MockerFixture) -> None:
        """scale_applied in result matches the scale value that was passed in.

        Tests: vm_mouse_click scale_applied result field.
        How: Call with scale=0.75, verify result.scale_applied == 0.75.
        Why: The pilot uses scale_applied to confirm which scale factor was
             used, enabling retrospective audit of click coordinate conversion.
        """
        # Arrange
        _mock_vboxapi(mocker)
        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act
        result = vm_mouse_click(vm_name="my-vm", x=300, y=200, scale=0.75)

        # Assert
        assert result.scale_applied == pytest.approx(0.75)


# ---------------------------------------------------------------------------
# Tests: mark_id resolution
# ---------------------------------------------------------------------------


class TestVmMouseClickMarkId:
    """Tests for vm_mouse_click coordinate resolution via mark_id.

    Scope: When mark_id is provided, coordinates are resolved from the
    in-process _marks_cache populated by vm_screenshot(som=True).
    Strategy: Directly populate _marks_cache before each test, then call
    vm_mouse_click(mark_id=N) and verify the result carries the correct
    native coordinates and mark_id_resolved field.
    """

    def test_mark_id_resolves_native_coordinates_from_cache(self, mocker: MockerFixture) -> None:
        """mark_id=2 resolves to the native_x/native_y of mark 2 in the cache.

        Tests: vm_mouse_click mark_id lookup and coordinate resolution.
        How: Pre-populate _marks_cache with two marks, call with mark_id=2,
             verify result.x and result.y match mark 2's native coordinates.
        Why: The primary use case of SoM marks is mark_id-based clicking;
             any resolution error causes a click at the wrong element.
        """
        # Arrange
        _mock_vboxapi(mocker)
        import mcp_vm_blackbox.vm_interaction as vm_interaction

        legend = _make_legend(
            "test-vm", [_make_mark(1, native_x=100, native_y=200), _make_mark(2, native_x=500, native_y=350)]
        )
        vm_interaction._marks_cache["test-vm"] = legend

        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act
        result = vm_mouse_click(vm_name="test-vm", mark_id=2)

        # Assert
        assert result.status == "ok"
        assert result.x == 500
        assert result.y == 350
        assert result.mark_id_resolved == 2
        assert result.scale_applied is None

        # Cleanup
        del vm_interaction._marks_cache["test-vm"]

    def test_mark_id_takes_precedence_over_scale(self, mocker: MockerFixture) -> None:
        """mark_id takes priority over scale when both are provided.

        Tests: vm_mouse_click coordinate resolution priority (mark_id > scale).
        How: Pre-populate cache with mark 1 at (800, 600), call with
             mark_id=1 AND scale=0.5 AND x=100, y=50; verify native coords
             come from the mark (800, 600), not from scale division (200, 100).
        Why: The documented priority is mark_id > scale > raw x/y; violating
             this would make mark_id unreliable whenever scale is also set.
        """
        # Arrange
        _mock_vboxapi(mocker)
        import mcp_vm_blackbox.vm_interaction as vm_interaction

        legend = _make_legend("priority-vm", [_make_mark(1, native_x=800, native_y=600)])
        vm_interaction._marks_cache["priority-vm"] = legend

        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act — both mark_id and scale provided; mark_id must win
        result = vm_mouse_click(vm_name="priority-vm", x=100, y=50, scale=0.5, mark_id=1)

        # Assert — coordinates from mark, not from scale division
        assert result.x == 800
        assert result.y == 600
        assert result.mark_id_resolved == 1

        # Cleanup
        del vm_interaction._marks_cache["priority-vm"]

    def test_mark_id_for_vm_with_no_cache_raises_tool_error(self, mocker: MockerFixture) -> None:
        """mark_id on a VM with no cached legend raises ToolError with useful message.

        Tests: vm_mouse_click ToolError when _marks_cache has no entry for the VM.
        How: Ensure _marks_cache has no entry for 'unknown-vm', call with mark_id=1;
             verify ToolError is raised and mentions the VM name.
        Why: A missing cache means vm_screenshot(som=True) was not called first;
             the error must clearly tell the pilot what to do (call vm_screenshot).
        """
        # Arrange
        _mock_vboxapi(mocker)
        import mcp_vm_blackbox.vm_interaction as vm_interaction

        vm_interaction._marks_cache.pop("unknown-vm", None)

        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act & Assert
        with pytest.raises(ToolError, match="unknown-vm"):
            vm_mouse_click(vm_name="unknown-vm", mark_id=1)

    def test_mark_id_not_in_legend_raises_tool_error(self, mocker: MockerFixture) -> None:
        """mark_id not present in the cached legend raises ToolError.

        Tests: vm_mouse_click ToolError when mark_id is not found in the legend.
        How: Cache a legend with mark 1 only, call with mark_id=99; verify
             ToolError is raised mentioning the missing mark ID.
        Why: Pilots may reference stale mark IDs from a previous screenshot;
             a clear error prevents silent misclicks at mark 1's position.
        """
        # Arrange
        _mock_vboxapi(mocker)
        import mcp_vm_blackbox.vm_interaction as vm_interaction

        legend = _make_legend("mark-vm", [_make_mark(1, native_x=100, native_y=200)])
        vm_interaction._marks_cache["mark-vm"] = legend

        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act & Assert
        with pytest.raises(ToolError, match="99"):
            vm_mouse_click(vm_name="mark-vm", mark_id=99)

        # Cleanup
        del vm_interaction._marks_cache["mark-vm"]

    def test_mark_id_resolved_field_populated_in_result(self, mocker: MockerFixture) -> None:
        """mark_id_resolved field in result matches the mark_id that was used.

        Tests: vm_mouse_click mark_id_resolved result field population.
        How: Resolve mark_id=3, verify result.mark_id_resolved == 3.
        Why: The pilot and inspector use mark_id_resolved to correlate clicks
             with the SoM legend entries in the job step log.
        """
        # Arrange
        _mock_vboxapi(mocker)
        import mcp_vm_blackbox.vm_interaction as vm_interaction

        legend = _make_legend("field-vm", [_make_mark(3, native_x=700, native_y=400)])
        vm_interaction._marks_cache["field-vm"] = legend

        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act
        result = vm_mouse_click(vm_name="field-vm", mark_id=3)

        # Assert
        assert result.mark_id_resolved == 3
        assert result.scale_applied is None

        # Cleanup
        del vm_interaction._marks_cache["field-vm"]


# ---------------------------------------------------------------------------
# Tests: raw x/y validation
# ---------------------------------------------------------------------------


class TestVmMouseClickRawCoords:
    """Tests for vm_mouse_click raw coordinate validation (no scale, no mark_id).

    Scope: When neither mark_id nor scale is provided, x and y are used as-is
    but must be > 0.
    Strategy: Call vm_mouse_click without scale or mark_id and verify the
    validation and pass-through behaviour.
    """

    def test_raw_coords_zero_x_raises_tool_error(self, mocker: MockerFixture) -> None:
        """Raw x=0 with no mark_id raises ToolError.

        Tests: vm_mouse_click validation for raw x=0.
        How: Call with x=0, y=100, no scale, no mark_id; expect ToolError.
        Why: Coordinate 0 is outside the valid 1-based pixel grid; accepting
             it would cause VirtualBox to reject the mouse event silently.
        """
        # Arrange
        _mock_vboxapi(mocker)
        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act & Assert
        with pytest.raises(ToolError, match="x and y must be > 0"):
            vm_mouse_click(vm_name="my-vm", x=0, y=100)

    def test_raw_coords_zero_y_raises_tool_error(self, mocker: MockerFixture) -> None:
        """Raw y=0 with no mark_id raises ToolError.

        Tests: vm_mouse_click validation for raw y=0.
        How: Call with x=100, y=0, no scale, no mark_id; expect ToolError.
        Why: Same boundary guard as x=0 — y must be within the valid pixel grid.
        """
        # Arrange
        _mock_vboxapi(mocker)
        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act & Assert
        with pytest.raises(ToolError, match="x and y must be > 0"):
            vm_mouse_click(vm_name="my-vm", x=100, y=0)

    def test_non_local_target_raises_tool_error(self, mocker: MockerFixture) -> None:
        """target != 'local' raises ToolError because vboxapi is XPCOM-local only.

        Tests: vm_mouse_click target validation.
        How: Call with target='ci'; expect ToolError mentioning 'local'.
        Why: vboxapi XPCOM bindings only work on the host running VirtualBox;
             a clear error prevents pilots from attempting remote clicks.
        """
        # Arrange
        _mock_vboxapi(mocker)
        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act & Assert
        with pytest.raises(ToolError, match="local"):
            vm_mouse_click(vm_name="my-vm", x=100, y=100, target="ci")

    def test_unknown_button_raises_tool_error(self, mocker: MockerFixture) -> None:
        """An unknown button name raises ToolError.

        Tests: vm_mouse_click button validation.
        How: Call with button='thumb'; expect ToolError mentioning the bad value.
        Why: VirtualBox button masks are fixed; an unknown button would produce
             a zero-mask no-op click that silently fails without this guard.
        """
        # Arrange
        _mock_vboxapi(mocker)
        from mcp_vm_blackbox.vm_interaction import vm_mouse_click

        # Act & Assert
        with pytest.raises(ToolError, match="thumb"):
            vm_mouse_click(vm_name="my-vm", x=100, y=100, button="thumb")
