"""Tests for vm_issue_create and vm_issue_list MCP tools.

Covers:
- vm_issue_create success — returns issue_id, created_at, job_id, vm_name
- vm_issue_create with unknown job — raises ToolError
- vm_issue_create DB error — raises ToolError
- vm_issue_list success — returns issues list with correct fields
- vm_issue_list empty — returns empty list, count 0
- vm_issue_list with classification filter
- vm_issue_list with severity filter
- vm_issue_list DB error — raises ToolError
"""

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.issue_tools import vm_issue_create, vm_issue_list
from mcp_vm_blackbox.models import JobRecord, JobStatus

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


# ---------------------------------------------------------------------------
# vm_issue_create tool tests
# ---------------------------------------------------------------------------


async def test_vm_issue_create_success_returns_expected_keys(tmp_path: Path) -> None:
    """vm_issue_create returns issue_id, created_at, job_id, and vm_name on success.

    Tests: vm_issue_create happy-path return shape
    How: Insert job in real DB, call tool via patched get_db, verify returned dict keys
    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.issue_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

            # Act
            result = await vm_issue_create(
                job_id=job.job_id,
                vm_name=job.vm_name,
                classification="user-facing",
                severity="major",
                summary="Service failed to start",
                detail="JVM DLL not found at expected path",
            )
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert set(result.keys()) == {"issue_id", "created_at", "job_id", "vm_name"}
    assert result["job_id"] == job.job_id
    assert result["vm_name"] == job.vm_name
    assert isinstance(result["issue_id"], str)
    assert isinstance(result["created_at"], str)
    assert "T" in result["created_at"]  # ISO-8601 contains 'T' separator


async def test_vm_issue_create_with_unknown_job_raises_tool_error(tmp_path: Path) -> None:
    """vm_issue_create raises ToolError when job_id does not exist for vm_name.

    Tests: vm_issue_create error propagation for missing job
    How: Empty DB, call tool with nonexistent job_id; expect ToolError with 'not found'
    Why: ToolError is the MCP-layer contract; ValueError from DB must not escape
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.issue_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_issue_create(
                    job_id="ghost-job",
                    vm_name="ghost-vm",
                    classification="harness",
                    severity="blocker",
                    summary="No job to attach to",
                )
    finally:
        await db.close()
        _reset_db_instance_for_testing()


async def test_vm_issue_create_db_error_raises_tool_error(tmp_path: Path) -> None:
    """vm_issue_create raises ToolError with 'Failed to create issue' on unexpected DB error.

    Tests: vm_issue_create unexpected exception handling
    How: Patch get_db to return a mock whose insert_issue raises Exception
    Why: Raw exceptions must not escape the tool boundary — ToolError is the contract
    """
    # Arrange
    mock_db = AsyncMock()
    mock_db.insert_issue.side_effect = Exception("disk full")

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.issue_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 create issue"):
                await vm_issue_create(
                    job_id="any-job",
                    vm_name="any-vm",
                    classification="environment",
                    severity="observation",
                    summary="Some error",
                )
    finally:
        _reset_db_instance_for_testing()


# ---------------------------------------------------------------------------
# vm_issue_list tool tests
# ---------------------------------------------------------------------------


async def test_vm_issue_list_success_returns_issues_and_count(tmp_path: Path) -> None:
    """vm_issue_list returns issues list and correct count for a job with issues.

    Tests: vm_issue_list happy-path return shape
    How: Insert job, create two issues, call tool, verify issues and count
    Why: Callers depend on 'issues' list and 'count' for display and health checks
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)
    await db.insert_issue(
        job_id=job.job_id, vm_name=job.vm_name, classification="user-facing", severity="major", summary="First issue"
    )
    await db.insert_issue(
        job_id=job.job_id, vm_name=job.vm_name, classification="harness", severity="minor", summary="Second issue"
    )

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

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

    # Assert
    assert set(result.keys()) == {"issues", "count"}
    assert result["count"] == 2
    assert len(result["issues"]) == 2
    summaries = [i["summary"] for i in result["issues"]]
    assert "First issue" in summaries
    assert "Second issue" in summaries


async def test_vm_issue_list_empty_returns_empty_list_and_zero_count(tmp_path: Path) -> None:
    """vm_issue_list returns empty list and count 0 when no issues exist.

    Tests: vm_issue_list empty result
    How: Insert job with no issues, call tool, verify empty response
    Why: Callers must handle the no-issue case without errors
    """
    # 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.issue_tools.get_db", new_callable=AsyncMock) as mock_get_db:
            mock_get_db.return_value = db

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

    # Assert
    assert result["issues"] == []
    assert result["count"] == 0


async def test_vm_issue_list_classification_filter_returns_only_matching(tmp_path: Path) -> None:
    """vm_issue_list with classification filter returns only matching issues.

    Tests: vm_issue_list classification filter
    How: Insert two issues with different classifications, filter by one, verify result
    Why: Callers use classification filter to focus on specific issue categories
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)
    await db.insert_issue(
        job_id=job.job_id,
        vm_name=job.vm_name,
        classification="user-facing",
        severity="major",
        summary="User facing issue",
    )
    await db.insert_issue(
        job_id=job.job_id, vm_name=job.vm_name, classification="harness", severity="blocker", summary="Harness issue"
    )

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

            # Act
            result = await vm_issue_list(job_id=job.job_id, vm_name=job.vm_name, classification="user-facing")
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert result["count"] == 1
    assert result["issues"][0]["summary"] == "User facing issue"
    assert result["issues"][0]["classification"] == "user-facing"


async def test_vm_issue_list_severity_filter_returns_only_matching(tmp_path: Path) -> None:
    """vm_issue_list with severity filter returns only issues matching that severity.

    Tests: vm_issue_list severity filter
    How: Insert three issues with different severities, filter by 'blocker', verify
    Why: Callers check for blockers separately to determine if work can proceed
    """
    # Arrange
    db = CoordinationDB(root=tmp_path)
    await db.initialize()
    job = _make_job()
    await db.insert_job(job)
    for sev, label in [("blocker", "Critical"), ("minor", "Small"), ("blocker", "Also critical")]:
        await db.insert_issue(
            job_id=job.job_id, vm_name=job.vm_name, classification="environment", severity=sev, summary=label
        )

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

            # Act
            result = await vm_issue_list(job_id=job.job_id, vm_name=job.vm_name, severity="blocker")
    finally:
        await db.close()
        _reset_db_instance_for_testing()

    # Assert
    assert result["count"] == 2
    severities = {i["severity"] for i in result["issues"]}
    assert severities == {"blocker"}


async def test_vm_issue_list_db_error_raises_tool_error(tmp_path: Path) -> None:
    """vm_issue_list raises ToolError with 'Failed to list issues' on unexpected DB error.

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

    _reset_db_instance_for_testing()
    try:
        with patch("mcp_vm_blackbox.issue_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 list issues"):
                await vm_issue_list(job_id="any-job", vm_name="any-vm")
    finally:
        _reset_db_instance_for_testing()
