"""Tests for EventEmitterMiddleware and related helpers.

Tests the FastMCP v3 middleware that broadcasts tool call results to the
dashboard via fire-and-forget POSTs to /api/events/ingest.
"""

from __future__ import annotations

import asyncio
import json
import logging
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock

from mcp.types import ImageContent, TextContent

from mcp_vm_blackbox.event_emitter import (
    TOOL_EVENT_MAP,
    EventEmitterMiddleware,
    EventPayload,
    EventType,
    _extract_payload_from_tool_result,
    _post_event,
)

if TYPE_CHECKING:
    import pytest
    from pytest_mock import MockerFixture


# ---------------------------------------------------------------------------
# Helpers — build ToolResult-like objects
# ---------------------------------------------------------------------------


def _make_tool_result(content: list[Any]) -> Any:
    """Return a minimal duck-typed ToolResult with a content list.

    Args:
        content: List of MCP content objects (TextContent, ImageContent, etc.)

    Returns:
        A MagicMock that satisfies the ToolResult.content protocol.
    """
    result = MagicMock()
    result.content = content
    return result


def _make_context(tool_name: str, arguments: dict[str, Any] | None = None) -> Any:
    """Return a minimal MiddlewareContext duck-type for the given tool name.

    Args:
        tool_name: The MCP tool name to embed in context.message.name.
        arguments: Optional arguments dict to embed in context.message.arguments.

    Returns:
        A MagicMock satisfying the MiddlewareContext protocol.
    """
    ctx = MagicMock()
    ctx.message.name = tool_name
    ctx.message.arguments = arguments or {}
    return ctx


# ---------------------------------------------------------------------------
# Test suite — EventEmitterMiddleware.on_call_tool
# ---------------------------------------------------------------------------


class TestOnCallTool:
    """Tests for EventEmitterMiddleware.on_call_tool behaviour.

    Scope: The middleware intercept layer — routing, fire-and-forget task
    creation, and pass-through when disabled or tool is unmapped.

    Strategy: Construct the middleware directly, mock call_next as an async
    callable, and mock httpx.AsyncClient.post to intercept network calls.
    """

    async def test_no_dashboard_url_calls_call_next_returns_result_unchanged(self, mocker: MockerFixture) -> None:
        """When dashboard_url is None, call_next is invoked and result is returned unchanged.

        Tests: Pass-through mode (ADR-005) with no side effects.
        How: Create middleware with dashboard_url=None, call on_call_tool, assert
             call_next was awaited exactly once and the original result is returned.
        Why: The middleware must be a zero-overhead no-op when the dashboard is
             not configured, ensuring no latency or errors in offline environments.
        """
        # Arrange
        middleware = EventEmitterMiddleware(dashboard_url=None)
        expected = _make_tool_result([TextContent(type="text", text='{"vm_name":"x"}')])
        call_next = mocker.AsyncMock(return_value=expected)
        ctx = _make_context("vm_job_start")
        mock_post = mocker.patch("mcp_vm_blackbox.event_emitter.httpx.AsyncClient.post")

        # Act
        result = await middleware.on_call_tool(ctx, call_next)

        # Assert
        call_next.assert_awaited_once_with(ctx)
        assert result is expected
        mock_post.assert_not_called()

    async def test_mapped_tool_fires_asyncio_create_task(self, mocker: MockerFixture) -> None:
        """When dashboard_url is set and tool is in TOOL_EVENT_MAP, a task is created.

        Tests: Fire-and-forget task dispatch for mapped tools (ADR-003).
        How: Patch asyncio.create_task to intercept task creation, call on_call_tool
             with a mapped tool name, assert create_task was called once.
        Why: Events must be dispatched asynchronously so the tool result is returned
             to the caller immediately without waiting for the HTTP POST.
        """
        # Arrange
        middleware = EventEmitterMiddleware(dashboard_url="http://localhost:5173")
        payload_content = [TextContent(type="text", text='{"vm_name":"vm1","job_id":"j1"}')]
        expected = _make_tool_result(payload_content)
        call_next = mocker.AsyncMock(return_value=expected)
        ctx = _make_context("vm_job_start", {"vm_name": "vm1"})

        mock_task = mocker.MagicMock(spec=asyncio.Task)
        mock_task.add_done_callback = mocker.MagicMock()

        def _create_task_and_close(coro: Any, *, name: str | None = None) -> MagicMock:
            # Close the coroutine immediately so it is not garbage-collected unawaited.
            coro.close()
            return mock_task

        create_task = mocker.patch("asyncio.create_task", side_effect=_create_task_and_close)
        mocker.patch("mcp_vm_blackbox.event_emitter.httpx.AsyncClient.post")

        # Act
        result = await middleware.on_call_tool(ctx, call_next)

        # Assert
        assert result is expected
        create_task.assert_called_once()
        call_args = create_task.call_args
        assert call_args.kwargs.get("name") == "emit-vm_job_start"

    async def test_unmapped_tool_no_post_fired(self, mocker: MockerFixture) -> None:
        """When tool is NOT in TOOL_EVENT_MAP, no POST is fired.

        Tests: Selective event emission — only mapped tools trigger events.
        How: Call on_call_tool with a tool name absent from TOOL_EVENT_MAP,
             assert asyncio.create_task was never called.
        Why: Emitting events for every tool call would flood the dashboard with
             irrelevant data and waste network bandwidth.
        """
        # Arrange
        middleware = EventEmitterMiddleware(dashboard_url="http://localhost:5173")
        expected = _make_tool_result([TextContent(type="text", text="ok")])
        call_next = mocker.AsyncMock(return_value=expected)
        ctx = _make_context("vm_list")  # not in TOOL_EVENT_MAP
        create_task = mocker.patch("asyncio.create_task")

        # Act
        result = await middleware.on_call_tool(ctx, call_next)

        # Assert
        assert result is expected
        create_task.assert_not_called()

    async def test_post_failure_logs_warning_no_exception_result_returned(
        self, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
    ) -> None:
        """POST failure logs a warning but does not raise and original result is returned.

        Tests: ADR-003 fire-and-forget resilience — failures must not propagate.
        How: Patch httpx.AsyncClient.post to raise httpx.ConnectError, run the
             background task to completion via asyncio.gather, assert warning logged
             and result still returned.
        Why: Dashboard downtime must never break MCP tool execution. The tool result
             must always reach the caller even when event emission fails.
        """
        # Arrange
        import httpx

        middleware = EventEmitterMiddleware(dashboard_url="http://localhost:5173")
        payload_content = [TextContent(type="text", text='{"vm_name":"vm1","job_id":"j1"}')]
        expected = _make_tool_result(payload_content)
        call_next = mocker.AsyncMock(return_value=expected)
        ctx = _make_context("vm_job_start", {"vm_name": "vm1"})

        def raise_connect_error(*args: Any, **kwargs: Any) -> None:
            raise httpx.ConnectError("connection refused")

        mocker.patch.object(httpx.AsyncClient, "post", side_effect=raise_connect_error)

        # Act — run on_call_tool and drain all background tasks
        with caplog.at_level(logging.WARNING, logger="mcp_vm_blackbox.event_emitter"):
            result = await middleware.on_call_tool(ctx, call_next)
            # Allow background tasks to complete
            await asyncio.gather(*middleware._tasks, return_exceptions=True)
            # Give event loop a tick for any just-finished tasks
            await asyncio.sleep(0)

        # Assert
        assert result is expected
        assert any("Event emission failed" in record.message for record in caplog.records)

    async def test_original_result_always_returned_for_mapped_tool(self, mocker: MockerFixture) -> None:
        """The original ToolResult is returned unchanged for a mapped tool.

        Tests: Return value contract — middleware must not modify the result.
        How: Patch asyncio.create_task to skip network call, verify the exact
             object returned by call_next is what on_call_tool returns.
        Why: Downstream MCP clients depend on receiving the unmodified tool result;
             middleware must be transparent to the caller.
        """
        # Arrange
        middleware = EventEmitterMiddleware(dashboard_url="http://localhost:5173")
        sentinel = _make_tool_result([TextContent(type="text", text='{"vm_name":"x"}')])
        call_next = mocker.AsyncMock(return_value=sentinel)
        ctx = _make_context("vm_screenshot", {"vm_name": "test-vm"})

        mock_task = mocker.MagicMock(spec=asyncio.Task)
        mock_task.add_done_callback = mocker.MagicMock()

        def _close_coro(coro: Any, *, name: str | None = None) -> MagicMock:
            coro.close()
            return mock_task

        mocker.patch("asyncio.create_task", side_effect=_close_coro)

        # Act
        result = await middleware.on_call_tool(ctx, call_next)

        # Assert
        assert result is sentinel


# ---------------------------------------------------------------------------
# Test suite — _extract_payload_from_tool_result
# ---------------------------------------------------------------------------


class TestExtractPayloadFromToolResult:
    """Tests for the _extract_payload_from_tool_result helper.

    Scope: Payload extraction logic — TextContent parsing and ImageContent handling.
    Strategy: Call the function directly with controlled ToolResult mocks.
    """

    def test_text_content_extracts_vm_name_and_job_id(self) -> None:
        """TextContent result: vm_name and job_id are extracted from parsed JSON.

        Tests: Payload extraction for dict-returning tools (vm_job_start, vm_job_step).
        How: Create a ToolResult with TextContent containing JSON with vm_name/job_id,
             call _extract_payload_from_tool_result, assert fields populated.
        Why: Accurate vm_name and job_id on the payload are required for the dashboard
             to route events to the correct fleet panel.
        """
        # Arrange
        text = json.dumps({"vm_name": "prod-vm", "job_id": "job-999", "status": "active"})
        result = _make_tool_result([TextContent(type="text", text=text)])

        # Act
        payload = _extract_payload_from_tool_result(
            tool_name="vm_job_start", event_type=EventType.JOB_CREATED, result=result
        )

        # Assert
        assert payload.vm_name == "prod-vm"
        assert payload.job_id == "job-999"
        assert payload.data["status"] == "active"
        assert payload.event_type == EventType.JOB_CREATED

    def test_text_content_invalid_json_falls_back_to_raw(self) -> None:
        """TextContent with non-JSON text: data contains raw key with original text.

        Tests: Graceful handling of non-JSON tool output.
        How: Provide TextContent with plain-text content, verify data={"raw": text}.
        Why: Some tools return plain strings; the middleware must not crash on
             unexpected output formats.
        """
        # Arrange
        raw_text = "Tool completed successfully"
        result = _make_tool_result([TextContent(type="text", text=raw_text)])

        # Act
        payload = _extract_payload_from_tool_result(
            tool_name="vm_job_step", event_type=EventType.STEP_ADDED, result=result
        )

        # Assert
        assert payload.data == {"raw": raw_text}
        assert payload.vm_name == ""
        assert payload.job_id == ""

    def test_image_content_sets_return_type_image(self) -> None:
        """ImageContent result: data contains {"return_type": "Image"}.

        Tests: Screenshot tool payload extraction (ADR-002 image handling).
        How: Create a ToolResult with ImageContent, call _extract_payload_from_tool_result,
             assert data equals {"return_type": "Image"}.
        Why: Screenshot events signal the dashboard to refresh the image panel;
             the data sentinel distinguishes them from text-based events.
        """
        # Arrange
        result = _make_tool_result([ImageContent(type="image", data="base64data", mimeType="image/png")])

        # Act
        payload = _extract_payload_from_tool_result(
            tool_name="vm_screenshot", event_type=EventType.SCREENSHOT_CAPTURED, result=result
        )

        # Assert
        assert payload.data == {"return_type": "Image"}

    def test_image_content_extracts_vm_name_from_arguments(self) -> None:
        """ImageContent result: vm_name is extracted from tool call arguments.

        Tests: vm_name population for screenshot tools that return binary data.
        How: Pass arguments={"vm_name": "my-vm"} alongside ImageContent, assert
             payload.vm_name equals "my-vm".
        Why: ImageContent carries no structured metadata; the tool arguments are
             the only source of vm_name for screenshot events.
        """
        # Arrange
        result = _make_tool_result([ImageContent(type="image", data="base64data", mimeType="image/png")])
        arguments = {"vm_name": "my-vm", "width": 1920}

        # Act
        payload = _extract_payload_from_tool_result(
            tool_name="vm_screenshot", event_type=EventType.SCREENSHOT_CAPTURED, result=result, arguments=arguments
        )

        # Assert
        assert payload.vm_name == "my-vm"

    def test_empty_content_list_produces_empty_payload(self) -> None:
        """Empty content list produces a payload with empty strings and empty data.

        Tests: Defensive handling of tools that return no content.
        How: Pass a ToolResult with content=[], verify all fields default to empty.
        Why: Some tools may return empty results in error or no-op cases; the
             middleware must not raise on missing content.
        """
        # Arrange
        result = _make_tool_result([])

        # Act
        payload = _extract_payload_from_tool_result(
            tool_name="vagrant_up", event_type=EventType.VM_STATE_CHANGED, result=result
        )

        # Assert
        assert payload.vm_name == ""
        assert payload.job_id == ""
        assert payload.data == {}

    def test_payload_has_iso8601_timestamp(self) -> None:
        """Every payload produced has a non-empty ISO-8601 timestamp.

        Tests: Unified observability requirement — all events carry timestamps.
        How: Extract a payload from a TextContent result, assert timestamp is a
             non-empty string parseable as a datetime.
        Why: The dashboard timeline view requires ISO-8601 timestamps to sort and
             display events in chronological order.
        """
        # Arrange
        import datetime

        result = _make_tool_result([TextContent(type="text", text='{"vm_name":"v"}')])

        # Act
        payload = _extract_payload_from_tool_result(
            tool_name="vm_job_start", event_type=EventType.JOB_CREATED, result=result
        )

        # Assert
        assert payload.timestamp != ""
        # Must be parseable as ISO-8601
        dt = datetime.datetime.fromisoformat(payload.timestamp)
        assert dt.tzinfo is not None  # timezone-aware


# ---------------------------------------------------------------------------
# Test suite — TOOL_EVENT_MAP cardinality
# ---------------------------------------------------------------------------


class TestToolEventMap:
    """Tests for the TOOL_EVENT_MAP constant.

    Scope: Architecture spec compliance — section 5.3 mandates exactly 9 entries.
    """

    def test_tool_event_map_has_exactly_14_entries(self) -> None:
        """TOOL_EVENT_MAP contains exactly 14 tool-to-event-type mappings.

        Tests: Architecture spec cardinality contract (9 original + 4 added in P036-T07 + 1 added in P087).
        How: Assert len(TOOL_EVENT_MAP) == 14.
        Why: P087 added vm_viewport_action to the map; the count guard must track the actual size.
        """
        # Assert
        assert len(TOOL_EVENT_MAP) == 14

    def test_tool_event_map_all_values_are_event_type(self) -> None:
        """Every value in TOOL_EVENT_MAP is an EventType instance.

        Tests: Type integrity of the TOOL_EVENT_MAP constant.
        How: Iterate values and assert each is an EventType member.
        Why: Non-EventType values would cause silent failures when the middleware
             constructs the EventPayload.
        """
        # Assert
        for tool_name, event_type in TOOL_EVENT_MAP.items():
            assert isinstance(event_type, EventType), (
                f"TOOL_EVENT_MAP[{tool_name!r}] is not an EventType: {event_type!r}"
            )

    def test_tool_event_map_expected_tools_present(self) -> None:
        """TOOL_EVENT_MAP contains the exact set of tools specified in the architecture.

        Tests: Architecture spec tool coverage (9 original + 4 added in P036-T07 + 1 added in P087).
        How: Assert all 14 expected tool names are keys in TOOL_EVENT_MAP.
        Why: Missing entries mean some tool completions never emit events;
             extra entries mean unintended events are broadcast.
        """
        # Arrange — 9 original entries + 4 added by P036-T07 (TODO + steering tools) + 1 added by P087
        expected_tools = {
            "vm_job_start",
            "vm_job_pause",
            "vm_job_resume",
            "vm_job_complete",
            "vm_job_step",
            "vm_screenshot",
            "vm_screenshot_api",
            "vagrant_up",
            "vagrant_destroy",
            # P036-T07 additions
            "vm_todo_add",
            "vm_todo_update",
            "vm_todo_reorder",
            "vm_steering_send",
            # P087 addition
            "vm_viewport_action",
        }

        # Assert
        assert set(TOOL_EVENT_MAP.keys()) == expected_tools


# ---------------------------------------------------------------------------
# Test suite — _post_event
# ---------------------------------------------------------------------------


class TestPostEvent:
    """Tests for the _post_event fire-and-forget helper.

    Scope: HTTP transport layer — success path and exception swallowing.
    """

    async def test_post_event_calls_client_post_with_payload(self, mocker: MockerFixture) -> None:
        """_post_event POSTs the payload model_dump to the given URL.

        Tests: HTTP transport contract — correct URL and JSON body.
        How: Mock httpx.AsyncClient, call _post_event, assert post was called
             with the correct URL and JSON body.
        Why: The dashboard ingest endpoint expects the exact field names from
             EventPayload.model_dump(); any deviation causes a 422 validation error.
        """
        # Arrange
        import httpx

        client = mocker.AsyncMock(spec=httpx.AsyncClient)
        url = "http://localhost:5173/api/events/ingest"
        payload = EventPayload(
            event_type=EventType.JOB_CREATED,
            vm_name="test-vm",
            job_id="job-001",
            data={"status": "active"},
            timestamp="2026-03-20T12:00:00+00:00",
        )

        # Act
        await _post_event(client, url, payload)

        # Assert
        client.post.assert_awaited_once_with(url, json=payload.model_dump(), timeout=mocker.ANY)

    async def test_post_event_swallows_exception_and_logs_warning(
        self, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
    ) -> None:
        """_post_event catches all exceptions and logs a warning; never re-raises.

        Tests: ADR-003 fire-and-forget resilience.
        How: Mock client.post to raise Exception, call _post_event, assert no
             exception propagates and a warning is logged.
        Why: A dashboard outage must never surface as an error to MCP tool callers.
        """
        # Arrange
        import httpx

        client = mocker.AsyncMock(spec=httpx.AsyncClient)
        client.post.side_effect = Exception("network error")
        url = "http://localhost:5173/api/events/ingest"
        payload = EventPayload(event_type=EventType.FLEET_STATUS, vm_name="vm1")

        # Act — must not raise
        with caplog.at_level(logging.WARNING, logger="mcp_vm_blackbox.event_emitter"):
            await _post_event(client, url, payload)

        # Assert — no exception raised; warning logged
        assert any("Event emission failed" in r.message for r in caplog.records)
