"""BGG client tests: canned transports, fake clocks — never online."""

from __future__ import annotations

import random

import httpx
import pytest

from bggpipe.bgg_client import BGGClient, BGGQueueTimeout, cache_key

SEARCH_XML = (
    '<items total="1"><item type="boardgame" id="13">'
    '<name type="primary" value="CATAN"/><yearpublished value="1995"/>'
    "</item></items>"
)


class FakeClock:
    def __init__(self) -> None:
        self.now = 1000.0
        self.sleeps: list[float] = []

    def monotonic(self) -> float:
        return self.now

    def sleep(self, seconds: float) -> None:
        self.sleeps.append(seconds)
        self.now += seconds


def make_client(tmp_path, responses, clock=None):
    """Client whose transport pops canned (status, body) responses."""
    calls = []

    def handler(request: httpx.Request) -> httpx.Response:
        calls.append(request)
        status, body = responses.pop(0)
        return httpx.Response(status, text=body)

    clock = clock or FakeClock()
    client = BGGClient(
        cache_dir=tmp_path / "cache",
        transport=httpx.MockTransport(handler),
        sleep=clock.sleep,
        monotonic=clock.monotonic,
        rng=random.Random(42),
    )
    return client, calls, clock


def test_caches_successful_responses(tmp_path):
    client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
    first = client.get_xml("search", {"query": "catan", "type": "boardgame"})
    second = client.get_xml("search", {"query": "catan", "type": "boardgame"})
    assert first == second == SEARCH_XML
    assert len(calls) == 1  # second call served from disk
    cached = (
        tmp_path
        / "cache"
        / cache_key("search", {"query": "catan", "type": "boardgame"})
    )
    assert cached.exists()


def test_202_retries_with_spec_backoff_then_succeeds(tmp_path):
    client, calls, clock = make_client(
        tmp_path, [(202, ""), (202, ""), (200, SEARCH_XML)]
    )
    xml = client.get_xml("collection", {"username": "someone", "own": "1"})
    assert xml == SEARCH_XML
    assert len(calls) == 3
    assert clock.sleeps == [2.0, 5.0]  # spec schedule between attempts


def test_202_gives_up_after_five_attempts(tmp_path):
    client, calls, clock = make_client(tmp_path, [(202, "")] * 5)
    with pytest.raises(BGGQueueTimeout):
        client.get_xml("collection", {"username": "someone", "own": "1"})
    assert len(calls) == 5
    assert clock.sleeps == [2.0, 5.0, 10.0, 30.0]
    # nothing cached on failure
    assert not (tmp_path / "cache").exists()


def test_429_backs_off_with_jitter(tmp_path):
    client, calls, clock = make_client(tmp_path, [(429, ""), (200, SEARCH_XML)])
    client.get_xml("search", {"query": "catan"})
    assert len(calls) == 2
    (backoff,) = clock.sleeps
    assert 2.0 <= backoff <= 3.0  # base 2s, up to +50% jitter


def test_rate_limit_spaces_consecutive_requests(tmp_path):
    client, calls, clock = make_client(tmp_path, [(200, SEARCH_XML), (200, SEARCH_XML)])
    client.get_xml("search", {"query": "catan"})
    client.get_xml("search", {"query": "wingspan"})
    # second request must wait the full 2s window (fake clock: no time passed)
    assert clock.sleeps == [2.0]


def test_http_error_raises(tmp_path):
    client, _, _ = make_client(tmp_path, [(500, "boom")])
    with pytest.raises(httpx.HTTPStatusError):
        client.get_xml("search", {"query": "catan"})


def test_cache_key_stable_and_filename_safe(tmp_path):
    key1 = cache_key("search", {"query": "Café & Krieg?", "type": "boardgame"})
    key2 = cache_key("search", {"type": "boardgame", "query": "Café & Krieg?"})
    assert key1 == key2  # param order must not matter
    assert "/" not in key1 and "?" not in key1 and key1.endswith(".xml")


def test_api_token_sent_as_bearer_header(tmp_path, monkeypatch):
    monkeypatch.setenv("BGG_API_TOKEN", "test-token-123")
    client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
    client.get_xml("search", {"query": "catan"})
    assert calls[0].headers["Authorization"] == "Bearer test-token-123"


def test_no_auth_header_without_token(tmp_path, monkeypatch):
    monkeypatch.delenv("BGG_API_TOKEN", raising=False)
    client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
    client.get_xml("search", {"query": "catan"})
    assert "authorization" not in calls[0].headers


def test_401_raises_actionable_auth_error(tmp_path, monkeypatch):
    monkeypatch.delenv("BGG_API_TOKEN", raising=False)
    from bggpipe.bgg_client import BGGAuthError

    client, _, _ = make_client(tmp_path, [(401, "Unauthorized")])
    with pytest.raises(BGGAuthError, match="BGG_API_TOKEN"):
        client.get_xml("search", {"query": "catan"})


# -- response validation and cache hygiene ------------------------------


def test_http_200_error_document_raises_and_is_never_cached(tmp_path):
    # BGG serves some errors as HTTP 200 <errors> XML; caching one would
    # poison every future run for that query
    from bggpipe.models import BGGResponseError

    errors_xml = "<errors><error><message>Invalid username</message></error></errors>"
    client, _, _ = make_client(tmp_path, [(200, errors_xml)])
    with pytest.raises(BGGResponseError):
        client.get_xml("collection", {"username": "nobody", "own": "1"})
    assert list((tmp_path / "cache").glob("*.xml")) == []


def test_collection_full_merges_and_dedupes_by_collid(tmp_path):
    base_xml = (
        '<items totalitems="2">'
        '<item objectid="13" collid="100" subtype="boardgame">'
        '<name>Catan</name><status own="1"/></item>'
        '<item objectid="177" collid="101" subtype="boardgame">'
        '<name>Advanced Civilization</name><status own="1"/></item>'
        "</items>"
    )
    expansion_xml = (
        '<items totalitems="1">'
        '<item objectid="177" collid="101" subtype="boardgameexpansion">'
        '<name>Advanced Civilization</name><status own="1"/></item>'
        "</items>"
    )
    client, _, _ = make_client(tmp_path, [(200, base_xml), (200, expansion_xml)])
    items = client.collection_full("eric")
    assert len(items) == 2  # collid 101 appears in both responses: one copy
    assert {i.coll_id for i in items} == {100, 101}


def test_collection_item_missing_collid_refuses_to_parse(tmp_path):
    # a truncated response must fail loudly, not coerce ids to 0 and let
    # the dedupe silently drop owned games
    from bggpipe.models import BGGResponseError, parse_collection

    bad_xml = (
        '<items totalitems="1">'
        '<item objectid="13" subtype="boardgame">'
        '<name>Catan</name><status own="1"/></item>'
        "</items>"
    )
    with pytest.raises(BGGResponseError):
        parse_collection(bad_xml)


def test_malformed_xml_raises_and_is_never_cached(tmp_path):
    from bggpipe.models import BGGResponseError

    torn = '<items total="1"><item type="boardgame" id="13"><na'
    client, _, _ = make_client(tmp_path, [(200, torn)])
    with pytest.raises(BGGResponseError):
        client.get_xml("search", {"query": "catan", "type": "boardgame"})
    assert list((tmp_path / "cache").glob("*.xml")) == []
