"""Tests for MCP job lifecycle tools.

Uses monkeypatch to replace ``default_job_store``, ``default_registry``,
and ``_AGENT_ID`` so tests run against isolated tmp_path-backed stores.
"""

from __future__ import annotations

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

import pytest
from fastmcp.exceptions import ToolError

import mcp_vm_blackbox.job_tools as job_tools_mod
from mcp_vm_blackbox.coordination_db import _reset_db_instance_for_testing
from mcp_vm_blackbox.job_store import JobStore
from mcp_vm_blackbox.job_tools import (
    vm_job_complete,
    vm_job_list,
    vm_job_pause,
    vm_job_resume,
    vm_job_start,
    vm_job_step,
)
from mcp_vm_blackbox.models import VMRegistryEntry

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

    from pytest_mock import MockerFixture


TEST_AGENT = "test-agent-42"


@pytest.fixture(autouse=True)
def _reset_db() -> Generator[None, None, None]:
    """Reset the CoordinationDB singleton between tests for isolation."""
    _reset_db_instance_for_testing()
    yield
    _reset_db_instance_for_testing()


@pytest.fixture(autouse=True)
def _isolated_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None:
    """Replace the module-level store, registry, and agent ID for every test."""
    store = JobStore(tmp_path)
    monkeypatch.setattr(job_tools_mod, "default_job_store", store)
    monkeypatch.setattr(job_tools_mod, "_AGENT_ID", TEST_AGENT)
    # Default registry returns None (no recording) unless overridden per test.
    mock_registry = mocker.MagicMock()
    mock_registry.get = AsyncMock(return_value=None)
    monkeypatch.setattr(job_tools_mod, "default_registry", mock_registry)


# --- vm_job_start ---


@pytest.mark.anyio
async def test_vm_job_start_returns_job_id() -> None:
    """Returned dict contains a 12-char job_id."""
    data = await vm_job_start("vm1", "install", "begin install")
    assert "job_id" in data
    assert len(data["job_id"]) == 12
    assert data["vm_name"] == "vm1"
    assert data["status"] == "active"


# --- vm_job_step ---


@pytest.mark.anyio
async def test_vm_job_step_appends_step() -> None:
    """After vm_job_step, the step appears in the loaded record."""
    start_data = await vm_job_start("vm1", "install", "step one")
    job_id = start_data["job_id"]

    data = await vm_job_step(job_id, "vm1", "step two", outcome="ok")
    assert data["status"] == "step_appended"

    # Verify via the store directly
    record = await job_tools_mod.default_job_store.load(job_id, "vm1")
    assert len(record.steps) == 2
    assert record.steps[1].action == "step two"
    assert record.steps[1].outcome == "ok"
    assert record.steps[1].agent_id == TEST_AGENT


@pytest.mark.anyio
async def test_vm_job_step_missing_job_raises() -> None:
    """vm_job_step raises ToolError for a non-existent job."""
    with pytest.raises(ToolError, match="not found"):
        await vm_job_step("nonexistent", "vm1", "action")


# --- vm_job_pause ---


@pytest.mark.anyio
async def test_vm_job_pause_sets_paused() -> None:
    """vm_job_pause transitions an active job to paused."""
    start_data = await vm_job_start("vm1", "install", "begin")
    job_id = start_data["job_id"]

    data = await vm_job_pause(job_id, "vm1")
    assert data["status"] == "paused"

    record = await job_tools_mod.default_job_store.load(job_id, "vm1")
    assert record.status == "paused"


# --- vm_job_resume ---


@pytest.mark.anyio
async def test_vm_job_resume_sets_active() -> None:
    """vm_job_resume transitions a paused job back to active."""
    start_data = await vm_job_start("vm1", "install", "begin")
    job_id = start_data["job_id"]

    await vm_job_pause(job_id, "vm1")
    data = await vm_job_resume(job_id, "vm1")
    assert data["status"] == "active"

    record = await job_tools_mod.default_job_store.load(job_id, "vm1")
    assert record.status == "active"


# --- vm_job_complete ---


@pytest.mark.anyio
async def test_vm_job_complete_sets_completed() -> None:
    """vm_job_complete transitions an active job to completed."""
    start_data = await vm_job_start("vm1", "install", "begin")
    job_id = start_data["job_id"]

    data = await vm_job_complete(job_id, "vm1")
    assert data["status"] == "completed"

    record = await job_tools_mod.default_job_store.load(job_id, "vm1")
    assert record.status == "completed"


# --- vm_job_list ---


@pytest.mark.anyio
async def test_vm_job_list_by_vm() -> None:
    """vm_job_list with vm_name returns only jobs for that VM."""
    await vm_job_start("vm1", "install", "begin")
    await vm_job_start("vm2", "smoke", "go")

    data = await vm_job_list(vm_name="vm1")
    assert len(data) == 1
    assert data[0]["vm_name"] == "vm1"


@pytest.mark.anyio
async def test_vm_job_list_by_status() -> None:
    """vm_job_list with status filter returns only matching jobs."""
    start_data = await vm_job_start("vm1", "install", "begin")
    job_id = start_data["job_id"]
    await vm_job_pause(job_id, "vm1")

    await vm_job_start("vm1", "smoke", "begin")  # still active

    data = await vm_job_list(status="paused")
    assert len(data) == 1
    assert data[0]["status"] == "paused"
    assert data[0]["job_id"] == job_id


# --- error paths ---


@pytest.mark.anyio
async def test_vm_job_resume_completed_raises() -> None:
    """vm_job_resume on a completed job raises ToolError."""
    start_data = await vm_job_start("vm1", "install", "begin")
    job_id = start_data["job_id"]
    await vm_job_complete(job_id, "vm1")

    with pytest.raises(ToolError, match="Cannot resume"):
        await vm_job_resume(job_id, "vm1")


# --- vm_job_step evidence parameter ---


@pytest.mark.anyio
async def test_job_step_with_valid_evidence() -> None:
    """vm_job_step stores evidence attachments when valid JSON array is passed.

    Tests: EvidenceAttachment parsing and persistence on a job step.
    How: Start a job, call vm_job_step with a JSON array containing a
        screenshot attachment and a shell_output attachment, then load the
        record from the store and assert both evidence items are present
        with the correct fields.
    Why: The PR3 feature allows agents to attach structured evidence (screenshots,
        shell output) to job steps for audit trails and summary video generation.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]
    evidence_payload = json.dumps([
        {"type": "screenshot", "path": "evidence/step1.png"},
        {"type": "shell_output", "content": "exit code: 0"},
    ])

    # Act
    result = await vm_job_step(job_id, "vm1", "capture state", evidence=evidence_payload)

    # Assert
    assert result["status"] == "step_appended"
    record = await job_tools_mod.default_job_store.load(job_id, "vm1")
    step = record.steps[1]
    assert step.evidence is not None
    assert len(step.evidence) == 2
    assert step.evidence[0].type == "screenshot"
    assert step.evidence[0].path == "evidence/step1.png"
    assert step.evidence[1].type == "shell_output"
    assert step.evidence[1].content == "exit code: 0"


@pytest.mark.anyio
async def test_job_step_with_invalid_json_evidence() -> None:
    """vm_job_step raises ToolError when evidence is malformed JSON.

    Tests: JSON parse error handling in the evidence parameter.
    How: Start a job, call vm_job_step with a syntactically invalid JSON
        string and assert ToolError is raised with a descriptive message.
    Why: Agents must receive clear feedback when they pass a malformed
        evidence payload rather than silently discarding evidence.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]

    # Act / Assert
    with pytest.raises(ToolError, match="Invalid JSON in evidence"):
        await vm_job_step(job_id, "vm1", "bad evidence", evidence="{not valid json")


@pytest.mark.anyio
async def test_job_step_with_non_array_evidence() -> None:
    """vm_job_step raises ToolError when evidence JSON is an object, not an array.

    Tests: Type guard that rejects non-array evidence values.
    How: Start a job, call vm_job_step with a valid JSON object (not an
        array) and assert ToolError is raised indicating an array is required.
    Why: The evidence parameter is typed as a list of EvidenceAttachment.
        Accepting objects silently would produce confusing downstream errors.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]

    # Act / Assert
    with pytest.raises(ToolError, match="evidence must be a JSON array"):
        await vm_job_step(job_id, "vm1", "object evidence", evidence="{}")


@pytest.mark.anyio
async def test_job_step_with_invalid_schema_evidence() -> None:
    """vm_job_step raises ToolError when an evidence item fails Pydantic validation.

    Tests: EvidenceAttachment schema enforcement via Pydantic model_validate.
    How: Start a job, call vm_job_step with a JSON array containing an item
        that has an unrecognised type field, triggering Pydantic ValidationError
        which the tool converts to ToolError.
    Why: Agents must not silently attach malformed evidence records that would
        corrupt the job store or produce unusable summary video inputs.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]
    bad_evidence = json.dumps([{"type": "unknown_type", "path": "x.png"}])

    # Act / Assert
    with pytest.raises(ToolError, match="Evidence validation failed"):
        await vm_job_step(job_id, "vm1", "invalid schema", evidence=bad_evidence)


@pytest.mark.anyio
async def test_job_step_without_evidence_backward_compat() -> None:
    """vm_job_step with no evidence parameter stores None on the step.

    Tests: Backward compatibility — callers that do not pass evidence still work.
    How: Start a job, call vm_job_step without the evidence keyword argument,
        load the record and assert evidence is None on the appended step.
    Why: The evidence parameter defaults to empty string and must not break
        existing callers that were written before the evidence feature landed.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "install", "begin")
    job_id = start_data["job_id"]

    # Act
    result = await vm_job_step(job_id, "vm1", "no evidence step", outcome="done")

    # Assert
    assert result["status"] == "step_appended"
    record = await job_tools_mod.default_job_store.load(job_id, "vm1")
    step = record.steps[1]
    assert step.evidence is None


# --- vm_job_complete video_hint ---


@pytest.mark.anyio
async def test_job_complete_returns_video_hint_with_recording(monkeypatch: pytest.MonkeyPatch) -> None:
    """vm_job_complete returns video_hint.ready=True when registry has a recording.

    Tests: video_hint construction when the VM registry entry carries a recording path.
    How: Build a VMRegistryEntry with recording_path set, patch default_registry.get
        to return it, complete a job, and assert video_hint fields.
    Why: Orchestrators use video_hint to decide whether to invoke
        vm_build_summary_video after completing a job.  A wrong ready flag
        would cause the orchestrator to skip or duplicate video generation.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]
    registry_entry = VMRegistryEntry(
        vm_name="vm1",
        uuid="abc-123",
        running=True,
        last_refreshed="2026-03-17T00:00:00Z",
        recording=True,
        recording_path=".mcp-vm-blackbox/recordings/vm1.webm",
    )
    monkeypatch.setattr(job_tools_mod.default_registry, "get", AsyncMock(return_value=registry_entry))

    # Act
    result = await vm_job_complete(job_id, "vm1")

    # Assert
    assert result["status"] == "completed"
    video_hint = result["video_hint"]
    assert video_hint["ready"] is True
    assert video_hint["recording_path"] == ".mcp-vm-blackbox/recordings/vm1.webm"
    assert video_hint["suggested_tool"] == "vm_build_summary_video"
    assert video_hint["evidence_root"] == ".mcp-vm-blackbox/evidence"


# --- vm_job_step DER fields ---


@pytest.mark.anyio
async def test_job_step_der_fields_round_trip() -> None:
    """vm_job_step with all DER fields stores every field on the persisted step.

    Tests: Full DER round-trip: tool params → JobStep fields in the job store.
    How: Start a job, call vm_job_step with intent, reason, outcome_status,
        outcome_detail, issue_classification, resolution, and next. Load the
        record from the store and assert all seven DER fields match.
    Why: If any DER kwarg is not threaded through to append_step, agents lose
        their declare-execute-reflect audit trail silently.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]

    # Act
    result = await vm_job_step(
        job_id,
        "vm1",
        "install service",
        intent="Install the Windows service",
        reason="Required by goal state",
        outcome_status="success",
        outcome_detail="Service started on first attempt",
        issue_classification="",
        resolution="",
        next="Verify via vm_powershell",
    )

    # Assert
    assert result["status"] == "step_appended"
    assert result["outcome_status"] == "success"
    assert result["issue_classification"] is None

    record = await job_tools_mod.default_job_store.load(job_id, "vm1")
    step = record.steps[1]
    assert step.intent == "Install the Windows service"
    assert step.reason == "Required by goal state"
    assert step.outcome_status == "success"
    assert step.outcome_detail == "Service started on first attempt"
    assert step.issue_classification is None
    assert step.resolution is None
    assert step.next == "Verify via vm_powershell"


@pytest.mark.anyio
async def test_job_step_legacy_action_outcome_only() -> None:
    """vm_job_step with only action and outcome succeeds (backward compatibility).

    Tests: Legacy callers that pass only action/outcome still work after DER extension.
    How: Start a job, call vm_job_step with only the required action param and the
        legacy outcome kwarg; assert the step is appended and DER fields are None.
    Why: Existing agents written before the DER feature must not break when the
        tool gains new optional parameters.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "install", "begin")
    job_id = start_data["job_id"]

    # Act
    result = await vm_job_step(job_id, "vm1", "legacy action", outcome="done")

    # Assert
    assert result["status"] == "step_appended"
    record = await job_tools_mod.default_job_store.load(job_id, "vm1")
    step = record.steps[1]
    assert step.outcome == "done"
    assert step.intent is None
    assert step.reason is None
    assert step.outcome_status is None
    assert step.outcome_detail is None
    assert step.issue_classification is None
    assert step.resolution is None
    assert step.next is None


@pytest.mark.anyio
async def test_job_step_invalid_outcome_status_raises_tool_error() -> None:
    """vm_job_step raises ToolError for an unrecognised outcome_status value.

    Tests: Enum guard on outcome_status parameter in vm_job_step.
    How: Start a job, call vm_job_step with outcome_status='bogus', assert
        ToolError is raised and the message names the invalid value and lists
        the valid choices.
    Why: Agents receive clear error feedback instead of a confusing Pydantic
        ValidationError or silent no-op when they misspell an outcome status.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]

    # Act / Assert
    with pytest.raises(ToolError, match="Invalid outcome_status"):
        await vm_job_step(job_id, "vm1", "bad outcome", outcome_status="bogus")


@pytest.mark.anyio
async def test_job_step_invalid_issue_classification_raises_tool_error() -> None:
    """vm_job_step raises ToolError for an unrecognised issue_classification value.

    Tests: Enum guard on issue_classification parameter in vm_job_step.
    How: Start a job, call vm_job_step with issue_classification='unknown-class',
        assert ToolError is raised with a message that includes the invalid value.
    Why: issue_classification drives triage and post-mortem filtering; accepting
        arbitrary strings would silently produce uncategorised issue entries.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]

    # Act / Assert
    with pytest.raises(ToolError, match="Invalid issue_classification"):
        await vm_job_step(job_id, "vm1", "bad class", issue_classification="unknown-class")


@pytest.mark.anyio
async def test_job_step_with_issue_classification_stored() -> None:
    """vm_job_step with a valid issue_classification stores it on the step.

    Tests: issue_classification round-trip through the tool to the job store.
    How: Start a job, call vm_job_step with issue_classification='harness',
        load the record and assert the step field equals IssueClassification.HARNESS.
    Why: Inspector tools filter by issue_classification to aggregate issue counts;
        a mismatch between the string and the enum would break that filtering.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]

    # Act
    result = await vm_job_step(
        job_id,
        "vm1",
        "provisioning failed",
        outcome_status="failed",
        issue_classification="harness",
        resolution="Reported upstream",
    )

    # Assert
    assert result["issue_classification"] == "harness"
    record = await job_tools_mod.default_job_store.load(job_id, "vm1")
    step = record.steps[1]
    assert step.issue_classification == "harness"
    assert step.resolution == "Reported upstream"


@pytest.mark.anyio
async def test_job_complete_returns_video_hint_without_recording() -> None:
    """vm_job_complete returns video_hint.ready=False when registry has no recording.

    Tests: video_hint construction when the VM registry entry is absent or has
        no recording_path.
    How: The autouse _isolated_store fixture already sets default_registry.get
        to return None.  Complete a job and assert video_hint.ready is False
        and recording_path is None.
    Why: Orchestrators must not attempt video generation when no recording exists.
        A spurious ready=True would send vm_build_summary_video to a non-existent file.
    """
    # Arrange
    start_data = await vm_job_start("vm1", "smoke", "begin")
    job_id = start_data["job_id"]

    # Act
    result = await vm_job_complete(job_id, "vm1")

    # Assert
    assert result["status"] == "completed"
    video_hint = result["video_hint"]
    assert video_hint["ready"] is False
    assert video_hint["recording_path"] is None
    assert video_hint["suggested_tool"] == "vm_build_summary_video"
