"""Tests for the "shodan" last-resort provider adapter.

These cover the AISBF-specific adapter (model resolution, failover routing,
handler return contract, config building) around the vendored open-router
engine. The vendored engine itself is exercised by its upstream project.
"""

import json

import pytest

from aisbf.providers.shodan.runtime import (
    ShodanRuntime,
    _build_router_config,
    get_runtime,
)
from aisbf.providers.shodan import open_router_engine as engine


# --- Fakes ------------------------------------------------------------------

class FakeConfig:
    def __init__(self, base_url):
        self.base_url = base_url

    def api_url(self, resource):
        return f"{self.base_url}/v1/{resource}"


class FakeState:
    def __init__(self, base_url, models, latency=0.1):
        self.config = FakeConfig(base_url)
        self.models = frozenset(models)
        self.latency_seconds = latency


class FakeRegistry:
    """Minimal stand-in for engine.EndpointRegistry."""

    def __init__(self, states):
        self._states = {s.config.base_url: s for s in states}
        self.discarded = []
        self.discarded_models = []
        self.recorded_latencies = []

    def models(self):
        return sorted({m for s in self._states.values() for m in s.models})

    def candidates(self, model):
        return [s for s in self._states.values() if model in s.models]

    def discard(self, base_url):
        self.discarded.append(base_url)
        self._states.pop(base_url, None)
        return True

    def discard_model(self, base_url, model):
        self.discarded_models.append((base_url, model))
        return True

    def record_generation_latency(self, base_url, model, latency):
        self.recorded_latencies.append((base_url, model, latency))

    def endpoint_count(self):
        return len(self._states)


class FakeContent:
    def __init__(self, body: bytes, chunks=None, raise_mid=False):
        self._body = body
        self._chunks = chunks or []
        self._raise_mid = raise_mid

    async def read(self, _limit=None):
        return self._body

    async def iter_any(self):
        import aiohttp
        for i, ch in enumerate(self._chunks):
            if self._raise_mid and i == 1:
                raise aiohttp.ClientError("mid-stream failure")
            yield ch


class FakeResponse:
    def __init__(self, status=200, body=b"", chunks=None, raise_mid=False):
        self.status = status
        self.reason = "OK"
        self.content = FakeContent(body, chunks, raise_mid)
        self.closed = False

    def close(self):
        self.closed = True


class FakeSession:
    """Records POSTs and returns queued responses per URL host."""

    def __init__(self, responses_by_host):
        # responses_by_host: dict base_host -> list[FakeResponse | Exception]
        self._by_host = {k: list(v) for k, v in responses_by_host.items()}
        self.posts = []

    async def post(self, url, data=None, headers=None, timeout=None, allow_redirects=False):
        self.posts.append(url)
        host = url.split("/v1/")[0]
        queue = self._by_host.get(host)
        if not queue:
            raise AssertionError(f"no queued response for {host}")
        item = queue.pop(0)
        if isinstance(item, Exception):
            raise item
        return item


def _runtime_with(registry, session, upstream_timeout=5.0):
    rt = ShodanRuntime.__new__(ShodanRuntime)
    rt._router_config = None
    rt._upstream_timeout = upstream_timeout
    rt._session = session
    rt._started = True

    class _R:
        def __init__(self, reg):
            self.registry = reg
    rt._runtime = _R(registry)
    return rt


# --- Config builder ---------------------------------------------------------

class TestBuildRouterConfig:
    def test_defaults_when_empty(self):
        rc = _build_router_config(None)
        assert rc.discovery.shodan_mode == "auto"
        assert rc.discovery.masscan_list_paths == ()
        assert rc.probe.workers == engine.DEFAULT_WORKERS

    def test_overrides_applied(self):
        rc = _build_router_config({
            "shodan_mode": "public",
            "discovery_interval": 1800,
            "health_interval": 300,
            "workers": 32,
            "masscan_lists": ["/tmp/a.list", "  ", "/tmp/b.list"],
            "upstream_timeout": 120,
        })
        assert rc.discovery.shodan_mode == "public"
        assert rc.discovery.interval == 1800
        assert rc.probe.health_interval == 300
        assert rc.probe.workers == 32
        # blank entries are dropped
        assert len(rc.discovery.masscan_list_paths) == 2
        assert rc.server.upstream_timeout == 120


# --- Model resolution -------------------------------------------------------

class TestResolveModel:
    def test_explicit_model_passthrough(self):
        rt = _runtime_with(FakeRegistry([FakeState("http://h:1", ["llama3"])]), None)
        assert rt._resolve_model("llama3") == "llama3"

    def test_auto_prefers_non_cloud_with_candidate(self):
        reg = FakeRegistry([
            FakeState("http://h:1", ["some-cloud-model"]),
            FakeState("http://h:2", ["qwen2.5:7b"]),
        ])
        rt = _runtime_with(reg, None)
        assert rt._resolve_model("auto") == "qwen2.5:7b"

    def test_auto_empty_pool_returns_none(self):
        rt = _runtime_with(FakeRegistry([]), None)
        assert rt._resolve_model("auto") is None

    @pytest.mark.parametrize("alias", ["", "auto", "any", "*", "shodan", "shodan/auto"])
    def test_alias_names(self, alias):
        reg = FakeRegistry([FakeState("http://h:1", ["m1"])])
        rt = _runtime_with(reg, None)
        assert rt._resolve_model(alias) == "m1"


# --- Failover routing -------------------------------------------------------

class TestRouteFailover:
    @pytest.mark.asyncio
    async def test_non_stream_success(self):
        reg = FakeRegistry([FakeState("http://good:1", ["m1"])])
        payload = {"id": "x", "choices": [{"message": {"content": "hi"}}]}
        session = FakeSession({"http://good:1": [FakeResponse(200, json.dumps(payload).encode())]})
        rt = _runtime_with(reg, session)
        result = await rt.route("chat/completions", {"model": "m1", "messages": []}, stream=False)
        assert result == payload
        assert reg.recorded_latencies  # latency recorded on success

    @pytest.mark.asyncio
    async def test_failover_to_second_endpoint(self):
        reg = FakeRegistry([
            FakeState("http://bad:1", ["m1"], latency=0.01),   # tried first (faster)
            FakeState("http://good:2", ["m1"], latency=0.02),
        ])
        payload = {"ok": True}
        session = FakeSession({
            "http://bad:1": [FakeResponse(500, b"boom")],
            "http://good:2": [FakeResponse(200, json.dumps(payload).encode())],
        })
        rt = _runtime_with(reg, session)
        result = await rt.route("chat/completions", {"model": "m1", "messages": []}, stream=False)
        assert result == payload
        assert "http://bad:1" in reg.discarded  # 5xx dropped the bad endpoint

    @pytest.mark.asyncio
    async def test_401_discards_endpoint_403_discards_model(self):
        reg = FakeRegistry([FakeState("http://auth:1", ["m1"])])
        session = FakeSession({"http://auth:1": [FakeResponse(401, b"nope")]})
        rt = _runtime_with(reg, session)
        with pytest.raises(RuntimeError):
            await rt.route("chat/completions", {"model": "m1", "messages": []}, stream=False)
        assert "http://auth:1" in reg.discarded

        reg2 = FakeRegistry([FakeState("http://f:1", ["m1"])])
        session2 = FakeSession({"http://f:1": [FakeResponse(403, b"forbidden")]})
        rt2 = _runtime_with(reg2, session2)
        with pytest.raises(RuntimeError):
            await rt2.route("chat/completions", {"model": "m1", "messages": []}, stream=False)
        assert ("http://f:1", "m1") in reg2.discarded_models

    @pytest.mark.asyncio
    async def test_all_fail_raises(self):
        reg = FakeRegistry([FakeState("http://x:1", ["m1"])])
        session = FakeSession({"http://x:1": [FakeResponse(503, b"down")]})
        rt = _runtime_with(reg, session)
        with pytest.raises(RuntimeError, match="candidate upstream"):
            await rt.route("chat/completions", {"model": "m1", "messages": []}, stream=False)

    @pytest.mark.asyncio
    async def test_no_candidates_for_model_raises(self):
        reg = FakeRegistry([FakeState("http://x:1", ["other"])])
        session = FakeSession({})
        rt = _runtime_with(reg, session)
        with pytest.raises(RuntimeError, match="no live upstream"):
            await rt.route("chat/completions", {"model": "m1", "messages": []}, stream=False)

    @pytest.mark.asyncio
    async def test_stream_relays_chunks(self):
        reg = FakeRegistry([FakeState("http://s:1", ["m1"])])
        chunks = [b"data: {\"a\":1}\n\n", b"data: [DONE]\n\n"]
        session = FakeSession({"http://s:1": [FakeResponse(200, chunks=chunks)]})
        rt = _runtime_with(reg, session)
        gen = await rt.route("chat/completions", {"model": "m1", "messages": []}, stream=True)
        received = [c async for c in gen]
        assert received == chunks


# --- Rotation last-resort partitioning --------------------------------------

class TestPartitionLastResort:
    def test_partition_splits_and_preserves_order(self):
        from aisbf.handlers import _partition_last_resort
        models = [
            {"name": "a", "_last_resort": False},
            {"name": "lr1", "_last_resort": True},
            {"name": "b"},  # untagged == normal
            {"name": "lr2", "_last_resort": True},
        ]
        normal, last_resort = _partition_last_resort(models)
        assert [m["name"] for m in normal] == ["a", "b"]
        assert [m["name"] for m in last_resort] == ["lr1", "lr2"]

    def test_no_last_resort(self):
        from aisbf.handlers import _partition_last_resort
        models = [{"name": "a"}, {"name": "b"}]
        normal, last_resort = _partition_last_resort(models)
        assert [m["name"] for m in normal] == ["a", "b"]
        assert last_resort == []

    def test_all_last_resort(self):
        from aisbf.handlers import _partition_last_resort
        models = [{"name": "a", "_last_resort": True}]
        normal, last_resort = _partition_last_resort(models)
        assert normal == []
        assert [m["name"] for m in last_resort] == ["a"]


# --- Singleton --------------------------------------------------------------

class TestSingleton:
    @pytest.mark.asyncio
    async def test_get_runtime_is_singleton(self):
        import aisbf.providers.shodan.runtime as rt_mod
        rt_mod._runtime_singleton = None
        a = await get_runtime({"shodan_mode": "public"})
        b = await get_runtime({"shodan_mode": "api"})  # later config ignored
        assert a is b
        rt_mod._runtime_singleton = None  # cleanup for other tests
