"""Tests for vm_recording MCP tools.

Tests: vm_recording_start, vm_recording_stop, vm_recording_status, vm_extract_frames.
How: Mock _resolve, _ssh_run, default_registry, av.open so tests run offline.
Why: CI environments lack VMs and WebM recordings; tools must return correct models.
"""

from __future__ import annotations

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

import pytest

from mcp_vm_blackbox.coordination_db import _reset_db_instance_for_testing
from mcp_vm_blackbox.models import (
    EvidenceAttachment,
    FrameExtractionResult,
    JobStep,
    RecordingStartResult,
    RecordingStatusResult,
    RecordingStopResult,
    VMRegistryEntry,
)
from mcp_vm_blackbox.targets import TargetConfig

if TYPE_CHECKING:
    from collections.abc import Generator

    from pytest_mock import MockerFixture


# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def reset_db_singleton() -> Generator[None, None, None]:
    """Reset the CoordinationDB singleton before and after each test to prevent xdist state leakage."""
    _reset_db_instance_for_testing()
    yield
    _reset_db_instance_for_testing()


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


@pytest.fixture
def mock_ssh_run(mocker: MockerFixture) -> AsyncMock:
    """Patch _ssh_run in vm_recording to return a successful process stub."""
    proc = MagicMock()
    proc.stdout = ""
    proc.stderr = ""
    proc.returncode = 0
    return mocker.patch("mcp_vm_blackbox.vm_recording._ssh_run", new_callable=AsyncMock, return_value=proc)


def _make_registry_entry(
    vm_name: str = "test-vm",
    recording: bool = False,
    recording_path: str | None = None,
    recording_started_at: str | None = None,
) -> VMRegistryEntry:
    """Create a VMRegistryEntry for test use.

    Args:
        vm_name: VM name.
        recording: Whether recording is active.
        recording_path: Optional path to current recording.
        recording_started_at: Optional ISO 8601 recording start timestamp.

    Returns:
        A populated VMRegistryEntry instance.
    """
    return VMRegistryEntry(
        vm_name=vm_name,
        uuid="test-uuid-1234",
        running=True,
        last_refreshed="2026-01-01T00:00:00+00:00",
        recording=recording,
        recording_path=recording_path,
        recording_started_at=recording_started_at,
    )


# ---------------------------------------------------------------------------
# Recording lifecycle — vm_recording_start
# ---------------------------------------------------------------------------


class TestVmRecordingStart:
    """Tests for the vm_recording_start MCP tool."""

    async def test_recording_start_success(
        self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_recording_start returns RecordingStartResult with recording_path and updates registry.

        Tests: Full happy-path recording start.
        How: Mock _ssh_run to return success for all 5 VBoxManage commands; mock
             default_registry.update and _recording_path to a tmp_path location.
        Why: Verifies the tool sequences VBoxManage commands and persists recording
             state to the registry on success.
        """
        # Arrange
        rec_path = tmp_path / "test-vm-20260101-000000-screen0.webm"
        mocker.patch("mcp_vm_blackbox.vm_recording._recording_path", return_value=rec_path)
        mock_registry_update = AsyncMock()
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.update = mock_registry_update
        # Ensure the parent directory exists (the tool calls .parent.mkdir)
        rec_path.parent.mkdir(parents=True, exist_ok=True)

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_start

        result = await vm_recording_start(vm_name="test-vm", target="local")

        # Assert
        assert isinstance(result, RecordingStartResult)
        assert result.status == "ok"
        assert result.vm_name == "test-vm"
        assert result.recording_path == str(rec_path)
        assert result.started_at is not None
        assert result.error is None
        assert result.fps == 5
        assert result.video_rate_kbps == 256
        # All 5 VBoxManage commands issued: filename, videofps, videorate, on, start
        assert mock_ssh_run.call_count == 5
        mock_registry_update.assert_called_once()
        call_kwargs = mock_registry_update.call_args
        assert call_kwargs[0][0] == "test-vm"
        assert call_kwargs[1]["recording"] is True
        assert call_kwargs[1]["recording_path"] == str(rec_path)

    async def test_recording_start_command_failure_returns_error(
        self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_recording_start returns error RecordingStartResult when VBoxManage fails.

        Tests: VBoxManage command failure propagates as structured error.
        How: Mock _ssh_run to return non-zero exit code with stderr message on the
             first command (filename). Verify no registry update occurs.
        Why: Ensures the tool fails fast on the first failing command and does not
             leave the registry in a partially-updated state.
        """
        # Arrange
        rec_path = tmp_path / "test-vm-20260101-000000-screen0.webm"
        mocker.patch("mcp_vm_blackbox.vm_recording._recording_path", return_value=rec_path)
        rec_path.parent.mkdir(parents=True, exist_ok=True)
        mock_ssh_run.return_value = MagicMock(returncode=1, stderr="VBoxManage: error: VM not running", stdout="")
        mock_registry_update = AsyncMock()
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.update = mock_registry_update

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_start

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

        # Assert
        assert result.status == "error"
        assert result.error is not None
        assert "VM not running" in result.error
        mock_registry_update.assert_not_called()

    async def test_recording_start_custom_fps_and_rate(
        self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_recording_start passes custom fps and video_rate_kbps to result.

        Tests: Parameter forwarding for fps and video_rate_kbps.
        How: Call with fps=10 and video_rate_kbps=512; verify result fields match.
        Why: Ensures caller-specified quality parameters are reflected in the result
             so the agent can confirm the correct settings were applied.
        """
        # Arrange
        rec_path = tmp_path / "test-vm-custom.webm"
        mocker.patch("mcp_vm_blackbox.vm_recording._recording_path", return_value=rec_path)
        rec_path.parent.mkdir(parents=True, exist_ok=True)
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.update = AsyncMock()

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_start

        result = await vm_recording_start(vm_name="test-vm", fps=10, video_rate_kbps=512, target="local")

        # Assert
        assert result.status == "ok"
        assert result.fps == 10
        assert result.video_rate_kbps == 512


# ---------------------------------------------------------------------------
# Recording lifecycle — vm_recording_stop
# ---------------------------------------------------------------------------


class TestVmRecordingStop:
    """Tests for the vm_recording_stop MCP tool."""

    async def test_recording_stop_success(
        self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture, tmp_path: Path
    ) -> None:
        """vm_recording_stop returns ok RecordingStopResult and clears registry recording flag.

        Tests: Full happy-path recording stop with file on disk.
        How: Mock registry.get to return an active recording entry; create a real
             file at recording_path so file_size_bytes is populated; mock
             default_registry.update to capture the cleared recording flag.
        Why: Validates that stopping a recording updates the registry, returns the
             finalized file path, and measures file size from disk.
        """
        # Arrange
        webm_file = tmp_path / "test-vm-20260101-000000-screen0.webm"
        webm_file.write_bytes(b"\x1aE\xdf\xa3" * 256)  # fake WebM header repeated
        started_at = "2026-01-01T00:00:00+00:00"
        entry = _make_registry_entry(recording=True, recording_path=str(webm_file), recording_started_at=started_at)
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=entry)
        mock_registry_update = AsyncMock()
        mock_reg.update = mock_registry_update

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_stop

        result = await vm_recording_stop(vm_name="test-vm", target="local")

        # Assert
        assert isinstance(result, RecordingStopResult)
        assert result.status == "ok"
        assert result.vm_name == "test-vm"
        assert result.recording_path == str(webm_file)
        assert result.error is None
        assert result.duration_s is not None
        assert result.duration_s >= 0.0
        assert result.file_size_bytes is not None
        assert result.file_size_bytes > 0
        # Two commands: stop and off
        assert mock_ssh_run.call_count == 2
        mock_registry_update.assert_called_once_with("test-vm", recording=False, recording_started_at=None)

    async def test_recording_stop_command_failure_returns_error(
        self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture
    ) -> None:
        """vm_recording_stop returns error RecordingStopResult on VBoxManage failure.

        Tests: VBoxManage stop command failure.
        How: Mock _ssh_run to return returncode=1. Verify registry is not cleared.
        Why: Ensures the tool surfaces the VBoxManage error to the caller and does
             not falsely mark the recording as stopped in the registry.
        """
        # Arrange
        entry = _make_registry_entry(recording=True, recording_path="/tmp/rec.webm")
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=entry)
        mock_registry_update = AsyncMock()
        mock_reg.update = mock_registry_update
        mock_ssh_run.return_value = MagicMock(returncode=1, stderr="VBoxManage: error: stop failed", stdout="")

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_stop

        result = await vm_recording_stop(vm_name="test-vm", target="local")

        # Assert
        assert result.status == "error"
        assert result.error is not None
        assert "stop failed" in result.error
        mock_registry_update.assert_not_called()

    async def test_recording_stop_no_registry_entry(
        self, mock_resolve: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture
    ) -> None:
        """vm_recording_stop proceeds gracefully when VM has no registry entry.

        Tests: Stop invoked on a VM not present in registry.
        How: Mock registry.get to return None; issue stop commands successfully.
        Why: The tool must not crash when the VM has no registry entry — it still
             issues the VBoxManage commands and returns ok with no file path.
        """
        # Arrange
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=None)
        mock_reg.update = AsyncMock()

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_stop

        result = await vm_recording_stop(vm_name="unknown-vm", target="local")

        # Assert
        assert result.status == "ok"
        assert result.recording_path is None
        assert result.duration_s is None


# ---------------------------------------------------------------------------
# Recording lifecycle — vm_recording_status
# ---------------------------------------------------------------------------


class TestVmRecordingStatus:
    """Tests for the vm_recording_status MCP tool."""

    async def test_recording_status_active(self, mocker: MockerFixture) -> None:
        """vm_recording_status returns recording=True and elapsed_s when recording is active.

        Tests: Status check for an actively recording VM.
        How: Mock registry.get with recording=True and a recent started_at timestamp.
             No file on disk — file_size_bytes should remain None.
        Why: Confirms the tool correctly reports active state and computes elapsed
             time from the registry start timestamp.
        """
        # Arrange
        from datetime import UTC, datetime, timedelta

        started = (datetime.now(tz=UTC) - timedelta(seconds=30)).isoformat()
        entry = _make_registry_entry(recording=True, recording_path="/tmp/active.webm", recording_started_at=started)
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=entry)

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_status

        result = await vm_recording_status(vm_name="test-vm", target="local")

        # Assert
        assert isinstance(result, RecordingStatusResult)
        assert result.recording is True
        assert result.vm_name == "test-vm"
        assert result.recording_path == "/tmp/active.webm"
        assert result.elapsed_s is not None
        assert result.elapsed_s >= 25.0  # allow a few seconds tolerance

    async def test_recording_status_inactive(self, mocker: MockerFixture) -> None:
        """vm_recording_status returns recording=False when recording is not active.

        Tests: Status check for a VM whose recording has been stopped.
        How: Mock registry.get with recording=False and no started_at.
        Why: Ensures the tool correctly reports inactive state with no elapsed time.
        """
        # Arrange
        entry = _make_registry_entry(recording=False, recording_path="/tmp/stopped.webm", recording_started_at=None)
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=entry)

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_status

        result = await vm_recording_status(vm_name="test-vm", target="local")

        # Assert
        assert result.recording is False
        assert result.elapsed_s is None

    async def test_recording_status_vm_not_in_registry(self, mocker: MockerFixture) -> None:
        """vm_recording_status returns recording=False when VM has no registry entry.

        Tests: Status check for an unknown VM.
        How: Mock registry.get to return None.
        Why: Ensures the tool returns a safe inactive status for unknown VMs rather
             than raising an exception.
        """
        # Arrange
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=None)

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_status

        result = await vm_recording_status(vm_name="unknown-vm", target="local")

        # Assert
        assert result.recording is False
        assert result.vm_name == "unknown-vm"
        assert result.recording_path is None

    async def test_recording_status_with_file_on_disk(self, mocker: MockerFixture, tmp_path: Path) -> None:
        """vm_recording_status populates file_size_bytes when WebM file exists on disk.

        Tests: File size reporting from disk.
        How: Create a real file in tmp_path; mock registry with that path; verify
             file_size_bytes matches the written file.
        Why: The status tool reads file size to let agents track recording growth
             without stopping the recording.
        """
        # Arrange
        webm_file = tmp_path / "recording.webm"
        webm_file.write_bytes(b"x" * 1024)
        entry = _make_registry_entry(
            recording=True, recording_path=str(webm_file), recording_started_at="2026-01-01T00:00:00+00:00"
        )
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=entry)

        # Act
        from mcp_vm_blackbox.vm_recording import vm_recording_status

        result = await vm_recording_status(vm_name="test-vm", target="local")

        # Assert
        assert result.file_size_bytes == 1024


# ---------------------------------------------------------------------------
# Frame extraction — vm_extract_frames
# ---------------------------------------------------------------------------


class TestVmExtractFrames:
    """Tests for the vm_extract_frames MCP tool."""

    async def test_extract_frames_success(self, mocker: MockerFixture, tmp_path: Path) -> None:
        """vm_extract_frames decodes frames and returns FrameExtractionResult with paths.

        Tests: Full happy-path frame extraction via PyAV.
        How: Create a real WebM file placeholder; mock av.open() to return a mock
             container; mock container.decode() to yield 3 mock frames at t=0, 1, 2s;
             mock frame.to_image().save() to write real PNG files; verify returned paths.
        Why: Ensures the tool correctly passes PyAV results back to the caller as
             a structured FrameExtractionResult without requiring a real WebM file.
        """
        # Arrange — create a fake WebM file so FileNotFoundError isn't raised
        webm_file = tmp_path / "recording.webm"
        webm_file.write_bytes(b"\x1aE\xdf\xa3")  # minimal fake content

        # Build mock frames
        def _make_mock_frame(time_s: float, idx: int) -> MagicMock:
            frame = MagicMock()
            frame.time = time_s
            img = MagicMock()
            # Make save() actually write a PNG so the path is real
            png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
            (tmp_path / ".mcp-vm-blackbox" / "scratch" / "frames" / f"frame_{idx:04d}.png")

            def _save(path: str) -> None:
                Path(path).parent.mkdir(parents=True, exist_ok=True)
                Path(path).write_bytes(png_bytes)

            img.save.side_effect = _save
            frame.to_image.return_value = img
            return frame

        mock_frames = [_make_mock_frame(float(i), i) for i in range(3)]

        mock_container = MagicMock()
        mock_container.seek.return_value = None
        mock_container.decode.return_value = iter(mock_frames)
        mock_container.close.return_value = None

        mocker.patch("mcp_vm_blackbox.vm_recording.av.open", return_value=mock_container)
        # Provide the recording path via the registry
        entry = _make_registry_entry(recording_path=str(webm_file))
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=entry)

        # Act
        from mcp_vm_blackbox.vm_recording import vm_extract_frames

        result = await vm_extract_frames(
            vm_name="test-vm",
            timestamp_start="0:00",
            timestamp_end="0:10",
            interval_ms=1000,
            recording_path="",
            target="local",
        )

        # Assert
        assert isinstance(result, FrameExtractionResult)
        assert result.error is None
        assert result.vm_name == "test-vm"
        assert result.source_recording == str(webm_file)
        assert result.frame_count == len(result.frames)
        assert result.frame_count >= 1
        mock_container.seek.assert_called_once()
        mock_container.decode.assert_called_once_with(video=0)

    async def test_extract_frames_explicit_recording_path(self, mocker: MockerFixture, tmp_path: Path) -> None:
        """vm_extract_frames uses explicit recording_path when provided.

        Tests: Explicit path argument bypasses registry lookup.
        How: Pass a recording_path directly; mock av.open; verify source_recording
             matches the explicit path and registry.get is not called.
        Why: Confirms that callers can extract frames from any recording, not just
             the VM's current/last recording in the registry.
        """
        # Arrange
        webm_file = tmp_path / "explicit.webm"
        webm_file.write_bytes(b"\x1aE\xdf\xa3")

        mock_container = MagicMock()
        mock_container.seek.return_value = None
        mock_container.decode.return_value = iter([])
        mock_container.close.return_value = None

        mock_av_open = mocker.patch("mcp_vm_blackbox.vm_recording.av.open", return_value=mock_container)
        mock_registry_get = AsyncMock()
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = mock_registry_get

        # Act
        from mcp_vm_blackbox.vm_recording import vm_extract_frames

        result = await vm_extract_frames(
            vm_name="test-vm",
            timestamp_start="0:00",
            timestamp_end="0:05",
            recording_path=str(webm_file),
            target="local",
        )

        # Assert
        assert result.source_recording == str(webm_file)
        mock_av_open.assert_called_once_with(str(webm_file))
        mock_registry_get.assert_not_called()

    async def test_extract_frames_no_recording_path_no_registry(self, mocker: MockerFixture) -> None:
        """vm_extract_frames returns error when no recording_path and registry has none.

        Tests: Error handling when no recording source is available.
        How: Pass recording_path=''; mock registry.get to return None (or entry
             with recording_path=None). Verify error result without calling av.open.
        Why: Agents must receive a clear error message telling them to provide a
             path or ensure the registry is populated before extracting frames.
        """
        # Arrange
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=None)
        mock_av_open = mocker.patch("mcp_vm_blackbox.vm_recording.av.open")

        # Act
        from mcp_vm_blackbox.vm_recording import vm_extract_frames

        result = await vm_extract_frames(
            vm_name="test-vm", timestamp_start="0:00", timestamp_end="0:05", recording_path="", target="local"
        )

        # Assert
        assert result.error is not None
        assert "No recording path found" in result.error
        mock_av_open.assert_not_called()

    async def test_extract_frames_registry_entry_without_path(self, mocker: MockerFixture) -> None:
        """vm_extract_frames returns error when registry entry has no recording_path.

        Tests: Error path when VM exists in registry but has no recording_path.
        How: Mock registry.get to return an entry with recording_path=None.
        Why: Validates the path resolution logic distinguishes between a missing VM
             and a VM that was never recorded.
        """
        # Arrange
        entry = _make_registry_entry(recording=False, recording_path=None)
        mock_reg = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_reg.get = AsyncMock(return_value=entry)
        mock_av_open = mocker.patch("mcp_vm_blackbox.vm_recording.av.open")

        # Act
        from mcp_vm_blackbox.vm_recording import vm_extract_frames

        result = await vm_extract_frames(
            vm_name="test-vm", timestamp_start="0:00", timestamp_end="0:05", recording_path="", target="local"
        )

        # Assert
        assert result.error is not None
        mock_av_open.assert_not_called()

    async def test_extract_frames_file_not_found(self, mocker: MockerFixture, tmp_path: Path) -> None:
        """vm_extract_frames returns error when recording file does not exist.

        Tests: FileNotFoundError from av.open is caught and reported cleanly.
        How: Provide a path that doesn't exist on disk; mock av.open to raise
             FileNotFoundError; verify the error message includes the path.
        Why: Ensures missing recording files surface as structured errors rather
             than unhandled exceptions escaping the tool boundary.
        """
        # Arrange
        missing_path = str(tmp_path / "nonexistent.webm")
        mocker.patch(
            "mcp_vm_blackbox.vm_recording.av.open",
            side_effect=FileNotFoundError(f"[Errno 2] No such file: '{missing_path}'"),
        )

        # Act
        from mcp_vm_blackbox.vm_recording import vm_extract_frames

        result = await vm_extract_frames(
            vm_name="test-vm", timestamp_start="0:00", timestamp_end="0:05", recording_path=missing_path, target="local"
        )

        # Assert
        assert result.error is not None
        assert "not found" in result.error
        assert result.frame_count == 0

    async def test_extract_frames_pyav_invalid_data_error(self, mocker: MockerFixture, tmp_path: Path) -> None:
        """vm_extract_frames returns error when av.open raises InvalidDataError.

        Tests: Corrupt or non-WebM file handling.
        How: Mock av.open to raise av.error.InvalidDataError; verify a clean error
             message is returned without a traceback escaping the tool.
        Why: Production recordings may be partially written or corrupt; the tool
             must degrade gracefully so agents can diagnose without crashing.
        """
        # Arrange
        import sys

        av_error = sys.modules.get("av.error") or pytest.importorskip("av.error")
        bad_path = str(tmp_path / "corrupt.webm")
        mocker.patch(
            "mcp_vm_blackbox.vm_recording.av.open",
            side_effect=av_error.InvalidDataError(-1094995529, "Invalid data found when processing input"),
        )

        # Act
        from mcp_vm_blackbox.vm_recording import vm_extract_frames

        result = await vm_extract_frames(
            vm_name="test-vm", timestamp_start="0:00", timestamp_end="0:05", recording_path=bad_path, target="local"
        )

        # Assert
        assert result.error is not None
        assert "Invalid media container" in result.error
        assert result.frame_count == 0

    async def test_extract_frames_invalid_timestamp_format(self, mocker: MockerFixture, tmp_path: Path) -> None:
        """vm_extract_frames returns error for unrecognisable timestamp strings.

        Tests: Timestamp parse error handling.
        How: Pass a non-numeric timestamp string like 'bad'; verify error result
             before av.open is ever called.
        Why: Bad timestamps from agent callers should produce informative errors,
             not confusing tracebacks from inside PyAV.
        """
        # Arrange
        webm_file = str(tmp_path / "recording.webm")
        mock_av_open = mocker.patch("mcp_vm_blackbox.vm_recording.av.open")

        # Act
        from mcp_vm_blackbox.vm_recording import vm_extract_frames

        result = await vm_extract_frames(
            vm_name="test-vm",
            timestamp_start="bad-timestamp",
            timestamp_end="0:10",
            recording_path=webm_file,
            target="local",
        )

        # Assert
        assert result.error is not None
        assert "Invalid timestamp format" in result.error
        mock_av_open.assert_not_called()

    async def test_extract_frames_partial_results_on_decode_error(self, mocker: MockerFixture, tmp_path: Path) -> None:
        """vm_extract_frames returns partial frames when _decode_frames hits FFmpegError.

        Tests: PyAV FFmpegError mid-decode returns partial results with error set.
        How: Mock _decode_frames (the internal helper) to return (['frame0.png'], 'Decode error').
             Verify the result includes both frames and the error message.
        Why: Partial extractions are better than nothing — agents should receive any
             frames captured before the error and a clear description of what failed.
        """
        # Arrange
        webm_file = tmp_path / "partial.webm"
        webm_file.write_bytes(b"\x1aE\xdf\xa3")

        mock_container = MagicMock()
        mock_container.close.return_value = None

        mocker.patch("mcp_vm_blackbox.vm_recording.av.open", return_value=mock_container)
        partial_frame_path = str(tmp_path / "frame_0000.png")
        mocker.patch(
            "mcp_vm_blackbox.vm_recording._decode_frames",
            return_value=([partial_frame_path], "Decode error (partial results): FFmpegError"),
        )

        # Act
        from mcp_vm_blackbox.vm_recording import vm_extract_frames

        result = await vm_extract_frames(
            vm_name="test-vm",
            timestamp_start="0:00",
            timestamp_end="0:10",
            recording_path=str(webm_file),
            target="local",
        )

        # Assert
        assert result.frames == [partial_frame_path]
        assert result.frame_count == 1
        assert result.error is not None
        assert "Decode error" in result.error
        mock_container.close.assert_called_once()


# ---------------------------------------------------------------------------
# _parse_time_offset unit tests
# ---------------------------------------------------------------------------


class TestParseTimeOffset:
    """Unit tests for the _parse_time_offset helper function."""

    def test_hhmmss_format(self) -> None:
        """_parse_time_offset parses HH:MM:SS into seconds.

        Tests: Three-part time string.
        How: Call with '01:02:03' and verify result equals 3723.0.
        Why: Ensures the agent can specify timestamps in standard video format.
        """
        from mcp_vm_blackbox.vm_recording import _parse_time_offset

        assert _parse_time_offset("01:02:03") == pytest.approx(3723.0)

    def test_mmss_format(self) -> None:
        """_parse_time_offset parses MM:SS into seconds.

        Tests: Two-part time string.
        How: Call with '02:30' and verify result equals 150.0.
        Why: Short recordings use MM:SS notation; both forms must be accepted.
        """
        from mcp_vm_blackbox.vm_recording import _parse_time_offset

        assert _parse_time_offset("02:30") == pytest.approx(150.0)

    def test_plain_seconds(self) -> None:
        """_parse_time_offset accepts plain float seconds.

        Tests: Plain seconds string.
        How: Call with '45.5' and verify result equals 45.5.
        Why: Programmatic callers may pass raw seconds without colon notation.
        """
        from mcp_vm_blackbox.vm_recording import _parse_time_offset

        assert _parse_time_offset("45.5") == pytest.approx(45.5)

    def test_invalid_format_raises_value_error(self) -> None:
        """_parse_time_offset raises ValueError for non-numeric input.

        Tests: Error handling for bad format.
        How: Call with 'bad' and assert ValueError is raised.
        Why: Callers depend on this ValueError to generate clean error messages.
        """
        from mcp_vm_blackbox.vm_recording import _parse_time_offset

        with pytest.raises(ValueError):
            _parse_time_offset("bad")


# ---------------------------------------------------------------------------
# _safe_vm_name unit tests
# ---------------------------------------------------------------------------


class TestSafeVmName:
    """Unit tests for the _safe_vm_name helper function."""

    def test_alphanumeric_unchanged(self) -> None:
        """_safe_vm_name leaves alphanumeric names unchanged.

        Tests: Clean name passthrough.
        How: Call with 'MyVM123' and verify output matches input.
        Why: Most VM names are already filesystem-safe.
        """
        from mcp_vm_blackbox.vm_recording import _safe_vm_name

        assert _safe_vm_name("MyVM123") == "MyVM123"

    def test_spaces_replaced_with_underscores(self) -> None:
        """_safe_vm_name replaces spaces with underscores.

        Tests: Space sanitisation.
        How: Call with 'My VM Name' and verify spaces are replaced.
        Why: VM names with spaces would break file paths.
        """
        from mcp_vm_blackbox.vm_recording import _safe_vm_name

        assert _safe_vm_name("My VM Name") == "My_VM_Name"

    def test_hyphens_preserved(self) -> None:
        """_safe_vm_name preserves hyphens in VM names.

        Tests: Hyphen passthrough.
        How: Call with 'my-vm' and verify hyphens are preserved.
        Why: Many VMs use hyphen-separated names as their primary identifier.
        """
        from mcp_vm_blackbox.vm_recording import _safe_vm_name

        assert _safe_vm_name("my-vm") == "my-vm"

    def test_special_chars_replaced(self) -> None:
        """_safe_vm_name replaces special characters with underscores.

        Tests: Special character sanitisation.
        How: Call with 'VM (Test)' and verify parens and space are replaced.
        Why: Parentheses and other special chars break shell path handling.
        """
        from mcp_vm_blackbox.vm_recording import _safe_vm_name

        result = _safe_vm_name("VM (Test)")
        assert "(" not in result
        assert ")" not in result


# ---------------------------------------------------------------------------
# Model validation tests
# ---------------------------------------------------------------------------


class TestEvidenceAttachmentValidation:
    """Tests for EvidenceAttachment model_validator constraints."""

    def test_screenshot_requires_path(self) -> None:
        """EvidenceAttachment of type 'screenshot' requires a non-empty path.

        Tests: Pydantic validation for screenshot type.
        How: Construct EvidenceAttachment(type='screenshot') without path and
             assert ValidationError is raised.
        Why: Enforces the contract that screenshot evidence must reference a file.
        """
        import pytest
        from pydantic import ValidationError

        with pytest.raises(ValidationError, match="requires 'path'"):
            EvidenceAttachment(type="screenshot")

    def test_screenshot_with_path_is_valid(self) -> None:
        """EvidenceAttachment of type 'screenshot' with a path is valid.

        Tests: Pydantic validation success for screenshot with path.
        How: Construct with type='screenshot' and path='/tmp/shot.png'.
        Why: Confirms the validator does not over-reject valid inputs.
        """
        attachment = EvidenceAttachment(type="screenshot", path="/tmp/shot.png")
        assert attachment.path == "/tmp/shot.png"

    def test_shell_output_requires_content(self) -> None:
        """EvidenceAttachment of type 'shell_output' requires non-empty content.

        Tests: Pydantic validation for shell_output type.
        How: Construct EvidenceAttachment(type='shell_output') without content
             and assert ValidationError is raised.
        Why: Enforces the contract that shell output evidence must carry inline text.
        """
        from pydantic import ValidationError

        with pytest.raises(ValidationError, match="requires 'content'"):
            EvidenceAttachment(type="shell_output")

    def test_shell_output_content_truncated_at_4096(self) -> None:
        """EvidenceAttachment truncates shell_output content exceeding 4096 characters.

        Tests: Content truncation enforcement.
        How: Pass a 5000-char string as content; assert stored content is exactly
             4096 characters.
        Why: Prevents oversized evidence from bloating job records stored on disk.
        """
        long_content = "x" * 5000
        attachment = EvidenceAttachment(type="shell_output", content=long_content)
        assert attachment.content is not None
        assert len(attachment.content) == 4096

    def test_shell_output_content_under_limit_unchanged(self) -> None:
        """EvidenceAttachment does not truncate shell_output content under 4096 chars.

        Tests: Truncation only applied when limit exceeded.
        How: Pass a 100-char content string; verify it's stored intact.
        Why: Normal-length evidence must not be modified.
        """
        content = "a" * 100
        attachment = EvidenceAttachment(type="shell_output", content=content)
        assert attachment.content == content

    def test_terminal_type_requires_path(self) -> None:
        """EvidenceAttachment of type 'terminal' requires a non-empty path.

        Tests: Pydantic validation for terminal type.
        How: Construct without path and assert ValidationError.
        Why: Terminal captures are file-backed, same constraint as screenshots.
        """
        from pydantic import ValidationError

        with pytest.raises(ValidationError, match="requires 'path'"):
            EvidenceAttachment(type="terminal")

    def test_tui_capture_type_requires_path(self) -> None:
        """EvidenceAttachment of type 'tui_capture' requires a non-empty path.

        Tests: Pydantic validation for tui_capture type.
        How: Construct without path and assert ValidationError.
        Why: TUI captures are file-backed like screenshots.
        """
        from pydantic import ValidationError

        with pytest.raises(ValidationError, match="requires 'path'"):
            EvidenceAttachment(type="tui_capture")


class TestJobStepDefaults:
    """Tests for JobStep model defaults."""

    def test_job_step_evidence_defaults_to_none(self) -> None:
        """JobStep constructed without evidence has evidence=None.

        Tests: JobStep default for optional evidence field.
        How: Construct a minimal JobStep and verify evidence is None.
        Why: Steps without attached evidence should not carry an empty list that
             wastes storage; None signals no evidence was captured.
        """
        step = JobStep(agent_id="agent-001", action="Booted VM", timestamp="2026-01-01T00:00:00+00:00")
        assert step.evidence is None

    def test_job_step_with_evidence_list(self) -> None:
        """JobStep accepts a list of EvidenceAttachment objects.

        Tests: JobStep evidence field accepts populated list.
        How: Construct with one screenshot attachment; verify the list is stored.
        Why: Steps with evidence must preserve all attachments without mutation.
        """
        attachments = [EvidenceAttachment(type="screenshot", path="/tmp/s.png")]
        step = JobStep(
            agent_id="agent-001",
            action="Captured screenshot",
            timestamp="2026-01-01T00:00:00+00:00",
            evidence=attachments,
        )
        assert step.evidence is not None
        assert len(step.evidence) == 1
        assert step.evidence[0].path == "/tmp/s.png"


class TestVMRegistryEntryDefaults:
    """Tests for VMRegistryEntry recording field defaults."""

    def test_registry_entry_recording_defaults_to_false(self) -> None:
        """VMRegistryEntry without recording fields deserialises with safe defaults.

        Tests: Backward-compatible default for recording fields.
        How: Construct a VMRegistryEntry without the optional recording fields and
             verify recording=False, recording_path=None, recording_started_at=None.
        Why: Existing registry entries written before the recording feature was added
             must deserialise without errors or unexpected truthy values.
        """
        entry = VMRegistryEntry(
            vm_name="old-vm", uuid="abc-123", running=False, last_refreshed="2025-01-01T00:00:00+00:00"
        )
        assert entry.recording is False
        assert entry.recording_path is None
        assert entry.recording_started_at is None

    def test_registry_entry_from_dict_without_recording_fields(self) -> None:
        """VMRegistryEntry.model_validate on dict without recording keys uses defaults.

        Tests: model_validate path for legacy JSON data.
        How: Call model_validate with a dict that omits all recording fields.
        Why: The file-backed registry may contain entries written by an older version
             of the tool; model_validate must handle missing optional keys gracefully.
        """
        data = {
            "vm_name": "legacy-vm",
            "uuid": "bbb-456",
            "running": True,
            "last_refreshed": "2025-06-01T12:00:00+00:00",
        }
        entry = VMRegistryEntry.model_validate(data)
        assert entry.recording is False
        assert entry.recording_path is None
        assert entry.recording_started_at is None

    def test_registry_entry_recording_fields_roundtrip(self) -> None:
        """VMRegistryEntry recording fields survive model_dump -> model_validate roundtrip.

        Tests: JSON serialisation roundtrip for recording fields.
        How: Create an entry with all recording fields set, dump to dict, re-validate.
        Why: The registry writes entries via model_dump; reading them back via
             model_validate must preserve all recording state exactly.
        """
        entry = VMRegistryEntry(
            vm_name="active-vm",
            uuid="ccc-789",
            running=True,
            last_refreshed="2026-01-01T00:00:00+00:00",
            recording=True,
            recording_path="/tmp/active.webm",
            recording_started_at="2026-01-01T00:01:00+00:00",
        )
        data = entry.model_dump()
        restored = VMRegistryEntry.model_validate(data)
        assert restored.recording is True
        assert restored.recording_path == "/tmp/active.webm"
        assert restored.recording_started_at == "2026-01-01T00:01:00+00:00"


# ---------------------------------------------------------------------------
# TestVmBuildSummaryVideo
# ---------------------------------------------------------------------------


class TestVmBuildSummaryVideo:
    """Test suite for vm_build_summary_video MCP tool.

    Tests: vm_build_summary_video — proof demo manifest construction and
    SummaryVideoResult assembly.
    Strategy: Mock build_proof_demo (from mcp_vm_blackbox.vm_recording),
    default_registry.get, and anyio.Path filesystem operations so all tests
    run offline without a real evidence directory or recording file.
    """

    def _make_replay_bundle(
        self,
        screenshot_count: int = 2,
        job_record_count: int = 1,
        video_path: str | None = "evidence/vm/job1/render_staging/output.mp4",
    ) -> MagicMock:
        """Build a MagicMock shaped like a ReplayBundle.

        Args:
            screenshot_count: Number of present screenshot EvidenceItems.
            job_record_count: Number of present job_record EvidenceItems.
            video_path: Value for video_output_path attribute.

        Returns:
            MagicMock with evidence, source_evidence_count, overlay_evidence_count,
            and video_output_path attributes matching ReplayBundle's schema.
        """
        screenshot_items = [MagicMock(source_kind="screenshot", present=True) for _ in range(screenshot_count)]
        job_record_items = [MagicMock(source_kind="job_record", present=True) for _ in range(job_record_count)]
        evidence = screenshot_items + job_record_items

        bundle = MagicMock()
        bundle.evidence = evidence
        # source_evidence_count counts screenshots + job_record items that are present
        bundle.source_evidence_count = screenshot_count + job_record_count
        bundle.overlay_evidence_count = 0
        bundle.video_output_path = video_path
        return bundle

    async def test_build_summary_video_success(self, mocker: MockerFixture) -> None:
        """vm_build_summary_video returns a correct SummaryVideoResult on success.

        Tests: vm_build_summary_video happy path.
        How:
            1. Patch default_registry.get to return None (no recording in registry).
            2. Patch build_proof_demo to return a ReplayBundle with 2 screenshot items
               (present=True, source_kind="screenshot") and 1 job_record item
               (present=True, source_kind="job_record").
            3. Call vm_build_summary_video.
            4. Assert SummaryVideoResult has video_path set, step_count==2 (not 3),
               evidence_count correct, and error is None.
        Why: step_count must count only screenshot evidence, not job_record items.
             Verifies the evidence filtering logic in vm_build_summary_video.
        """
        from mcp_vm_blackbox.vm_recording import vm_build_summary_video

        # Arrange
        mock_registry = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_registry.get = AsyncMock(return_value=None)
        bundle = self._make_replay_bundle(
            screenshot_count=2, job_record_count=1, video_path="evidence/vm/job1/render_staging/output.mp4"
        )
        mocker.patch("mcp_vm_blackbox.vm_recording.build_proof_demo", new_callable=AsyncMock, return_value=bundle)

        # Act
        result = await vm_build_summary_video(
            job_id="job1", vm_name="test-vm", evidence_root=".mcp-vm-blackbox/evidence"
        )

        # Assert
        assert result.error is None
        assert result.video_path == "evidence/vm/job1/render_staging/output.mp4"
        assert result.step_count == 2  # only screenshots, not job_record
        assert result.evidence_count == 3  # source_evidence_count(3) + overlay(0)
        assert result.job_id == "job1"
        assert result.vm_name == "test-vm"

    async def test_build_summary_video_validation_failure(self, mocker: MockerFixture) -> None:
        """vm_build_summary_video returns SummaryVideoResult.error on ValueError from build_proof_demo.

        Tests: ValueError from build_proof_demo is caught and returned as error field.
        How:
            1. Patch default_registry.get to return None.
            2. Patch build_proof_demo to raise ValueError("missing screenshots").
            3. Call vm_build_summary_video.
            4. Assert result.error contains "missing screenshots", video_path is None,
               no exception propagates to the caller.
        Why: vm_build_summary_video must absorb validation failures and surface them
             as structured error results rather than crashing the MCP tool call.
        """
        from mcp_vm_blackbox.vm_recording import vm_build_summary_video

        # Arrange
        mock_registry = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_registry.get = AsyncMock(return_value=None)
        mocker.patch(
            "mcp_vm_blackbox.vm_recording.build_proof_demo",
            new_callable=AsyncMock,
            side_effect=ValueError("missing screenshots"),
        )

        # Act
        result = await vm_build_summary_video(
            job_id="job2", vm_name="test-vm", evidence_root=".mcp-vm-blackbox/evidence"
        )

        # Assert
        assert "missing screenshots" in (result.error or "")
        assert result.video_path is None
        assert result.job_id == "job2"
        assert result.vm_name == "test-vm"

    async def test_build_summary_video_copies_recording_when_registry_has_path(self, mocker: MockerFixture) -> None:
        """vm_build_summary_video creates recordings dir and symlinks when registry has a recording_path.

        Tests: Recording symlink creation path in vm_build_summary_video.
        How:
            1. Patch default_registry.get to return a VMRegistryEntry with
               recording_path="scratch/recordings/test.webm".
            2. Patch anyio.Path so exists() returns True for src, False for dest,
               and mkdir/symlink_to are async no-ops.
            3. Patch build_proof_demo to return a minimal ReplayBundle.
            4. Call vm_build_summary_video.
            5. Assert mkdir was called (recordings dir created) and symlink_to was
               called with the resolved source path.
        Why: The recordings dir and symlink must be created before build_proof_demo
             runs its validation gate, or it will fail to find the recording.
        """
        from mcp_vm_blackbox.vm_recording import vm_build_summary_video

        # Arrange — registry entry with a recording path
        entry = _make_registry_entry(vm_name="test-vm", recording=False, recording_path="scratch/recordings/test.webm")
        mock_registry = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_registry.get = AsyncMock(return_value=entry)

        # Track mkdir and symlink_to calls via a shared call log keyed by path string
        mkdir_calls: list[str] = []
        symlink_calls: list[tuple[str, str]] = []

        resolved_path_str = "/abs/scratch/recordings/test.webm"

        class _FakeAnyioPath:
            """Minimal anyio.Path stand-in that records mkdir and symlink_to calls."""

            def __init__(self, p: object) -> None:
                self._p = str(p)

            async def mkdir(self, **kwargs: object) -> None:
                mkdir_calls.append(self._p)

            async def exists(self) -> bool:
                # src ("scratch/recordings/test.webm") exists;
                # dest (under evidence/.../job3/recordings/test.webm) does not.
                # The dest path contains the job_id "job3"; the src path does not.
                return "job3" not in self._p

            async def resolve(self) -> _FakeAnyioPath:
                return _FakeAnyioPath(resolved_path_str)

            async def symlink_to(self, target: object) -> None:
                symlink_calls.append((self._p, str(target)))

            def __str__(self) -> str:
                return self._p

        mocker.patch("mcp_vm_blackbox.vm_recording.anyio.Path", _FakeAnyioPath)

        bundle = self._make_replay_bundle(screenshot_count=1, job_record_count=1)
        mocker.patch("mcp_vm_blackbox.vm_recording.build_proof_demo", new_callable=AsyncMock, return_value=bundle)

        # Act
        result = await vm_build_summary_video(
            job_id="job3", vm_name="test-vm", evidence_root=".mcp-vm-blackbox/evidence"
        )

        # Assert
        assert result.error is None
        assert len(mkdir_calls) >= 1, "mkdir should have been called for recordings dir"
        assert len(symlink_calls) >= 1, "symlink_to should have been called"
        # The symlink target must be the resolved absolute path
        assert any(resolved_path_str in target for _, target in symlink_calls)

    async def test_build_summary_video_no_recording_in_registry(self, mocker: MockerFixture) -> None:
        """vm_build_summary_video still calls build_proof_demo when registry has no entry.

        Tests: vm_build_summary_video with no registry entry for the VM.
        How:
            1. Patch default_registry.get to return None.
            2. Patch build_proof_demo to return a minimal ReplayBundle.
            3. Call vm_build_summary_video.
            4. Assert build_proof_demo was called exactly once and result has no error.
        Why: The recording symlink path is optional; absence of a registry entry must
             not prevent the tool from calling build_proof_demo and returning a result.
        """
        from mcp_vm_blackbox.vm_recording import vm_build_summary_video

        # Arrange
        mock_registry = mocker.patch("mcp_vm_blackbox.vm_recording.default_registry")
        mock_registry.get = AsyncMock(return_value=None)
        bundle = self._make_replay_bundle(screenshot_count=1, job_record_count=0)
        mock_build = mocker.patch(
            "mcp_vm_blackbox.vm_recording.build_proof_demo", new_callable=AsyncMock, return_value=bundle
        )

        # Act
        result = await vm_build_summary_video(
            job_id="job4", vm_name="test-vm", evidence_root=".mcp-vm-blackbox/evidence"
        )

        # Assert
        mock_build.assert_called_once()
        assert result.error is None
        assert result.job_id == "job4"
