"""Tests for vagrant_tools MCP tools.

Tests: vagrant_status, vagrant_up, vagrant_provision, vagrant_destroy, vagrant_winrm.
How: Mock _vagrant_run and _resolve so tests run offline without Vagrant/VMs.
Why: CI environments lack Vagrant; tools must return correct Pydantic models.
"""

from __future__ import annotations

from typing import TYPE_CHECKING
from unittest.mock import AsyncMock

import pytest

from mcp_vm_blackbox.models import VagrantCommandResult, VagrantDetachedResult
from mcp_vm_blackbox.targets import TargetConfig

if TYPE_CHECKING:
    from unittest.mock import MagicMock

    from pytest_mock import MockerFixture


# ---------------------------------------------------------------------------
# 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.vagrant_tools._resolve", return_value=cfg)


@pytest.fixture
def mock_vagrant_run(mocker: MockerFixture) -> MagicMock:
    """Patch _vagrant_run (async) to return controlled (stdout, stderr, exit_code, duration_s)."""
    return mocker.patch(
        "mcp_vm_blackbox.vagrant_tools._vagrant_run", new_callable=AsyncMock, return_value=("stdout", "stderr", 0, 1.5)
    )


@pytest.fixture(autouse=True)
def mock_progress(mocker: MockerFixture) -> MagicMock:
    """Patch Progress.set_message to be a no-op outside the FastMCP framework.

    ``progress: Progress = Progress()`` is evaluated at definition time, so
    patching the constructor is too late. Patching the method on the class
    itself covers the already-created default instance.
    """
    return mocker.patch("fastmcp.server.dependencies.Progress.set_message", new_callable=AsyncMock)


@pytest.fixture(autouse=True)
def mock_mark_run_complete(mocker: MockerFixture) -> MagicMock:
    """Patch _mark_run_complete to be a no-op outside the FastMCP framework.

    vagrant_up and vagrant_provision call _mark_run_complete(composite_key, ctx)
    after a successful run. In unit tests there is no live FastMCP context, so
    ctx is the _CurrentContext sentinel which has no set_state/enable_components.
    Patching _mark_run_complete prevents AttributeError and keeps these tests
    focused on vagrant run behaviour, not goal-state side effects.
    """
    return mocker.patch("mcp_vm_blackbox.vagrant_tools._mark_run_complete", new_callable=AsyncMock)


# ---------------------------------------------------------------------------
# vagrant_status
# ---------------------------------------------------------------------------


class TestVagrantStatus:
    """Tests for vagrant_status tool."""

    @pytest.mark.asyncio
    async def test_vagrant_status_success(self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock) -> None:
        """vagrant_status returns VagrantCommandResult on success."""
        mock_vagrant_run.return_value = ("Current machine states:\ndefault running", "", 0, 0.5)

        from mcp_vm_blackbox.vagrant_tools import vagrant_status

        result = await vagrant_status(target="local", timeout_s=30)

        assert result.stdout == "Current machine states:\ndefault running"
        assert result.stderr == ""
        assert result.exit_code == 0
        assert result.duration_s == pytest.approx(0.5)
        assert result.error is None

    @pytest.mark.asyncio
    async def test_vagrant_status_timeout_returns_error_result(
        self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock
    ) -> None:
        """vagrant_status returns error when exit_code is -1 (timeout)."""
        mock_vagrant_run.return_value = ("", "timed out after 30s", -1, 30.0)

        from mcp_vm_blackbox.vagrant_tools import vagrant_status

        result = await vagrant_status(target="local", timeout_s=30)

        assert result.exit_code == -1
        assert result.error == "timed out after 30s"
        assert result.stdout is None


# ---------------------------------------------------------------------------
# vagrant_up — MCP-01: background task, no detached parameter
# ---------------------------------------------------------------------------


class TestVagrantUp:
    """Tests for vagrant_up tool (background task, detached param removed)."""

    @pytest.mark.asyncio
    async def test_vagrant_up_all_success(self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock) -> None:
        """vagrant_up with vm='all' runs vagrant up with no VM argument."""
        mock_vagrant_run.return_value = ("Bringing machine 'default' up...", "", 0, 120.0)

        from mcp_vm_blackbox.vagrant_tools import vagrant_up

        result = await vagrant_up(vm="all", target="local", timeout_s=600)

        assert isinstance(result, VagrantCommandResult)
        assert result.stdout == "Bringing machine 'default' up..."
        assert result.exit_code == 0
        mock_vagrant_run.assert_called_once()
        call_args = mock_vagrant_run.call_args[0]
        assert call_args[1] == ["up"]

    @pytest.mark.asyncio
    async def test_vagrant_up_specific_vm(self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock) -> None:
        """vagrant_up with specific vm runs vagrant up <vm>."""
        from mcp_vm_blackbox.vagrant_tools import vagrant_up

        await vagrant_up(vm="my-vm", target="local", timeout_s=600)

        mock_vagrant_run.assert_called_once()
        call_args = mock_vagrant_run.call_args[0]
        assert call_args[1] == ["up", "my-vm"]

    def test_vagrant_up_has_no_detached_parameter(self) -> None:
        """vagrant_up must not accept a 'detached' parameter (MCP-01: task=True replaces it)."""
        import inspect

        from mcp_vm_blackbox.vagrant_tools import vagrant_up

        sig = inspect.signature(vagrant_up)
        assert "detached" not in sig.parameters, (
            "vagrant_up must not have a 'detached' parameter — "
            "background dispatch is handled by task=TaskConfig(mode='required')"
        )

    @pytest.mark.asyncio
    async def test_vagrant_up_has_task_config(self) -> None:
        """vagrant_up must be registered with task=TaskConfig(mode='optional')."""
        from fastmcp.server.tasks import TaskConfig

        from mcp_vm_blackbox.server import mcp

        tool = await mcp.get_tool("vagrant_up")
        assert tool is not None, "vagrant_up must be registered on mcp"
        assert tool.task_config is not None, "vagrant_up must have a task_config"
        assert isinstance(tool.task_config, TaskConfig)
        assert tool.task_config.mode == "optional"


# ---------------------------------------------------------------------------
# vagrant_provision — MCP-01: background task, no detached parameter
# ---------------------------------------------------------------------------


class TestVagrantProvision:
    """Tests for vagrant_provision tool (background task, detached param removed)."""

    @pytest.mark.asyncio
    async def test_vagrant_provision_with_provisioner(
        self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock
    ) -> None:
        """vagrant_provision with provisioner adds --provision-with flag."""
        from mcp_vm_blackbox.vagrant_tools import vagrant_provision

        await vagrant_provision(vm="my-vm", provisioner="ansible_local", target="local", timeout_s=1800)

        mock_vagrant_run.assert_called_once()
        call_args = mock_vagrant_run.call_args[0]
        assert call_args[1] == ["provision", "my-vm", "--provision-with", "ansible_local"]

    @pytest.mark.asyncio
    async def test_vagrant_provision_without_provisioner(
        self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock
    ) -> None:
        """vagrant_provision without provisioner runs all provisioners."""
        from mcp_vm_blackbox.vagrant_tools import vagrant_provision

        await vagrant_provision(vm="my-vm", target="local", timeout_s=1800)

        mock_vagrant_run.assert_called_once()
        call_args = mock_vagrant_run.call_args[0]
        assert call_args[1] == ["provision", "my-vm"]

    def test_vagrant_provision_has_no_detached_parameter(self) -> None:
        """vagrant_provision must not accept a 'detached' parameter."""
        import inspect

        from mcp_vm_blackbox.vagrant_tools import vagrant_provision

        sig = inspect.signature(vagrant_provision)
        assert "detached" not in sig.parameters, (
            "vagrant_provision must not have a 'detached' parameter — "
            "background dispatch is handled by task=TaskConfig(mode='required')"
        )

    @pytest.mark.asyncio
    async def test_vagrant_provision_has_task_config(self) -> None:
        """vagrant_provision must be registered with task=TaskConfig(mode='optional')."""
        from fastmcp.server.tasks import TaskConfig

        from mcp_vm_blackbox.server import mcp

        tool = await mcp.get_tool("vagrant_provision")
        assert tool is not None, "vagrant_provision must be registered on mcp"
        assert tool.task_config is not None, "vagrant_provision must have a task_config"
        assert isinstance(tool.task_config, TaskConfig)
        assert tool.task_config.mode == "optional"


# ---------------------------------------------------------------------------
# vagrant_destroy — keeps detached parameter (tmux-based, unchanged)
# ---------------------------------------------------------------------------


class TestVagrantDestroy:
    """Tests for vagrant_destroy tool."""

    @pytest.mark.asyncio
    async def test_vagrant_destroy_with_force(self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock) -> None:
        """vagrant_destroy with force=True passes -f flag."""
        from mcp_vm_blackbox.vagrant_tools import vagrant_destroy

        await vagrant_destroy(vm="my-vm", force=True, target="local")

        mock_vagrant_run.assert_called_once()
        call_args = mock_vagrant_run.call_args[0]
        assert call_args[1] == ["destroy", "my-vm", "-f"]

    @pytest.mark.asyncio
    async def test_vagrant_destroy_without_force(self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock) -> None:
        """vagrant_destroy with force=False omits -f flag."""
        from mcp_vm_blackbox.vagrant_tools import vagrant_destroy

        await vagrant_destroy(vm="my-vm", force=False, target="local")

        call_args = mock_vagrant_run.call_args[0]
        assert "-f" not in call_args[1]

    def test_vagrant_destroy_still_has_detached_parameter(self) -> None:
        """vagrant_destroy keeps its detached parameter (tmux-based, not converted)."""
        import inspect

        from mcp_vm_blackbox.vagrant_tools import vagrant_destroy

        sig = inspect.signature(vagrant_destroy)
        assert "detached" in sig.parameters, (
            "vagrant_destroy must retain 'detached' parameter — it uses tmux-based detach, not MCP background tasks"
        )

    def test_vagrant_detached_result_still_importable(self) -> None:
        """VagrantDetachedResult must remain importable (vagrant_destroy uses it)."""

        assert VagrantDetachedResult is not None

    @pytest.mark.asyncio
    async def test_destroy_blocked_by_active_job(
        self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock, mocker: MockerFixture
    ) -> None:
        """vagrant_destroy raises ToolError when an active job exists on the VM."""
        from fastmcp.exceptions import ToolError

        from mcp_vm_blackbox.models import JobRecord, JobStep
        from mcp_vm_blackbox.vagrant_tools import vagrant_destroy

        active_job = JobRecord(
            job_id="aabbccdd1122",
            vm_name="my-vm",
            scenario_name="win11-install",
            created_by="agent-a",
            last_agent_id="agent-b",
            status="active",
            steps=[JobStep(agent_id="agent-a", action="started install", timestamp="2026-01-01T00:00:00+00:00")],
            created_at="2026-01-01T00:00:00+00:00",
            updated_at="2026-01-01T00:00:00+00:00",
        )
        mocker.patch("mcp_vm_blackbox.job_store.default_job_store.list_by_vm", return_value=[active_job])

        with pytest.raises(ToolError, match="aabbccdd1122"):
            await vagrant_destroy(vm="my-vm", force=True, target="local")

        mock_vagrant_run.assert_not_called()

    @pytest.mark.asyncio
    async def test_destroy_allowed_with_no_jobs(
        self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock, mocker: MockerFixture
    ) -> None:
        """vagrant_destroy proceeds when no jobs exist for the VM."""
        from mcp_vm_blackbox.vagrant_tools import vagrant_destroy

        mocker.patch("mcp_vm_blackbox.job_store.default_job_store.list_by_vm", return_value=[])

        await vagrant_destroy(vm="my-vm", force=True, target="local")

        mock_vagrant_run.assert_called_once()

    @pytest.mark.asyncio
    async def test_destroy_allowed_with_paused_and_completed_jobs(
        self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock, mocker: MockerFixture
    ) -> None:
        """vagrant_destroy proceeds when only paused/completed jobs exist."""
        from mcp_vm_blackbox.models import JobRecord, JobStep
        from mcp_vm_blackbox.vagrant_tools import vagrant_destroy

        paused_job = JobRecord(
            job_id="paused111111",
            vm_name="my-vm",
            scenario_name="win11-install",
            created_by="agent-a",
            last_agent_id="agent-a",
            status="paused",
            steps=[JobStep(agent_id="agent-a", action="paused midway", timestamp="2026-01-01T00:00:00+00:00")],
            created_at="2026-01-01T00:00:00+00:00",
            updated_at="2026-01-01T00:00:00+00:00",
        )
        completed_job = JobRecord(
            job_id="done22222222",
            vm_name="my-vm",
            scenario_name="win11-install",
            created_by="agent-c",
            last_agent_id="agent-c",
            status="completed",
            steps=[JobStep(agent_id="agent-c", action="finished install", timestamp="2026-01-01T00:00:00+00:00")],
            created_at="2026-01-01T00:00:00+00:00",
            updated_at="2026-01-01T00:00:00+00:00",
        )
        mocker.patch("mcp_vm_blackbox.job_store.default_job_store.list_by_vm", return_value=[paused_job, completed_job])

        await vagrant_destroy(vm="my-vm", force=True, target="local")

        mock_vagrant_run.assert_called_once()


# ---------------------------------------------------------------------------
# vagrant_winrm
# ---------------------------------------------------------------------------


class TestVagrantWinrm:
    """Tests for vagrant_winrm tool."""

    @pytest.mark.asyncio
    async def test_vagrant_winrm_success(self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock) -> None:
        """vagrant_winrm returns VagrantCommandResult with command output."""
        mock_vagrant_run.return_value = ("Hello from Windows", "", 0, 1.0)

        from mcp_vm_blackbox.vagrant_tools import vagrant_winrm

        result = await vagrant_winrm(vm="my-vm", command="echo Hello from Windows", target="local")

        assert result.stdout == "Hello from Windows"
        assert result.exit_code == 0
        mock_vagrant_run.assert_called_once()
        call_args = mock_vagrant_run.call_args
        assert call_args.args[1] == ["winrm", "my-vm", "-c", "echo Hello from Windows"]

    @pytest.mark.asyncio
    async def test_vagrant_winrm_forwards_repo_root(self, mock_resolve: MagicMock, mock_vagrant_run: MagicMock) -> None:
        """vagrant_winrm must honor explicit repo_root for external Vagrant repos."""
        from mcp_vm_blackbox.vagrant_tools import vagrant_winrm

        repo_root = "/tmp/external-vagrant-repo"
        await vagrant_winrm(vm="my-vm", command="Write-Output 'ok'", target="local", repo_root=repo_root)

        assert mock_vagrant_run.call_args.kwargs["repo_root"] == repo_root


# ---------------------------------------------------------------------------
# _vagrant_run error propagation (COMP-02)
# ---------------------------------------------------------------------------


class TestVagrantRunToolError:
    """Verify that _vagrant_run raises ToolError on exception — never returns a 4-tuple."""

    @pytest.mark.asyncio
    async def test_vagrant_run_raises_tool_error_on_exception(self, mocker: MockerFixture) -> None:
        """_vagrant_run must raise ToolError (with captured output) when vagrant exits non-zero."""
        import subprocess

        from fastmcp.exceptions import ToolError

        from mcp_vm_blackbox.targets import TargetConfig
        from mcp_vm_blackbox.vagrant_tools import _vagrant_run

        cfg = TargetConfig(ssh_host="")

        # Patch _make_vagrant_command so no real vagrant binary is needed.
        mocker.patch(
            "mcp_vm_blackbox.vagrant_tools.vagrant.Vagrant._make_vagrant_command",
            return_value=["/usr/bin/vagrant", "up"],
        )
        # Simulate vagrant exiting non-zero with captured output on both streams.
        mocker.patch(
            "mcp_vm_blackbox.vagrant_tools.subprocess.run",
            return_value=subprocess.CompletedProcess(
                args=["/usr/bin/vagrant", "up"],
                returncode=1,
                stdout=b"some stdout output",
                stderr=b"some stderr output",
            ),
        )

        with pytest.raises(ToolError, match="vagrant 'up' failed"):
            await _vagrant_run(cfg, ["up"])
