"""Export the library as self-contained static pages for any web host.

Reads games.json (the enrich output) and writes an index page, one page
per game, and a local copy of every cover image — no server, no build
step, no external requests from the published pages. Cover art is
downloaded once from BGG's CDN (public pages must not hotlink it) and
re-runs skip images already on disk, so the export is idempotent and
resumable like every other stage.

Shelf photos are deliberately NOT exported: they picture the inside of
the owner's home. Covers and hand-added local art only.
"""

from __future__ import annotations

import html
import json
import re
import shutil
import time
from pathlib import Path
from urllib.parse import urlsplit

import httpx
import typer

from bggpipe.config import Config
from bggpipe.fsio import atomic_write_bytes, atomic_write_text

# records which slugs THIS export owns, and their key assignments: slug
# stability across runs (a published URL never changes or is stolen) and
# stale-page cleanup (only ever deletes what the manifest claims) both
# hang off it
MANIFEST_NAME = ".bggpipe-export.json"

_SLUG_RE = re.compile(r"[^a-z0-9]+")

# BGG's API policy: public-facing apps must display the "Powered by BGG"
# logo linking back. The official asset comes from the owner's registered
# application page — save it as data/powered-by-bgg.png and the export
# ships the image badge; until then, a text badge keeps the link.
BADGE_NAME = "powered-by-bgg.png"


def _powered_by(rel: str, badge_present: bool) -> str:
    inner = (
        f'<img src="{rel}art/{BADGE_NAME}" alt="Powered by BGG" height="40">'
        if badge_present
        else "Powered by BGG"
    )
    return f'<a class="pbb" href="https://boardgamegeek.com" rel="noopener">{inner}</a>'


def _slug(name: str) -> str:
    return _SLUG_RE.sub("-", (name or "game").casefold()).strip("-") or "game"


def _slugs(games: dict[str, dict], prior: dict[str, str]) -> dict[str, str]:
    """Deterministic, unique page names that survive re-export. A key seen
    before KEEPS its slug (a published URL never moves, even after a
    rename), and a new key can never steal one — collisions take -2, -3...
    in sorted-key order."""
    out: dict[str, str] = {k: v for k, v in prior.items() if k in games}
    # "art" is the asset directory: a game named Art must not move in
    taken: set[str] = set(out.values()) | {"art"}
    for key in sorted(games):
        if key in out:
            continue
        base = _slug(games[key].get("name", ""))
        slug, n = base, 2
        while slug in taken:
            slug = f"{base}-{n}"
            n += 1
        taken.add(slug)
        out[key] = slug
    return out


_IMAGE_MAGIC = (b"\xff\xd8", b"\x89PNG", b"GIF8", b"RIFF", b"<svg", b"<?xml")


def _looks_like_image(body: bytes) -> bool:
    return len(body) > 64 and body.lstrip()[:5].startswith(_IMAGE_MAGIC)


def _art_name(key: str, url: str, *, thumb: bool) -> str:
    ext = Path(urlsplit(url).path).suffix.lower() or ".jpg"
    safe = _SLUG_RE.sub("-", key.casefold()).strip("-")
    return f"{safe}-thumb{ext}" if thumb else f"{safe}{ext}"


def _fetch_art(
    games: dict[str, dict],
    art_dir: Path,
    cfg: Config,
    fetch: httpx.Client,
    sleep,
    prior_urls: dict[str, dict[str, str]] | None = None,
) -> tuple[dict[str, dict[str, str]], int, list[str], dict[str, dict[str, str]]]:
    """Local filename per game for thumb/full art. Downloads only what is
    missing; a failed download is reported and the page ships without
    that image — never a broken half-written file."""
    art: dict[str, dict[str, str]] = {}
    urls: dict[str, dict[str, str]] = {}
    prior_urls = prior_urls or {}
    downloaded = 0
    failures: list[str] = []
    art_dir.mkdir(parents=True, exist_ok=True)
    for key, game in games.items():
        entry: dict[str, str] = {}
        image = game.get("image") or ""
        thumb = game.get("thumbnail") or ""
        if image.startswith("/local-art/"):
            # hand-added cover: already on disk, just carried across.
            # local_art is the ONLY source for these — a missing file is a
            # failure to report, not a silent coverless page
            src = cfg.local_art_dir / Path(image).name
            if src.exists():
                atomic_write_bytes(art_dir / src.name, src.read_bytes())
                entry["full"] = entry["thumb"] = src.name
            else:
                failures.append(
                    f"{game.get('name', key)}: hand-added cover "
                    f"{src.name} missing from {cfg.local_art_dir}"
                )
        else:
            for url, is_thumb in ((image, False), (thumb, True)):
                if not url.startswith("http"):
                    continue
                name = _art_name(key, url, thumb=is_thumb)
                target = art_dir / name
                slot = "thumb" if is_thumb else "full"
                urls.setdefault(key, {})[slot] = url
                # BGG replaces box art under the SAME extension: an existing
                # file only counts if it came from this URL (absent record =
                # grandfathered pre-manifest download, assumed current)
                recorded = prior_urls.get(key, {}).get(slot)
                if not target.exists() or (recorded and recorded != url):
                    try:
                        resp = fetch.get(url)
                        resp.raise_for_status()
                    except httpx.HTTPError as err:
                        failures.append(f"{game.get('name', key)}: {err}")
                        continue
                    if not _looks_like_image(resp.content):
                        # a 200 with an interstitial/error body must not be
                        # cached as a cover: exists() would skip it forever
                        failures.append(
                            f"{game.get('name', key)}: response is not an image"
                        )
                        continue
                    atomic_write_bytes(target, resp.content)
                    downloaded += 1
                    sleep(1.0)  # image-CDN spacing per docs/spec.md
                entry[slot] = name
        if "thumb" not in entry and "full" in entry:
            entry["thumb"] = entry["full"]
        art[key] = entry
    return art, downloaded, failures, urls


_CSS = """
:root { --board:#faf3e3; --ink:#2a2438; --ink-soft:#6b6478; --line:#d8cdb4;
  --sky:#cfe4f5; --navy:#2f3d5c; --accent:#7a4a9e;
  /* on the sky background, --ink-soft misses WCAG AA (4.32:1); this
     darker variant clears it — use it for any fine print outside a card */
  --ink-soft-on-sky:#5d5669; }
* { box-sizing:border-box; }
body { margin:0; font:16px/1.55 system-ui, sans-serif; color:var(--ink);
  background:var(--sky); }
main { max-width:72rem; margin:0 auto; padding:1.2rem; }
header.site { background:var(--navy); color:#fff; padding:.9rem 1.2rem; }
header.site a { color:#fff; text-decoration:none; font-weight:700; }
header.site .sub { color:#cfe4f5; font-size:.85rem; }
.controls { display:flex; gap:.5rem; flex-wrap:wrap; margin:1rem 0; }
.controls input[type=search] { flex:1 1 14rem; padding:.45rem .6rem;
  border:2px solid var(--line); border-radius:.5rem; font:inherit; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11.5rem,1fr));
  gap:1rem; }
a.game { background:var(--board); border:1px solid var(--line);
  border-radius:.8rem; overflow:hidden; text-decoration:none; color:inherit;
  display:flex; flex-direction:column; box-shadow:0 2px 5px rgba(42,36,56,.15); }
a.game:hover, a.game:focus-visible { outline:3px solid var(--accent); }
a.game img { width:100%; aspect-ratio:1; object-fit:cover; display:block;
  background:#e8ddc5; }
a.game .noart { width:100%; aspect-ratio:1; display:flex; align-items:center;
  justify-content:center; font-size:3rem; font-weight:700; color:var(--navy);
  background:#e8ddc5; }
a.game .info, .colophon .info { padding:.55rem .7rem .7rem; }
.colophon { background:var(--board); border:1px solid var(--line);
  border-radius:.8rem; overflow:hidden; display:flex; flex-direction:column;
  box-shadow:0 2px 5px rgba(42,36,56,.15); }
.colophon > img { width:100%; aspect-ratio:1; object-fit:cover; display:block;
  background:#cfe4f5; }
.colophon .meta { margin:.35rem 0 0; font-size:.72rem; line-height:1.45; }
.colophon a { color:var(--accent); }
.gname { font-weight:700; }
.meta { color:var(--ink-soft); font-size:.82rem; }
main > .meta { color:var(--ink-soft-on-sky); }
.chip { display:inline-block; font-size:.7rem; border:1px solid var(--accent);
  color:var(--accent); border-radius:1rem; padding:0 .5rem; }
.detail { display:flex; gap:1.4rem; flex-wrap:wrap; align-items:flex-start;
  margin-top:1rem; }
.detail .art { flex:0 0 clamp(11rem,26vw,17rem); }
.detail .art img { width:100%; border-radius:.8rem; border:1px solid var(--line); }
.facts { flex:1 1 18rem; background:var(--board); border:1px solid var(--line);
  border-radius:.8rem; padding:.9rem 1.1rem; }
.factrow { margin:.45rem 0; }
.factrow b { font-size:.72rem; text-transform:uppercase; letter-spacing:.06em;
  color:var(--ink-soft); margin-right:.4rem; }
.card { background:var(--board); border:1px solid var(--line);
  border-radius:.8rem; padding:.9rem 1.1rem; margin-top:1rem; }
.desc { white-space:pre-wrap; }
footer { margin:2rem 0 1rem; color:var(--ink-soft-on-sky); font-size:.8rem;
  text-align:center; }
footer a { color:var(--accent); }
a.pbb { font-weight:700; }
"""


def _chips(label: str, values: list | None) -> str:
    if not values:
        return ""
    spans = " ".join(f'<span class="chip">{html.escape(str(v))}</span>' for v in values)
    return f'<div class="factrow"><b>{html.escape(label)}</b> {spans}</div>'


def _fact(label: str, value) -> str:
    if value in (None, "", []):
        return ""
    return (
        f'<div class="factrow"><b>{html.escape(label)}</b> '
        f"{html.escape(str(value))}</div>"
    )


def _players(g: dict) -> str:
    # mirror of players() in templates/pages/library.html — keep in sync
    lo, hi = g.get("min_players"), g.get("max_players")
    lo, hi = lo or hi, hi or lo
    if not lo:
        return ""
    span = str(lo) if lo == hi else f"{lo}–{hi}"
    noun = "player" if lo == hi == 1 else "players"
    best = [str(b) for b in g.get("best_player_counts") or []]
    return f"{span} {noun}" + (f" (best at {', '.join(best)})" if best else "")


def _playtime(g: dict) -> str:
    lo, hi = g.get("min_playtime"), g.get("max_playtime")
    if lo and hi and lo != hi:
        return f"{lo}–{hi} min"
    minutes = g.get("playtime") or lo
    return f"{minutes} min" if minutes else ""


def _footer(title: str, rel: str, badge_present: bool) -> str:
    return f"""<footer>
  <p>{_powered_by(rel, badge_present)}</p>
  <p>{html.escape(title)} · cataloged from shelf photos by
    <a href="https://git.kestrelsnest.social/eric/bggpipe"
    rel="noopener">bggpipe</a></p>
  <p>Game data and cover images from <a href="https://boardgamegeek.com"
    rel="noopener">BoardGameGeek</a>. BoardGameGeek and BGG are trademarks of
    BoardGameGeek, LLC; this page is an independent project, not affiliated
    with or endorsed by BoardGameGeek.</p>
</footer>"""


_PIPER_NAME = "bggpipe-piper.jpg"
_PIPER_SRC = Path(__file__).parent / "static" / "logo-full.jpg"


def _colophon_card(title: str, badge_present: bool) -> str:
    """The last card on the shelf: mascot, badge, and the fine print. A
    <div>, not an <a> — the search filter selects a.game, so the colophon
    stays visible whatever is typed."""
    badge = (
        f'<img src="art/{BADGE_NAME}" alt="Powered by BGG" height="34">'
        if badge_present
        else "Powered by BGG"
    )
    return f"""<div class="game colophon">
  <img src="art/{_PIPER_NAME}"
       alt="the bggpipe piper — a bagpiper whose bag is a board game box">
  <div class="info">
    <div class="gname">{html.escape(title)}</div>
    <p class="meta">Cataloged from shelf photos by
      <a href="https://git.kestrelsnest.social/eric/bggpipe" rel="noopener">bggpipe</a>.
      Game data and covers from
      <a href="https://boardgamegeek.com" rel="noopener">{badge}</a></p>
    <p class="meta">BoardGameGeek and BGG are trademarks of BoardGameGeek,
      LLC; this site is an independent project, not affiliated with or
      endorsed by BoardGameGeek. Piper art by Juniper.</p>
  </div>
</div>"""


_FAVICON = (
    "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' "
    "viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎲</text></svg>"
)


def _page(
    title: str,
    body: str,
    *,
    rel: str,
    site_title: str,
    badge_present: bool,
    description: str = "",
    og_image: str = "",
) -> str:
    description = description or f"{site_title}: a board game library"
    og = (
        f'<meta property="og:image" content="{html.escape(og_image)}">'
        if og_image
        else ""
    )
    return f"""<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<meta name="description" content="{html.escape(description)}">
<meta property="og:title" content="{html.escape(title)}">
{og}
<link rel="icon" href="{_FAVICON}">
<link rel="stylesheet" href="{rel}style.css">
<header class="site"><a href="{rel}./">{html.escape(site_title)}</a>
  <span class="sub">a board game library</span></header>
<main>
{body}
{_footer(site_title, rel, badge_present)}
</main>
</html>"""


def _card(g: dict, slug: str, art: dict[str, str]) -> str:
    img = (
        f'<img src="art/{html.escape(art["thumb"])}" alt="" loading="lazy">'
        if art.get("thumb")
        else '<div class="noart" aria-hidden="true">'
        + html.escape((g.get("name") or "?")[0].upper())
        + "</div>"
    )
    year = (
        f' <span class="meta">({html.escape(str(g["year"]))})</span>'
        if g.get("year")
        else ""
    )
    bits = " · ".join(
        b
        for b in (
            _players(g),
            _playtime(g),
            f"rank {g['rank']}" if g.get("rank") else "",
        )
        if b
    )
    kind = ""
    if g.get("type") == "rpgitem":
        kind = ' <span class="chip">RPG</span>'
    elif g.get("type") == "localgame":
        kind = ' <span class="chip">not on BGG</span>'
    name = html.escape(g.get("name") or "?")
    return f"""<a class="game" href="{slug}/" data-name="{name}">
  {img}
  <div class="info"><div class="gname">{name}{year}{kind}</div>
  <div class="meta">{html.escape(bits)}</div></div>
</a>"""


def _detail_body(g: dict, art: dict[str, str]) -> str:
    art_html = (
        f'<img src="../art/{html.escape(art["full"])}" '
        f'alt="box art for {html.escape(g.get("name") or "")}">'
        if art.get("full")
        else ""
    )
    bgg_link = ""
    if g.get("bgg_id") and g.get("type") != "localgame":
        bgg_id = html.escape(str(g["bgg_id"]))
        site = (
            f"https://rpggeek.com/rpgitem/{bgg_id}"
            if g.get("type") == "rpgitem"
            else f"https://boardgamegeek.com/boardgame/{bgg_id}"
        )
        label = "RPGGeek" if g.get("type") == "rpgitem" else "BGG"
        bgg_link = f'<a href="{site}" rel="noopener">view on {label} ↗</a>'
    facts = "".join(
        (
            _fact("players", _players(g)),
            _fact("playing time", _playtime(g)),
            _fact("ages", f"{g['min_age']}+" if g.get("min_age") else ""),
            _fact("weight", f"{g['weight']:.2f} / 5" if g.get("weight") else ""),
            _fact("BGG rank", g.get("rank")),
            _fact("BGG rating", f"{g['rating']:.2f}" if g.get("rating") else ""),
            _chips("designers", g.get("designers")),
            _chips("artists", (g.get("artists") or [])[:8]),
            _chips("publishers", (g.get("publishers") or [])[:6]),
            _chips("categories", g.get("categories")),
            _chips("mechanics", g.get("mechanics")),
        )
    )
    version = ""
    if g.get("version"):
        v = g["version"]
        vyear = f" · {html.escape(str(v['year']))}" if v.get("year") else ""
        version = (
            '<div class="card"><div class="factrow">'
            f"<b>my copy</b> {html.escape(v.get('name') or '—')}{vyear}</div>"
            + _chips("publishers", v.get("publishers"))
            + _chips("languages", v.get("languages"))
            + "</div>"
        )
    desc = (
        # BGG descriptions arrive with entities still encoded (&rsquo; etc);
        # unescape their layer, then escape once for this page
        f'<div class="card desc">{html.escape(html.unescape(g["description"]))}</div>'
        if g.get("description")
        else ""
    )
    year = (
        f' <span class="meta">({html.escape(str(g["year"]))})</span>'
        if g.get("year")
        else ""
    )
    return f"""<p><a href="../">← the whole library</a> · {bgg_link}</p>
<h1>{html.escape(g.get("name") or "?")}{year}</h1>
<div class="detail">
  <div class="art">{art_html}</div>
  <div class="facts">{facts}</div>
</div>
{version}
{desc}"""


_INDEX_JS = """
<script>
"use strict";
const cards = Array.from(document.querySelectorAll("a.game"));
const rows = cards.map(c => ({el: c, text: (c.dataset.name + " " +
  c.textContent).toLowerCase()}));
const q = document.getElementById("q");
const nomatch = document.getElementById("nomatch");
q.addEventListener("input", () => {
  const needle = q.value.trim().toLowerCase();
  let shown = 0;
  rows.forEach(r => {
    const hit = !needle || r.text.includes(needle);
    r.el.style.display = hit ? "" : "none";
    if (hit) shown += 1;
  });
  nomatch.hidden = shown > 0;
});
</script>
"""


def run_export(
    cfg: Config,
    out_dir: Path,
    *,
    title: str = "My Game Library",
    fetch: httpx.Client | None = None,
    sleep=time.sleep,
) -> dict:
    if not cfg.games_path.exists():
        typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
        raise typer.Exit(code=1)
    games: dict[str, dict] = json.loads(cfg.games_path.read_text())
    out_dir.mkdir(parents=True, exist_ok=True)
    manifest_path = out_dir / MANIFEST_NAME
    prior: dict[str, str] = {}
    prior_urls: dict[str, dict[str, str]] = {}
    if manifest_path.exists():
        try:
            manifest = json.loads(manifest_path.read_text())
            prior = manifest.get("slugs", {})
            prior_urls = manifest.get("art_urls", {})
        except json.JSONDecodeError:
            typer.echo(
                f"  warning: {MANIFEST_NAME} is corrupt — slug assignments "
                "reset; published URLs may move this run"
            )
    slugs = _slugs(games, prior)
    own_client = fetch is None
    fetch = fetch or httpx.Client(
        timeout=30, follow_redirects=True, headers={"User-Agent": "bggpipe-export"}
    )
    try:
        art, downloaded, failures, art_urls = _fetch_art(
            games, out_dir / "art", cfg, fetch, sleep, prior_urls
        )
    finally:
        if own_client:
            fetch.close()

    atomic_write_text(out_dir / "style.css", _CSS)
    # the official badge, when the owner has saved it from their BGG
    # application page; the footer falls back to a text badge without it
    badge_src = cfg.data_dir / BADGE_NAME
    badge_bytes = badge_src.read_bytes() if badge_src.exists() else b""
    badge_present = _looks_like_image(badge_bytes)
    if badge_present:
        atomic_write_bytes(out_dir / "art" / BADGE_NAME, badge_bytes)
    elif badge_bytes:
        typer.echo(
            f"  warning: {badge_src} doesn't look like an image — "
            "footer falls back to the text badge"
        )
    else:
        typer.echo(
            "  note: no data/powered-by-bgg.png — footer uses a text "
            '"Powered by BGG" link; save the official badge from '
            "boardgamegeek.com/applications to upgrade it"
        )
    # game pages FIRST, index last: a killed run must not leave an index
    # linking to pages that were never written
    for key, game in games.items():
        page_dir = out_dir / slugs[key]
        page_dir.mkdir(exist_ok=True)
        cover = art.get(key, {}).get("full", "")
        atomic_write_text(
            page_dir / "index.html",
            _page(
                f"{game.get('name') or 'game'} · {title}",
                _detail_body(game, art.get(key, {})),
                rel="../",
                site_title=title,
                badge_present=badge_present,
                description=f"{game.get('name') or 'A game'} in {title}",
                og_image=f"../art/{cover}" if cover else "",
            ),
        )
    ordered = sorted(games, key=lambda k: (games[k].get("name") or "").casefold())
    cards = "\n".join(_card(games[k], slugs[k], art.get(k, {})) for k in ordered)
    index_body = (
        f"<h1>{html.escape(title)}</h1>"
        f'<p class="meta">{len(games)} games, cataloged from shelf photos.</p>'
        '<div class="controls"><input type="search" id="q" '
        'placeholder="search the shelves…" aria-label="search"></div>'
        f'<div class="grid">{cards}\n{_colophon_card(title, badge_present)}</div>'
        '<p class="meta" id="nomatch" hidden>No games match that search.</p>'
        f"{_INDEX_JS}"
    )
    atomic_write_text(
        out_dir / "index.html",
        _page(title, index_body, rel="", site_title=title, badge_present=badge_present),
    )
    # stale cleanup: pages the PRIOR manifest owned whose games are gone,
    # and art files no current entry references. Only manifest-claimed
    # slugs are ever deleted — user files in out_dir are not ours to touch.
    removed = 0
    current_slugs = set(slugs.values())
    for old_slug in set(prior.values()) - current_slugs:
        stale_dir = out_dir / old_slug
        if stale_dir.is_dir():
            shutil.rmtree(stale_dir)
            removed += 1
    if _PIPER_SRC.exists():
        atomic_write_bytes(out_dir / "art" / _PIPER_NAME, _PIPER_SRC.read_bytes())
    wanted_art = {n for entry in art.values() for n in entry.values()}
    wanted_art.update((BADGE_NAME, _PIPER_NAME))
    for art_file in (out_dir / "art").iterdir():
        if art_file.name not in wanted_art:
            art_file.unlink()
    atomic_write_text(
        manifest_path,
        json.dumps(
            {"slugs": slugs, "art_urls": art_urls},
            indent=2,
            ensure_ascii=False,
            sort_keys=True,
        )
        + "\n",
    )
    for line in failures:
        typer.echo(f"  warning: cover not fetched — {line}")
    stale_note = f", {removed} stale page(s) removed" if removed else ""
    typer.echo(
        f"exported {len(games)} game page(s) to {out_dir} "
        f"({downloaded} cover(s) downloaded, "
        f"{len(failures)} failed — re-run to retry{stale_note})"
    )
    return {
        "games": len(games),
        "downloaded": downloaded,
        "failures": len(failures),
        "removed": removed,
    }
