"""BGG XML API2 client: disk cache, rate limiting, 202-queue and 429/503 retry.

Every successful response is cached under data/bgg_cache/ keyed by
endpoint + params, so re-runs never re-hit the API. The rate limiter
guarantees ≤1 request every rate_limit_seconds to any BGG endpoint.
"""

from __future__ import annotations

import hashlib
import os
import random
import re
import time
from collections.abc import Callable, Iterable
from pathlib import Path
from urllib.parse import urlencode

import httpx

from bggpipe import __version__
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
from bggpipe.models import (
    BGGResponseError,
    CollectionItem,
    SearchResult,
    ThingDetails,
    parse_collection,
    parse_search,
    parse_things,
    parse_things_full,
    validate_response,
)

BASE_URL = "https://boardgamegeek.com/xmlapi2"
# One home for the search-type filter: the fixture generators must build
# cache keys with the byte-identical string or every lookup silently misses.
SEARCH_TYPES = "boardgame,boardgameexpansion"
QUEUE_BACKOFF = (2.0, 5.0, 10.0, 30.0)  # sleeps between the 5 attempts (spec)
MAX_ATTEMPTS = 5
_UNSAFE = re.compile(r"[^A-Za-z0-9._=,-]+")


class BGGQueueTimeout(Exception):
    """BGG kept answering 202 (or throttling) past the retry budget."""


class BGGAuthError(Exception):
    """BGG rejected the request as unauthorized (missing/invalid API token)."""


def cache_key(endpoint: str, params: dict[str, str]) -> str:
    query = urlencode(sorted(params.items()))
    digest = hashlib.md5(f"{endpoint}?{query}".encode()).hexdigest()[:10]
    slug = _UNSAFE.sub("-", query)[:80].strip("-")
    return f"{endpoint}_{slug}_{digest}.xml"


class BGGClient:
    def __init__(
        self,
        cache_dir: Path,
        rate_limit_seconds: float = 2.0,
        transport: httpx.BaseTransport | None = None,
        sleep: Callable[[float], None] = time.sleep,
        monotonic: Callable[[], float] = time.monotonic,
        rng: random.Random | None = None,
    ) -> None:
        self.cache_dir = cache_dir
        self._rate = rate_limit_seconds
        self._sleep = sleep
        self._monotonic = monotonic
        self._rng = rng or random.Random()
        self._last_request: float | None = None
        headers = {"User-Agent": f"bggpipe/{__version__} (shelf-collection pipeline)"}
        # BGG requires registered applications since 2025: the token from
        # https://boardgamegeek.com/applications must accompany every request.
        # Env var only — never config, disk, or logs.
        if token := os.environ.get("BGG_API_TOKEN"):
            headers["Authorization"] = f"Bearer {token}"
        self._http = httpx.Client(
            base_url=BASE_URL,
            timeout=30.0,
            headers=headers,
            transport=transport,
        )

    def _throttle(self) -> None:
        if self._last_request is not None:
            wait = self._rate - (self._monotonic() - self._last_request)
            if wait > 0:
                self._sleep(wait)

    def get_xml(
        self, endpoint: str, params: dict[str, str], *, refresh: bool = False
    ) -> str:
        """Fetch one endpoint, serving from and filling the disk cache.
        refresh=True skips the cache read (still writes) — for data that
        drifts over time, like ranks and ratings."""
        cache_path = self.cache_dir / cache_key(endpoint, params)
        if cache_path.exists() and not refresh:
            return cache_path.read_text()

        for attempt in range(MAX_ATTEMPTS):
            self._throttle()
            response = self._http.get(f"/{endpoint}", params=params)
            self._last_request = self._monotonic()

            if response.status_code == 202:
                if attempt < MAX_ATTEMPTS - 1:
                    self._sleep(QUEUE_BACKOFF[min(attempt, len(QUEUE_BACKOFF) - 1)])
                continue
            if response.status_code == 401:
                raise BGGAuthError(
                    "BGG returned 401 Unauthorized. The XML API requires a "
                    "registered application token since 2025: register at "
                    "https://boardgamegeek.com/applications, create a token, "
                    "and export it as BGG_API_TOKEN."
                )
            if response.status_code in (429, 503):
                if attempt < MAX_ATTEMPTS - 1:
                    backoff = 2.0 * (2**attempt) * (1 + self._rng.uniform(0, 0.5))
                    self._sleep(backoff)
                continue

            response.raise_for_status()
            try:
                # error documents AND malformed/truncated bodies must never
                # reach the cache — they would poison every future run
                validate_response(response.text)
            except BGGResponseError as err:
                raise BGGResponseError(f"/{endpoint}: {err}") from err
            atomic_write_text(cache_path, response.text)
            return response.text

        raise BGGQueueTimeout(
            f"BGG did not serve /{endpoint} after {MAX_ATTEMPTS} attempts "
            "(still queued or throttled) — wait a minute and re-run; "
            "completed work is cached."
        )

    # -- typed endpoint wrappers ------------------------------------------

    def search(self, query: str, types: str = SEARCH_TYPES) -> list[SearchResult]:
        return parse_search(self.get_xml("search", {"query": query, "type": types}))

    def things(
        self,
        ids: Iterable[int],
        *,
        stats: bool = False,
        versions: bool = False,
    ) -> list[ThingDetails]:
        params = {"id": ",".join(str(i) for i in ids)}
        if stats:
            params["stats"] = "1"
        if versions:
            params["versions"] = "1"
        return parse_things(self.get_xml("thing", params))

    def things_full(self, ids: Iterable[int], *, refresh: bool = False) -> list[dict]:
        """Full metadata dicts for the enrich stage."""
        params = {"id": ",".join(str(i) for i in ids), "stats": "1"}
        return parse_things_full(self.get_xml("thing", params, refresh=refresh))

    def collection(
        self,
        username: str,
        *,
        subtype: str | None = None,
        version: bool = True,
        refresh: bool = False,
    ) -> list[CollectionItem]:
        params = {"username": username, "own": "1"}
        if subtype:
            params["subtype"] = subtype
        if version:
            params["version"] = "1"
        return parse_collection(self.get_xml("collection", params, refresh=refresh))

    def collection_full(
        self, username: str, *, refresh: bool = False
    ) -> list[CollectionItem]:
        """Owned items incl. expansions (excluded from the default subtype).
        refresh=True bypasses the cache — upload --verify must see the live
        collection, not the snapshot resolve ran against."""
        base = self.collection(username, refresh=refresh)
        expansions = self.collection(
            username, subtype="boardgameexpansion", refresh=refresh
        )
        seen = {item.coll_id for item in base}
        return base + [e for e in expansions if e.coll_id not in seen]


def client_for(cfg: Config) -> BGGClient:
    """The standard injection fallback: every stage's `client or client_for(cfg)`."""
    return BGGClient(cfg.cache_dir, cfg.rate_limit_seconds)


def cached_paths(cache_dir: Path, endpoint: str) -> list[Path]:
    """Cache files for one endpoint. Glob the cache only through this
    helper so the filename layout stays private to cache_key."""
    return sorted(cache_dir.glob(f"{endpoint}_*.xml"))
