"""
Unit tests for orchestration.discovery_client (Step 10).

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

from __future__ import annotations

import pytest
from unittest.mock import AsyncMock, patch

from mcp_proxy_adapter.core.orchestration.discovery_client import (
    DiscoveryCandidate,
    _merge_and_normalize,
    _parse_proxy_server_entry,
    _proxy_response_to_candidates,
    _static_entries_to_candidates,
    run_discovery,
)


def _static_entry(host: str, port: int, id: str):  # noqa: A002
    """Minimal static entry (host, port, id) to avoid importing orchestration_config."""
    from types import SimpleNamespace

    return SimpleNamespace(host=host, port=port, id=id)


class TestParseProxyServerEntry:
    """Tests for _parse_proxy_server_entry."""

    def test_missing_server_url_returns_none(self):
        """Entry without server_url or url returns None."""
        assert _parse_proxy_server_entry({}) is None
        assert _parse_proxy_server_entry({"server_id": "x"}) is None

    def test_empty_server_url_returns_none(self):
        """Entry with empty server_url returns None."""
        assert _parse_proxy_server_entry({"server_url": ""}) is None

    def test_parses_host_port_scheme_from_server_url(self):
        """Extracts host, port, scheme from server_url."""
        out = _parse_proxy_server_entry(
            {"server_url": "http://myhost:9090", "server_id": "s1"}
        )
        assert out is not None
        host, port, sid, scheme = out
        assert host == "myhost"
        assert port == 9090
        assert sid == "s1"
        assert scheme == "http"

    def test_https_default_port_443(self):
        """HTTPS URL without explicit port yields 443."""
        out = _parse_proxy_server_entry(
            {"server_url": "https://secure.example.com", "server_id": "s2"}
        )
        assert out is not None
        _, port, __, scheme = out
        assert port == 443
        assert scheme == "https"

    def test_id_from_metadata_uuid(self):
        """Uses metadata.uuid as id when present."""
        out = _parse_proxy_server_entry(
            {
                "server_url": "http://h:80",
                "server_id": "fallback",
                "metadata": {"uuid": "550e8400-e29b-41d4-a716-446655440000"},
            }
        )
        assert out is not None
        _, __, sid, ___ = out
        assert sid == "550e8400-e29b-41d4-a716-446655440000"

    def test_id_fallback_server_id_then_host_port(self):
        """Id falls back to server_id then to host:port."""
        out = _parse_proxy_server_entry(
            {"server_url": "http://h:80", "server_id": "sid"}
        )
        assert out is not None
        _, __, sid, ___ = out
        assert sid == "sid"

        out2 = _parse_proxy_server_entry({"server_url": "http://h:80"})
        assert out2 is not None
        _, __, sid2, ___ = out2
        assert sid2 == "h:80"


class TestProxyResponseToCandidates:
    """Tests for _proxy_response_to_candidates."""

    def test_list_top_level(self):
        """Response as list of dicts is accepted."""
        resp = [
            {"server_url": "http://a:1", "server_id": "id1"},
            {"server_url": "http://b:2", "server_id": "id2"},
        ]
        out = _proxy_response_to_candidates(resp)
        assert len(out) == 2
        assert (out[0][0], out[0][1], out[0][2]) == ("a", 1, "id1")
        assert (out[1][0], out[1][1], out[1][2]) == ("b", 2, "id2")

    def test_dict_servers_key(self):
        """Response with 'servers' key is accepted."""
        resp = {"servers": [{"server_url": "http://c:3", "server_id": "id3"}]}
        out = _proxy_response_to_candidates(resp)
        assert len(out) == 1
        assert (out[0][0], out[0][1], out[0][2]) == ("c", 3, "id3")

    def test_dict_data_key(self):
        """Response with 'data' key is accepted."""
        resp = {"data": [{"server_url": "http://d:4", "server_id": "id4"}]}
        out = _proxy_response_to_candidates(resp)
        assert len(out) == 1
        assert (out[0][0], out[0][1], out[0][2]) == ("d", 4, "id4")

    def test_skips_invalid_entries(self):
        """Entries without server_url are skipped."""
        resp = [
            {"server_url": "http://ok:1", "server_id": "ok"},
            {},
            {"server_id": "no_url"},
        ]
        out = _proxy_response_to_candidates(resp)
        assert len(out) == 1
        assert out[0][2] == "ok"

    def test_deduplicates_by_host_port_id(self):
        """Duplicate (host, port, id) appears once."""
        resp = [
            {"server_url": "http://e:5", "server_id": "id5"},
            {"server_url": "http://e:5", "server_id": "id5"},
        ]
        out = _proxy_response_to_candidates(resp)
        assert len(out) == 1


class TestStaticEntriesToCandidates:
    """Tests for _static_entries_to_candidates."""

    def test_empty_returns_empty(self):
        """None or empty list returns empty."""
        assert _static_entries_to_candidates(None) == []
        assert _static_entries_to_candidates([]) == []

    def test_converts_static_entries(self):
        """Static-like entries become (host, port, id, http)."""
        entries = [
            _static_entry("h1", 100, "uuid-1"),
            _static_entry("h2", 200, "uuid-2"),
        ]
        out = _static_entries_to_candidates(entries)
        assert out == [("h1", 100, "uuid-1", "http"), ("h2", 200, "uuid-2", "http")]


class TestMergeAndNormalize:
    """Tests for _merge_and_normalize."""

    def test_proxy_then_static_order(self):
        """Proxy list first, then static; dedupe by (host, port, id)."""
        proxy = [("a", 1, "id1", "http")]
        static = [("b", 2, "id2", "http")]
        out = _merge_and_normalize(proxy, static)
        assert len(out) == 2
        assert (out[0][0], out[0][1], out[0][2]) == ("a", 1, "id1")
        assert (out[1][0], out[1][1], out[1][2]) == ("b", 2, "id2")

    def test_duplicate_from_static_dropped(self):
        """Same (host, port, id) in static is dropped when already in proxy."""
        proxy = [("a", 1, "id1", "http")]
        static = [("a", 1, "id1", "http")]
        out = _merge_and_normalize(proxy, static)
        assert len(out) == 1
        assert out[0][2] == "id1"


class TestRunDiscovery:
    """Tests for run_discovery (async, with mocks)."""

    @pytest.mark.asyncio
    async def test_returns_candidates_with_commands_response(self):
        """run_discovery returns list of DiscoveryCandidate with commands_response."""
        proxy_response = {
            "servers": [{"server_url": "http://localhost:8080", "server_id": "local"}]
        }
        with patch(
            "mcp_proxy_adapter.core.orchestration.discovery_client.JsonRpcClient"
        ) as mock_cls:
            mock_instance = AsyncMock()
            mock_instance.list_proxy_servers = AsyncMock(return_value=proxy_response)
            mock_instance.get_commands_list = AsyncMock(
                return_value={"commands": [{"name": "echo"}]}
            )
            mock_instance.close = AsyncMock()
            mock_cls.return_value = mock_instance

            candidates = await run_discovery("http://proxy:3005", static_servers=None)

        assert len(candidates) == 1
        assert isinstance(candidates[0], DiscoveryCandidate)
        assert candidates[0].host == "localhost"
        assert candidates[0].port == 8080
        assert candidates[0].id == "local"
        assert candidates[0].commands_response == {"commands": [{"name": "echo"}]}

    @pytest.mark.asyncio
    async def test_merges_static_servers(self):
        """run_discovery merges proxy list with static_servers."""
        proxy_response = {"servers": []}
        static = [_static_entry("static-host", 9999, "static-uuid")]
        with patch(
            "mcp_proxy_adapter.core.orchestration.discovery_client.JsonRpcClient"
        ) as mock_cls:
            mock_instance = AsyncMock()
            mock_instance.list_proxy_servers = AsyncMock(return_value=proxy_response)
            mock_instance.get_commands_list = AsyncMock(return_value={})
            mock_instance.close = AsyncMock()
            mock_cls.return_value = mock_instance

            candidates = await run_discovery("http://proxy:3005", static_servers=static)

        assert len(candidates) == 1
        assert candidates[0].host == "static-host"
        assert candidates[0].port == 9999
        assert candidates[0].id == "static-uuid"

    @pytest.mark.asyncio
    async def test_empty_proxy_and_no_static_returns_empty(self):
        """No proxy entries and no static yields empty list."""
        with patch(
            "mcp_proxy_adapter.core.orchestration.discovery_client.JsonRpcClient"
        ) as mock_cls:
            mock_instance = AsyncMock()
            mock_instance.list_proxy_servers = AsyncMock(return_value={"servers": []})
            mock_instance.close = AsyncMock()
            mock_cls.return_value = mock_instance

            candidates = await run_discovery("http://proxy:3005", static_servers=None)

        assert candidates == []
