"""Tests for coordinate scaling in the JPEG optimisation helper (_to_jpeg).

Tests: ScaleInfo fields computed by _to_jpeg in vm_inspection.py.
How: Create synthetic PNG bytes at known dimensions, call _to_jpeg, assert
     ScaleInfo fields match expected ratios.
Why: Correct scale_x/scale_y are critical for SoM coordinate mapping and
     vm_mouse_click(scale=...) to deliver clicks at the right native position.
"""

from __future__ import annotations

import io
from typing import Self

import pytest
from PIL import Image as PilImage

from mcp_vm_blackbox.models import ScaleInfo

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


def _make_png(width: int, height: int, color: tuple[int, int, int] = (128, 128, 128)) -> bytes:
    """Return a solid-colour PNG at *width* x *height*.

    Args:
        width: Image width in pixels.
        height: Image height in pixels.
        color: RGB fill colour tuple.

    Returns:
        Raw PNG bytes.
    """
    img = PilImage.new("RGB", (width, height), color=color)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue()


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestToJpegScaleInfo:
    """Tests for the _to_jpeg coordinate scaling implementation.

    Scope: _to_jpeg returns a ScaleInfo that accurately reflects the ratio
    between native VM resolution and the downscaled JPEG output dimensions.
    Strategy: Construct controlled PNG images of known sizes, pass them to
    _to_jpeg, and assert every ScaleInfo field matches the expected math.
    """

    def test_standard_downscale_1920x1080_produces_correct_scale_factors(self) -> None:
        """Standard 1920x1080 PNG downscaled to max 1024x768 yields expected scale_x and scale_y.

        Tests: _to_jpeg scale factor computation for full-HD input.
        How: Create a 1920x1080 PNG, call _to_jpeg with default max_size=(1024, 768),
             assert scale_x = output_width / 1920 and scale_y = output_height / 1080.
        Why: Pilot relies on scale_x/scale_y to convert JPEG-space click coordinates
             back to native VM coordinates; any error here propagates to every click.
        """
        # Arrange
        from mcp_vm_blackbox.vm_inspection import _to_jpeg

        png_bytes = _make_png(1920, 1080)

        # Act
        _jpeg_bytes, scale_info = _to_jpeg(png_bytes, quality=85, max_size=(1024, 768))

        # Assert — output must fit within 1024x768
        assert scale_info.native_width == 1920
        assert scale_info.native_height == 1080
        assert scale_info.output_width <= 1024
        assert scale_info.output_height <= 768

        # scale factors must be ratio of output to native
        expected_scale_x = scale_info.output_width / 1920
        expected_scale_y = scale_info.output_height / 1080
        assert scale_info.scale_x == pytest.approx(expected_scale_x, rel=1e-6)
        assert scale_info.scale_y == pytest.approx(expected_scale_y, rel=1e-6)

        # Scales must be less than 1.0 for a downscale
        assert scale_info.scale_x < 1.0
        assert scale_info.scale_y < 1.0

    def test_identity_case_image_already_fits_max_size_returns_scale_1(self) -> None:
        """Image smaller than max_size passes through unchanged with scale factors of 1.0.

        Tests: _to_jpeg identity path when no downscale is needed.
        How: Create a 640x480 PNG (fits within default 1024x768), verify scale_x = scale_y = 1.0.
        Why: Pilots running on low-resolution VMs should not have coordinates scaled;
             scale=1.0 must be the no-op identity for vm_mouse_click.
        """
        # Arrange
        from mcp_vm_blackbox.vm_inspection import _to_jpeg

        png_bytes = _make_png(640, 480)

        # Act
        _jpeg_bytes, scale_info = _to_jpeg(png_bytes, quality=85, max_size=(1024, 768))

        # Assert
        assert scale_info.native_width == 640
        assert scale_info.native_height == 480
        assert scale_info.output_width == 640
        assert scale_info.output_height == 480
        assert scale_info.scale_x == pytest.approx(1.0, rel=1e-6)
        assert scale_info.scale_y == pytest.approx(1.0, rel=1e-6)

    def test_zero_dimension_guard_returns_scale_1(self) -> None:
        """Zero native dimensions fall back to scale 1.0 without raising.

        Tests: _to_jpeg defensive guard for degenerate zero-dimension images.
        How: Patch the PIL image size to (0, 0) so _to_jpeg sees zero native dims.
        Why: A zero-divide must never crash the screenshot pipeline; 1.0 is a
             safe fallback that prevents invalid coordinate calculations.
        """
        # Arrange
        from unittest.mock import patch

        from mcp_vm_blackbox.vm_inspection import _to_jpeg

        png_bytes = _make_png(64, 48)

        # Patch PilImage.open to return an image with size (0, 0)

        class _ZeroSizeImage:
            size = (0, 0)

            def __enter__(self) -> Self:
                return self

            def __exit__(self, *args: object) -> None:
                pass

            def thumbnail(self, *args: object, **kwargs: object) -> None:
                self.size = (0, 0)

            def convert(self, mode: str) -> _ZeroSizeImage:
                return self

            def save(self, buf: object, **kwargs: object) -> None:
                import io as _io

                # Write a minimal JPEG so buf.getvalue() works
                img = PilImage.new("RGB", (1, 1))
                assert isinstance(buf, _io.BytesIO)
                img.save(buf, format="JPEG")

        with patch("mcp_vm_blackbox.vm_inspection.PilImage.open", return_value=_ZeroSizeImage()):
            _jpeg_bytes, scale_info = _to_jpeg(png_bytes)

        # Assert — zero dimension must produce scale 1.0 fallback
        assert scale_info.scale_x == pytest.approx(1.0)
        assert scale_info.scale_y == pytest.approx(1.0)

    def test_wide_image_produces_scale_x_different_from_scale_y(self) -> None:
        """Asymmetric aspect ratio yields independent scale_x and scale_y values.

        Tests: _to_jpeg preserves aspect ratio such that scale_x may differ from scale_y.
        How: Use a 2048x512 PNG (very wide) with max_size=(1024, 768); PIL thumbnail
             preserves aspect ratio, so only one axis is constrained at a time.
        Why: SoM coordinate stamping uses scale_x for horizontal and scale_y for
             vertical independently; they must be computed correctly per axis.
        """
        # Arrange
        from mcp_vm_blackbox.vm_inspection import _to_jpeg

        # 2048x512: width-constrained; height stays at 256 (half of 512)
        png_bytes = _make_png(2048, 512)

        # Act
        _jpeg_bytes, scale_info = _to_jpeg(png_bytes, quality=85, max_size=(1024, 768))

        # Assert — native dims recorded correctly
        assert scale_info.native_width == 2048
        assert scale_info.native_height == 512

        # Output fits within max_size
        assert scale_info.output_width <= 1024
        assert scale_info.output_height <= 768

        # scale_x and scale_y are independently correct ratios
        assert scale_info.scale_x == pytest.approx(scale_info.output_width / 2048, rel=1e-6)
        assert scale_info.scale_y == pytest.approx(scale_info.output_height / 512, rel=1e-6)

    def test_scale_info_is_pydantic_model_with_expected_fields(self) -> None:
        """_to_jpeg returns a ScaleInfo Pydantic model with all required fields.

        Tests: ScaleInfo model completeness and field types.
        How: Call _to_jpeg on a small PNG and introspect the returned ScaleInfo.
        Why: Downstream tools (vm_mouse_click, stamp_marks) access these fields
             by name; missing fields cause AttributeError at runtime.
        """
        # Arrange
        from mcp_vm_blackbox.vm_inspection import _to_jpeg

        png_bytes = _make_png(800, 600)

        # Act
        _jpeg_bytes, scale_info = _to_jpeg(png_bytes)

        # Assert — ScaleInfo is the right type and has all fields
        assert isinstance(scale_info, ScaleInfo)
        assert isinstance(scale_info.scale_x, float)
        assert isinstance(scale_info.scale_y, float)
        assert isinstance(scale_info.native_width, int)
        assert isinstance(scale_info.native_height, int)
        assert isinstance(scale_info.output_width, int)
        assert isinstance(scale_info.output_height, int)

    def test_jpeg_bytes_are_non_empty(self) -> None:
        """_to_jpeg returns non-empty JPEG bytes alongside ScaleInfo.

        Tests: _to_jpeg JPEG output is a valid non-empty byte sequence.
        How: Call _to_jpeg and check the byte length and JPEG magic bytes.
        Why: An empty or corrupt JPEG would cause the pilot to receive no visual
             information, silently degrading quality without an obvious error.
        """
        # Arrange
        from mcp_vm_blackbox.vm_inspection import _to_jpeg

        png_bytes = _make_png(1920, 1080)

        # Act
        jpeg_bytes, _scale_info = _to_jpeg(png_bytes)

        # Assert — JPEG magic bytes and non-zero length
        assert len(jpeg_bytes) > 0
        assert jpeg_bytes[:2] == b"\xff\xd8"  # JPEG SOI marker

    def test_custom_max_size_constrains_output_dimensions(self) -> None:
        """A custom max_size parameter limits output to those bounds.

        Tests: _to_jpeg respects caller-supplied max_size.
        How: Pass max_size=(512, 384) and verify output fits within those bounds.
        Why: Callers may override max_size for smaller context windows or
             constrained bandwidth; the scaling must honour the override.
        """
        # Arrange
        from mcp_vm_blackbox.vm_inspection import _to_jpeg

        png_bytes = _make_png(1920, 1080)

        # Act
        _jpeg_bytes, scale_info = _to_jpeg(png_bytes, quality=85, max_size=(512, 384))

        # Assert
        assert scale_info.output_width <= 512
        assert scale_info.output_height <= 384
        assert scale_info.scale_x < 1.0
        assert scale_info.scale_y < 1.0
