"""Tests for vm_inspection MCP tools.

Tests: vm_list, vm_info, vm_screenshot, vm_screenshot_api.
How: Mock _ssh_run, _vbox, _resolve so tests run offline without VirtualBox.
Why: CI environments lack VMs; tools must parse mock outputs into Pydantic models.
"""

from __future__ import annotations

import io
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock

import pytest
from PIL import Image as PilImage

from mcp_vm_blackbox.targets import TargetConfig

if TYPE_CHECKING:
    from pytest_mock import MockerFixture


# ---------------------------------------------------------------------------
# PNG factory for tests
# ---------------------------------------------------------------------------


def _make_png(r: int = 128, g: int = 128, b: int = 128) -> bytes:
    """Return a 64x48 solid-colour PNG as bytes."""
    img = PilImage.new("RGB", (64, 48), color=(r, g, b))
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue()


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_resolve(mocker: MockerFixture) -> MagicMock:
    """Patch _resolve to return a local TargetConfig."""
    cfg = TargetConfig(ssh_host="")
    return mocker.patch("mcp_vm_blackbox.vm_inspection._resolve", return_value=cfg)


@pytest.fixture
def mock_ssh_run(mocker: MockerFixture) -> AsyncMock:
    """Patch _ssh_run for controlled VBoxManage output (async)."""
    proc = MagicMock()
    proc.stdout = ""
    proc.stderr = ""
    proc.returncode = 0
    return mocker.patch("mcp_vm_blackbox.vm_inspection._ssh_run", new_callable=AsyncMock, return_value=proc)


# ---------------------------------------------------------------------------
# _parse_vm_list (indirectly via vm_list)
# ---------------------------------------------------------------------------


class TestVmList:
    """Tests for vm_list tool."""

    async def test_vm_list_parses_vbox_output(self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """vm_list parses VBoxManage list vms and runningvms into VmListEntry models."""
        mock_ssh_run.side_effect = [
            MagicMock(stdout='"my-vm" {abc-123-def}\n"other-vm" {xyz-456}', stderr="", returncode=0),
            MagicMock(stdout='"my-vm" {abc-123-def}', stderr="", returncode=0),
        ]

        from mcp_vm_blackbox.vm_inspection import vm_list

        result = await vm_list(target="local")

        assert len(result) == 2
        assert result[0].name == "my-vm"
        assert result[0].uuid == "abc-123-def"
        assert result[0].running is True
        assert result[1].name == "other-vm"
        assert result[1].uuid == "xyz-456"
        assert result[1].running is False


# ---------------------------------------------------------------------------
# vm_info
# ---------------------------------------------------------------------------


class TestVmInfo:
    """Tests for vm_info tool."""

    async def test_vm_info_parses_showvminfo_output(self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """vm_info parses VBoxManage showvminfo --machinereadable into VmInfoResult."""
        mock_ssh_run.return_value = MagicMock(
            stdout='memory="4096"\ncpus="4"\nvram="128"\nVMState="running"\nGuestAdditionsVersion="7.0.0"',
            stderr="",
            returncode=0,
        )

        from mcp_vm_blackbox.vm_inspection import vm_info

        result = await vm_info(vm_name="my-vm", target="local")

        assert result.memory_mb == "4096"
        assert result.cpus == "4"
        assert result.vram_mb == "128"
        assert result.state == "running"
        assert result.guest_additions == "7.0.0"
        assert result.error is None

    async def test_vm_info_not_found_returns_error(self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """vm_info returns VmInfoResult with error when VM not found."""
        mock_ssh_run.return_value = MagicMock(stdout="", stderr="Could not find", returncode=1)

        from mcp_vm_blackbox.vm_inspection import vm_info

        result = await vm_info(vm_name="missing-vm", target="local")

        assert result.error is not None
        assert "not found" in result.error


# ---------------------------------------------------------------------------
# vm_screenshot (needs _vbox mock)
# ---------------------------------------------------------------------------


class TestVmScreenshot:
    """Tests for vm_screenshot tool."""

    @pytest.mark.asyncio
    async def test_vm_screenshot_returns_image(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_screenshot saves PNG to disk and returns a JPEG Image inline."""
        from fastmcp.utilities.types import Image

        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(128, 128, 128)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)

        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=PerceptualHashStore())

        output_path = str(tmp_path / "screenshot.png")
        result = await vm_screenshot(vm_name="my-vm", target="local", output_path=output_path)

        import anyio

        # The inline result must be an Image (JPEG).
        assert isinstance(result, Image)
        img_content = result.to_image_content()
        assert img_content.mimeType == "image/jpeg"
        # The evidence file on disk must be the original full-resolution PNG.
        assert await anyio.Path(output_path).read_bytes() == png_bytes

    @pytest.mark.asyncio
    async def test_vm_screenshot_auto_path_saves_file_to_scratch(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_screenshot writes PNG to scratch/screenshots/ when output_path is omitted.

        Regression test for Issue #83: auto-save path not resolving when
        output_path is None.  The file must exist on disk after the call,
        not only be returned inline.
        """
        import anyio
        from fastmcp.utilities.types import Image

        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(64, 128, 192)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)

        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        # Redirect SCRATCH_DIR to tmp_path so the test is hermetic.
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=PerceptualHashStore())

        result = await vm_screenshot(vm_name="my-vm", target="local", output_path=None)

        # Inline result must be a JPEG Image.
        assert isinstance(result, Image)
        assert result.to_image_content().mimeType == "image/jpeg"

        # Exactly one PNG file must be saved under the (mocked) scratch dir.
        saved_files = list(tmp_path.glob("*.png"))
        assert len(saved_files) == 1, f"Expected 1 PNG saved to scratch dir, found {len(saved_files)}: {saved_files}"

        # File name must follow the timestamped pattern: <vm_name>-<ts>-<uid>.png
        saved_name = saved_files[0].name
        assert saved_name.startswith("my-vm-"), f"Unexpected filename: {saved_name}"

        # Evidence file on disk must be the original full-resolution PNG bytes.
        assert await anyio.Path(saved_files[0]).read_bytes() == png_bytes


# ---------------------------------------------------------------------------
# vm_screenshot — Layer 1+2 (JPEG downscale + perceptual dedup)
# ---------------------------------------------------------------------------


class TestVmScreenshotEfficiency:
    """Tests for JPEG downscale (Layer 1) and perceptual dedup (Layer 2)."""

    @pytest.mark.asyncio
    async def test_vm_screenshot_returns_jpeg_format(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_screenshot returns Image with format='jpeg' for a changed screen."""
        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(100, 100, 100)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)
        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)
        # Use a fresh store so there is no prior baseline.
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=PerceptualHashStore())

        result = await vm_screenshot(vm_name="my-vm", target="local")

        from fastmcp.utilities.types import Image

        assert isinstance(result, Image)
        img_content = result.to_image_content()
        assert img_content.mimeType == "image/jpeg"

    @pytest.mark.asyncio
    async def test_vm_screenshot_evidence_saved_as_png(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """Evidence file on disk is always a full-resolution PNG, not JPEG."""
        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(50, 100, 150)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)
        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=PerceptualHashStore())

        await vm_screenshot(vm_name="evidence-vm", target="local")

        saved_files = list(tmp_path.glob("*.png"))
        assert len(saved_files) == 1
        # The file on disk must be the original full-resolution PNG bytes.
        assert saved_files[0].read_bytes() == png_bytes

    @pytest.mark.asyncio
    async def test_vm_screenshot_unchanged_screen_returns_unchanged_result(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_screenshot returns ScreenshotUnchangedResult on repeated identical frames."""
        from mcp_vm_blackbox.models import ScreenshotUnchangedResult
        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(200, 200, 200)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)
        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)

        store = PerceptualHashStore()
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=store)

        # First call: changed (no baseline), stores hash.
        result_first = await vm_screenshot(vm_name="dedup-vm", target="local")
        from fastmcp.utilities.types import Image

        assert isinstance(result_first, Image)

        # Second call: same image → dedup fires.
        result_second = await vm_screenshot(vm_name="dedup-vm", target="local")
        assert isinstance(result_second, ScreenshotUnchangedResult)
        assert result_second.vm_name == "dedup-vm"
        assert result_second.provider == "dedup_layer"
        assert result_second.last_capture_seconds_ago >= 0.0

    @pytest.mark.asyncio
    async def test_vm_screenshot_force_bypasses_dedup(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_screenshot with force=True always returns Image, skipping dedup."""
        from fastmcp.utilities.types import Image

        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(10, 10, 10)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)
        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)

        store = PerceptualHashStore()
        store.store("force-vm", png_bytes)  # pre-populate so dedup would normally fire
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=store)

        result = await vm_screenshot(vm_name="force-vm", target="local", force=True)
        assert isinstance(result, Image)
        assert result.to_image_content().mimeType == "image/jpeg"

    @pytest.mark.asyncio
    async def test_vm_screenshot_inline_bytes_are_valid_jpeg(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """Bytes returned inline start with the JPEG magic header (0xFF 0xD8)."""
        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(128, 64, 32)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)
        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=PerceptualHashStore())

        from fastmcp.utilities.types import Image

        result = await vm_screenshot(vm_name="size-vm", target="local")
        assert isinstance(result, Image)
        assert result.data is not None
        # JPEG magic: first two bytes are 0xFF 0xD8.
        assert result.data[:2] == b"\xff\xd8"

    @pytest.mark.asyncio
    async def test_vm_screenshot_unchanged_message_contains_elapsed(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """ScreenshotUnchangedResult.message includes elapsed seconds."""
        from mcp_vm_blackbox.models import ScreenshotUnchangedResult
        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(77, 88, 99)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)
        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)
        store = PerceptualHashStore()
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=store)

        await vm_screenshot(vm_name="msg-vm", target="local")  # populate baseline
        result = await vm_screenshot(vm_name="msg-vm", target="local")

        assert isinstance(result, ScreenshotUnchangedResult)
        assert "ago" in result.message

    @pytest.mark.asyncio
    async def test_vm_screenshot_dedup_no_evidence_file_written_on_unchanged(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """No new PNG is written to disk when dedup returns unchanged."""
        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(11, 22, 33)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)
        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)
        store = PerceptualHashStore()
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=store)

        await vm_screenshot(vm_name="nowrite-vm", target="local")  # first: 1 PNG
        count_after_first = len(list(tmp_path.glob("*.png")))
        await vm_screenshot(vm_name="nowrite-vm", target="local")  # second: unchanged
        count_after_second = len(list(tmp_path.glob("*.png")))

        assert count_after_first == 1
        assert count_after_second == 1  # unchanged path writes nothing

    @pytest.mark.asyncio
    async def test_vm_screenshot_different_content_returns_new_image(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """A genuinely changed frame is NOT deduplicated — returns a fresh Image.

        Tests: Layer 2 dedup only fires when frames are perceptually identical.
        How: First call stores a baseline (grey 128). Second call uses a
             visually distinct frame (bright red 255,0,0). Dedup must NOT fire.
        Why: Dedup must only suppress unchanged screens; changed screens must
             always return a fresh JPEG so the pilot sees real state updates.
        """
        from fastmcp.utilities.types import Image

        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_first = _make_png(128, 128, 128)  # mid-grey baseline
        png_second = _make_png(255, 0, 0)  # bright red — visually distinct

        mock_dispatch = MagicMock()
        # side_effect alternates: first call returns grey, second returns red
        mock_dispatch.screenshot = AsyncMock(side_effect=[png_first, png_second])
        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)

        store = PerceptualHashStore()
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=store)

        # First call: no baseline — always returns Image.
        result_first = await vm_screenshot(vm_name="change-vm", target="local")
        assert isinstance(result_first, Image)

        # Second call: distinctly different frame — dedup must NOT fire.
        result_second = await vm_screenshot(vm_name="change-vm", target="local")
        assert isinstance(result_second, Image), (
            "Expected a fresh Image for visually changed screen, got ScreenshotUnchangedResult"
        )
        assert result_second.to_image_content().mimeType == "image/jpeg"

    @pytest.mark.asyncio
    async def test_vm_screenshot_dedup_isolated_per_vm_name(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """Dedup state is keyed by vm_name; two different VMs never share a baseline.

        Tests: Layer 2 dedup isolation across VM names.
        How: Populate the store with a baseline for "vm-alpha". Then call
             vm_screenshot for "vm-beta" with the identical PNG bytes. The
             second VM must return an Image (no baseline exists for it), not
             ScreenshotUnchangedResult.
        Why: Cross-VM dedup contamination would cause a pilot managing multiple
             VMs to miss real screen changes on any VM added after the first.
        """
        from fastmcp.utilities.types import Image

        from mcp_vm_blackbox.vm_inspection import PerceptualHashStore, vm_screenshot

        png_bytes = _make_png(90, 90, 90)
        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(return_value=png_bytes)
        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)
        mocker.patch("mcp_vm_blackbox.vm_inspection.SCRATCH_DIR", tmp_path)

        store = PerceptualHashStore()
        # Pre-populate store only for "vm-alpha".
        store.store("vm-alpha", png_bytes)
        mocker.patch("mcp_vm_blackbox.vm_inspection._hash_store", new=store)

        # "vm-beta" has no baseline — must return Image even though bytes are identical.
        result = await vm_screenshot(vm_name="vm-beta", target="local")
        assert isinstance(result, Image), (
            "Expected Image for vm-beta (no prior baseline), got ScreenshotUnchangedResult"
        )


# ---------------------------------------------------------------------------
# vm_screenshot_api
# ---------------------------------------------------------------------------


class TestVmScreenshotApi:
    """Tests for vm_screenshot_api tool."""

    @pytest.mark.asyncio
    async def test_vm_screenshot_api_non_local_raises_tool_error(self, mock_resolve: MagicMock) -> None:
        """vm_screenshot_api raises ToolError for target != 'local'."""
        from fastmcp.exceptions import ToolError

        from mcp_vm_blackbox.vm_inspection import vm_screenshot_api

        with pytest.raises(ToolError, match="only supports target='local'"):
            await vm_screenshot_api(vm_name="my-vm", target="ci")

    @pytest.mark.asyncio
    async def test_vm_screenshot_api_falls_back_to_dispatch(
        self, mock_resolve: MagicMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_screenshot_api falls back to VBoxDispatch when vboxapi unavailable."""
        from unittest.mock import AsyncMock

        mocker.patch("mcp_vm_blackbox.vm_inspection._screenshot_via_api", return_value=None)
        png_bytes = b"\x89PNG\r\n\x1a\n"

        def mock_screenshot(path: str) -> bytes:
            Path(path).write_bytes(png_bytes)
            return png_bytes

        mock_dispatch = MagicMock()
        mock_dispatch.screenshot = AsyncMock(side_effect=mock_screenshot)

        mocker.patch("mcp_vm_blackbox.vm_inspection._vbox", return_value=mock_dispatch)

        from mcp_vm_blackbox.vm_inspection import _screenshot_via_subprocess

        out = tmp_path / "out.png"
        result = await _screenshot_via_subprocess("my-vm", "local", out)

        assert result.saved_to == str(out)
        assert result.error is None


# ---------------------------------------------------------------------------
# vm_list registry side-effect
# ---------------------------------------------------------------------------


class TestVmListRegistrySideEffect:
    """Tests for vm_list populating the VM registry as a side-effect."""

    async def test_vm_list_populates_registry(
        self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture
    ) -> None:
        """vm_list calls default_registry.populate() with parsed entries."""
        mock_ssh_run.side_effect = [
            MagicMock(stdout='"web-vm" {aaa-111}\n"db-vm" {bbb-222}', stderr="", returncode=0),
            MagicMock(stdout='"web-vm" {aaa-111}', stderr="", returncode=0),
        ]

        mock_populate = mocker.patch("mcp_vm_blackbox.vm_inspection.default_registry.populate")

        from mcp_vm_blackbox.vm_inspection import vm_list

        result = await vm_list(target="local")

        assert len(result) == 2
        mock_populate.assert_called_once_with(result)

    async def test_vm_list_returns_entries_when_registry_fails(
        self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture
    ) -> None:
        """vm_list still returns entries even when registry populate raises."""
        mock_ssh_run.side_effect = [
            MagicMock(stdout='"web-vm" {aaa-111}', stderr="", returncode=0),
            MagicMock(stdout='"web-vm" {aaa-111}', stderr="", returncode=0),
        ]

        mocker.patch("mcp_vm_blackbox.vm_inspection.default_registry.populate", side_effect=OSError("disk full"))

        from mcp_vm_blackbox.vm_inspection import vm_list

        result = await vm_list(target="local")

        assert len(result) == 1
        assert result[0].name == "web-vm"
