"""Web review UI tests via FastAPI's TestClient — no server, no network,
no live BGG (the injected client 401s on any cache miss)."""

from __future__ import annotations

import json

import httpx
from fastapi.testclient import TestClient

from bggpipe.bgg_client import BGGClient, cache_key
from bggpipe.config import Config
from bggpipe.resolve import read_matches, write_matches
from bggpipe.webreview import create_app, load_thumbnails

CITADELS_CANDIDATES = json.dumps(
    [
        {
            "bgg_id": 478,
            "name": "Citadels",
            "year": 2000,
            "type": "boardgame",
            "owned": 85000,
            "rank": 250,
        },
        {
            "bgg_id": 205398,
            "name": "Citadels",
            "year": 2016,
            "type": "boardgame",
            "owned": 24000,
            "rank": 400,
        },
    ]
)
VERSION_CANDIDATES = json.dumps(
    [
        {
            "version_id": 111,
            "name": "First edition",
            "year": 1975,
            "publishers": ["TSR"],
            "languages": ["English"],
            "score": 3,
        },
        {
            "version_id": 222,
            "name": "Second edition",
            "year": 1980,
            "publishers": ["TSR"],
            "languages": ["English"],
            "score": 3,
        },
    ]
)


def _row(**overrides) -> dict:
    row = {
        "title_raw": "",
        "bgg_id": "",
        "bgg_name": "",
        "year": "",
        "type": "",
        "match_status": "auto",
        "version_id": "",
        "version_name": "",
        "version_status": "version_unknown",
        "candidates_json": "[]",
        "version_candidates_json": "[]",
        "source_photos": "shelf.jpg",
    }
    row.update(overrides)
    return row


def make_cfg(tmp_path) -> Config:
    cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
    cfg.photos_dir.mkdir(parents=True)
    (cfg.photos_dir / "shelf.jpg").write_bytes(b"\xff\xd8\xff\xdbfakejpeg")
    write_matches(
        cfg.matches_path,
        [
            _row(
                title_raw="Citadels",
                match_status="ambiguous",
                candidates_json=CITADELS_CANDIDATES,
            ),
            _row(title_raw="Mystery", match_status="unmatched"),
            _row(
                title_raw="Dungeon!",
                match_status="auto",
                bgg_id="1339",
                bgg_name="Dungeon!",
                version_status="version_ambiguous",
                version_candidates_json=VERSION_CANDIDATES,
            ),
        ],
    )
    cfg.titles_path.write_text(
        json.dumps(
            [
                {
                    "title_raw": "Citadels",
                    "publisher_hint": "Fantasy Flight",
                    "edition_hint": "",
                    "source_photos": ["shelf.jpg"],
                },
                {
                    # extracted but never resolved (no BGG token yet)
                    "title_raw": "Fresh Off The Shelf",
                    "confidence": "high",
                    "source_photos": ["shelf.jpg"],
                },
            ]
        )
    )
    cfg.unidentified_path.write_text(
        json.dumps(
            {
                "shelf.jpg": [
                    {
                        "location": "top shelf, far left",
                        "partial_text": "EMP",
                        "art_notes": "black box, gold letters",
                    }
                ]
            }
        )
    )
    return cfg


def unauthorized_client(tmp_path) -> BGGClient:
    return BGGClient(
        cache_dir=tmp_path / "no_cache",
        transport=httpx.MockTransport(
            lambda req: httpx.Response(401, text="Unauthorized")
        ),
    )


def make_client(tmp_path) -> tuple[TestClient, Config]:
    cfg = make_cfg(tmp_path)
    app = create_app(cfg, client=unauthorized_client(tmp_path))
    return TestClient(app), cfg


def test_index_serves_page(tmp_path):
    web, _ = make_client(tmp_path)
    response = web.get("/")
    assert response.status_code == 200
    assert "bggpipe" in response.text


def test_state_lists_pending_versions_and_tickets(tmp_path):
    web, _ = make_client(tmp_path)
    state = web.get("/api/state").json()
    assert [r["title_raw"] for r in state["pending"]] == ["Citadels", "Mystery"]
    citadels = state["pending"][0]
    assert citadels["cues"]["publisher"] == "Fantasy Flight"
    assert citadels["photos"] == ["shelf.jpg"]
    assert citadels["candidates"][0]["thumbnail"] is None  # stub cache: placeholder
    assert [v["title_raw"] for v in state["versions"]] == ["Dungeon!"]
    assert state["unidentified"][0]["location"] == "top shelf, far left"
    assert state["summary"]["total"] == 3


def test_pick_candidate_persists(tmp_path):
    web, cfg = make_client(tmp_path)
    state = web.post(
        "/api/decision",
        json={
            "title_raw": "Citadels",
            "source_photos": "shelf.jpg",
            "action": "pick",
            "bgg_id": 205398,
        },
    ).json()
    assert [r["title_raw"] for r in state["pending"]] == ["Mystery"]
    saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
    assert saved["Citadels"]["match_status"] == "approved"
    assert saved["Citadels"]["bgg_id"] == "205398"


def test_manual_id_degrades_without_token(tmp_path):
    web, cfg = make_client(tmp_path)
    response = web.post(
        "/api/decision",
        json={
            "title_raw": "Mystery",
            "source_photos": "shelf.jpg",
            "action": "manual",
            "bgg_id": 99999,
        },
    )
    assert response.status_code == 200
    saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
    assert saved["Mystery"]["match_status"] == "approved"
    assert saved["Mystery"]["bgg_id"] == "99999"
    assert saved["Mystery"]["bgg_name"] == ""  # lookup blocked, id recorded


def test_reject_and_bad_pick(tmp_path):
    web, cfg = make_client(tmp_path)
    web.post(
        "/api/decision",
        json={"title_raw": "Mystery", "source_photos": "shelf.jpg", "action": "reject"},
    )
    saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
    assert saved["Mystery"]["match_status"] == "rejected"
    bad = web.post(
        "/api/decision",
        json={
            "title_raw": "Citadels",
            "source_photos": "shelf.jpg",
            "action": "pick",
            "bgg_id": 42,
        },
    )
    assert bad.status_code == 400


def test_version_pick_and_unknown(tmp_path):
    web, cfg = make_client(tmp_path)
    state = web.post(
        "/api/version",
        json={
            "title_raw": "Dungeon!",
            "source_photos": "shelf.jpg",
            "action": "pick",
            "version_id": 222,
        },
    ).json()
    assert state["versions"] == []
    saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
    assert saved["Dungeon!"]["version_status"] == "version_approved"
    assert saved["Dungeon!"]["version_id"] == "222"
    assert state["summary"]["version_updates"] == 1


def test_dismiss_persists_across_restarts(tmp_path):
    web, cfg = make_client(tmp_path)
    state = web.post(
        "/api/dismiss",
        json={
            "photo": "shelf.jpg",
            "location": "top shelf, far left",
            "partial_text": "EMP",
            "art_notes": "black box, gold letters",
        },
    ).json()
    assert state["unidentified"] == []
    # a brand-new app instance (fresh server) still honors the dismissal
    web2 = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
    assert web2.get("/api/state").json()["unidentified"] == []


def test_photo_serving_is_locked_down(tmp_path):
    web, _ = make_client(tmp_path)
    assert web.get("/photos/shelf.jpg").status_code == 200
    assert web.get("/photos/nope.jpg").status_code == 404
    assert web.get("/photos/..%2Fdata%2Fmatches.csv").status_code == 404


def test_thumbnails_come_from_cached_thing_xml(tmp_path):
    cache = tmp_path / "cache"
    cache.mkdir()
    key = cache_key("thing", {"id": "478", "stats": "1"})
    (cache / key).write_text(
        '<items><item type="boardgame" id="478">'
        "<thumbnail>https://cf.example/citadels.jpg</thumbnail>"
        '<name type="primary" value="Citadels"/></item></items>'
    )
    thumbs = load_thumbnails(cache)
    assert thumbs == {478: "https://cf.example/citadels.jpg"}


def test_catalog_and_unresolved_backlog_are_visible(tmp_path):
    """Extracted-but-unresolved titles must not vanish from the UI, and the
    done state must not claim diff-readiness while they exist."""
    web, _ = make_client(tmp_path)
    state = web.get("/api/state").json()
    assert state["summary"]["extracted"] == 2
    assert state["summary"]["unresolved"] == 1
    by_title = {c["title_raw"]: c for c in state["catalog"]}
    assert by_title["Fresh Off The Shelf"]["status"] == "awaiting_resolve"
    assert by_title["Citadels"]["status"] == "ambiguous"


def test_merge_veto_roundtrip(tmp_path):
    cfg = make_cfg(tmp_path)
    rows = read_matches(cfg.matches_path)
    rows.append(
        _row(
            title_raw="Jokin Ha...",
            match_status="merged",
            bgg_id="193621",
            bgg_name="Joking Hazard",
            source_photos="shelf.jpg",
        )
    )
    rows[-1]["merged_into"] = "Joking Hazard"
    write_matches(cfg.matches_path, rows)
    web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))

    state = web.get("/api/state").json()
    (merge,) = state["merges"]
    assert merge["title_raw"] == "Jokin Ha..."
    assert merge["merged_into"] == "Joking Hazard"

    state = web.post(
        "/api/veto-merge",
        json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
    ).json()
    assert state["merges"] == []
    saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
    assert saved["Jokin Ha..."]["match_status"] == "approved"
    assert saved["Jokin Ha..."]["merged_into"] == ""

    # veto on a non-merged row is refused
    bad = web.post(
        "/api/veto-merge",
        json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
    )
    assert bad.status_code == 400


def test_static_assets_served_with_allowlist(tmp_path):
    web, _ = make_client(tmp_path)
    logo = web.get("/static/logo.jpg")
    assert logo.status_code == 200
    assert logo.headers["content-type"] == "image/jpeg"
    assert web.get("/static/favicon.png").status_code == 200
    assert web.get("/static/nope.js").status_code == 404
    assert web.get("/static/..%2Ftemplates%2Freview.html").status_code == 404


# -- live data reload ---------------------------------------------------


def test_state_picks_up_external_matches_rewrite(tmp_path):
    web, cfg = make_client(tmp_path)
    assert web.get("/api/state").json()["summary"]["total"] == 3

    rows = read_matches(cfg.matches_path)
    rows.append(_row(title_raw="Newcomer", match_status="unmatched"))
    write_matches(cfg.matches_path, rows)

    fresh = web.get("/api/state").json()
    assert fresh["summary"]["total"] == 4
    assert any(p["title_raw"] == "Newcomer" for p in fresh["pending"])


def test_decision_lands_on_externally_added_row(tmp_path):
    # A row that did not exist at server start is still reviewable.
    web, cfg = make_client(tmp_path)
    rows = read_matches(cfg.matches_path)
    rows.append(_row(title_raw="Newcomer", match_status="unmatched"))
    write_matches(cfg.matches_path, rows)

    res = web.post(
        "/api/decision",
        json={
            "title_raw": "Newcomer",
            "source_photos": "shelf.jpg",
            "action": "reject",
        },
    )
    assert res.status_code == 200
    saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
    assert saved["Newcomer"]["match_status"] == "rejected"


def test_own_saves_do_not_count_as_external_changes(tmp_path):
    from bggpipe.review import ReviewSession

    cfg = make_cfg(tmp_path)
    session = ReviewSession(cfg, client=unauthorized_client(tmp_path))
    row = next(r for r in session.rows if r["title_raw"] == "Mystery")
    session.decide_reject(row)
    assert session.reload_if_changed() is False  # my own write
    write_matches(cfg.matches_path, session.rows + [_row(title_raw="X")])
    assert session.reload_if_changed() is True  # someone else's
    assert any(r["title_raw"] == "X" for r in session.rows)


def test_session_warnings_surface_in_state(tmp_path):
    web, cfg = make_client(tmp_path)
    # a manual id triggers a lookup against the 401-ing client: the session
    # degrades and the warning must reach the payload (the session console
    # is a StringIO here — this is the only way the user ever sees it)
    res = web.post(
        "/api/decision",
        json={
            "title_raw": "Mystery",
            "source_photos": "shelf.jpg",
            "action": "manual",
            "bgg_id": 42,
        },
    )
    assert res.status_code == 200
    warnings = res.json()["warnings"]
    assert any("couldn't look up id 42" in w for w in warnings)
    assert warnings == web.get("/api/state").json()["warnings"]


def test_duplicate_rows_are_individually_decidable_via_row_ix(tmp_path):
    # two editions of one game in one photo: byte-identical rows. The
    # ordinal must land each decision on its own row.
    from bggpipe.resolve import read_matches as read_m
    from bggpipe.resolve import write_matches as write_m

    cfg = make_cfg(tmp_path)
    dup = _row(title_raw="Twins", match_status="unmatched")
    write_m(cfg.matches_path, [dict(dup), dict(dup)])
    web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))

    pending = web.get("/api/state").json()["pending"]
    assert [p["title_raw"] for p in pending] == ["Twins", "Twins"]
    assert pending[0]["row_ix"] != pending[1]["row_ix"]  # identity, not ==

    second = pending[1]
    web.post(
        "/api/decision",
        json={
            "title_raw": second["title_raw"],
            "source_photos": second["source_photos"],
            "row_ix": second["row_ix"],
            "action": "reject",
        },
    )
    rows = read_m(cfg.matches_path)
    assert [r["match_status"] for r in rows] == ["unmatched", "rejected"]
