"""Unit tests for scenario_runner.py — vm_run_scenario and vm_set_state tools.

Tests cover:
- Goal gate enforcement (no state, draft state, committed-session, committed-file)
- Run ID storage in session state
- Dispatch receipt shape
- vm_set_state thin wrapper
"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

try:
    from mcp_vm_blackbox.goal_state_store import composite_key
    from mcp_vm_blackbox.models import GoalState
    from mcp_vm_blackbox.scenario_runner import (
        _classify_ostype,
        detect_vm_platform,
        vm_run_platform_installer,
        vm_run_scenario,
        vm_set_state,
    )
except ImportError as exc:  # pragma: no cover
    pytest.fail(f"scenario_runner module missing or broken: {exc}")


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

_VM = "DSR-QA-Windows"
_SCENARIO = "dsr-prod-install"
_CK = composite_key(_VM, _SCENARIO)


def _make_goal_state(status: str) -> GoalState:
    """Build a minimal GoalState for tests using model_validate (xdist-safe)."""
    return GoalState.model_validate({
        "vm_name": _VM,
        "scenario_name": _SCENARIO,
        "status": status,
        "criteria": [],
        "hosts": [],
    })


def _make_vagrant_result() -> MagicMock:
    """Minimal VagrantCommandResult stub."""
    result = MagicMock()
    result.success = True
    result.output = ""
    return result


# ---------------------------------------------------------------------------
# Test 1: goal gate blocks when no session state and no file
# ---------------------------------------------------------------------------


async def test_goal_gate_blocks_no_state(mock_ctx: MagicMock) -> None:
    """vm_run_scenario raises ToolError when ctx has no goal state and file store is empty."""
    mock_ctx.get_state = AsyncMock(return_value=None)

    with patch("mcp_vm_blackbox.scenario_runner.default_store") as mock_store:
        mock_store.load = MagicMock(return_value=None)

        from fastmcp.exceptions import ToolError

        with pytest.raises(ToolError) as exc_info:
            await vm_run_scenario(_VM, _SCENARIO, mock_ctx)

    error_msg = str(exc_info.value)
    assert "No committed goal state" in error_msg
    assert "vm_goal_interview" in error_msg


# ---------------------------------------------------------------------------
# Test 2: goal gate blocks when only draft state exists in file store
# ---------------------------------------------------------------------------


async def test_goal_gate_blocks_draft(mock_ctx: MagicMock) -> None:
    """vm_run_scenario raises ToolError when only a draft goal state exists."""
    mock_ctx.get_state = AsyncMock(return_value=None)
    draft = _make_goal_state("draft")

    with patch("mcp_vm_blackbox.scenario_runner.default_store") as mock_store:
        mock_store.load = MagicMock(return_value=draft)

        from fastmcp.exceptions import ToolError

        with pytest.raises(ToolError) as exc_info:
            await vm_run_scenario(_VM, _SCENARIO, mock_ctx)

    assert "No committed goal state" in str(exc_info.value)


# ---------------------------------------------------------------------------
# Test 3: goal gate passes when committed goal state in session state
# ---------------------------------------------------------------------------


async def test_goal_gate_passes_committed_session(mock_ctx: MagicMock) -> None:
    """vm_run_scenario does not raise when session state holds a goal state value."""
    # Returning any non-None value from get_state signals goal present in session
    mock_ctx.get_state = AsyncMock(return_value="committed")

    with patch(
        "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
    ):
        result = await vm_run_scenario(_VM, _SCENARIO, mock_ctx)

    assert result["status"] == "dispatched"


# ---------------------------------------------------------------------------
# Test 4: goal gate passes when committed goal state exists in file store
# ---------------------------------------------------------------------------


async def test_goal_gate_passes_committed_file(mock_ctx: MagicMock) -> None:
    """vm_run_scenario does not raise when file store holds committed goal state."""
    mock_ctx.get_state = AsyncMock(return_value=None)
    committed = _make_goal_state("committed")

    with (
        patch("mcp_vm_blackbox.scenario_runner.default_store") as mock_store,
        patch(
            "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
        ),
    ):
        mock_store.load = MagicMock(return_value=committed)
        result = await vm_run_scenario(_VM, _SCENARIO, mock_ctx)

    assert result["status"] == "dispatched"


# ---------------------------------------------------------------------------
# Test 5: run ID is stored in session state after successful dispatch
# ---------------------------------------------------------------------------


async def test_run_id_stored_in_state(mock_ctx: MagicMock) -> None:
    """After successful dispatch, ctx.set_state is called with a non-empty run ID."""
    mock_ctx.get_state = AsyncMock(return_value="committed")

    with patch(
        "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
    ):
        await vm_run_scenario(_VM, _SCENARIO, mock_ctx)

    # ctx.set_state must have been called
    assert mock_ctx.set_state.called, "ctx.set_state was never called"

    # Find the call that stored the run_id
    stored_calls = mock_ctx.set_state.call_args_list
    # At least one call must have run_id:{ck} key pattern
    run_id_calls = [call for call in stored_calls if call.args and "run_id:" in str(call.args[0])]
    assert run_id_calls, f"No run_id:* key found in set_state calls: {stored_calls}"

    # The stored value must be a non-empty string — not a placeholder literal
    stored_value = run_id_calls[0].args[1]
    assert isinstance(stored_value, str), f"run_id value is not a string: {stored_value!r}"
    assert stored_value, "run_id value is empty"
    assert stored_value != "<agent_task_id>", "run_id must not be a placeholder literal"
    assert stored_value != "<task_id_from_dispatch>", "run_id must not be a placeholder literal"


# ---------------------------------------------------------------------------
# Test 6: dispatch receipt has required shape
# ---------------------------------------------------------------------------


async def test_dispatch_receipt_shape(mock_ctx: MagicMock) -> None:
    """Return value is a dict with keys: scenario, status, run_id_key, video, next."""
    mock_ctx.get_state = AsyncMock(return_value="committed")

    with patch(
        "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
    ):
        result = await vm_run_scenario(_VM, _SCENARIO, mock_ctx)

    assert isinstance(result, dict), f"Expected dict, got {type(result)}"
    for key in ("scenario", "status", "run_id_key", "video", "next"):
        assert key in result, f"Missing key '{key}' in receipt: {result}"

    assert result["scenario"] == _CK
    assert result["status"] == "dispatched"
    assert "run_id:" in result["run_id_key"]


# ---------------------------------------------------------------------------
# Test 8: video option defaults to False
# ---------------------------------------------------------------------------


async def test_video_defaults_to_false(mock_ctx: MagicMock) -> None:
    """When video is not specified, dispatch receipt shows video=False."""
    mock_ctx.get_state = AsyncMock(return_value="committed")

    with patch(
        "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
    ):
        result = await vm_run_scenario(_VM, _SCENARIO, mock_ctx)

    assert result["video"] is False, "video should default to False"


# ---------------------------------------------------------------------------
# Test 9: video option is accepted and surfaced in receipt
# ---------------------------------------------------------------------------


async def test_video_option_accepted(mock_ctx: MagicMock) -> None:
    """When video=True, dispatch receipt surfaces video=True."""
    mock_ctx.get_state = AsyncMock(return_value="committed")

    with patch(
        "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
    ):
        result = await vm_run_scenario(_VM, _SCENARIO, mock_ctx, video=True)

    assert result["video"] is True, "video should be True when passed True"


# ---------------------------------------------------------------------------
# Test 10: video intent is persisted in session state
# ---------------------------------------------------------------------------


async def test_video_intent_persisted_in_state(mock_ctx: MagicMock) -> None:
    """When video=True, ctx.set_state is called with video:{ck} key and 'True' value."""
    mock_ctx.get_state = AsyncMock(return_value="committed")

    with patch(
        "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
    ):
        await vm_run_scenario(_VM, _SCENARIO, mock_ctx, video=True)

    # Find the call that stored the video intent
    stored_calls = mock_ctx.set_state.call_args_list
    video_calls = [call for call in stored_calls if call.args and "video:" in str(call.args[0])]
    assert video_calls, f"No video:* key found in set_state calls: {stored_calls}"

    # The stored value must be "True" as a string
    stored_value = video_calls[0].args[1]
    assert stored_value == "True", f"video value should be 'True', got {stored_value!r}"


# ---------------------------------------------------------------------------
# Test 11: video=False also persisted in session state
# ---------------------------------------------------------------------------


async def test_video_false_persisted_in_state(mock_ctx: MagicMock) -> None:
    """When video=False (default), ctx.set_state is called with 'False' value."""
    mock_ctx.get_state = AsyncMock(return_value="committed")

    with patch(
        "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
    ):
        await vm_run_scenario(_VM, _SCENARIO, mock_ctx)

    stored_calls = mock_ctx.set_state.call_args_list
    video_calls = [call for call in stored_calls if call.args and "video:" in str(call.args[0])]
    assert video_calls, f"No video:* key found in set_state calls: {stored_calls}"

    stored_value = video_calls[0].args[1]
    assert stored_value == "False", f"video value should be 'False', got {stored_value!r}"


# ---------------------------------------------------------------------------
# Test 12: video propagation chain — request → receipt → session state
# ---------------------------------------------------------------------------


async def test_video_propagation_chain(mock_ctx: MagicMock) -> None:
    """Verify complete video intent propagation in a single assertion.

    The propagation chain is:
    1. Caller passes video=True to vm_run_scenario
    2. Dispatch receipt includes video=True
    3. Session state stores video:{ck} = "True"

    This test asserts all three steps are connected, enabling future agents
    to trace video intent from request through to downstream consumer surfaces.
    """
    mock_ctx.get_state = AsyncMock(return_value="committed")

    with patch(
        "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
    ):
        result = await vm_run_scenario(_VM, _SCENARIO, mock_ctx, video=True)

    # Step 1: Caller requested video=True (implicit in the call above)

    # Step 2: Dispatch receipt surfaces video intent
    assert result["video"] is True, f"Dispatch receipt must surface video=True, got {result['video']}"

    # Step 3: Session state persists video intent for downstream consumers
    stored_calls = mock_ctx.set_state.call_args_list
    video_calls = [call for call in stored_calls if call.args and "video:" in str(call.args[0])]
    assert video_calls, "video:* key not found in session state"

    video_key = video_calls[0].args[0]
    video_value = video_calls[0].args[1]

    # Verify key format matches expected pattern for downstream consumers
    expected_key = f"video:{_CK}"
    assert video_key == expected_key, (
        f"Session state key must be '{expected_key}' for downstream consumers, got {video_key}"
    )

    # Verify value is string "True" (session state stores strings)
    assert video_value == "True", f"Session state must store 'True' for video intent, got {video_value!r}"


# ---------------------------------------------------------------------------
# Test 13: video session state key format follows downstream consumer contract
# ---------------------------------------------------------------------------


async def test_video_session_state_key_format(mock_ctx: MagicMock) -> None:
    """Verify video session state key follows the downstream consumer contract.

    The key format `video:{vm_name}/{scenario_name}` is documented in:
    - skills/vm-ground-control/SKILL.md (metadata table)
    - skills/vm-scenario-runner/SKILL.md (video intent persistence)

    Downstream recording/rendering slices depend on this exact format.
    """
    mock_ctx.get_state = AsyncMock(return_value="committed")

    with patch(
        "mcp_vm_blackbox.scenario_runner.vagrant_up", new_callable=AsyncMock, return_value=_make_vagrant_result()
    ):
        await vm_run_scenario(_VM, _SCENARIO, mock_ctx, video=True)

    stored_calls = mock_ctx.set_state.call_args_list
    video_calls = [call for call in stored_calls if call.args and "video:" in str(call.args[0])]
    assert video_calls, "No video:* key found in session state"

    video_key = video_calls[0].args[0]

    # Key must follow the exact format documented for downstream consumers
    assert video_key.startswith("video:"), f"Video session key must start with 'video:', got {video_key}"
    assert "/" in video_key, f"Video session key must include vm_name/scenario_name separator '/', got {video_key}"

    # Extract the composite key portion and verify it matches expected format
    ck_portion = video_key[len("video:") :]
    assert ck_portion == _CK, f"Video session key composite portion must be '{_CK}', got {ck_portion}"


# ---------------------------------------------------------------------------
# Test 7: vm_set_state stores key-value in session state
# ---------------------------------------------------------------------------


async def test_vm_set_state(mock_ctx: MagicMock) -> None:
    """vm_set_state calls ctx.set_state with the given key/value and returns stored=True."""
    result = await vm_set_state("test_key", "test_val", mock_ctx)

    mock_ctx.set_state.assert_called_once_with("test_key", "test_val")
    assert result == {"key": "test_key", "stored": True}


# ---------------------------------------------------------------------------
# Platform detection — _classify_ostype (PLAT-01/02)
# ---------------------------------------------------------------------------


def test_classify_windows_ostype() -> None:
    """Windows OS type IDs map to 'windows' platform."""
    assert _classify_ostype("Windows10_64") == "windows"
    assert _classify_ostype("Win2019_64") == "windows"
    assert _classify_ostype("WindowsXP") == "windows"


def test_classify_macos_ostype() -> None:
    """macOS OS type IDs map to 'macos' platform."""
    assert _classify_ostype("MacOS_64") == "macos"
    assert _classify_ostype("Darwin") == "macos"
    assert _classify_ostype("Mac_10_15") == "macos"


def test_classify_linux_ostype() -> None:
    """Linux OS type IDs map to 'linux' platform."""
    assert _classify_ostype("Ubuntu_64") == "linux"
    assert _classify_ostype("Debian_64") == "linux"
    assert _classify_ostype("Fedora_64") == "linux"
    assert _classify_ostype("RedHat_64") == "linux"
    assert _classify_ostype("Linux_64") == "linux"


def test_classify_unknown_ostype_raises() -> None:
    """Unknown OS type IDs raise ToolError with no fallback."""
    from fastmcp.exceptions import ToolError as FMCPToolError

    with pytest.raises(FMCPToolError, match="Cannot detect platform"):
        _classify_ostype("ReactOS_5_0")


# ---------------------------------------------------------------------------
# detect_vm_platform (PLAT-01/02)
# ---------------------------------------------------------------------------


async def test_detect_vm_platform_happy_path() -> None:
    """detect_vm_platform returns PlatformDetectionResult with platform + os_type_id."""
    from mcp_vm_blackbox import scenario_runner

    with patch.object(scenario_runner, "_detect_platform_from_vbox") as mock_detect:
        from mcp_vm_blackbox.models import PlatformDetectionResult

        mock_detect.return_value = PlatformDetectionResult(vm_name="test-vm", platform="linux", os_type_id="Ubuntu_64")
        result = await detect_vm_platform("test-vm")

    assert result.platform == "linux"
    assert result.os_type_id == "Ubuntu_64"
    assert result.vm_name == "test-vm"


async def test_detect_vm_platform_vboxapi_missing() -> None:
    """detect_vm_platform raises ToolError when vboxapi is not available."""
    from fastmcp.exceptions import ToolError as FMCPToolError

    from mcp_vm_blackbox import scenario_runner

    with (
        patch.object(
            scenario_runner, "_detect_platform_from_vbox", side_effect=FMCPToolError("vboxapi is not available")
        ),
        pytest.raises(FMCPToolError, match="vboxapi is not available"),
    ):
        await detect_vm_platform("test-vm")


# ---------------------------------------------------------------------------
# vm_run_platform_installer (PLAT-01/02)
# ---------------------------------------------------------------------------


async def test_platform_installer_raises_for_windows() -> None:
    """vm_run_platform_installer raises ToolError when VM is detected as Windows."""
    from fastmcp.exceptions import ToolError as FMCPToolError

    from mcp_vm_blackbox import scenario_runner
    from mcp_vm_blackbox.models import PlatformDetectionResult

    with (
        patch.object(
            scenario_runner,
            "_detect_platform_from_vbox",
            return_value=PlatformDetectionResult(vm_name="win-vm", platform="windows", os_type_id="Windows10_64"),
        ),
        pytest.raises(FMCPToolError, match="macOS and Linux only"),
    ):
        await vm_run_platform_installer(
            vm_name="win-vm", scenario_name="test-scenario", installer_cmd="choco install dsr"
        )


async def test_platform_installer_linux_happy_path() -> None:
    """vm_run_platform_installer SSH-execs installer_cmd on Linux VM."""
    from mcp_vm_blackbox import scenario_runner
    from mcp_vm_blackbox.models import PlatformDetectionResult

    with (
        patch.object(
            scenario_runner,
            "_detect_platform_from_vbox",
            return_value=PlatformDetectionResult(vm_name="linux-vm", platform="linux", os_type_id="Ubuntu_64"),
        ),
        patch("mcp_vm_blackbox.scenario_runner._ssh_exec", AsyncMock(return_value=("install ok\n", "", 0))),
    ):
        result = await vm_run_platform_installer(
            vm_name="linux-vm", scenario_name="ubuntu-install", installer_cmd="apt-get install -y dsr"
        )

    assert result.platform == "linux"
    assert result.exit_code == 0
    assert "install ok" in result.stdout
    assert result.installer_cmd == "apt-get install -y dsr"


async def test_platform_installer_macos_happy_path() -> None:
    """vm_run_platform_installer SSH-execs installer_cmd on macOS VM."""
    from mcp_vm_blackbox import scenario_runner
    from mcp_vm_blackbox.models import PlatformDetectionResult

    with (
        patch.object(
            scenario_runner,
            "_detect_platform_from_vbox",
            return_value=PlatformDetectionResult(vm_name="mac-vm", platform="macos", os_type_id="MacOS_64"),
        ),
        patch("mcp_vm_blackbox.scenario_runner._ssh_exec", AsyncMock(return_value=("brew install ok\n", "", 0))),
    ):
        result = await vm_run_platform_installer(
            vm_name="mac-vm", scenario_name="brew-install", installer_cmd="brew install dsr"
        )

    assert result.platform == "macos"
    assert result.exit_code == 0
    assert result.installer_cmd == "brew install dsr"


async def test_platform_installer_ssh_failure_raises() -> None:
    """vm_run_platform_installer raises ToolError on SSH execution failure."""
    from fastmcp.exceptions import ToolError as FMCPToolError

    from mcp_vm_blackbox import scenario_runner
    from mcp_vm_blackbox.models import PlatformDetectionResult

    with (
        patch.object(
            scenario_runner,
            "_detect_platform_from_vbox",
            return_value=PlatformDetectionResult(vm_name="linux-vm", platform="linux", os_type_id="Ubuntu_64"),
        ),
        patch("mcp_vm_blackbox.scenario_runner._ssh_exec", AsyncMock(side_effect=Exception("Connection refused"))),
        pytest.raises(FMCPToolError, match="SSH installer execution failed"),
    ):
        await vm_run_platform_installer(
            vm_name="linux-vm", scenario_name="ubuntu-install", installer_cmd="apt-get install -y dsr"
        )
