"""Integration tests proving fleet status and destroy guard through JSON-RPC.

Exercises the complete lifecycle:
  registry populate → job start → step → fleet status (R005) →
  vagrant_destroy blocked (R006) → job complete → destroy succeeds.

Deferred imports
----------------
``fastmcp`` is intentionally **not** imported at module level.  Importing it
at collection time (before conftest patches are in place) causes
``ModuleNotFoundError`` for ``rich.logging`` in pytest-xdist workers.  All
fastmcp symbols are accessed via local imports inside test bodies — matching
the pattern established in ``test_job_lifecycle.py``.
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock

if TYPE_CHECKING:
    import types
    from collections.abc import Generator
    from pathlib import Path


import pytest

import mcp_vm_blackbox.fleet_tools as fleet_tools_mod
import mcp_vm_blackbox.job_store as job_store_mod
import mcp_vm_blackbox.job_tools as job_tools_mod
import mcp_vm_blackbox.vagrant_tools as vagrant_tools_mod
from mcp_vm_blackbox.job_store import JobStore
from mcp_vm_blackbox.models import VmListEntry
from mcp_vm_blackbox.targets import TargetConfig
from mcp_vm_blackbox.vm_registry import VMRegistry


class TestFleetLifecycle:
    """Integration test exercising R005 + R006 end-to-end through fastmcp.Client."""

    async def test_fleet_lifecycle(
        self, server_module: types.ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Full lifecycle: populate → start → step → fleet status → destroy blocked → complete → destroy ok.

        Assertions:
        - ``vm_fleet_status`` returns FleetEntry with active job data
        - ``vagrant_destroy`` returns is_error=True when an active job exists
        - ``vagrant_destroy`` succeeds after the job is completed
        - Job file persisted on disk
        """
        from fastmcp import Client

        # --- Isolated stores ---
        store = JobStore(tmp_path)
        registry = VMRegistry(tmp_path)

        # Monkeypatch job_store on all modules that reference it
        monkeypatch.setattr(job_tools_mod, "default_job_store", store)
        monkeypatch.setattr(fleet_tools_mod, "default_job_store", store)
        monkeypatch.setattr(job_store_mod, "default_job_store", store)

        # Monkeypatch registry on fleet_tools
        monkeypatch.setattr(fleet_tools_mod, "default_registry", registry)

        # Monkeypatch agent ID
        monkeypatch.setattr(job_tools_mod, "_AGENT_ID", "test-agent")

        # Mock vagrant internals so vagrant_destroy doesn't need real Vagrant
        local_cfg = TargetConfig(ssh_host="")
        monkeypatch.setattr(vagrant_tools_mod, "_resolve", lambda _target: local_cfg)

        mock_run = AsyncMock(return_value=("", "", 0, 0.1))
        monkeypatch.setattr(vagrant_tools_mod, "_vagrant_run", mock_run)

        mock_run_detached = AsyncMock()
        monkeypatch.setattr(vagrant_tools_mod, "_vagrant_run_detached", mock_run_detached)

        # --- Populate registry with a test VM ---
        vm_name = "fleet-test-vm"
        await registry.populate([VmListEntry(name=vm_name, uuid="uuid-fleet-1", running=True)])

        async with Client(server_module.mcp) as client:
            # ── 1. Start a job ──────────────────────────────────
            start_result = await client.call_tool(
                "vm_job_start", {"vm_name": vm_name, "scenario_name": "install-os", "step": "boot VM"}
            )
            start_data = json.loads(start_result.content[0].text)
            job_id = start_data["job_id"]
            assert start_data["status"] == "active"

            # ── 2. Append a step ────────────────────────────────
            step_result = await client.call_tool(
                "vm_job_step",
                {"job_id": job_id, "vm_name": vm_name, "action": "run installer wizard", "outcome": "page 1 complete"},
            )
            step_data = json.loads(step_result.content[0].text)
            assert step_data["status"] == "step_appended"

            # ── 3. Call vm_fleet_status (R005) ──────────────────
            fleet_result = await client.call_tool("vm_fleet_status", {})
            fleet_text = fleet_result.content[0].text

            assert vm_name in fleet_text, f"Expected VM name in fleet text, got: {fleet_text}"
            assert "Fleet: 1 VMs" in fleet_text, f"Expected fleet summary, got: {fleet_text}"
            assert "active" in fleet_text, f"Expected active status in fleet text, got: {fleet_text}"
            assert "install-os" in fleet_text, f"Expected scenario name in fleet text, got: {fleet_text}"

            # ── 4. vagrant_destroy should be BLOCKED (R006) ─────
            #    Use raise_on_error=False so the ToolError surfaces as
            #    is_error on the CallToolResult instead of propagating.
            destroy_result = await client.call_tool("vagrant_destroy", {"vm": vm_name}, raise_on_error=False)
            assert destroy_result.is_error is True, f"Expected isError=True from destroy guard, got: {destroy_result}"
            error_text = destroy_result.content[0].text
            assert f"Cannot destroy VM '{vm_name}'" in error_text
            assert job_id in error_text
            assert "install-os" in error_text

            # ── 5. Complete the job ─────────────────────────────
            complete_result = await client.call_tool("vm_job_complete", {"job_id": job_id, "vm_name": vm_name})
            complete_data = json.loads(complete_result.content[0].text)
            assert complete_data["status"] == "completed"

            # ── 6. vagrant_destroy should now SUCCEED ───────────
            destroy_ok_result = await client.call_tool("vagrant_destroy", {"vm": vm_name})
            assert destroy_ok_result.is_error is not True, (
                f"Expected destroy to succeed after job completion, got error: {destroy_ok_result.content[0].text}"
            )

        # ── Post-session: verify file persistence ──────────────
        job_path = tmp_path / "jobs" / vm_name / f"{job_id}.json"
        assert job_path.exists(), f"Job file missing at {job_path}"

        raw = json.loads(job_path.read_text(encoding="utf-8"))
        assert raw["status"] == "completed"
        assert raw["job_id"] == job_id


class TestFleetStatusDiscoverable:
    """Verify ``vm_fleet_status`` is discoverable via ``list_tools``."""

    async def test_fleet_status_discoverable(self, server_module: types.ModuleType) -> None:
        """``vm_fleet_status`` must appear in the tool list."""
        from fastmcp import Client

        async with Client(server_module.mcp) as client:
            tools = await client.list_tools()

        tool_names = {t.name for t in tools}
        assert "vm_fleet_status" in tool_names, (
            f"vm_fleet_status not found in list_tools. Available tools: {sorted(tool_names)}"
        )


class TestFleetStatusNewFields:
    """Integration tests proving recording and pilot_context_usage fields appear in JSON-RPC responses."""

    @pytest.fixture(autouse=True)
    def _reset_db(self) -> Generator[None, None, None]:
        """Reset the CoordinationDB singleton before and after each test.

        Prevents the session-scoped server_module fixture from leaking DB state
        between tests that use different tmp_path-backed stores.  Without this,
        the first call to get_db() wins and all subsequent tmp_path stores share
        the same SQLite database.
        """
        from mcp_vm_blackbox.coordination_db import _reset_db_instance_for_testing

        _reset_db_instance_for_testing()
        yield
        _reset_db_instance_for_testing()

    async def test_fleet_status_recording_fields_present_in_response(
        self, server_module: types.ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """FleetEntry recording fields and JobSummary.pilot_context_usage appear in vm_fleet_status JSON.

        Tests: New fields added in feature #46 are serialized and present in MCP tool response
        How: Populate registry with a VM (recording active), start a job, call vm_fleet_status
             via fastmcp.Client, assert recording/recording_path/recording_started_at and
             pilot_context_usage keys exist with correct values in the JSON response.
        Why: Confirms the end-to-end path: DB -> VMRegistryEntry -> FleetEntry -> JSON-RPC.
             Catches missing field wiring that unit tests of isolated constructors might miss.
        """
        from fastmcp import Client

        # --- Isolated stores ---
        store = JobStore(tmp_path)
        registry = VMRegistry(tmp_path)

        monkeypatch.setattr(fleet_tools_mod, "default_job_store", store)
        monkeypatch.setattr(fleet_tools_mod, "default_registry", registry)

        # --- Populate registry with a VM in active recording state ---
        vm_name = "fleet-rec-test-vm"
        await registry.populate([VmListEntry(name=vm_name, uuid="uuid-fleet-rec-1", running=True)])
        await registry.update(
            vm_name,
            recording=True,
            recording_path="/evidence/session.webm",
            recording_started_at="2026-03-28T09:00:00+00:00",
        )

        # --- Start a job (pilot_context_usage will be NULL) ---
        await store.start(vm_name, "recording-scenario", "agent-rec", "begin test")

        async with Client(server_module.mcp) as client:
            fleet_result = await client.call_tool("vm_fleet_status", {})

        fleet_text = fleet_result.content[0].text

        # --- Verify the VM appears in the fleet summary ---
        assert vm_name in fleet_text, f"VM name missing from fleet text: {fleet_text}"
        assert "Fleet: 1 VMs" in fleet_text, f"Expected fleet summary line, got: {fleet_text}"

        # --- Verify recording state appears in text (recording=True → "Yes") ---
        assert "Yes" in fleet_text, f"Expected 'Yes' for recording in fleet text: {fleet_text}"

        # --- Verify context usage N/A (pilot_context_usage is None) ---
        assert "N/A" in fleet_text, f"Expected N/A for null context usage in fleet text: {fleet_text}"


class TestFleetStatusAppMode:
    """Integration tests for vm_fleet_status App mode (ToolResult + PrefabApp)."""

    @pytest.fixture(autouse=True)
    def _reset_db(self) -> Generator[None, None, None]:
        """Reset the CoordinationDB singleton before and after each test.

        Prevents DB singleton leakage between tests that use different
        ``tmp_path``-backed stores.
        """
        from mcp_vm_blackbox.coordination_db import _reset_db_instance_for_testing

        _reset_db_instance_for_testing()
        yield
        _reset_db_instance_for_testing()

    async def test_fleet_status_returns_tool_result(
        self, server_module: types.ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """vm_fleet_status returns a ToolResult with content and structured_content.

        Tests: tool returns ToolResult (not raw list) after App mode change
        How: populate registry with one VM + one active job, call tool via Client
        Why: regression guard — confirms the return type changed from list[FleetEntry]
        """
        from fastmcp import Client
        from mcp.types import TextContent

        store = JobStore(tmp_path)
        registry = VMRegistry(tmp_path)
        monkeypatch.setattr(fleet_tools_mod, "default_job_store", store)
        monkeypatch.setattr(fleet_tools_mod, "default_registry", registry)

        vm_name = "app-mode-test-vm"
        await registry.populate([VmListEntry(name=vm_name, uuid="uuid-am-1", running=True)])
        await store.start(vm_name, "app-scenario", "agent-am", "first step")

        async with Client(server_module.mcp) as client:
            result = await client.call_tool("vm_fleet_status", {})

        assert len(result.content) >= 1, "Expected at least one content item"
        assert isinstance(result.content[0], TextContent), f"Expected TextContent, got {type(result.content[0])}"
        assert result.content[0].text, "content text must be non-empty"
        assert result.structured_content is not None, "structured_content must not be None"

    async def test_fleet_status_text_content_format(
        self, server_module: types.ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """content text contains the correct prose summary and markdown table.

        Tests: _build_fleet_text output format
        How: 2 VMs — one running+active job, one stopped+no jobs
        Why: prose format is the agent contract; test stabilises the string format
        """
        from fastmcp import Client

        store = JobStore(tmp_path)
        registry = VMRegistry(tmp_path)
        monkeypatch.setattr(fleet_tools_mod, "default_job_store", store)
        monkeypatch.setattr(fleet_tools_mod, "default_registry", registry)

        running_vm = "running-vm"
        stopped_vm = "stopped-vm"
        await registry.populate([
            VmListEntry(name=running_vm, uuid="uuid-r-1", running=True),
            VmListEntry(name=stopped_vm, uuid="uuid-s-1", running=False),
        ])
        await store.start(running_vm, "my-scenario", "agent-1", "doing work")

        async with Client(server_module.mcp) as client:
            result = await client.call_tool("vm_fleet_status", {})

        text = result.content[0].text
        assert "Fleet: 2 VMs" in text, f"Expected '2 VMs' in text: {text}"
        assert "1 running" in text, f"Expected '1 running' in text: {text}"
        assert "1 stopped" in text, f"Expected '1 stopped' in text: {text}"
        assert "1 active" in text, f"Expected '1 active' in text: {text}"
        assert "VM Name" in text, f"Expected table header in text: {text}"
        assert running_vm in text, f"Expected {running_vm!r} in text: {text}"
        assert stopped_vm in text, f"Expected {stopped_vm!r} in text: {text}"

    async def test_fleet_status_structured_content_columns(
        self, server_module: types.ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """structured_content DataTable has all expected column keys and one row.

        Tests: _build_fleet_table column structure
        How: 1 VM with 1 active job
        Why: confirms column contract — key names are part of the UI API
        """
        from fastmcp import Client

        store = JobStore(tmp_path)
        registry = VMRegistry(tmp_path)
        monkeypatch.setattr(fleet_tools_mod, "default_job_store", store)
        monkeypatch.setattr(fleet_tools_mod, "default_registry", registry)

        vm_name = "col-test-vm"
        await registry.populate([VmListEntry(name=vm_name, uuid="uuid-col-1", running=True)])
        await store.start(vm_name, "col-scenario", "agent-col", "col step")

        async with Client(server_module.mcp) as client:
            result = await client.call_tool("vm_fleet_status", {})

        sc = result.structured_content
        assert sc is not None, "structured_content must not be None"

        # structured_content is a dict (serialised PrefabApp)
        sc_dict = sc if isinstance(sc, dict) else sc.model_dump()

        # Navigate to the DataTable: PrefabApp wraps view in a Div root, so path is
        # view.children[0] (Column) → children[1] (DataTable)
        view = sc_dict["view"]
        column = view["children"][0]
        data_table = column["children"][1]
        assert data_table["type"] == "DataTable", f"Expected DataTable, got {data_table.get('type')}"

        expected_keys = {
            "vm_name",
            "running",
            "job_id",
            "recording",
            "job_status",
            "scenario_name",
            "last_step",
            "context_usage",
            "updated_at",
        }
        actual_keys = {col["key"] for col in data_table["columns"]}
        assert expected_keys == actual_keys, f"Column keys mismatch. Got: {actual_keys}"

        rows = data_table["rows"]
        assert len(rows) == 1, f"Expected 1 row, got {len(rows)}"
        assert rows[0]["vm_name"] == vm_name
        assert rows[0]["job_status"] == "active"

    async def test_fleet_status_empty_fleet_graceful(
        self, server_module: types.ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Empty registry produces a valid ToolResult with no exceptions.

        Tests: empty-fleet path in both _build_fleet_text and _build_fleet_table
        How: monkeypatched registry with no VMs
        Why: edge-case stability — empty fleet must not raise
        """
        from fastmcp import Client

        store = JobStore(tmp_path)
        registry = VMRegistry(tmp_path)
        monkeypatch.setattr(fleet_tools_mod, "default_job_store", store)
        monkeypatch.setattr(fleet_tools_mod, "default_registry", registry)

        async with Client(server_module.mcp) as client:
            result = await client.call_tool("vm_fleet_status", {})

        assert result.content[0].text == "No VMs registered.", (
            f"Expected empty-fleet message, got: {result.content[0].text!r}"
        )
        assert result.structured_content is not None, "structured_content must not be None for empty fleet"

        sc = result.structured_content
        sc_dict = sc if isinstance(sc, dict) else sc.model_dump()
        view = sc_dict["view"]
        data_table = view["children"][0]["children"][1]
        assert data_table["rows"] == [], f"Expected empty rows for empty fleet, got: {data_table['rows']}"

    async def test_fleet_status_active_job_selected_over_completed(
        self, server_module: types.ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """_active_or_latest_job returns the active job when mixed statuses exist.

        Tests: job selection logic when VM has both a completed and an active job
        How: start job1, complete it, start job2 (active), check row shows active
        Why: display contract — active job should always be surfaced
        """
        from fastmcp import Client

        store = JobStore(tmp_path)
        registry = VMRegistry(tmp_path)
        monkeypatch.setattr(fleet_tools_mod, "default_job_store", store)
        monkeypatch.setattr(fleet_tools_mod, "default_registry", registry)

        vm_name = "mixed-status-vm"
        await registry.populate([VmListEntry(name=vm_name, uuid="uuid-ms-1", running=True)])
        job1_id = await store.start(vm_name, "scenario-1", "agent-1", "step 1")
        await store.set_status(job1_id, vm_name, "completed")
        await store.start(vm_name, "scenario-2", "agent-2", "step 2")

        async with Client(server_module.mcp) as client:
            result = await client.call_tool("vm_fleet_status", {})

        sc = result.structured_content
        sc_dict = sc if isinstance(sc, dict) else sc.model_dump()
        view = sc_dict["view"]
        data_table = view["children"][0]["children"][1]
        rows = data_table["rows"]
        assert len(rows) == 1, f"Expected 1 row, got {len(rows)}"
        assert rows[0]["job_status"] == "active", f"Expected active job to be selected, got: {rows[0]['job_status']!r}"

    async def test_fleet_status_context_usage_formatting(
        self, server_module: types.ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """pilot_context_usage is formatted as percentage or N/A in table rows.

        Tests: context_usage column formatting in _build_fleet_table
        How: two sub-cases — float 0.73 and None
        Why: format contract — agents and humans rely on this display value
        """
        from fastmcp import Client

        from mcp_vm_blackbox.coordination_db import _reset_db_instance_for_testing, get_db
        from mcp_vm_blackbox.job_store import JobStore as _JobStore

        async def _run_with_context(ctx_usage: float | None, vm_suffix: str) -> str:
            """Helper: set up VM with given context usage, return context_usage cell."""
            # Reset the CoordinationDB singleton so each sub-call gets a fresh DB
            # scoped to its own tmp_path subdirectory.
            _reset_db_instance_for_testing()
            local_store = _JobStore(tmp_path / vm_suffix)
            local_registry = VMRegistry(tmp_path / vm_suffix)
            monkeypatch.setattr(fleet_tools_mod, "default_job_store", local_store)
            monkeypatch.setattr(fleet_tools_mod, "default_registry", local_registry)

            vm_name = f"ctx-vm-{vm_suffix}"
            await local_registry.populate([VmListEntry(name=vm_name, uuid=f"uuid-ctx-{vm_suffix}", running=True)])
            job_id = await local_store.start(vm_name, "ctx-scenario", "agent-ctx", "ctx step")
            if ctx_usage is not None:
                db = await get_db(tmp_path / vm_suffix)
                assert db._conn is not None, "CoordinationDB connection must be open"
                async with db._conn.execute(
                    "UPDATE jobs SET pilot_context_usage = ? WHERE job_id = ?", (ctx_usage, job_id)
                ):
                    pass
                await db._conn.commit()

            async with Client(server_module.mcp) as client:
                result = await client.call_tool("vm_fleet_status", {})

            sc = result.structured_content
            sc_dict = sc if isinstance(sc, dict) else sc.model_dump()
            view = sc_dict["view"]
            data_table = view["children"][0]["children"][1]
            return data_table["rows"][0]["context_usage"]

        cell_with_value = await _run_with_context(0.73, "with-value")
        assert cell_with_value == "73%", f"Expected '73%', got {cell_with_value!r}"

        cell_with_none = await _run_with_context(None, "with-none")
        assert cell_with_none == "N/A", f"Expected 'N/A', got {cell_with_none!r}"

    async def test_fleet_status_discoverable_after_app_mode(self, server_module: types.ModuleType) -> None:
        """vm_fleet_status tool name is unchanged after the App mode decorator change.

        Tests: tool discovery still works after switching to @tool(meta={"ui": True})
        How: list_tools and check name present
        Why: regression guard for tool name stability
        """
        from fastmcp import Client

        async with Client(server_module.mcp) as client:
            tools = await client.list_tools()

        tool_names = {t.name for t in tools}
        assert "vm_fleet_status" in tool_names, (
            f"vm_fleet_status not found after app mode change. Tools: {sorted(tool_names)}"
        )

    def test_event_emitter_does_not_raise_on_prose_text_content(self) -> None:
        """_extract_payload_from_tool_result silently degrades when content is non-JSON prose.

        Tests: EventEmitterMiddleware silent degradation path (json.JSONDecodeError handler)
        How: Call _extract_payload_from_tool_result with a ToolResult whose TextContent
             contains fleet status prose (not JSON); assert no exception raised and
             data["raw"] contains the original text.
        Why: vm_fleet_status returns human-readable prose, not JSON. If EventEmitterMiddleware
             were ever wired to vm_fleet_status, it must not crash — the pre-existing
             except branch guarantees this. This test locks in that contract.
        """
        from unittest.mock import MagicMock

        from mcp.types import TextContent

        from mcp_vm_blackbox.event_emitter import EventType, _extract_payload_from_tool_result

        prose = "Fleet: 2 VMs — 1 running, 1 stopped\n1 active job\n\n| VM Name | Running |\n|---------|---------|"

        mock_result = MagicMock()
        mock_result.content = [TextContent(type="text", text=prose)]

        # Act — must not raise even though prose is not valid JSON
        payload = _extract_payload_from_tool_result(
            tool_name="vm_fleet_status", event_type=EventType.JOB_UPDATED, result=mock_result
        )

        # Assert — data falls back to {"raw": <original text>}
        assert payload.data == {"raw": prose}, f'Expected silent degradation to {{"raw": text}}, got: {payload.data!r}'
        assert payload.vm_name == "", "vm_name must be empty string when JSON parse fails"
        assert payload.job_id == "", "job_id must be empty string when JSON parse fails"
