"""Tests for MarketplaceRefresher — background refresh loop for remote backends.

Covers:
- start/stop lifecycle
- Node registration updates on refresh
- Change detection (added/removed nodes)
- Error handling when catalog is unreachable
- Minimum interval floor
- Duplicate start prevention
- Empty catalog URL handling
"""

from __future__ import annotations

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

import pytest

from llmhosts.marketplace.refresh import _DEFAULT_INTERVAL, MarketplaceRefresher
from llmhosts.proxy.dispatcher import BackendDispatcher

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_nodes(names: list[str]) -> list[dict[str, Any]]:
    """Build a list of node dicts suitable for the marketplace catalog response."""
    return [
        {
            "id": f"dev-{name}",
            "name": name,
            "tunnel_url": f"https://{name}.example.com",
            "models": ["llama3.2:8b"],
            "price_cents_per_hour": 10,
            "latency_ms": 50,
        }
        for name in names
    ]


# ---------------------------------------------------------------------------
# 1. Start / stop lifecycle
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_start_creates_background_task() -> None:
    """start() creates a running asyncio task."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    refresher = MarketplaceRefresher()

    with patch("llmhosts.marketplace.refresh.load_and_register", new_callable=AsyncMock, return_value=0):
        refresher.start(dispatcher, "https://catalog.example.com/api/marketplace/nodes", interval=3600)
        assert refresher.running is True
        await refresher.stop()
        assert refresher.running is False

    await dispatcher.shutdown()


@pytest.mark.unit
async def test_stop_when_not_started_is_noop() -> None:
    """stop() on an unstarted refresher does nothing."""
    refresher = MarketplaceRefresher()
    await refresher.stop()  # should not raise
    assert refresher.running is False


@pytest.mark.unit
async def test_duplicate_start_is_ignored() -> None:
    """Calling start() twice does not create a second task."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    refresher = MarketplaceRefresher()

    with patch("llmhosts.marketplace.refresh.load_and_register", new_callable=AsyncMock, return_value=0):
        refresher.start(dispatcher, "https://catalog.example.com/api/marketplace/nodes", interval=3600)
        first_task = refresher._task
        refresher.start(dispatcher, "https://catalog.example.com/api/marketplace/nodes", interval=3600)
        assert refresher._task is first_task
        await refresher.stop()

    await dispatcher.shutdown()


@pytest.mark.unit
async def test_empty_catalog_url_does_not_start() -> None:
    """start() with empty catalog URL does not create a task."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    refresher = MarketplaceRefresher()

    refresher.start(dispatcher, "", interval=10)
    assert refresher.running is False

    refresher.start(dispatcher, "  ", interval=10)
    assert refresher.running is False

    await dispatcher.shutdown()


# ---------------------------------------------------------------------------
# 2. Node registration updates
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_refresh_registers_nodes() -> None:
    """A refresh iteration registers nodes from the catalog."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    refresher = MarketplaceRefresher()
    refresher._dispatcher = dispatcher
    refresher._catalog_url = "https://catalog.example.com"

    nodes = _make_nodes(["alpha", "bravo"])
    with patch("llmhosts.marketplace.refresh.load_and_register", new_callable=AsyncMock, return_value=2):
        # Simulate what load_and_register does: register backends
        for n in nodes:
            dispatcher.register_remote_backend(
                tunnel_url=n["tunnel_url"],
                name=n["name"],
                models=n["models"],
                device_id=n["id"],
            )
        await refresher._refresh()

    remote_names = {b.name for b in dispatcher._backends if b.backend_type == "remote"}
    assert "alpha" in remote_names
    assert "bravo" in remote_names

    await dispatcher.shutdown()


@pytest.mark.unit
async def test_refresh_detects_added_nodes() -> None:
    """Refresh logs newly added nodes."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    refresher = MarketplaceRefresher()
    refresher._dispatcher = dispatcher
    refresher._catalog_url = "https://catalog.example.com"

    # Pre-register one node
    dispatcher.register_remote_backend(
        tunnel_url="https://alpha.example.com",
        name="alpha",
        models=["llama3.2:8b"],
        device_id="dev-alpha",
    )

    # Mock load_and_register to add a second
    async def _mock_load(disp: Any, url: str) -> int:
        disp.register_remote_backend(
            tunnel_url="https://bravo.example.com",
            name="bravo",
            models=["llama3.2:8b"],
            device_id="dev-bravo",
        )
        return 2

    with patch("llmhosts.marketplace.refresh.load_and_register", side_effect=_mock_load):
        await refresher._refresh()

    assert "bravo" in refresher._known_names

    await dispatcher.shutdown()


@pytest.mark.unit
async def test_refresh_detects_removed_nodes() -> None:
    """Refresh detects when nodes are removed from catalog."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    refresher = MarketplaceRefresher()
    refresher._dispatcher = dispatcher
    refresher._catalog_url = "https://catalog.example.com"

    # Pre-register two nodes
    dispatcher.register_remote_backend(
        tunnel_url="https://alpha.example.com",
        name="alpha",
        models=["llama3.2:8b"],
        device_id="dev-alpha",
    )
    dispatcher.register_remote_backend(
        tunnel_url="https://bravo.example.com",
        name="bravo",
        models=["llama3.2:8b"],
        device_id="dev-bravo",
    )

    # Mock load_and_register to unregister all and re-register only alpha
    async def _mock_load(disp: Any, url: str) -> int:
        disp.unregister_remote_backends()
        disp.register_remote_backend(
            tunnel_url="https://alpha.example.com",
            name="alpha",
            models=["llama3.2:8b"],
            device_id="dev-alpha",
        )
        return 1

    with patch("llmhosts.marketplace.refresh.load_and_register", side_effect=_mock_load):
        await refresher._refresh()

    assert "alpha" in refresher._known_names
    assert "bravo" not in refresher._known_names

    await dispatcher.shutdown()


# ---------------------------------------------------------------------------
# 3. Error handling
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_refresh_error_does_not_crash_loop() -> None:
    """An error during _refresh() is caught and the loop continues."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    refresher = MarketplaceRefresher()

    call_count = 0

    async def _failing_load(disp: Any, url: str) -> int:
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            raise RuntimeError("catalog unreachable")
        return 0

    # Bypass the 10s minimum floor by setting _interval directly
    with patch("llmhosts.marketplace.refresh.load_and_register", side_effect=_failing_load):
        refresher._dispatcher = dispatcher
        refresher._catalog_url = "https://catalog.example.com"
        refresher._interval = 0.1  # bypass the floor for testing
        refresher._stop_event.clear()
        refresher._task = asyncio.create_task(refresher._loop())
        # Let it run for ~0.4s (enough for 2+ iterations at 0.1s interval)
        await asyncio.sleep(0.5)
        await refresher.stop()

    # The loop should have survived the first error and run again
    assert call_count >= 2

    await dispatcher.shutdown()


# ---------------------------------------------------------------------------
# 4. Minimum interval floor
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_minimum_interval_floor() -> None:
    """Interval is floored at 10 seconds."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    refresher = MarketplaceRefresher()

    with patch("llmhosts.marketplace.refresh.load_and_register", new_callable=AsyncMock, return_value=0):
        refresher.start(dispatcher, "https://catalog.example.com", interval=1.0)
        assert refresher._interval == 10.0
        await refresher.stop()

    await dispatcher.shutdown()


@pytest.mark.unit
async def test_default_interval() -> None:
    """Default interval is 300 seconds (5 minutes)."""
    assert _DEFAULT_INTERVAL == 300


@pytest.mark.unit
async def test_running_property_false_after_task_done() -> None:
    """running property returns False when the task has completed."""
    dispatcher = BackendDispatcher()
    await dispatcher.startup()
    refresher = MarketplaceRefresher()

    with patch("llmhosts.marketplace.refresh.load_and_register", new_callable=AsyncMock, return_value=0):
        refresher.start(dispatcher, "https://catalog.example.com", interval=3600)
        await refresher.stop()
        assert refresher.running is False

    await dispatcher.shutdown()
