"""Tests for viewport_tools.py — vm_viewport_action provider detection and responses.

Covers provider routing hierarchy (CLI → OpenRouter → inline fallback), JSON parse
failure handling, subprocess safety, ViewportResponse schema, ScreenState/VisibleElement
model correctness, and inline fallback robustness.
"""

from __future__ import annotations

import inspect
import json
import subprocess
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import MagicMock

import mcp_vm_blackbox.viewport_tools as viewport_module
from mcp_vm_blackbox.models import (
    ElementCoordinates,
    ElementType,
    ScaleInfo,
    ScreenState,
    ViewportResponse,
    VisibleElement,
    VisionProvider,
)
from mcp_vm_blackbox.viewport_tools import _provider_inline_fallback, vm_viewport_action

if TYPE_CHECKING:
    import pytest
    from pytest_mock import MockerFixture

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


def _valid_viewport_payload(provider: str = "claude") -> str:
    """Return JSON-encoded ViewportResponse payload for subprocess mock stdout.

    Args:
        provider: VisionProvider string value to embed in the payload.

    Returns:
        Valid JSON string that passes ViewportResponse.model_validate.
    """
    return json.dumps({
        "provider": provider,
        "action_taken": "observed the screen",
        "screen_state": {
            "description": "Desktop with taskbar visible",
            "visible_elements": [],
            "warnings": [],
            "errors": [],
        },
    })


def _completed(returncode: int = 0, stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess[str]:
    """Build a subprocess.CompletedProcess stub for use in mocker.patch targets.

    Args:
        returncode: Process exit code.
        stdout: Captured standard output text.
        stderr: Captured standard error text.

    Returns:
        CompletedProcess instance populated with the provided fields.
    """
    return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)


def _stub_screenshot_capture() -> tuple[MagicMock, ScaleInfo, None]:
    """Return a minimal (path, scale_info, marks_legend) tuple for async screenshot mock."""
    mock_path = MagicMock(spec=Path)
    scale_info = ScaleInfo(
        scale_x=1.0, scale_y=1.0, native_width=1024, native_height=768, output_width=1024, output_height=768
    )
    return (mock_path, scale_info, None)


@dataclass
class _FakeRunProcessResult:
    """Minimal object matching anyio.run_process return shape (tests)."""

    returncode: int
    stdout: bytes
    stderr: bytes


def _anyio_process_stub(stdout: bytes, *, returncode: int = 0, stderr: bytes = b"") -> _FakeRunProcessResult:
    """Build a stub result for patched ``anyio.run_process``."""
    return _FakeRunProcessResult(returncode=returncode, stdout=stdout, stderr=stderr)


def _disk_screenshot_stub(base: Path) -> tuple[Path, ScaleInfo, None]:
    """Real temp dir + file for OpenRouter tests (provider unlinks path and rmdir parent)."""
    cap_dir = base / f"viewport-cap-{uuid.uuid4().hex[:10]}"
    cap_dir.mkdir(parents=True, exist_ok=True)
    shot = cap_dir / "frame.jpg"
    shot.write_bytes(b"\xff\xd8\xff\xd9")
    scale_info = ScaleInfo(
        scale_x=1.0, scale_y=1.0, native_width=1024, native_height=768, output_width=1024, output_height=768
    )
    return (shot, scale_info, None)


# ---------------------------------------------------------------------------
# Provider detection and routing
# ---------------------------------------------------------------------------


class TestProviderRouting:
    """Provider selection hierarchy tests: CLI → OpenRouter → inline fallback."""

    async def test_cli_provider_used_when_uv_and_claude_on_path(self, mocker: MockerFixture) -> None:
        """vm_viewport_action routes to Claude CLI when uv and claude are both discoverable.

        Tests: vm_viewport_action provider routing — priority 1 (CLI)
        How: Patch shutil.which to return paths for both uv and claude;
             patch subprocess.run to emit valid JSON; assert provider == 'claude'
        Why: CLI provider must be preferred when available — highest quality vision analysis
        """
        # Arrange
        mocker.patch.object(viewport_module.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}")
        mocker.patch.object(
            viewport_module.subprocess, "run", return_value=_completed(stdout=_valid_viewport_payload("claude"))
        )
        mocker.patch.object(
            viewport_module,
            "_take_screenshot_to_tempfile",
            new=mocker.AsyncMock(return_value=_stub_screenshot_capture()),
        )

        # Act
        result = await vm_viewport_action("win-vm", "describe current screen state")

        # Assert
        assert result.provider == VisionProvider.CLAUDE_CLI

    async def test_openrouter_provider_used_when_claude_absent_and_api_key_set(
        self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """vm_viewport_action routes to OpenRouter when claude CLI absent and API key present.

        Tests: vm_viewport_action provider routing — priority 2 (OpenRouter)
        How: shutil.which returns None for claude; OPENROUTER_API_KEY set; stub screenshot
             capture and subprocess.run; assert provider == 'openrouter'
        Why: OpenRouter is the fallback when local claude CLI is not installed
        """
        # Arrange — uv present, claude absent
        mocker.patch.object(
            viewport_module.shutil, "which", side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None
        )
        monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test-key")

        fake_scripts = tmp_path / "scripts"
        fake_scripts.mkdir()
        (fake_scripts / "vision_query.py").write_text("# stub script\n")
        mocker.patch.object(viewport_module, "_SCRIPTS_DIR", fake_scripts)

        mocker.patch.object(
            viewport_module,
            "_take_screenshot_to_tempfile",
            new=mocker.AsyncMock(side_effect=lambda *_a, **_k: _disk_screenshot_stub(tmp_path)),
        )
        mocker.patch.object(
            viewport_module.anyio,
            "run_process",
            new=mocker.AsyncMock(return_value=_anyio_process_stub(_valid_viewport_payload("openrouter").encode())),
        )

        # Act
        result = await vm_viewport_action("win-vm", "describe current screen state")

        # Assert
        assert result.provider == VisionProvider.OPENROUTER

    async def test_inline_fallback_used_when_no_providers_available(
        self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """vm_viewport_action routes to inline fallback when no vision provider is available.

        Tests: vm_viewport_action provider routing — priority 3 (inline fallback)
        How: shutil.which returns None for all executables; OPENROUTER_API_KEY absent;
             assert provider == 'inline_fallback'
        Why: Tool must always return a usable response regardless of provider availability
        """
        # Arrange
        mocker.patch.object(viewport_module.shutil, "which", return_value=None)
        monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
        mocker.patch.object(viewport_module, "_take_screenshot_to_tempfile", new=mocker.AsyncMock(return_value=None))

        # Act
        result = await vm_viewport_action("win-vm", "describe current screen state")

        # Assert
        assert result.provider == VisionProvider.INLINE_FALLBACK


# ---------------------------------------------------------------------------
# JSON parse failure → inline_after_parse_failure
# ---------------------------------------------------------------------------


class TestJsonParseFailure:
    """Non-JSON subprocess output must set provider='inline_after_parse_failure'."""

    async def test_cli_json_parse_failure_sets_provider_inline_after_parse_failure(self, mocker: MockerFixture) -> None:
        """CLI provider emitting non-JSON stdout sets provider to inline_after_parse_failure.

        Tests: _provider_claude_cli JSON parse error handling
        How: Enable CLI path; subprocess.run returns garbled non-JSON output;
             assert result.provider == 'inline_after_parse_failure'
        Why: Parse failures must be surfaced transparently; 'inline_after_parse_failure'
             is distinguishable from the clean 'inline_fallback' sentinel
        """
        # Arrange
        mocker.patch.object(viewport_module.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}")
        mocker.patch.object(
            viewport_module.subprocess, "run", return_value=_completed(stdout="<html>unexpected error page</html>")
        )
        mocker.patch.object(
            viewport_module,
            "_take_screenshot_to_tempfile",
            new=mocker.AsyncMock(return_value=_stub_screenshot_capture()),
        )

        # Act
        result = await vm_viewport_action("win-vm", "describe screen")

        # Assert
        assert result.provider == VisionProvider.INLINE_AFTER_PARSE_FAILURE

    async def test_openrouter_json_parse_failure_sets_provider_inline_after_parse_failure(
        self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """OpenRouter provider emitting non-JSON stdout sets provider to inline_after_parse_failure.

        Tests: _provider_openrouter JSON parse error handling
        How: Enable OpenRouter path only; subprocess.run returns plain text output;
             assert provider == 'inline_after_parse_failure'
        Why: OpenRouter parse failures must be distinguishable from clean inline fallback
        """
        # Arrange — only OpenRouter available
        mocker.patch.object(
            viewport_module.shutil, "which", side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None
        )
        monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test-key")

        fake_scripts = tmp_path / "scripts"
        fake_scripts.mkdir()
        (fake_scripts / "vision_query.py").write_text("# stub\n")
        mocker.patch.object(viewport_module, "_SCRIPTS_DIR", fake_scripts)

        mocker.patch.object(
            viewport_module,
            "_take_screenshot_to_tempfile",
            new=mocker.AsyncMock(side_effect=lambda *_a, **_k: _disk_screenshot_stub(tmp_path)),
        )
        mocker.patch.object(
            viewport_module.anyio,
            "run_process",
            new=mocker.AsyncMock(return_value=_anyio_process_stub(b"error: connection timeout")),
        )

        # Act
        result = await vm_viewport_action("win-vm", "describe screen")

        # Assert
        assert result.provider == VisionProvider.INLINE_AFTER_PARSE_FAILURE


# ---------------------------------------------------------------------------
# subprocess safety
# ---------------------------------------------------------------------------


class TestSubprocessSafety:
    """Subprocess calls in viewport_tools must not use shell=True."""

    def test_no_shell_true_in_subprocess_calls_in_module_source(self) -> None:
        """viewport_tools module source contains no shell=True in any subprocess call.

        Tests: Security property of viewport_tools module
        How: Inspect full module source for the 'shell=True' literal string
        Why: shell=True enables shell injection attacks; explicit argument lists are
             the required safe pattern for all subprocess invocations
        """
        # Arrange + Act
        source = inspect.getsource(viewport_module)

        # Assert
        assert "shell=True" not in source


# ---------------------------------------------------------------------------
# ViewportResponse and ScreenState structure
# ---------------------------------------------------------------------------


class TestViewportResponseStructure:
    """ViewportResponse field types and provider values for each code path."""

    def test_viewport_response_screen_state_is_screen_state_instance(self) -> None:
        """ViewportResponse.screen_state is a ScreenState model instance, not a raw dict.

        Tests: ViewportResponse.screen_state field type
        How: Call _provider_inline_fallback directly; inspect result.screen_state type
        Why: Callers depend on screen_state being a structured ScreenState with typed fields
        """
        # Arrange + Act
        result = _provider_inline_fallback("any-vm")

        # Assert
        assert isinstance(result.screen_state, ScreenState)

    async def test_cli_path_response_provider_field_equals_claude(self, mocker: MockerFixture) -> None:
        """CLI provider path sets ViewportResponse.provider to VisionProvider.CLAUDE_CLI.

        Tests: ViewportResponse.provider for CLI code path
        How: Enable CLI; mock subprocess.run with provider='claude' payload;
             assert provider == VisionProvider.CLAUDE_CLI
        Why: provider field must precisely identify the system that produced the analysis
        """
        # Arrange
        mocker.patch.object(viewport_module.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}")
        mocker.patch.object(
            viewport_module.subprocess, "run", return_value=_completed(stdout=_valid_viewport_payload("claude"))
        )
        mocker.patch.object(
            viewport_module,
            "_take_screenshot_to_tempfile",
            new=mocker.AsyncMock(return_value=_stub_screenshot_capture()),
        )

        # Act
        result = await vm_viewport_action("win-vm", "describe screen")

        # Assert
        assert result.provider == VisionProvider.CLAUDE_CLI

    async def test_inline_fallback_path_response_provider_field_equals_inline_fallback(
        self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Inline fallback path sets ViewportResponse.provider to VisionProvider.INLINE_FALLBACK.

        Tests: ViewportResponse.provider for inline fallback code path
        How: Remove all providers; call vm_viewport_action; assert provider enum value
        Why: 'inline_fallback' sentinel is distinct from 'inline_after_parse_failure'
        """
        # Arrange
        mocker.patch.object(viewport_module.shutil, "which", return_value=None)
        monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
        mocker.patch.object(viewport_module, "_take_screenshot_to_tempfile", new=mocker.AsyncMock(return_value=None))

        # Act
        result = await vm_viewport_action("win-vm", "describe screen")

        # Assert
        assert result.provider == VisionProvider.INLINE_FALLBACK

    def test_viewport_response_schema_contains_required_fields(self) -> None:
        """ViewportResponse JSON schema includes provider, action_taken, and screen_state.

        Tests: ViewportResponse Pydantic model schema completeness
        How: Call model_json_schema() and check presence of expected property names
        Why: MCP tool structured output must expose the full ViewportResponse schema
             for client compatibility
        """
        # Arrange + Act
        schema = ViewportResponse.model_json_schema()

        # Assert
        properties = schema.get("properties", {})
        assert "provider" in properties
        assert "action_taken" in properties
        assert "screen_state" in properties


# ---------------------------------------------------------------------------
# VisibleElement and ElementType
# ---------------------------------------------------------------------------


class TestVisibleElementAndElementType:
    """VisibleElement model and ElementType enum correctness."""

    def test_visible_element_stores_element_type_enum_instance(self) -> None:
        """VisibleElement.element_type holds an ElementType enum instance.

        Tests: VisibleElement.element_type field type enforcement
        How: Construct VisibleElement with ElementType.BUTTON; assert isinstance
        Why: Typed element_type enables programmatic element lookup without string comparison
        """
        # Arrange + Act
        element = VisibleElement(
            name="OK Button", element_type=ElementType.BUTTON, coordinates=ElementCoordinates(x=320, y=240)
        )

        # Assert
        assert isinstance(element.element_type, ElementType)
        assert element.element_type == ElementType.BUTTON

    def test_all_element_type_values_accepted_by_visible_element(self) -> None:
        """All ElementType enum members are valid for VisibleElement.element_type.

        Tests: ElementType enum completeness and VisibleElement model validation
        How: Iterate all ElementType members; construct VisibleElement for each;
             assert no ValidationError raised and type is preserved
        Why: vm_viewport_action may return any element type — all must validate without error
        """
        # Arrange + Act + Assert
        for element_type in ElementType:
            elem = VisibleElement(
                name=f"test-{element_type.value}", element_type=element_type, coordinates=ElementCoordinates(x=0, y=0)
            )
            assert isinstance(elem.element_type, ElementType)


# ---------------------------------------------------------------------------
# Inline fallback robustness
# ---------------------------------------------------------------------------


class TestInlineFallbackRobustness:
    """Inline fallback must never raise and always return a valid ViewportResponse."""

    def test_inline_fallback_never_raises_returns_viewport_response(self) -> None:
        """_provider_inline_fallback always returns ViewportResponse without raising.

        Tests: _provider_inline_fallback error resilience
        How: Call directly with an arbitrary vm_name; assert isinstance ViewportResponse
        Why: Last-resort provider must always produce a valid structured response
        """
        # Arrange + Act
        result = _provider_inline_fallback("arbitrary-vm-name-xyz")

        # Assert — no exception raised, all fields are well-typed
        assert isinstance(result, ViewportResponse)
        assert isinstance(result.screen_state, ScreenState)
        assert result.provider == VisionProvider.INLINE_FALLBACK

    def test_inline_fallback_with_empty_vm_name_does_not_raise(self) -> None:
        """_provider_inline_fallback does not raise even with an empty vm_name string.

        Tests: _provider_inline_fallback edge case — empty vm_name
        How: Call with vm_name=""; assert ViewportResponse returned
        Why: vm_name is used only descriptively in fallback; empty input must not crash
        """
        # Arrange + Act + Assert
        result = _provider_inline_fallback("")
        assert isinstance(result, ViewportResponse)


# ---------------------------------------------------------------------------
# Async tool surface
# ---------------------------------------------------------------------------


class TestFunctionSignature:
    """vm_viewport_action is an async MCP tool (awaits screenshot + OpenRouter path)."""

    def test_vm_viewport_action_is_async_coroutine_function(self) -> None:
        """vm_viewport_action is defined with async def for FastMCP @tool async tools.

        Tests: vm_viewport_action must be awaitable
        How: Inspect module source for ``async def vm_viewport_action``
        Why: Implementation awaits screenshot capture and OpenRouter subprocess orchestration.
        """
        # Arrange + Act
        source = inspect.getsource(viewport_module)

        # Assert
        assert "async def vm_viewport_action" in source
        assert inspect.iscoroutinefunction(vm_viewport_action)
