"""Tests for the Set-of-Marks overlay module (som_overlay.py).

Tests: stamp_marks, detect_elements, DetectedElement, SOM_MAX_ELEMENTS cap.
How: Use in-memory PIL images and mock vm_powershell to drive all paths
     without a live VM or WinRM connection.
Why: SoM overlays are the primary mechanism by which pilots identify and
     click specific UI elements; incorrect mark placement or legend data
     would result in misclicks or missed interactions.
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock

from PIL import Image as PilImage

from mcp_vm_blackbox.models import ScaleInfo, SoMMarksLegend
from mcp_vm_blackbox.som_overlay import SOM_MAX_ELEMENTS, DetectedElement, detect_elements, stamp_marks

if TYPE_CHECKING:
    from pytest_mock import MockerFixture


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


def _make_rgb_image(width: int = 1024, height: int = 768) -> PilImage.Image:
    """Return a solid white RGB PIL image at given dimensions.

    Args:
        width: Image width in pixels.
        height: Image height in pixels.

    Returns:
        A new RGB PIL Image.
    """
    return PilImage.new("RGB", (width, height), color=(255, 255, 255))


def _make_scale_info(
    native_width: int = 1920, native_height: int = 1080, output_width: int = 1024, output_height: int = 576
) -> ScaleInfo:
    """Build a ScaleInfo matching given native and output dimensions.

    Args:
        native_width: Native VM resolution width.
        native_height: Native VM resolution height.
        output_width: Downscaled output width.
        output_height: Downscaled output height.

    Returns:
        ScaleInfo with computed scale_x and scale_y.
    """
    return ScaleInfo(
        scale_x=output_width / native_width,
        scale_y=output_height / native_height,
        native_width=native_width,
        native_height=native_height,
        output_width=output_width,
        output_height=output_height,
    )


def _make_element(
    name: str = "OK", control_type: str = "Button", bounding_rect: tuple[int, int, int, int] = (100, 200, 200, 240)
) -> DetectedElement:
    """Build a DetectedElement for testing.

    Args:
        name: Element accessibility name.
        control_type: UIAutomation control type string.
        bounding_rect: (left, top, right, bottom) in native coordinates.

    Returns:
        DetectedElement instance.
    """
    return DetectedElement(name=name, control_type=control_type, bounding_rect=bounding_rect)


def _mock_powershell_result(stdout: str, exit_code: int = 0) -> MagicMock:
    """Build a mock CommandResult for vm_powershell.

    Args:
        stdout: Simulated stdout from PowerShell.
        exit_code: Simulated exit code.

    Returns:
        MagicMock matching the CommandResult interface.
    """
    result = MagicMock()
    result.stdout = stdout
    result.stderr = ""
    result.exit_code = exit_code
    return result


# ---------------------------------------------------------------------------
# stamp_marks tests
# ---------------------------------------------------------------------------


class TestStampMarks:
    """Tests for stamp_marks — the mark rendering and legend generation function.

    Scope: stamp_marks draws numbered labels on a PIL image and returns a
    SoMMarksLegend with native-resolution coordinates.
    Strategy: Create controlled PIL images and DetectedElement lists, call
    stamp_marks, and verify both the legend contents and the image modification.
    """

    def test_stamp_marks_with_single_element_returns_legend_with_one_entry(self) -> None:
        """stamp_marks with one element produces a legend with exactly one MarkEntry.

        Tests: stamp_marks legend construction for a single element.
        How: Pass one DetectedElement, verify legend.marks has one entry with
             correct mark_id, name, control_type, and native coordinates.
        Why: The pilot uses the legend to map mark IDs back to native click
             coordinates; a missing or wrong entry causes a misclick.
        """
        # Arrange
        image = _make_rgb_image(1024, 576)
        elements = [_make_element("OK Button", "Button", (100, 200, 300, 240))]
        scale_info = _make_scale_info()

        # Act
        _out_image, legend = stamp_marks(image, elements, scale_info)

        # Assert
        assert isinstance(legend, SoMMarksLegend)
        assert len(legend.marks) == 1
        entry = legend.marks[0]
        assert entry.mark_id == 1
        assert entry.name == "OK Button"
        assert entry.control_type == "Button"
        # native_x is midpoint of bounding_rect left/right: (100+300)//2 = 200
        assert entry.native_x == 200
        # native_y is midpoint of bounding_rect top/bottom: (200+240)//2 = 220
        assert entry.native_y == 220
        assert entry.bounding_rect == (100, 200, 300, 240)

    def test_stamp_marks_with_multiple_elements_assigns_sequential_mark_ids(self) -> None:
        """stamp_marks assigns 1-based sequential mark IDs to each element.

        Tests: stamp_marks mark_id assignment for multiple elements.
        How: Pass three elements, verify mark_ids are 1, 2, 3 in order.
        Why: Pilots refer to marks by ID; gaps or out-of-order IDs break
             the mark_id -> coordinate mapping.
        """
        # Arrange
        image = _make_rgb_image(1024, 576)
        elements = [
            _make_element("Button A", "Button", (0, 0, 100, 40)),
            _make_element("Edit B", "Edit", (100, 0, 300, 40)),
            _make_element("Label C", "Text", (300, 0, 500, 40)),
        ]
        scale_info = _make_scale_info()

        # Act
        _out_image, legend = stamp_marks(image, elements, scale_info)

        # Assert
        assert len(legend.marks) == 3
        assert [e.mark_id for e in legend.marks] == [1, 2, 3]
        assert legend.marks[0].name == "Button A"
        assert legend.marks[1].name == "Edit B"
        assert legend.marks[2].name == "Label C"

    def test_stamp_marks_empty_elements_returns_empty_legend(self) -> None:
        """stamp_marks with an empty element list returns an empty legend.

        Tests: stamp_marks graceful handling of no detected elements.
        How: Pass an empty list, verify legend.marks is empty and no exceptions.
        Why: SoM screenshots on VMs with no interactive elements must still
             succeed and return a clean, empty legend without crashing.
        """
        # Arrange
        image = _make_rgb_image()
        scale_info = _make_scale_info()

        # Act
        _out_image, legend = stamp_marks(image, [], scale_info)

        # Assert
        assert isinstance(legend, SoMMarksLegend)
        assert legend.marks == []

    def test_stamp_marks_caps_at_som_max_elements(self) -> None:
        """stamp_marks processes at most SOM_MAX_ELEMENTS elements.

        Tests: stamp_marks SOM_MAX_ELEMENTS cap enforcement.
        How: Pass SOM_MAX_ELEMENTS + 10 elements, verify exactly SOM_MAX_ELEMENTS
             MarkEntry records appear in the legend.
        Why: Exceeding the cap would produce an oversized legend that degrades
             pilot context and may cause JSON serialisation failures.
        """
        # Arrange
        image = _make_rgb_image(4096, 2048)
        elements = [
            _make_element(f"Elem{i}", "Button", (i * 10, 0, i * 10 + 8, 10)) for i in range(SOM_MAX_ELEMENTS + 10)
        ]
        scale_info = _make_scale_info(native_width=4096, native_height=2048, output_width=2048, output_height=1024)

        # Act
        _out_image, legend = stamp_marks(image, elements, scale_info)

        # Assert
        assert len(legend.marks) == SOM_MAX_ELEMENTS

    def test_stamp_marks_label_positioned_at_scaled_coordinates(self) -> None:
        """Labels are stamped at top-left of element scaled to output space.

        Tests: stamp_marks coordinate scaling for label placement.
        How: Use 1920x1080 native with 0.5x scale, element at (200, 100).
             Scaled position should be at approximately (100, 50) in output space.
        Why: Marks must appear visually at the element location in the delivered
             JPEG; displacement from incorrect scaling confuses the pilot.
        """
        # Arrange — exact 0.5x scale: output=960x540 from native=1920x1080
        scale_info = ScaleInfo(
            scale_x=0.5, scale_y=0.5, native_width=1920, native_height=1080, output_width=960, output_height=540
        )
        image = _make_rgb_image(960, 540)
        elements = [_make_element("Btn", "Button", (200, 100, 300, 140))]

        # Act — stamp produces the image and legend
        _out_image, legend = stamp_marks(image, elements, scale_info)

        # Assert — native coords are midpoints of bounding_rect
        entry = legend.marks[0]
        # native_x = (200 + 300) // 2 = 250; native_y = (100 + 140) // 2 = 120
        assert entry.native_x == 250
        assert entry.native_y == 120

    def test_stamp_marks_returns_same_image_object(self) -> None:
        """stamp_marks returns the same PIL Image object it was given (in-place).

        Tests: stamp_marks in-place modification contract.
        How: Compare returned image identity with the input image.
        Why: Callers rely on in-place modification to avoid extra allocations;
             returning a copy would break the re-encode step after stamping.
        """
        # Arrange
        image = _make_rgb_image(640, 480)
        elements = [_make_element()]
        scale_info = _make_scale_info(native_width=640, native_height=480, output_width=640, output_height=480)

        # Act
        returned_image, _legend = stamp_marks(image, elements, scale_info)

        # Assert
        assert returned_image is image

    def test_stamp_marks_legend_has_timestamp(self) -> None:
        """stamp_marks legend carries an ISO 8601 timestamp.

        Tests: SoMMarksLegend timestamp field population.
        How: Call stamp_marks, verify legend.timestamp is a non-empty string.
        Why: Timestamps enable the dashboard and inspector to correlate SoM
             legends with job step timelines.
        """
        # Arrange
        image = _make_rgb_image()
        elements = [_make_element()]
        scale_info = _make_scale_info()

        # Act
        _out_image, legend = stamp_marks(image, elements, scale_info)

        # Assert
        assert isinstance(legend.timestamp, str)
        assert len(legend.timestamp) > 0

    def test_stamp_marks_native_coords_stored_not_scaled(self) -> None:
        """MarkEntry coordinates are native resolution, not scaled output coordinates.

        Tests: stamp_marks stores pre-scale coordinates in MarkEntry.
        How: Use 0.5x scale and verify native_x / scale_x gives the correct
             native value rather than the scaled value.
        Why: vm_mouse_click receives native coordinates directly from the legend;
             if scaled coordinates were stored, every click would land off-target.
        """
        # Arrange
        scale_info = ScaleInfo(
            scale_x=0.5, scale_y=0.5, native_width=1920, native_height=1080, output_width=960, output_height=540
        )
        image = _make_rgb_image(960, 540)
        # Element at native (400, 200, 600, 280) -> midpoint (500, 240)
        elements = [_make_element("Btn", "Button", (400, 200, 600, 280))]

        # Act
        _out_image, legend = stamp_marks(image, elements, scale_info)

        # Assert — native coords, not scaled
        entry = legend.marks[0]
        assert entry.native_x == 500  # (400 + 600) // 2
        assert entry.native_y == 240  # (200 + 280) // 2


# ---------------------------------------------------------------------------
# detect_elements tests
# ---------------------------------------------------------------------------


class TestDetectElements:
    """Tests for detect_elements — async UIAutomation-based element detection.

    Scope: detect_elements calls vm_powershell, parses JSON, and converts
    the results to DetectedElement instances.
    Strategy: Mock vm_powershell to control every execution path, including
    valid JSON, empty results, JSON errors, and PowerShell failures.
    """

    async def test_detect_elements_valid_json_returns_detected_elements(self, mocker: MockerFixture) -> None:
        """detect_elements parses valid JSON into a DetectedElement list.

        Tests: detect_elements happy-path JSON parsing.
        How: Mock vm_powershell to return a JSON array of two elements; verify
             the returned list has matching DetectedElement instances.
        Why: Element detection is the first step of the SoM pipeline; a parsing
             bug here silently drops all interactive elements from the legend.
        """
        # Arrange
        elements_json = json.dumps([
            {"Name": "OK", "ControlType": "button", "Left": 10, "Top": 20, "Right": 100, "Bottom": 50},
            {"Name": "Cancel", "ControlType": "button", "Left": 110, "Top": 20, "Right": 200, "Bottom": 50},
        ])
        mock_result = _mock_powershell_result(elements_json)
        mocker.patch("mcp_vm_blackbox.vm_interaction.vm_powershell", new=AsyncMock(return_value=mock_result))

        # Act
        elements = await detect_elements("my-vm", target="local")

        # Assert
        assert len(elements) == 2
        assert elements[0].name == "OK"
        assert elements[0].control_type == "button"
        assert elements[0].bounding_rect == (10, 20, 100, 50)
        assert elements[1].name == "Cancel"

    async def test_detect_elements_empty_result_returns_empty_list(self, mocker: MockerFixture) -> None:
        """detect_elements with empty JSON array returns an empty list.

        Tests: detect_elements handles zero-element JSON response.
        How: Mock vm_powershell to return '[]'; verify empty list is returned.
        Why: VMs with no interactive foreground elements are valid; the caller
             must receive [] rather than raising or returning None.
        """
        # Arrange
        mocker.patch(
            "mcp_vm_blackbox.vm_interaction.vm_powershell", new=AsyncMock(return_value=_mock_powershell_result("[]"))
        )

        # Act
        elements = await detect_elements("my-vm")

        # Assert
        assert elements == []

    async def test_detect_elements_powershell_exception_returns_empty_list(self, mocker: MockerFixture) -> None:
        """detect_elements returns empty list when vm_powershell raises an exception.

        Tests: detect_elements exception safety (NEVER raises).
        How: Mock vm_powershell to raise RuntimeError; verify [] is returned.
        Why: detect_elements has a documented contract of never raising; any
             propagated exception would crash vm_screenshot(som=True).
        """
        # Arrange
        mocker.patch(
            "mcp_vm_blackbox.vm_interaction.vm_powershell",
            new=AsyncMock(side_effect=RuntimeError("WinRM connection refused")),
        )

        # Act
        elements = await detect_elements("my-vm")

        # Assert
        assert elements == []

    async def test_detect_elements_nonzero_exit_code_returns_empty_list(self, mocker: MockerFixture) -> None:
        """detect_elements returns empty list when PowerShell exits non-zero.

        Tests: detect_elements exit-code check.
        How: Mock vm_powershell to return exit_code=1; verify [] is returned.
        Why: A non-zero exit code means the UIAutomation script failed (missing
             .NET, access denied, etc.); silencing it avoids crashing the pipeline.
        """
        # Arrange
        mocker.patch(
            "mcp_vm_blackbox.vm_interaction.vm_powershell",
            new=AsyncMock(return_value=_mock_powershell_result("", exit_code=1)),
        )

        # Act
        elements = await detect_elements("my-vm")

        # Assert
        assert elements == []

    async def test_detect_elements_invalid_json_returns_empty_list(self, mocker: MockerFixture) -> None:
        """detect_elements returns empty list when PowerShell stdout is not valid JSON.

        Tests: detect_elements JSON parse failure handling.
        How: Mock vm_powershell to return plain text (not JSON); verify [] returned.
        Why: PowerShell may print warning messages before JSON output; a partial
             or corrupt JSON output must not crash the screenshot pipeline.
        """
        # Arrange
        mocker.patch(
            "mcp_vm_blackbox.vm_interaction.vm_powershell",
            new=AsyncMock(return_value=_mock_powershell_result("WARNING: not json output here")),
        )

        # Act
        elements = await detect_elements("my-vm")

        # Assert
        assert elements == []

    async def test_detect_elements_single_object_json_normalised_to_list(self, mocker: MockerFixture) -> None:
        """PowerShell single-item output (dict, not array) is normalised to a list.

        Tests: detect_elements PowerShell single-element JSON normalisation.
        How: Mock stdout with a JSON object (not array), verify one element returned.
        Why: PowerShell ConvertTo-Json with one item returns a dict, not an array;
             the normalisation path prevents this edge case from yielding zero elements.
        """
        # Arrange
        single_object = json.dumps({
            "Name": "OK",
            "ControlType": "button",
            "Left": 0,
            "Top": 0,
            "Right": 100,
            "Bottom": 40,
        })
        mocker.patch(
            "mcp_vm_blackbox.vm_interaction.vm_powershell",
            new=AsyncMock(return_value=_mock_powershell_result(single_object)),
        )

        # Act
        elements = await detect_elements("my-vm")

        # Assert
        assert len(elements) == 1
        assert elements[0].name == "OK"

    async def test_detect_elements_caps_at_som_max_elements(self, mocker: MockerFixture) -> None:
        """detect_elements caps the returned list at SOM_MAX_ELEMENTS.

        Tests: detect_elements SOM_MAX_ELEMENTS cap.
        How: Provide SOM_MAX_ELEMENTS + 5 elements in JSON, verify exactly
             SOM_MAX_ELEMENTS are returned.
        Why: Exceeding the cap floods the context window and violates the SoM
             marking budget; the cap must be enforced at detection time.
        """
        # Arrange
        data = [
            {"Name": f"Btn{i}", "ControlType": "button", "Left": i, "Top": 0, "Right": i + 10, "Bottom": 20}
            for i in range(SOM_MAX_ELEMENTS + 5)
        ]
        mocker.patch(
            "mcp_vm_blackbox.vm_interaction.vm_powershell",
            new=AsyncMock(return_value=_mock_powershell_result(json.dumps(data))),
        )

        # Act
        elements = await detect_elements("my-vm")

        # Assert
        assert len(elements) == SOM_MAX_ELEMENTS

    async def test_detect_elements_skips_malformed_entries(self, mocker: MockerFixture) -> None:
        """detect_elements silently skips malformed JSON element entries.

        Tests: detect_elements robustness against partial or bad data.
        How: Include one valid and one malformed entry (missing 'Left'); verify
             only the valid element is returned.
        Why: UIAutomation may produce mixed-quality output; skipping bad rows
             is safer than discarding the entire result set.
        """
        # Arrange
        data = [
            {"Name": "Good", "ControlType": "button", "Left": 0, "Top": 0, "Right": 80, "Bottom": 40},
            {"Name": "Bad"},  # missing Left/Top/Right/Bottom
        ]
        mocker.patch(
            "mcp_vm_blackbox.vm_interaction.vm_powershell",
            new=AsyncMock(return_value=_mock_powershell_result(json.dumps(data))),
        )

        # Act
        elements = await detect_elements("my-vm")

        # Assert — only the valid element survives
        assert len(elements) == 1
        assert elements[0].name == "Good"

    async def test_detect_elements_skips_zero_size_rects(self, mocker: MockerFixture) -> None:
        """detect_elements skips elements with degenerate bounding rectangles.

        Tests: detect_elements zero/negative-size bounding rect filtering.
        How: Include an element where right <= left; verify it is excluded.
        Why: Zero-size or inverted rectangles represent invisible or off-screen
             elements that cannot be clicked; including them pollutes the legend.
        """
        # Arrange
        data = [
            {"Name": "Visible", "ControlType": "button", "Left": 10, "Top": 10, "Right": 100, "Bottom": 50},
            {"Name": "ZeroSize", "ControlType": "button", "Left": 50, "Top": 50, "Right": 50, "Bottom": 90},
        ]
        mocker.patch(
            "mcp_vm_blackbox.vm_interaction.vm_powershell",
            new=AsyncMock(return_value=_mock_powershell_result(json.dumps(data))),
        )

        # Act
        elements = await detect_elements("my-vm")

        # Assert
        assert len(elements) == 1
        assert elements[0].name == "Visible"
