"""
Unit tests for orchestration.polling_loop (Step 14).

Author: Vasiliy Zdanovskiy
email: vasilyvz@gmail.com
"""

from __future__ import annotations

import threading
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from mcp_proxy_adapter.core.orchestration.discovery_client import DiscoveryCandidate
from mcp_proxy_adapter.core.orchestration.polling_loop import (
    run_one_cycle,
    run_one_cycle_sync,
    run_polling_loop,
)
from mcp_proxy_adapter.core.orchestration.snapshot_holder import SnapshotHolder


def _make_candidate(
    host: str = "localhost",
    port: int = 8080,
    sid: str = "11111111-1111-4111-a111-111111111111",
    commands_response: dict | None = None,
    scheme: str = "http",
) -> DiscoveryCandidate:
    """Build a DiscoveryCandidate with optional commands response."""
    if commands_response is None:
        commands_response = {
            "commands": {
                "echo": {
                    "name": "echo",
                    "schema": {
                        "type": "object",
                        "properties": {"message": {"type": "string"}},
                    },
                    "description": "Echo command",
                }
            }
        }
    return DiscoveryCandidate(
        host=host,
        port=port,
        id=sid,
        commands_response=commands_response,
        scheme=scheme,
    )


@pytest.fixture
def holder() -> SnapshotHolder:
    """Fresh snapshot holder."""
    return SnapshotHolder()


@pytest.mark.asyncio
async def test_run_one_cycle_success_swaps_snapshot(holder: SnapshotHolder) -> None:
    """Successful discovery and validation leads to swap with new catalog."""
    candidates = [_make_candidate()]
    with (
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.run_discovery",
            new_callable=AsyncMock,
            return_value=candidates,
        ),
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.validate_candidate",
            new_callable=AsyncMock,
            return_value=MagicMock(
                valid=True, reason=None, reported_id=candidates[0].id
            ),
        ),
    ):
        result = await run_one_cycle(
            "http://localhost:3005",
            None,
            holder,
        )
    assert result is True
    assert holder.get_version() == 1
    assert holder.get_catalog() is not None
    assert holder.get_tool_context() is not None


@pytest.mark.asyncio
async def test_run_one_cycle_discovery_failure_no_swap(holder: SnapshotHolder) -> None:
    """When discovery raises, no swap occurs; returns False."""
    with patch(
        "mcp_proxy_adapter.core.orchestration.polling_loop.run_discovery",
        new_callable=AsyncMock,
        side_effect=RuntimeError("proxy unreachable"),
    ):
        result = await run_one_cycle(
            "http://localhost:3005",
            None,
            holder,
        )
    assert result is False
    assert holder.get_version() is None
    assert holder.get_catalog() is None


@pytest.mark.asyncio
async def test_run_one_cycle_all_invalid_no_swap(holder: SnapshotHolder) -> None:
    """When all candidates fail validation, no swap; returns False."""
    candidates = [_make_candidate()]
    with (
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.run_discovery",
            new_callable=AsyncMock,
            return_value=candidates,
        ),
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.validate_candidate",
            new_callable=AsyncMock,
            return_value=MagicMock(valid=False, reason="unreachable", reported_id=None),
        ),
    ):
        result = await run_one_cycle(
            "http://localhost:3005",
            None,
            holder,
        )
    assert result is False
    assert holder.get_version() is None


@pytest.mark.asyncio
async def test_run_one_cycle_build_failure_no_swap(holder: SnapshotHolder) -> None:
    """When catalog build raises, no swap; returns False."""
    candidates = [_make_candidate()]
    with (
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.run_discovery",
            new_callable=AsyncMock,
            return_value=candidates,
        ),
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.validate_candidate",
            new_callable=AsyncMock,
            return_value=MagicMock(
                valid=True, reason=None, reported_id=candidates[0].id
            ),
        ),
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.build_catalog_and_tool_context",
            side_effect=ValueError("build failed"),
        ),
    ):
        result = await run_one_cycle(
            "http://localhost:3005",
            None,
            holder,
        )
    assert result is False
    assert holder.get_version() is None


@pytest.mark.asyncio
async def test_run_one_cycle_includes_candidates_with_command_error_as_empty(
    holder: SnapshotHolder,
) -> None:
    """Candidates with commands_response error are included with empty commands list."""
    valid = _make_candidate(port=8080, sid="aaaaaaaa-aaaa-4aaa-a111-111111111111")
    invalid_fetch = _make_candidate(
        port=8081,
        sid="bbbbbbbb-bbbb-4bbb-b111-111111111111",
        commands_response={"error": "fetch failed"},
    )
    candidates = [valid, invalid_fetch]
    with (
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.run_discovery",
            new_callable=AsyncMock,
            return_value=candidates,
        ),
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.validate_candidate",
            new_callable=AsyncMock,
            side_effect=[
                MagicMock(valid=True, reason=None, reported_id=valid.id),
                MagicMock(valid=True, reason=None, reported_id=invalid_fetch.id),
            ],
        ),
    ):
        result = await run_one_cycle(
            "http://localhost:3005",
            None,
            holder,
        )
    assert result is True
    catalog = holder.get_catalog()
    assert catalog is not None
    servers = catalog.get_servers()
    assert len(servers) == 2
    assert "aaaaaaaa-aaaa-4aaa-a111-111111111111" in servers
    assert "bbbbbbbb-bbbb-4bbb-b111-111111111111" in servers


def test_run_one_cycle_sync_returns_result(holder: SnapshotHolder) -> None:
    """run_one_cycle_sync runs one cycle and returns bool."""
    candidates = [_make_candidate()]
    with (
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.run_discovery",
            new_callable=AsyncMock,
            return_value=candidates,
        ),
        patch(
            "mcp_proxy_adapter.core.orchestration.polling_loop.validate_candidate",
            new_callable=AsyncMock,
            return_value=MagicMock(
                valid=True, reason=None, reported_id=candidates[0].id
            ),
        ),
    ):
        result = run_one_cycle_sync(
            "http://localhost:3005",
            None,
            holder,
        )
    assert result is True
    assert holder.get_version() == 1


def test_run_polling_loop_stops_when_event_set() -> None:
    """run_polling_loop exits when stop_event is set without running cycle."""
    stop = threading.Event()
    stop.set()
    holder = SnapshotHolder()
    config = MagicMock()
    config.polling_interval = 60.0
    config.static_servers = None
    run_polling_loop(
        "http://localhost:3005",
        config,
        holder,
        stop,
    )
    assert holder.get_version() is None
