"""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 re
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 net, 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")

# The square icon is not at a fixed path like the banners — it is named by a
# hash only the store API knows, so it costs one extra lookup. Worth it: a
# chapter row wants a small square mark, and cropping a 460x215 banner to a
# square throws away most of the picture and usually lands on empty sky.
APPDETAILS = "https://store.steampowered.com/api/appdetails?appids={appid}&filters=basic"
ICON_CDN = ("https://cdn.cloudflare.steamstatic.com/steamcommunity/public/"
            "images/apps/{appid}/{h}.jpg")
ICON_DIR_NAME = "icons"
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 resolve_slug(tag: str) -> str:
    """A chapter's tag is not always a Steam slug.

    Chapters are tagged with whatever the adapter calls the game — 'cs2',
    'rocketleague' — while the bundled games database is keyed by its own
    slugs, 'counter-strike-2' and 'rocket-league'. So art lookups for those
    quietly found nothing, and only Deadlock ever had any, because its tag
    happens to match its slug.

    The bridge is the executable. Adapters are named after the process they
    watch, and the database is an index OF processes, so 'cs2' finds
    'cs2.exe' finds 'counter-strike-2'. That works with no pack installed,
    which matters: the tag outlives the pack that created it, and old
    chapters should not lose their art when one is removed."""
    from clipshelf import game_detect
    tag = str(tag or "").lower().strip()
    if not tag:
        return tag
    if game_detect.steam_appids(tag):
        return tag
    db = game_detect._games_db() or {}
    for exe in (f"{tag}.exe", tag.replace("-", "") + ".exe", tag):
        hit = db.get(exe)
        if hit:
            return hit[0]
    # last resort: an adapter that is installed can tell us the real name
    try:
        from clipshelf.plugin_host import registry
        mod = (registry.game_adapters or {}).get(tag)
        label = str(getattr(mod, "LABEL", "") or "").strip().lower() if mod else ""
        if label:
            game_detect._games_db()
            for slug, name in (game_detect._LABELS or {}).items():
                if str(name).strip().lower() == label:
                    return slug
    except Exception:
        pass
    return tag


def _icon_path(slug: str) -> Path:
    return ART_DIR / ICON_DIR_NAME / f"{slug}.jpg"


def fetch_icon(slug: str) -> Path | None:
    """The square app icon, cached. None is a normal answer.

    Falls back to a centre crop of the banner we may already hold, so a game
    whose store page has no icon still gets a square rather than nothing —
    and so this never costs a second network round trip to find that out."""
    from clipshelf import game_detect
    dest = _icon_path(slug)
    if dest.is_file():
        return dest
    if time.time() - _misses.get("icon:" + slug, 0) < RETRY_AFTER_S:
        return None

    for appid in game_detect.steam_appids(slug)[:2]:
        try:
            req = urllib.request.Request(
                APPDETAILS.format(appid=appid),
                headers={"User-Agent": "Clipshelf game art"})
            with urllib.request.urlopen(req, timeout=TIMEOUT_S, context=net.context()) as r:
                meta = json.loads(r.read(400_000).decode("utf-8", "replace"))
            h = (((meta.get(str(appid)) or {}).get("data") or {}).get("icon") or "")
            if not h or not re.fullmatch(r"[0-9a-f]{8,64}", str(h)):
                continue
            req = urllib.request.Request(
                ICON_CDN.format(appid=appid, h=h),
                headers={"User-Agent": "Clipshelf game art"})
            with urllib.request.urlopen(req, timeout=TIMEOUT_S, context=net.context()) as r:
                data = r.read(MAX_BYTES + 1)
            if len(data) > MAX_BYTES or not data.startswith(b"\xff\xd8"):
                continue
            dest.parent.mkdir(parents=True, exist_ok=True)
            dest.write_bytes(data)
            return dest
        except Exception:
            continue

    # no icon published — square off the banner instead of giving up
    banner = cached_path(slug)
    if banner:
        try:
            from PIL import Image
            with Image.open(banner) as im:
                w, h = im.size
                side = min(w, h)
                # the middle of a Steam header is where the logo sits
                box = ((w - side) // 2, (h - side) // 2,
                       (w + side) // 2, (h + side) // 2)
                dest.parent.mkdir(parents=True, exist_ok=True)
                im.convert("RGB").crop(box).resize((64, 64), Image.LANCZOS).save(dest, "JPEG", quality=88)
            return dest
        except Exception:
            pass
    _misses["icon:" + slug] = time.time()
    return None


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, context=net.context()) 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)
            fetch_icon(slug)      # the square as well, off the request path
        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 slug in _queued:
        return
    if cached_path(slug) and _icon_path(slug).is_file():
        return                    # both halves already on disk
    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")
    slug = resolve_slug(slug)
    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.get("/api/games/square/{slug}")
def game_square(slug: str):
    """The small square mark a chapter row wears.

    404 is ordinary and cheap to handle: the caller has already drawn a
    monogram, so a missing icon costs nothing and never leaves a hole."""
    slug = "".join(c for c in str(slug).lower() if c.isalnum() or c in "-_")[:60]
    if not slug or not enabled():
        raise HTTPException(404, "no square for that")
    slug = resolve_slug(slug)            # 'cs2' -> 'counter-strike-2'
    p = _icon_path(slug)
    if not p.is_file():
        # queue it and answer now. Fetching here would block the request on
        # somebody else's CDN, and a page of chapters would block once per
        # chapter; the caller already draws a monogram and redraws shortly.
        want(slug)
    if not p.is_file():
        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/known")
def games_known(q: str = "", limit: int = 20):
    """Search the bundled games database, for the clip modal's game field.

    Server-side because the database has about nine thousand entries -- fine
    on disk, silly to hand to a browser on every keystroke.

    Ranked so the obvious answer is first: an exact slug, then names that
    START with what was typed, then names that merely contain it. Sorting
    those three groups alphabetically without ranking them puts "Counter
    Terrorist Simulator" above "Counter-Strike 2" for the query "counter".
    """
    from clipshelf import game_detect
    game_detect._games_db()
    ql = str(q or "").strip().lower()
    limit = max(1, min(100, limit))

    # Nothing typed yet is not "show me all nine thousand alphabetically",
    # which surfaces whichever titles happen to sort first. The caller shows
    # the games it already has clips for instead, which is nearly always the
    # answer anyway.
    if not ql:
        return {"status": "success", "games": [], "total": 0}

    rows = []
    for slug, name in game_detect._LABELS.items():
        low = name.lower()
        if slug == ql or low == ql:
            rank = 0
        elif low.startswith(ql) and not low[len(ql):len(ql) + 1].isalnum():
            # a match that ends on a word boundary: "counter" should find
            # Counter-Strike before CounterSide, and length alone does not
            rank = 1
        elif low.startswith(ql) or slug.startswith(ql):
            rank = 2
        elif ql in low or ql in slug:
            rank = 3
        else:
            continue
        rows.append((rank, len(name), name, slug))
    rows.sort()
    return {"status": "success",
            "games": [{"slug": s, "name": n} for _, _, n, s in rows[:limit]],
            "total": len(rows)}


@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())}
