"""Tests for vm_handoff_create tool and coordination_db handoff CRUD methods.

Covers:
- Happy path handoff creation with all fields
- Nullable last_completed_step_id (explicit None)
- Auto-resolve last_completed_step_id from MAX(step_id) when omitted
- Unknown job_id raises ValueError from DB / ToolError from tool
- get_latest_handoff returns the most recent record
- list_handoffs_by_job returns all records in ascending order
- HandoffReference model is importable with all required fields
"""

from __future__ import annotations

import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch

import pytest
import pytest_asyncio
from fastmcp.exceptions import ToolError

from mcp_vm_blackbox.coordination_db import CoordinationDB, _reset_db_instance_for_testing
from mcp_vm_blackbox.handoff_tools import vm_handoff_create, vm_handoff_read
from mcp_vm_blackbox.models import HandoffReference, JobRecord, JobStatus, JobStep

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


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest_asyncio.fixture
async def tmp_db(tmp_path: Path) -> AsyncIterator[CoordinationDB]:
    """Yield an initialised CoordinationDB backed by a tmp_path directory.

    Tests: DB lifecycle
    How: Direct construction with isolated tmp_path; yield and close
    Why: Each test gets a fresh, isolated database with no cross-test pollution
    """
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    yield db
    await db.close()


def _make_job(
    vm_name: str = "test-vm", scenario_name: str = "smoke", agent_id: str = "agent-1", status: JobStatus = "active"
) -> JobRecord:
    """Build a minimal JobRecord with a unique job_id.

    Args:
        vm_name: Target VM name.
        scenario_name: Scenario identifier.
        agent_id: Agent that created the job.
        status: Job status.

    Returns:
        A ready-to-insert JobRecord.
    """
    now = datetime.now(UTC).isoformat()
    job_id = uuid.uuid4().hex[:12]
    return JobRecord(
        job_id=job_id,
        vm_name=vm_name,
        scenario_name=scenario_name,
        created_by=agent_id,
        last_agent_id=agent_id,
        status=status,
        steps=[],
        created_at=now,
        updated_at=now,
    )


def _make_step(action: str, agent_id: str = "agent-1") -> JobStep:
    """Build a minimal JobStep.

    Args:
        action: Step action label.
        agent_id: Agent identifier for the step.

    Returns:
        A ready-to-insert JobStep.
    """
    return JobStep(agent_id=agent_id, action=action, timestamp=datetime.now(UTC).isoformat(), outcome=None)


# ---------------------------------------------------------------------------
# HandoffReference model tests
# ---------------------------------------------------------------------------


def test_handoff_reference_model_is_importable() -> None:
    """HandoffReference is importable and instantiable with all required fields.

    Tests: HandoffReference Pydantic model
    How: Instantiate with all fields and verify attribute access
    Why: Ensures the model exists and all documented fields are present
    """
    # Arrange
    now = datetime.now(UTC).isoformat()

    # Act
    ref = HandoffReference(
        handoff_id=str(uuid.uuid4()),
        job_id="job-abc",
        vm_name="my-vm",
        todo_position="milestone-2 / step-4",
        next_intended_action="Run vm_powershell to verify JDK",
        key_observations="JDK installer downloaded; screen shows desktop",
        full_log_path=".mcp-vm-blackbox/evidence/my-vm/job-abc/",
        last_completed_step_id=7,
        created_at=now,
    )

    # Assert
    assert ref.job_id == "job-abc"
    assert ref.vm_name == "my-vm"
    assert ref.last_completed_step_id == 7
    assert ref.created_at == now


def test_handoff_reference_last_completed_step_id_is_optional() -> None:
    """HandoffReference accepts None for last_completed_step_id.

    Tests: HandoffReference nullable field
    How: Instantiate without providing last_completed_step_id
    Why: The field must be optional per the handoff design spec
    """
    # Arrange / Act
    ref = HandoffReference(
        handoff_id=str(uuid.uuid4()),
        job_id="job-xyz",
        vm_name="vm-2",
        todo_position="step-1",
        next_intended_action="Take screenshot",
        key_observations="Fresh VM, no state",
        full_log_path=".mcp-vm-blackbox/evidence/vm-2/job-xyz/",
        created_at=datetime.now(UTC).isoformat(),
    )

    # Assert
    assert ref.last_completed_step_id is None


# ---------------------------------------------------------------------------
# coordination_db create_handoff tests
# ---------------------------------------------------------------------------


async def test_create_handoff_happy_path_returns_id_and_timestamp(tmp_db: CoordinationDB) -> None:
    """create_handoff returns a UUID handoff_id and ISO-8601 created_at.

    Tests: CoordinationDB.create_handoff
    How: Insert a job, call create_handoff with all fields, verify return values
    Why: Core happy-path contract — callers depend on these two return values
    """
    # Arrange
    job = _make_job()
    await tmp_db.insert_job(job)

    # Act
    handoff_id, created_at = await tmp_db.create_handoff(
        job_id=job.job_id,
        vm_name=job.vm_name,
        todo_position="milestone-1 / step-3",
        next_intended_action="Install JDK",
        key_observations="Desktop visible; no popups",
        full_log_path=".mcp-vm-blackbox/evidence/test-vm/abc/",
        last_completed_step_id=5,
    )

    # Assert
    assert isinstance(handoff_id, str)
    assert len(handoff_id) == 36  # UUID4 string length
    assert "T" in created_at  # ISO-8601 contains 'T' separator


async def test_create_handoff_persists_all_fields(tmp_db: CoordinationDB) -> None:
    """create_handoff writes all provided fields to the handoffs table.

    Tests: CoordinationDB.create_handoff field persistence
    How: Insert job, create handoff, read back via get_latest_handoff, verify fields
    Why: All fields must survive the write-read round-trip
    """
    # Arrange
    job = _make_job()
    await tmp_db.insert_job(job)
    todo = "milestone-3 / step-7: Install JDK"
    next_action = "Run vm_powershell to verify JDK installation"
    observations = "JDK installer visible on desktop"
    log_path = ".mcp-vm-blackbox/evidence/test-vm/job123/"

    # Act
    handoff_id, _ = await tmp_db.create_handoff(
        job_id=job.job_id,
        vm_name=job.vm_name,
        todo_position=todo,
        next_intended_action=next_action,
        key_observations=observations,
        full_log_path=log_path,
        last_completed_step_id=42,
    )

    # Assert
    record = await tmp_db.get_latest_handoff(job.job_id, job.vm_name)
    assert record is not None
    assert record["handoff_id"] == handoff_id
    assert record["job_id"] == job.job_id
    assert record["vm_name"] == job.vm_name
    assert record["todo_position"] == todo
    assert record["next_intended_action"] == next_action
    assert record["key_observations"] == observations
    assert record["full_log_path"] == log_path
    assert record["last_completed_step_id"] == 42


async def test_create_handoff_with_explicit_none_step_id_stores_none(tmp_db: CoordinationDB) -> None:
    """create_handoff with last_completed_step_id=None and no steps stores None.

    Tests: CoordinationDB.create_handoff nullable step resolution
    How: Insert job with no steps, create handoff omitting step_id, verify None stored
    Why: When no steps exist, last_completed_step_id must be None not an error
    """
    # Arrange
    job = _make_job()
    await tmp_db.insert_job(job)

    # Act — omit last_completed_step_id entirely (defaults to None)
    _handoff_id, _ = await tmp_db.create_handoff(
        job_id=job.job_id,
        vm_name=job.vm_name,
        todo_position="step-1",
        next_intended_action="Take first screenshot",
        key_observations="No prior state",
        full_log_path=".mcp-vm-blackbox/evidence/test-vm/job/",
    )

    # Assert
    record = await tmp_db.get_latest_handoff(job.job_id, job.vm_name)
    assert record is not None
    assert record["last_completed_step_id"] is None


async def test_create_handoff_auto_resolves_step_id_from_max(tmp_db: CoordinationDB) -> None:
    """create_handoff auto-resolves last_completed_step_id from MAX(step_id).

    Tests: CoordinationDB.create_handoff step_id auto-resolution
    How: Insert job + 3 steps, create handoff without step_id, verify resolved value
    Why: The pilot should not need to track step IDs manually; DB resolves them
    """
    # Arrange
    job = _make_job()
    await tmp_db.insert_job(job)
    for action in ["alpha", "beta", "gamma"]:
        await tmp_db.insert_step(job.job_id, job.vm_name, _make_step(action))

    # Verify steps exist so we know the resolved value will be non-None
    fetched = await tmp_db.get_job(job.job_id, job.vm_name)
    assert fetched is not None
    assert len(fetched.steps) == 3

    # Act — omit last_completed_step_id; should auto-resolve to MAX step_id
    await tmp_db.create_handoff(
        job_id=job.job_id,
        vm_name=job.vm_name,
        todo_position="step-3",
        next_intended_action="Verify gamma completed",
        key_observations="gamma step done",
        full_log_path=".mcp-vm-blackbox/evidence/test-vm/job/",
    )

    # Assert
    record = await tmp_db.get_latest_handoff(job.job_id, job.vm_name)
    assert record is not None
    assert record["last_completed_step_id"] is not None
    assert isinstance(record["last_completed_step_id"], int)


async def test_create_handoff_raises_value_error_for_unknown_job(tmp_db: CoordinationDB) -> None:
    """create_handoff raises ValueError when job_id does not exist.

    Tests: CoordinationDB.create_handoff FK validation
    How: Call create_handoff with a nonexistent job_id; expect ValueError
    Why: Must guard against dangling handoffs with no associated job
    """
    # Arrange — no job inserted

    # Act / Assert
    with pytest.raises(ValueError, match="No job found"):
        await tmp_db.create_handoff(
            job_id="nonexistent-job",
            vm_name="ghost-vm",
            todo_position="step-1",
            next_intended_action="Do something",
            key_observations="No state",
            full_log_path=".mcp-vm-blackbox/evidence/ghost-vm/nope/",
        )


# ---------------------------------------------------------------------------
# coordination_db get_latest_handoff tests
# ---------------------------------------------------------------------------


async def test_get_latest_handoff_returns_none_when_no_handoffs(tmp_db: CoordinationDB) -> None:
    """get_latest_handoff returns None when no handoffs exist for the job.

    Tests: CoordinationDB.get_latest_handoff empty result
    How: Insert job only, call get_latest_handoff, verify None
    Why: Callers must handle the no-handoff case cleanly
    """
    # Arrange
    job = _make_job()
    await tmp_db.insert_job(job)

    # Act
    result = await tmp_db.get_latest_handoff(job.job_id, job.vm_name)

    # Assert
    assert result is None


async def test_get_latest_handoff_returns_most_recent_of_multiple(tmp_db: CoordinationDB) -> None:
    """get_latest_handoff returns the most recently created handoff.

    Tests: CoordinationDB.get_latest_handoff ordering
    How: Insert job, create two handoffs with distinct todo_positions, verify latest
    Why: The incoming pilot must get current state, not stale state from earlier handoff
    """
    # Arrange
    job = _make_job()
    await tmp_db.insert_job(job)

    await tmp_db.create_handoff(
        job_id=job.job_id,
        vm_name=job.vm_name,
        todo_position="step-1",
        next_intended_action="First action",
        key_observations="Early state",
        full_log_path=".mcp-vm-blackbox/evidence/test-vm/job/",
        last_completed_step_id=1,
    )
    await tmp_db.create_handoff(
        job_id=job.job_id,
        vm_name=job.vm_name,
        todo_position="step-5",
        next_intended_action="Later action",
        key_observations="Later state",
        full_log_path=".mcp-vm-blackbox/evidence/test-vm/job/",
        last_completed_step_id=5,
    )

    # Act
    latest = await tmp_db.get_latest_handoff(job.job_id, job.vm_name)

    # Assert
    assert latest is not None
    assert latest["todo_position"] == "step-5"
    assert latest["last_completed_step_id"] == 5


# ---------------------------------------------------------------------------
# coordination_db list_handoffs_by_job tests
# ---------------------------------------------------------------------------


async def test_list_handoffs_by_job_returns_empty_when_none_created(tmp_db: CoordinationDB) -> None:
    """list_handoffs_by_job returns an empty list when no handoffs exist.

    Tests: CoordinationDB.list_handoffs_by_job empty result
    How: Insert job, call list_handoffs_by_job, verify empty list
    Why: Callers must handle the no-handoff case without errors
    """
    # Arrange
    job = _make_job()
    await tmp_db.insert_job(job)

    # Act
    result = await tmp_db.list_handoffs_by_job(job.job_id, job.vm_name)

    # Assert
    assert result == []


async def test_list_handoffs_by_job_returns_all_in_ascending_order(tmp_db: CoordinationDB) -> None:
    """list_handoffs_by_job returns all handoffs ordered by created_at ascending.

    Tests: CoordinationDB.list_handoffs_by_job ordering and completeness
    How: Insert job, create 3 handoffs with distinct positions, verify all returned
    Why: Consumers need the full handoff history; oldest first for chronological review
    """
    # Arrange
    job = _make_job()
    await tmp_db.insert_job(job)
    positions = ["step-1", "step-3", "step-7"]

    for pos in positions:
        await tmp_db.create_handoff(
            job_id=job.job_id,
            vm_name=job.vm_name,
            todo_position=pos,
            next_intended_action=f"Next after {pos}",
            key_observations=f"State at {pos}",
            full_log_path=".mcp-vm-blackbox/evidence/test-vm/job/",
        )

    # Act
    result = await tmp_db.list_handoffs_by_job(job.job_id, job.vm_name)

    # Assert
    assert len(result) == 3
    returned_positions = [r["todo_position"] for r in result]
    assert returned_positions == positions


async def test_list_handoffs_by_job_isolates_to_job(tmp_db: CoordinationDB) -> None:
    """list_handoffs_by_job returns only handoffs for the specified job.

    Tests: CoordinationDB.list_handoffs_by_job job isolation
    How: Insert two jobs, create handoff for each, verify list only returns own job
    Why: Handoffs from other jobs must never appear in another job's list
    """
    # Arrange
    job_a = _make_job(vm_name="vm-a")
    job_b = _make_job(vm_name="vm-b")
    await tmp_db.insert_job(job_a)
    await tmp_db.insert_job(job_b)

    await tmp_db.create_handoff(
        job_id=job_a.job_id,
        vm_name=job_a.vm_name,
        todo_position="step-a",
        next_intended_action="A next",
        key_observations="A state",
        full_log_path=".mcp-vm-blackbox/evidence/vm-a/job/",
    )
    await tmp_db.create_handoff(
        job_id=job_b.job_id,
        vm_name=job_b.vm_name,
        todo_position="step-b",
        next_intended_action="B next",
        key_observations="B state",
        full_log_path=".mcp-vm-blackbox/evidence/vm-b/job/",
    )

    # Act
    result_a = await tmp_db.list_handoffs_by_job(job_a.job_id, job_a.vm_name)

    # Assert
    assert len(result_a) == 1
    assert result_a[0]["todo_position"] == "step-a"


# ---------------------------------------------------------------------------
# vm_handoff_create MCP tool tests
# ---------------------------------------------------------------------------


async def test_vm_handoff_create_tool_returns_expected_keys(tmp_path: Path) -> None:
    """vm_handoff_create returns a dict with handoff_id, created_at, job_id, vm_name.

    Tests: vm_handoff_create MCP tool return shape
    How: Mock get_db to return a real CoordinationDB with inserted job; call tool
    Why: Callers depend on this exact dict structure as the tool's return contract
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.handoff_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

            # Act
            result = await vm_handoff_create(
                job_id=job.job_id,
                vm_name=job.vm_name,
                todo_position="milestone-2 / step-4",
                next_intended_action="Verify service started",
                key_observations="Service log shows no errors",
                full_log_path=".mcp-vm-blackbox/evidence/test-vm/job/",
                last_completed_step_id=3,
            )
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert set(result.keys()) == {"handoff_id", "created_at", "job_id", "vm_name"}
    assert result["job_id"] == job.job_id
    assert result["vm_name"] == job.vm_name
    assert isinstance(result["handoff_id"], str)
    assert isinstance(result["created_at"], str)


async def test_vm_handoff_create_tool_raises_tool_error_for_unknown_job(tmp_path: Path) -> None:
    """vm_handoff_create raises ToolError when job_id does not exist.

    Tests: vm_handoff_create error propagation
    How: Mock get_db to return a real empty DB; call tool with nonexistent job_id
    Why: ToolError is the MCP-layer contract for invalid inputs; ValueError must not escape
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.handoff_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

            # Act / Assert
            with pytest.raises(ToolError, match="not found"):
                await vm_handoff_create(
                    job_id="ghost-job",
                    vm_name="ghost-vm",
                    todo_position="step-1",
                    next_intended_action="Do something",
                    key_observations="No state",
                    full_log_path=".mcp-vm-blackbox/evidence/ghost-vm/ghost-job/",
                )
    finally:
        await db.close()
        _reset_db_instance_for_testing()


# ---------------------------------------------------------------------------
# vm_handoff_read MCP tool tests
# ---------------------------------------------------------------------------


async def test_vm_handoff_read_returns_found_true_with_all_fields(tmp_path: Path) -> None:
    """vm_handoff_read returns found=True with all HandoffReference fields present.

    Tests: vm_handoff_read happy-path return shape
    How: Create job and handoff in real DB, call tool via patched get_db, verify keys
    Why: Callers branch on 'found' and access all HandoffReference fields by key
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)
    await db.create_handoff(
        job_id=job.job_id,
        vm_name=job.vm_name,
        todo_position="step-3",
        next_intended_action="verify install",
        key_observations="JDK installed",
        full_log_path=".mcp-vm-blackbox/evidence/test-vm/job/",
        last_completed_step_id=3,
    )

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.handoff_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

            # Act
            result = await vm_handoff_read(job_id=job.job_id, vm_name=job.vm_name)
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert result["found"] is True
    expected_keys = {
        "found",
        "handoff_id",
        "job_id",
        "vm_name",
        "todo_position",
        "last_completed_step_id",
        "next_intended_action",
        "key_observations",
        "full_log_path",
        "created_at",
    }
    assert set(result.keys()) == expected_keys
    assert result["job_id"] == job.job_id
    assert result["vm_name"] == job.vm_name
    assert result["todo_position"] == "step-3"
    assert result["next_intended_action"] == "verify install"
    assert result["key_observations"] == "JDK installed"
    assert result["last_completed_step_id"] == 3


async def test_vm_handoff_read_returns_found_false_when_no_handoff(tmp_path: Path) -> None:
    """vm_handoff_read returns found=False when job exists but no handoff was written.

    Tests: vm_handoff_read not-found path (job present, handoff absent)
    How: Create job only, call tool, verify structured not-found response
    Why: Not-found is not an error; caller branches on 'found' without catching exceptions
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.handoff_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

            # Act
            result = await vm_handoff_read(job_id=job.job_id, vm_name=job.vm_name)
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert result == {"found": False, "job_id": job.job_id, "vm_name": job.vm_name}


async def test_vm_handoff_read_returns_found_false_when_job_missing(tmp_path: Path) -> None:
    """vm_handoff_read returns found=False when job does not exist — not an error.

    Tests: vm_handoff_read not-found path (no job at all)
    How: Empty DB, call tool with arbitrary IDs, verify found=False
    Why: The read path does not validate job existence; no row = not found
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.handoff_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

            # Act
            result = await vm_handoff_read(job_id="nonexistent-job", vm_name="ghost-vm")
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert result["found"] is False
    assert result["job_id"] == "nonexistent-job"
    assert result["vm_name"] == "ghost-vm"


async def test_vm_handoff_read_returns_latest_when_multiple_handoffs(tmp_path: Path) -> None:
    """vm_handoff_read returns the most recent handoff when multiple exist for the job.

    Tests: vm_handoff_read ordering — latest wins
    How: Create job, write two handoffs with distinct todo_positions, verify latest returned
    Why: Incoming pilot must resume from current state, not stale earlier state
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)

    await db.create_handoff(
        job_id=job.job_id,
        vm_name=job.vm_name,
        todo_position="step-1",
        next_intended_action="First action",
        key_observations="Early state",
        full_log_path=".mcp-vm-blackbox/evidence/test-vm/job/",
        last_completed_step_id=1,
    )
    await db.create_handoff(
        job_id=job.job_id,
        vm_name=job.vm_name,
        todo_position="step-7",
        next_intended_action="Latest action",
        key_observations="Later state",
        full_log_path=".mcp-vm-blackbox/evidence/test-vm/job/",
        last_completed_step_id=7,
    )

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.handoff_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

            # Act
            result = await vm_handoff_read(job_id=job.job_id, vm_name=job.vm_name)
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert result["found"] is True
    assert result["todo_position"] == "step-7"
    assert result["last_completed_step_id"] == 7


async def test_vm_handoff_read_raises_tool_error_on_db_failure(tmp_path: Path) -> None:
    """vm_handoff_read raises ToolError with 'Failed to read handoff' on unexpected DB error.

    Tests: vm_handoff_read DB error propagation
    How: Patch get_db to return a mock whose get_latest_handoff raises Exception
    Why: ToolError is the MCP-layer contract; raw exceptions must not escape the tool boundary
    """
    # Arrange
    mock_db = AsyncMock()
    mock_db.get_latest_handoff.side_effect = Exception("boom")

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.handoff_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = mock_db

            # Act / Assert
            with pytest.raises(ToolError, match="Failed to read handoff"):
                await vm_handoff_read(job_id="any-job", vm_name="any-vm")
    finally:
        _reset_db_instance_for_testing()
