"""Static export: pages, art downloads, badge slot, privacy. Offline —
cover downloads run against a mock CDN transport."""

from __future__ import annotations

import json
from pathlib import Path

import httpx
import pytest

from bggpipe.config import Config
from bggpipe.export import run_export

GAMES = {
    "240:24621": {
        "bgg_id": 240,
        "type": "boardgame",
        "name": "Britannia",
        "year": 1986,
        "description": "A game of <invasions> & settlement.",
        "image": "https://cf.geekdo-images.com/orig/pic1.jpg",
        "thumbnail": "https://cf.geekdo-images.com/thumb/pic1.jpg",
        "min_players": 3,
        "max_players": 5,
        "best_player_counts": ["4"],
        "playtime": 240,
        "designers": ["Lewis Pulsipher"],
        "rank": 1210,
        "weight": 3.16,
        "version": {
            "version_id": 24621,
            "name": "Avalon Hill second edition",
            "year": 1987,
            "publishers": ["Avalon Hill"],
            "languages": ["English"],
        },
        "source_photos": ["IMG_1.jpeg"],
    },
    "311654": {
        "bgg_id": 311654,
        "type": "rpgitem",
        "name": "Alice is Missing",
        "year": 2020,
        "image": "https://cf.geekdo-images.com/orig/pic2.png",
        "thumbnail": "",
        "source_photos": ["IMG_2.jpeg"],
    },
    "local:homebrew:x.jpg": {
        "type": "localgame",
        "name": "Britannia",  # deliberate name collision with 240:24621
        "image": "/local-art/abc123.jpg",
        "source_photos": ["x.jpg"],
    },
}


def _cfg(tmp_path: Path) -> Config:
    cfg = Config(data_dir=tmp_path / "data")
    cfg.data_dir.mkdir(parents=True)
    cfg.games_path.write_text(json.dumps(GAMES))
    cfg.local_art_dir.mkdir()
    (cfg.local_art_dir / "abc123.jpg").write_bytes(FAKE_JPEG)
    return cfg


FAKE_JPEG = b"\xff\xd8" + b"\x00" * 100  # magic + enough body to pass sniffing


def _cdn(requests_log: list[str]) -> httpx.Client:
    def handler(request: httpx.Request) -> httpx.Response:
        requests_log.append(str(request.url))
        return httpx.Response(200, content=FAKE_JPEG)

    return httpx.Client(transport=httpx.MockTransport(handler))


def test_export_writes_pages_art_and_no_shelf_photos(tmp_path):
    cfg = _cfg(tmp_path)
    out = tmp_path / "site"
    log: list[str] = []
    summary = run_export(
        cfg, out, title="Test Shelves", fetch=_cdn(log), sleep=lambda s: None
    )
    assert summary == {"games": 3, "downloaded": 3, "failures": 0, "removed": 0}

    index = (out / "index.html").read_text()
    assert "Test Shelves" in index and "Powered by BGG" in index
    assert "trademarks of" in index

    # name collision -> distinct stable slugs
    assert (out / "britannia" / "index.html").exists()
    assert (out / "britannia-2" / "index.html").exists()
    assert (out / "alice-is-missing" / "index.html").exists()

    detail = (out / "britannia" / "index.html").read_text()
    assert "Avalon Hill second edition" in detail
    assert "&lt;invasions&gt; &amp; settlement" in detail  # escaped, not raw
    # BGG double-encodes entities; the page shows the character, not &rsquo;
    assert "&amp;rsquo" not in detail
    assert "boardgamegeek.com/boardgame/240" in detail
    rpg = (out / "alice-is-missing" / "index.html").read_text()
    assert "rpggeek.com/rpgitem/311654" in rpg

    # covers landed locally and pages never hotlink the CDN
    # 2 full + 1 thumb + local art + the colophon's piper
    assert len(list((out / "art").glob("*"))) == 5
    all_html = index + detail + rpg
    assert "geekdo-images.com" not in all_html

    # the privacy rule: shelf photo names appear nowhere in the output
    assert "IMG_1.jpeg" not in all_html and "IMG_2.jpeg" not in all_html


def test_export_is_idempotent_and_offline_on_rerun(tmp_path):
    cfg = _cfg(tmp_path)
    out = tmp_path / "site"
    run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)

    def refuse(request: httpx.Request) -> httpx.Response:
        raise AssertionError(f"re-run fetched {request.url}")

    summary = run_export(
        cfg,
        out,
        fetch=httpx.Client(transport=httpx.MockTransport(refuse)),
        sleep=lambda s: None,
    )
    assert summary["downloaded"] == 0


def test_failed_cover_reports_and_ships_page_anyway(tmp_path):
    cfg = _cfg(tmp_path)

    def flaky(request: httpx.Request) -> httpx.Response:
        if "pic2" in str(request.url):
            return httpx.Response(503)
        return httpx.Response(200, content=FAKE_JPEG)

    summary = run_export(
        cfg,
        tmp_path / "site",
        fetch=httpx.Client(transport=httpx.MockTransport(flaky)),
        sleep=lambda s: None,
    )
    assert summary["failures"] == 1
    page = (tmp_path / "site" / "alice-is-missing" / "index.html").read_text()
    assert "Alice is Missing" in page  # the page exists, just coverless
    # nothing half-written left behind to poison the idempotent re-run
    assert not list((tmp_path / "site" / "art").glob("*.part"))


def test_official_badge_upgrades_the_footer(tmp_path):
    cfg = _cfg(tmp_path)
    (cfg.data_dir / "powered-by-bgg.png").write_bytes(b"\x89PNG" + b"\x00" * 100)
    out = tmp_path / "site"
    run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
    assert (out / "art" / "powered-by-bgg.png").exists()
    index = (out / "index.html").read_text()
    assert "art/powered-by-bgg.png" in index and "Powered by BGG" in index
    detail = (out / "britannia" / "index.html").read_text()
    assert "../art/powered-by-bgg.png" in detail


def test_export_without_games_json_exits_with_guidance(tmp_path):
    cfg = Config(data_dir=tmp_path / "data")
    import typer

    with pytest.raises(typer.Exit):
        run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None)


def test_urls_survive_adding_and_removing_games(tmp_path):
    """The manifest's whole point: a published URL never moves and is
    never stolen — not by an earlier-sorting new edition, not by the
    base-slug holder leaving."""
    cfg = _cfg(tmp_path)
    out = tmp_path / "site"
    run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
    original = (out / "britannia" / "index.html").read_text()
    assert "Avalon Hill second edition" in original

    # a new same-named edition whose key sorts FIRST must not steal slugs
    games = json.loads(cfg.games_path.read_text())
    games["100:1"] = {"bgg_id": 100, "type": "boardgame", "name": "Britannia"}
    cfg.games_path.write_text(json.dumps(games))
    run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
    assert (
        "Avalon Hill second edition" in (out / "britannia" / "index.html").read_text()
    )
    assert (out / "britannia-3" / "index.html").exists()  # the newcomer

    # removing the base-slug holder must not renumber the survivors —
    # and must actually remove the stale page
    del games["240:24621"]
    cfg.games_path.write_text(json.dumps(games))
    summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
    assert summary["removed"] == 1
    assert not (out / "britannia" / "index.html").exists()
    assert (out / "britannia-2" / "index.html").exists()  # localgame, unmoved
    index = (out / "index.html").read_text()
    assert "britannia/" not in index.replace("britannia-2/", "").replace(
        "britannia-3/", ""
    )


def test_changed_cover_url_is_refetched(tmp_path):
    """BGG replaces box art under the same extension: the manifest records
    each cover's source URL so a change re-downloads."""
    cfg = _cfg(tmp_path)
    out = tmp_path / "site"
    run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)

    games = json.loads(cfg.games_path.read_text())
    games["240:24621"]["image"] = "https://cf.geekdo-images.com/orig/pic9.jpg"
    cfg.games_path.write_text(json.dumps(games))
    log: list[str] = []
    summary = run_export(cfg, out, fetch=_cdn(log), sleep=lambda s: None)
    assert summary["downloaded"] == 1
    assert any("pic9" in u for u in log)


def test_garbage_200_is_a_failure_not_a_cached_cover(tmp_path):
    """A CDN interstitial served with 200 must not be cached as a cover:
    exists() would then skip it forever."""
    cfg = _cfg(tmp_path)

    def interstitial(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, content=b"<html>challenge page</html>" * 10)

    summary = run_export(
        cfg,
        tmp_path / "site",
        fetch=httpx.Client(transport=httpx.MockTransport(interstitial)),
        sleep=lambda s: None,
    )
    assert summary["failures"] == 3
    assert summary["downloaded"] == 0
    # only the local-art copy survives; no CDN garbage was cached
    names = {f.name for f in (tmp_path / "site" / "art").iterdir()}
    assert names == {"abc123.jpg", "bggpipe-piper.jpg"}


def test_missing_local_art_is_a_counted_failure(tmp_path):
    cfg = _cfg(tmp_path)
    (cfg.local_art_dir / "abc123.jpg").unlink()
    summary = run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None)
    assert summary["failures"] == 1  # local_art is the ONLY source: say so


def test_interrupted_download_leaves_no_cover_and_recovers(tmp_path, monkeypatch):
    """The non-vacuous atomicity test: die BETWEEN response and write,
    assert nothing half-made survives and the re-run completes."""
    import bggpipe.export as export_mod

    cfg = _cfg(tmp_path)
    out = tmp_path / "site"
    real_write = export_mod.atomic_write_bytes
    calls = {"n": 0}

    def die_on_second(path, data):
        calls["n"] += 1
        if calls["n"] == 2:
            raise KeyboardInterrupt
        real_write(path, data)

    monkeypatch.setattr(export_mod, "atomic_write_bytes", die_on_second)
    with pytest.raises(KeyboardInterrupt):
        run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
    assert len(list((out / "art").glob("*.part"))) == 0

    monkeypatch.setattr(export_mod, "atomic_write_bytes", real_write)
    summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
    assert summary["failures"] == 0
    assert (out / "index.html").exists()


def test_minimal_and_numeric_entries_render(tmp_path):
    """Entries vary wildly: a name-only localgame and enrich-shaped
    numerics (int best counts, int year) must render, not crash."""
    cfg = Config(data_dir=tmp_path / "data")
    cfg.data_dir.mkdir(parents=True)
    cfg.games_path.write_text(
        json.dumps(
            {
                "local:mystery:x.jpg": {"type": "localgame", "name": "Mystery"},
                "7": {
                    "bgg_id": 7,
                    "type": "boardgame",
                    "name": "Numeric",
                    "year": 1980,
                    "min_players": 1,
                    "max_players": 1,
                    "best_player_counts": [1],
                    "weight": 2,
                    "rating": 7,
                },
            }
        )
    )
    out = tmp_path / "site"
    run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
    numeric = (out / "numeric" / "index.html").read_text()
    assert "1 player" in numeric and "best at 1" in numeric
    assert (out / "mystery" / "index.html").exists()


def test_cli_export_wiring(tmp_path, monkeypatch):
    from typer.testing import CliRunner

    from bggpipe.cli import app

    received = {}

    def fake_run_export(cfg, out, *, title):
        received.update(out=out, title=title)
        return {"games": 0, "downloaded": 0, "failures": 0, "removed": 0}

    monkeypatch.setattr("bggpipe.export.run_export", fake_run_export)
    runner = CliRunner()
    result = runner.invoke(
        app, ["export", "--out", str(tmp_path / "s"), "--title", "T"]
    )
    assert result.exit_code == 0
    assert received["title"] == "T" and received["out"] == tmp_path / "s"
    assert runner.invoke(app, ["export"]).exit_code != 0  # --out is required

    def failing_run_export(cfg, out, *, title):
        return {"games": 1, "downloaded": 0, "failures": 2, "removed": 0}

    monkeypatch.setattr("bggpipe.export.run_export", failing_run_export)
    result = runner.invoke(app, ["export", "--out", str(tmp_path / "s")])
    assert result.exit_code == 1  # scripted publishes must see failures


def test_colophon_card_closes_the_shelf(tmp_path):
    """The last card: piper, badge, fine print — a div the search filter
    (which selects a.game) can never hide."""
    cfg = _cfg(tmp_path)
    out = tmp_path / "site"
    run_export(cfg, out, title="Test Shelves", fetch=_cdn([]), sleep=lambda s: None)
    index = (out / "index.html").read_text()
    assert 'class="game colophon"' in index
    assert "bggpipe-piper.jpg" in index and "Piper art by Juniper" in index
    assert "trademarks of BoardGameGeek" in index
    assert (out / "art" / "bggpipe-piper.jpg").exists()
    # the filter only ever hides <a class="game"> — the colophon is a div
    assert '<div class="game colophon"' in index
