"""Game cover art for chapter markers.

The art is NOT shipped: game capsules are the publisher's trademark, and
bundling ~9,000 of them into a distributed zip is redistribution. Instead a
slug is resolved to a Steam app id (from the same list that builds the game
database) and the capsule is fetched from Steam's own CDN the first time it
is needed, then cached on this machine forever. Identifying a game by its
own store art, on demand, is the narrow case that stays defensible.

Two consequences shape the design:

  * it reaches the internet, and this is otherwise a local-first app. So it
    is disclosed in settings, switchable off, and each game is fetched at
    most once — the only thing that ever leaves is a Steam app id.
  * it can always fail (offline, no store page, art removed). A missing
    icon must cost nothing: the route NEVER fetches inline — it answers from
    the cache or 404s immediately and queues the download on one background
    thread. A timeline draw asking for eight icons must not put eight
    request threads into a 15-second socket wait.
"""
import json
import queue
import threading
import time
import urllib.request
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response

from clipshelf import config
from clipshelf.config import DATA

router = APIRouter(tags=["gameart"])

ART_DIR = DATA / "gameart"
INDEX = ART_DIR / "index.json"
CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{art}"
# header first: it exists for store pages that have no capsule yet (Deadlock)
ART_FILES = ("header.jpg", "capsule_231x87.jpg")
MAX_BYTES = 2_000_000
TIMEOUT_S = 8
_lock = threading.Lock()
_misses: dict = {}          # slug -> when we last failed, so we stop hammering
RETRY_AFTER_S = 6 * 3600

_queue: "queue.Queue[str]" = queue.Queue()
_queued: set = set()
_worker_started = False


def enabled() -> bool:
    return bool(config.CFG.get("game_icons", True))


def _index() -> dict:
    try:
        return json.loads(INDEX.read_text(encoding="utf-8"))
    except Exception:
        return {}


def _remember(slug: str, name: str):
    with _lock:
        idx = _index()
        idx[slug] = name
        ART_DIR.mkdir(parents=True, exist_ok=True)
        tmp = INDEX.with_suffix(".json.tmp")
        tmp.write_text(json.dumps(idx), encoding="utf-8")
        tmp.replace(INDEX)


def cached_path(slug: str) -> Path | None:
    name = _index().get(slug)
    if not name:
        return None
    p = ART_DIR / name
    return p if p.is_file() else None


def fetch(slug: str) -> Path | None:
    """Find art for a slug and cache it. Returns None when there is none —
    which is a normal answer, not an error."""
    from clipshelf import game_detect
    if cached_path(slug):
        return cached_path(slug)
    if time.time() - _misses.get(slug, 0) < RETRY_AFTER_S:
        return None
    for appid in game_detect.steam_appids(slug)[:4]:
        for art in ART_FILES:
            try:
                req = urllib.request.Request(
                    CDN.format(appid=appid, art=art),
                    headers={"User-Agent": "Clipshelf game art"})
                with urllib.request.urlopen(req, timeout=TIMEOUT_S) as r:
                    if r.status != 200:
                        continue
                    data = r.read(MAX_BYTES + 1)
                if len(data) > MAX_BYTES or not data.startswith(b"\xff\xd8"):
                    continue            # not a jpeg, or implausibly large
                ART_DIR.mkdir(parents=True, exist_ok=True)
                name = f"{slug}.jpg"
                (ART_DIR / name).write_bytes(data)
                _remember(slug, name)
                return ART_DIR / name
            except Exception:
                continue
    _misses[slug] = time.time()
    return None


def _drain():
    while True:
        slug = _queue.get()
        try:
            fetch(slug)
        except Exception:
            pass
        finally:
            _queued.discard(slug)
            _queue.task_done()


def want(slug: str):
    """Ask for art without waiting for it. One thread does the fetching, so
    a page full of chapters costs one queue entry each, not one blocked
    request each."""
    global _worker_started
    if not enabled() or cached_path(slug) or slug in _queued:
        return
    if time.time() - _misses.get(slug, 0) < RETRY_AFTER_S:
        return
    with _lock:
        if not _worker_started:
            threading.Thread(target=_drain, daemon=True).start()
            _worker_started = True
    _queued.add(slug)
    _queue.put(slug)


@router.get("/api/games/icon/{slug}")
def game_icon(slug: str):
    """Cover art for a chapter marker. 404 is an ordinary outcome; the
    caller draws its text label and moves on."""
    slug = "".join(c for c in str(slug).lower() if c.isalnum() or c in "-_")[:60]
    if not slug:
        raise HTTPException(404, "no such game")
    p = cached_path(slug)
    if not p:
        want(slug)          # returns immediately; the icon appears next draw
    if not p:
        # NOT cached: the fetch this request just queued usually finishes
        # within a second, and a cached 404 would hide the art it produced
        # until the cache expired. The server-side _misses window is what
        # stops us hammering Steam.
        return Response(status_code=404, headers={"Cache-Control": "no-store"})
    return FileResponse(p, media_type="image/jpeg",
                        headers={"Cache-Control": "max-age=604800"})


@router.post("/api/games/art")
async def game_art_settings(request: Request):
    body = await request.json()
    if "enabled" in body:
        config.CFG["game_icons"] = bool(body["enabled"])
        config.save(config.CFG)
    return game_art_state()


@router.get("/api/games/art")
def game_art_state():
    idx = _index()
    return {"enabled": enabled(), "cached": len(idx),
            "bytes": sum((ART_DIR / n).stat().st_size
                         for n in idx.values() if (ART_DIR / n).is_file())}
