"""Tests for vm_steering_check MCP tool and steering DB methods.

Covers:
- vm_steering_check success — returns found=True with nested message dict
- vm_steering_check no pending — returns found=False
- vm_steering_check DB error — raises ToolError
- get_pending_steering_message returns oldest unacknowledged message
- get_pending_steering_message returns None when no messages
- get_pending_steering_message skips acknowledged messages
- insert_steering_message creates valid row
- insert_steering_message raises ValueError for unknown job
- acknowledge_steering_message marks message acknowledged
- acknowledge_steering_message raises ValueError for unknown message_id
- acted_on stored as int(0/1) but returned as bool
"""

from __future__ import annotations

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

import pytest
from fastmcp.exceptions import ToolError

from mcp_vm_blackbox.coordination_db import CoordinationDB, _reset_db_instance_for_testing
from mcp_vm_blackbox.models import JobRecord, JobStatus
from mcp_vm_blackbox.steering_tools import vm_steering_ack, vm_steering_check, vm_steering_send

if TYPE_CHECKING:
    from pathlib import Path


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


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,
    )


async def _insert_steering(
    db: CoordinationDB,
    job_id: str,
    vm_name: str,
    content: str = "Stop and screenshot",
    sender: str = "orchestrator",
    priority: str = "normal",
    delivery: str = "hook",
) -> tuple[str, str]:
    """Insert a steering message with defaults and return (message_id, created_at).

    Args:
        db: Initialized CoordinationDB instance.
        job_id: Job identifier (must exist in jobs table).
        vm_name: VM name.
        content: Steering instruction text.
        sender: Sender type string.
        priority: Priority level string.
        delivery: Delivery mechanism string.

    Returns:
        Tuple of (message_id, created_at).
    """
    return await db.insert_steering_message(
        job_id=job_id, vm_name=vm_name, sender=sender, content=content, priority=priority, delivery=delivery
    )


# ---------------------------------------------------------------------------
# Tool-level tests (patch get_db)
# ---------------------------------------------------------------------------


async def test_vm_steering_check_returns_message_when_pending(tmp_path: Path) -> None:
    """vm_steering_check returns found=True with nested message dict when a pending message exists.

    Tests: vm_steering_check happy-path return shape
    How: Insert job and steering message in real DB, patch get_db, verify result structure
    Why: PreToolUse hook reads result["message"]["content"] and result["message"]["message_id"]
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)
    message_id, _ = await _insert_steering(db, job.job_id, job.vm_name, content="Pause and screenshot")

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

            # Act
            result = await vm_steering_check(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
    msg = result["message"]
    assert isinstance(msg, dict)
    assert msg["message_id"] == message_id
    assert msg["content"] == "Pause and screenshot"
    assert msg["job_id"] == job.job_id
    assert msg["vm_name"] == job.vm_name
    assert msg["acknowledged_at"] is None
    assert msg["acted_on"] is False


async def test_vm_steering_check_returns_not_found_when_no_message(tmp_path: Path) -> None:
    """vm_steering_check returns found=False when no pending message exists.

    Tests: vm_steering_check no-message path
    How: Insert job with no steering messages, patch get_db, verify not-found response
    Why: Hook must handle the no-message case without error and let tool call proceed
    """
    # 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.steering_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

            # Act
            result = await vm_steering_check(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_steering_check_db_error_raises_tool_error(tmp_path: Path) -> None:
    """vm_steering_check raises ToolError with 'Failed to check steering' on DB error.

    Tests: vm_steering_check DB error path
    How: Patch get_db to return a mock whose get_pending_steering_message raises Exception
    Why: Raw exceptions must not escape the tool boundary — ToolError is the MCP contract
    """
    # Arrange
    mock_db = AsyncMock()
    mock_db.get_pending_steering_message.side_effect = Exception("disk full")

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.steering_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 check steering"):
                await vm_steering_check(job_id="any-job", vm_name="any-vm")
    finally:
        _reset_db_instance_for_testing()


# ---------------------------------------------------------------------------
# DB-level tests (real CoordinationDB)
# ---------------------------------------------------------------------------


async def test_get_pending_returns_oldest_unacknowledged(tmp_path: Path) -> None:
    """get_pending_steering_message returns the oldest unacknowledged message (FIFO).

    Tests: get_pending_steering_message ordering
    How: Insert two messages, verify the first inserted is returned
    Why: The pilot must act on messages in insertion order for correct orchestration
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)

    msg_id_first, _ = await _insert_steering(db, job.job_id, job.vm_name, content="First instruction")
    msg_id_second, _ = await _insert_steering(db, job.job_id, job.vm_name, content="Second instruction")

    # Act
    row = await db.get_pending_steering_message(job.job_id, job.vm_name)
    await db.close()

    # Assert
    assert row is not None
    assert row["message_id"] == msg_id_first
    assert row["content"] == "First instruction"
    # Second message not yet returned
    assert row["message_id"] != msg_id_second


async def test_get_pending_returns_none_when_no_messages(tmp_path: Path) -> None:
    """get_pending_steering_message returns None when no steering messages exist.

    Tests: get_pending_steering_message empty table
    How: Create job with no messages, call get_pending, verify None
    Why: Tool must not error when message queue is empty
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)

    # Act
    row = await db.get_pending_steering_message(job.job_id, job.vm_name)
    await db.close()

    # Assert
    assert row is None


async def test_get_pending_skips_acknowledged_messages(tmp_path: Path) -> None:
    """get_pending_steering_message returns None when all messages are acknowledged.

    Tests: get_pending_steering_message acknowledgement filtering
    How: Insert message, acknowledge it, call get_pending again
    Why: Acknowledged messages must not be re-delivered to the pilot
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)

    message_id, _ = await _insert_steering(db, job.job_id, job.vm_name)
    await db.acknowledge_steering_message(message_id)

    # Act
    row = await db.get_pending_steering_message(job.job_id, job.vm_name)
    await db.close()

    # Assert
    assert row is None


async def test_insert_steering_message_creates_valid_row(tmp_path: Path) -> None:
    """insert_steering_message returns (message_id, created_at) as non-empty strings.

    Tests: insert_steering_message return contract
    How: Insert job, call insert_steering_message, verify return types and non-emptiness
    Why: Callers depend on message_id for acknowledgement and created_at for audit
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)

    # Act
    message_id, created_at = await db.insert_steering_message(
        job_id=job.job_id,
        vm_name=job.vm_name,
        sender="orchestrator",
        content="Run screenshot",
        priority="urgent",
        delivery="hook",
    )
    await db.close()

    # Assert
    assert isinstance(message_id, str)
    assert len(message_id) > 0
    assert isinstance(created_at, str)
    assert "T" in created_at  # ISO-8601 contains 'T' separator


async def test_insert_steering_message_raises_for_unknown_job(tmp_path: Path) -> None:
    """insert_steering_message raises ValueError when (job_id, vm_name) not in jobs table.

    Tests: insert_steering_message FK validation
    How: Empty DB (no job inserted), call insert_steering_message with ghost IDs
    Why: Messages must be anchored to existing jobs to maintain referential integrity
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()

    # Act / Assert
    with pytest.raises(ValueError, match="No job found"):
        await db.insert_steering_message(
            job_id="ghost-job",
            vm_name="ghost-vm",
            sender="orchestrator",
            content="Does not matter",
            priority="normal",
            delivery="hook",
        )
    await db.close()


async def test_acknowledge_steering_message_marks_message_acknowledged(tmp_path: Path) -> None:
    """acknowledge_steering_message sets acknowledged_at and removes message from pending queue.

    Tests: acknowledge_steering_message happy path
    How: Insert message, acknowledge it, verify get_pending returns None and timestamp is set
    Why: Acknowledging must durably mark the message so it is not re-delivered
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)
    message_id, _ = await _insert_steering(db, job.job_id, job.vm_name)

    # Act
    acknowledged_at = await db.acknowledge_steering_message(message_id)

    # Verify pending queue is now empty
    row = await db.get_pending_steering_message(job.job_id, job.vm_name)
    await db.close()

    # Assert
    assert isinstance(acknowledged_at, str)
    assert "T" in acknowledged_at  # ISO-8601
    assert row is None


async def test_acknowledge_steering_message_raises_for_unknown_message_id(tmp_path: Path) -> None:
    """acknowledge_steering_message raises ValueError for a nonexistent message_id.

    Tests: acknowledge_steering_message not-found error path
    How: Empty DB, call acknowledge with a random UUID
    Why: Callers must know when acknowledgement fails to avoid silent data loss
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()

    # Act / Assert
    with pytest.raises(ValueError):
        await db.acknowledge_steering_message("nonexistent-message-id")
    await db.close()


# ---------------------------------------------------------------------------
# vm_steering_send tool tests
# ---------------------------------------------------------------------------


async def test_vm_steering_send_happy_path(tmp_path: Path) -> None:
    """vm_steering_send returns message_id, created_at, job_id, vm_name on success.

    Tests: vm_steering_send return contract
    How: Create job in real DB, patch get_db, call vm_steering_send, verify result shape
    Why: Callers depend on message_id to later call vm_steering_ack
    """
    # 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.steering_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

            # Act
            result = await vm_steering_send(
                job_id=job.job_id,
                vm_name=job.vm_name,
                sender="orchestrator",
                content="Switch to Edge",
                priority="normal",
                delivery="hook",
            )
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert isinstance(result["message_id"], str)
    assert len(result["message_id"]) > 0
    assert isinstance(result["created_at"], str)
    assert "T" in result["created_at"]
    assert result["job_id"] == job.job_id
    assert result["vm_name"] == job.vm_name


async def test_vm_steering_send_job_not_found_raises_tool_error(tmp_path: Path) -> None:
    """vm_steering_send raises ToolError with 'Job .* not found' when job missing.

    Tests: vm_steering_send ValueError path
    How: Mock insert_steering_message to raise ValueError, verify ToolError message
    Why: Domain errors must surface as ToolError with a user-readable message
    """
    # Arrange
    mock_db = AsyncMock()
    mock_db.insert_steering_message.side_effect = ValueError("No job found")

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

            # Act / Assert
            with pytest.raises(ToolError, match=r"Job .* not found"):
                await vm_steering_send(
                    job_id="ghost-job",
                    vm_name="ghost-vm",
                    sender="orchestrator",
                    content="Does not matter",
                    priority="normal",
                    delivery="hook",
                )
    finally:
        _reset_db_instance_for_testing()


async def test_vm_steering_send_db_error_raises_tool_error(tmp_path: Path) -> None:
    """vm_steering_send raises ToolError with 'Failed to send' on infrastructure error.

    Tests: vm_steering_send Exception path
    How: Mock insert_steering_message to raise Exception, verify ToolError message
    Why: Infrastructure errors must not escape the tool boundary as raw exceptions
    """
    # Arrange
    mock_db = AsyncMock()
    mock_db.insert_steering_message.side_effect = Exception("disk full")

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.steering_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 send steering message"):
                await vm_steering_send(
                    job_id="any-job",
                    vm_name="any-vm",
                    sender="orchestrator",
                    content="Does not matter",
                    priority="normal",
                    delivery="hook",
                )
    finally:
        _reset_db_instance_for_testing()


# ---------------------------------------------------------------------------
# vm_steering_ack tool tests
# ---------------------------------------------------------------------------


async def test_vm_steering_ack_happy_path(tmp_path: Path) -> None:
    """vm_steering_ack returns acknowledged_at and message_id; message no longer pending.

    Tests: vm_steering_ack return contract and side effect
    How: Insert job and message in real DB, patch get_db, call vm_steering_ack, verify result
    Why: Pilot depends on acknowledged_at for audit; message must leave pending queue
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)
    message_id, _ = await _insert_steering(db, job.job_id, job.vm_name)

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

            # Act
            result = await vm_steering_ack(message_id=message_id)

        # Verify message is consumed (pending queue empty)
        pending = await db.get_pending_steering_message(job.job_id, job.vm_name)
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert isinstance(result["acknowledged_at"], str)
    assert "T" in result["acknowledged_at"]
    assert result["message_id"] == message_id
    assert pending is None


async def test_vm_steering_ack_message_not_found_raises_tool_error(tmp_path: Path) -> None:
    """vm_steering_ack raises ToolError with 'Steering message .* not found' when missing.

    Tests: vm_steering_ack ValueError path
    How: Mock acknowledge_steering_message to raise ValueError, verify ToolError message
    Why: Pilot must know when ack fails — silent failure would leave message in pending queue
    """
    # Arrange
    mock_db = AsyncMock()
    mock_db.acknowledge_steering_message.side_effect = ValueError("No steering message found")

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

            # Act / Assert
            with pytest.raises(ToolError, match=r"Steering message .* not found"):
                await vm_steering_ack(message_id="ghost-message-id")
    finally:
        _reset_db_instance_for_testing()


async def test_vm_steering_ack_db_error_raises_tool_error(tmp_path: Path) -> None:
    """vm_steering_ack raises ToolError with 'Failed to acknowledge' on infrastructure error.

    Tests: vm_steering_ack Exception path
    How: Mock acknowledge_steering_message to raise Exception, verify ToolError message
    Why: Infrastructure errors must not escape the tool boundary as raw exceptions
    """
    # Arrange
    mock_db = AsyncMock()
    mock_db.acknowledge_steering_message.side_effect = Exception("disk full")

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.steering_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 acknowledge steering message"):
                await vm_steering_ack(message_id="any-message-id")
    finally:
        _reset_db_instance_for_testing()


async def test_acted_on_stored_as_int_returned_as_bool(tmp_path: Path) -> None:
    """acted_on is stored as INTEGER 0 in SQLite but returned as bool False by get_pending.

    Tests: acted_on type conversion in get_pending_steering_message
    How: Insert message (acted_on defaults to 0), retrieve via get_pending, check type
    Why: Python callers expect a bool; SQLite stores integers — the DB layer must convert
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)
    await _insert_steering(db, job.job_id, job.vm_name)

    # Act
    row = await db.get_pending_steering_message(job.job_id, job.vm_name)
    await db.close()

    # Assert
    assert row is not None
    acted_on = row["acted_on"]
    assert type(acted_on) is bool  # Must be bool, not int
    assert acted_on is False
