"""Tests for marketplace router — node selection engine (Phase 5)."""

from __future__ import annotations

from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from llmhosts.marketplace.router import fetch_nodes, filter_by_model, load_and_register, rank_nodes
from llmhosts.proxy.dispatcher import BackendDispatcher

# ---------------------------------------------------------------------------
# filter_by_model
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_filter_by_model_exact_match() -> None:
    """Nodes with exact model match are included."""
    nodes = [
        {"id": "1", "name": "a", "models": ["llama3.2:8b", "qwen3:4b"]},
        {"id": "2", "name": "b", "models": ["mistral:7b"]},
    ]
    out = filter_by_model(nodes, "llama3.2:8b")
    assert len(out) == 1
    assert out[0]["id"] == "1"


@pytest.mark.unit
def test_filter_by_model_tag_prefix() -> None:
    """Nodes with model tag (model:tag) are included."""
    nodes = [
        {"id": "1", "name": "a", "models": ["llama3.2:8b"]},
    ]
    out = filter_by_model(nodes, "llama3.2")
    assert len(out) == 1
    assert out[0]["id"] == "1"


@pytest.mark.unit
def test_filter_by_model_empty_model_returns_all() -> None:
    """Empty model string returns all nodes (no filter)."""
    nodes = [
        {"id": "1", "name": "a", "models": ["m1"]},
    ]
    out = filter_by_model(nodes, "")
    assert len(out) == 1


@pytest.mark.unit
def test_filter_by_model_no_match_returns_empty() -> None:
    """No matching model returns empty list."""
    nodes = [
        {"id": "1", "name": "a", "models": ["llama3.2:8b"]},
    ]
    out = filter_by_model(nodes, "qwen3:32b")
    assert len(out) == 0


@pytest.mark.unit
def test_filter_by_model_empty_nodes() -> None:
    """Empty nodes returns empty list when model given."""
    out = filter_by_model([], "llama3.2")
    assert out == []


@pytest.mark.unit
def test_filter_by_model_skips_missing_models_key() -> None:
    """Nodes without 'models' key are skipped."""
    nodes = [
        {"id": "1", "name": "a"},
        {"id": "2", "name": "b", "models": ["llama3.2:8b"]},
    ]
    out = filter_by_model(nodes, "llama3.2:8b")
    assert len(out) == 1
    assert out[0]["id"] == "2"


# ---------------------------------------------------------------------------
# rank_nodes
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_rank_nodes_by_price_then_latency() -> None:
    """Nodes sorted by price_cents_per_hour asc, then latency_ms asc."""
    nodes = [
        {"id": "high", "price_cents_per_hour": 20, "latency_ms": 100},
        {"id": "low", "price_cents_per_hour": 5, "latency_ms": 50},
        {"id": "mid", "price_cents_per_hour": 10, "latency_ms": 80},
    ]
    out = rank_nodes(nodes)
    assert [n["id"] for n in out] == ["low", "mid", "high"]


@pytest.mark.unit
def test_rank_nodes_same_price_sorts_by_latency() -> None:
    """When price ties, lower latency comes first."""
    nodes = [
        {"id": "b", "price_cents_per_hour": 10, "latency_ms": 200},
        {"id": "a", "price_cents_per_hour": 10, "latency_ms": 50},
    ]
    out = rank_nodes(nodes)
    assert [n["id"] for n in out] == ["a", "b"]


@pytest.mark.unit
def test_rank_nodes_nulls_last() -> None:
    """Missing price or latency sorts last (sentinel)."""
    nodes = [
        {"id": "no_price", "price_cents_per_hour": None, "latency_ms": 10},
        {"id": "has_both", "price_cents_per_hour": 5, "latency_ms": 20},
        {"id": "no_latency", "price_cents_per_hour": 1, "latency_ms": None},
    ]
    out = rank_nodes(nodes)
    assert out[0]["id"] == "has_both"
    assert out[1]["id"] == "no_latency"
    assert out[2]["id"] == "no_price"


@pytest.mark.unit
def test_rank_nodes_empty() -> None:
    """Empty list returns empty list."""
    assert rank_nodes([]) == []


# ---------------------------------------------------------------------------
# fetch_nodes (mocked httpx)
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_fetch_nodes_returns_list_from_nodes_key() -> None:
    """Response body {"nodes": [...]} returns that list."""
    mock_response = MagicMock()
    mock_response.raise_for_status = MagicMock()
    mock_response.json.return_value = {
        "nodes": [
            {"id": "1", "name": "n1", "tunnel_url": "https://a.com", "models": []},
        ],
    }
    mock_get = AsyncMock(return_value=mock_response)
    mock_client = MagicMock()
    mock_client.get = mock_get
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)

    with patch("llmhosts.marketplace.router.httpx.AsyncClient", return_value=mock_client):
        result = await fetch_nodes("http://example.com/nodes")

    assert len(result) == 1
    assert result[0]["name"] == "n1"
    assert result[0]["tunnel_url"] == "https://a.com"


@pytest.mark.unit
async def test_fetch_nodes_accepts_direct_list() -> None:
    """Response body that is a direct list is returned as-is."""
    mock_response = MagicMock()
    mock_response.raise_for_status = MagicMock()
    mock_response.json.return_value = [
        {"id": "1", "name": "n1", "tunnel_url": "https://a.com", "models": []},
    ]
    mock_get = AsyncMock(return_value=mock_response)
    mock_client = MagicMock()
    mock_client.get = mock_get
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)

    with patch("llmhosts.marketplace.router.httpx.AsyncClient", return_value=mock_client):
        result = await fetch_nodes("http://example.com/nodes")

    assert len(result) == 1
    assert result[0]["name"] == "n1"


@pytest.mark.unit
async def test_fetch_nodes_empty_url_returns_empty() -> None:
    """Empty catalog_url returns empty list without HTTP call."""
    result = await fetch_nodes("")
    assert result == []


@pytest.mark.unit
async def test_fetch_nodes_http_error_returns_empty() -> None:
    """HTTP error or timeout returns empty list (logged)."""
    mock_get = AsyncMock(side_effect=Exception("connection failed"))
    mock_client = MagicMock()
    mock_client.get = mock_get
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)

    with patch("llmhosts.marketplace.router.httpx.AsyncClient", return_value=mock_client):
        result = await fetch_nodes("http://example.com/nodes")

    assert result == []


# ---------------------------------------------------------------------------
# load_and_register (integration with dispatcher)
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_load_and_register_registers_ranked_with_priority() -> None:
    """load_and_register fetches, ranks, unregisters existing remote, registers with priority 0,1,2."""
    catalog_url = "http://example.com/nodes"
    mock_nodes = [
        {
            "id": "d1",
            "name": "node-a",
            "tunnel_url": "https://a.com",
            "models": ["m1"],
            "price_cents_per_hour": 10,
            "latency_ms": 50,
        },
        {
            "id": "d2",
            "name": "node-b",
            "tunnel_url": "https://b.com",
            "models": ["m2"],
            "price_cents_per_hour": 5,
            "latency_ms": 20,
        },
    ]
    mock_response = MagicMock()
    mock_response.raise_for_status = MagicMock()
    mock_response.json.return_value = {"nodes": mock_nodes}
    mock_get = AsyncMock(return_value=mock_response)
    mock_client = MagicMock()
    mock_client.get = mock_get
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)

    dispatcher = BackendDispatcher()
    await dispatcher.startup()

    with patch("llmhosts.marketplace.router.httpx.AsyncClient", return_value=mock_client):
        count = await load_and_register(dispatcher, catalog_url)

    assert count == 2
    remote = [b for b in dispatcher._backends if b.backend_type == "remote"]
    assert len(remote) == 2
    by_name = {b.name: b for b in remote}
    # Rank order: node-b (5¢, 20ms) then node-a (10¢, 50ms) → priority 0, 1
    assert by_name["node-b"].priority == 0
    assert by_name["node-a"].priority == 1

    await dispatcher.shutdown()


@pytest.mark.unit
async def test_load_and_register_empty_catalog_unregisters_remote() -> None:
    """When catalog returns no nodes, remote backends are unregistered."""
    mock_response = MagicMock()
    mock_response.raise_for_status = MagicMock()
    mock_response.json.return_value = {"nodes": []}
    mock_get = AsyncMock(return_value=mock_response)
    mock_client = MagicMock()
    mock_client.get = mock_get
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)

    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    dispatcher.register_remote_backend(tunnel_url="https://old.com", name="old", models=["m1"])
    assert len([b for b in dispatcher._backends if b.backend_type == "remote"]) == 1

    with patch("llmhosts.marketplace.router.httpx.AsyncClient", return_value=mock_client):
        count = await load_and_register(dispatcher, "http://example.com/nodes")

    assert count == 0
    assert len([b for b in dispatcher._backends if b.backend_type == "remote"]) == 0

    await dispatcher.shutdown()


@pytest.mark.unit
async def test_register_remote_backends_accepts_priority_key() -> None:
    """Dispatcher register_remote_backends uses priority from device dict when priority_key set."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()

    devices: list[dict[str, Any]] = [
        {"id": "1", "name": "first", "tunnel_url": "https://first.com", "models": ["m1"], "priority": 0},
        {"id": "2", "name": "second", "tunnel_url": "https://second.com", "models": ["m2"], "priority": 1},
    ]
    count = dispatcher.register_remote_backends(devices, priority_key="priority")
    assert count == 2

    remote = [b for b in dispatcher._backends if b.backend_type == "remote"]
    by_name = {b.name: b for b in remote}
    assert by_name["first"].priority == 0
    assert by_name["second"].priority == 1

    await dispatcher.shutdown()
