"""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"]


# -- catalog curation: rowless splits and title edits ---------------------


def _rowless_wizwar(cfg) -> None:
    """Add a multi-photo extracted title with NO matches row (still awaiting
    resolve — the Wiz-War shape)."""
    titles = json.loads(cfg.titles_path.read_text())
    titles.append(
        {
            "title_raw": "Wiz-War",
            "confidence": "high",
            "publisher_hint": "Fantasy Flight Games",
            "edition_hint": "",
            "source_photos": ["shelf.jpg", "shelf2.jpg"],
        }
    )
    cfg.titles_path.write_text(json.dumps(titles))


def test_rowless_multiphoto_title_is_splittable(tmp_path):
    cfg = make_cfg(tmp_path)
    _rowless_wizwar(cfg)
    web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
    line = next(
        c
        for c in web.get("/api/state").json()["catalog"]
        if c["title_raw"] == "Wiz-War"
    )
    assert line["status"] == "awaiting_resolve"
    assert line["can_split"] is True

    res = web.post(
        "/api/split",
        json={
            "title_raw": "Wiz-War",
            "source_photos": "shelf.jpg;shelf2.jpg",
            "row_ix": None,
        },
    )
    assert res.status_code == 200
    copies = [c for c in res.json()["catalog"] if c["title_raw"] == "Wiz-War"]
    assert [c["photos"] for c in copies] == [["shelf.jpg"], ["shelf2.jpg"]]
    assert all(c["can_split"] is False for c in copies)
    # the decision persisted: the store holds it (photo-scoped) and
    # titles.json is split
    (record,) = json.loads(cfg.title_splits_path.read_text())
    assert record == {"title": "Wiz-War", "photos": ["shelf.jpg", "shelf2.jpg"]}
    per_photo = [
        e["source_photos"]
        for e in json.loads(cfg.titles_path.read_text())
        if e["title_raw"] == "Wiz-War"
    ]
    assert per_photo == [["shelf.jpg"], ["shelf2.jpg"]]


def test_split_still_requires_multiple_photos(tmp_path):
    web, _ = make_client(tmp_path)
    res = web.post(
        "/api/split",
        json={
            "title_raw": "Fresh Off The Shelf",
            "source_photos": "shelf.jpg",
            "row_ix": None,
        },
    )
    assert res.status_code == 400


def test_edit_title_corrects_read_and_requeues_row(tmp_path):
    web, cfg = make_client(tmp_path)
    # Citadels has an ambiguous matches row; correcting its read must drop
    # the stale row (it was searched with the old data) and store the fix
    res = web.post(
        "/api/edit-title",
        json={
            "title_raw": "Citadels",
            "source_photos": "shelf.jpg",
            "title_new": "Citadels: Dark City",
            "edition": "2nd Edition",
            "publisher": "",
            "year": "2004",
        },
    )
    assert res.status_code == 200
    state = res.json()
    titles = [c["title_raw"] for c in state["catalog"]]
    assert "Citadels: Dark City" in titles and "Citadels" not in titles
    corrected = next(
        e
        for e in json.loads(cfg.titles_path.read_text())
        if e["title_raw"] == "Citadels: Dark City"
    )
    assert corrected["year_hint"] == 2004
    assert corrected["edition_hint"] == "2nd Edition"
    assert corrected["publisher_hint"] == ""  # empty string cleared the cue
    assert not corrected.get("language_hint")  # untouched (was unset)
    assert not any(r["title_raw"] == "Citadels" for r in read_matches(cfg.matches_path))
    (record,) = json.loads(cfg.title_edits_path.read_text())
    assert record["match"] == "Citadels"
    assert record["publisher_hint"] == ""


def test_edit_title_rejects_empty_and_noop(tmp_path):
    web, _ = make_client(tmp_path)
    assert (
        web.post(
            "/api/edit-title",
            json={
                "title_raw": "Citadels",
                "source_photos": "shelf.jpg",
                "title_new": "  ",
            },
        ).status_code
        == 400
    )
    assert (
        web.post(
            "/api/edit-title",
            json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
        ).status_code
        == 400
    )
    assert (
        web.post(
            "/api/edit-title",
            json={
                "title_raw": "No Such Game",
                "source_photos": "shelf.jpg",
                "title_new": "X",
            },
        ).status_code
        == 404
    )


def test_edit_preserves_vetoed_rows_and_renames_them(tmp_path):
    cfg = make_cfg(tmp_path)
    rows = read_matches(cfg.matches_path)
    rows.append(
        _row(
            title_raw="Citadels",
            match_status="approved",
            bgg_id="478",
            source_photos="shelf2.jpg",
            dedupe_veto="1",
        )
    )
    write_matches(cfg.matches_path, rows)
    web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
    res = web.post(
        "/api/edit-title",
        json={
            "title_raw": "Citadels",
            "source_photos": "shelf.jpg",
            "title_new": "Citadels (2016)",
        },
    )
    assert res.status_code == 200
    after = read_matches(cfg.matches_path)
    # the human-vetoed row survives the cull, renamed to follow the entry;
    # the unvetoed ambiguous row is requeued (dropped)
    veto = [r for r in after if r.get("dedupe_veto")]
    assert len(veto) == 1
    assert veto[0]["title_raw"] == "Citadels (2016)"
    assert veto[0]["match_status"] == "approved"
    assert not any(
        r["title_raw"] == "Citadels" and not r.get("dedupe_veto") for r in after
    )


def test_drop_rows_photo_narrowing_spares_other_copy(tmp_path):
    import io as _io

    from rich.console import Console

    from bggpipe.review import ReviewSession

    cfg = make_cfg(tmp_path)
    write_matches(
        cfg.matches_path,
        [
            _row(title_raw="Wiz-War", match_status="auto", source_photos="a.jpg"),
            _row(title_raw="Wiz-War", match_status="auto", source_photos="b.jpg"),
        ],
    )
    session = ReviewSession(
        cfg,
        console=Console(file=_io.StringIO()),
        input_fn=lambda prompt: "",
        client=unauthorized_client(tmp_path),
    )
    assert session.drop_rows("Wiz-War", ["a.jpg"]) == 1
    (survivor,) = read_matches(cfg.matches_path)
    assert survivor["source_photos"] == "b.jpg"


def test_split_and_edit_refuse_while_pipeline_rewrites(tmp_path):
    import threading

    from bggpipe.jobs import JobRunner

    cfg = make_cfg(tmp_path)
    _rowless_wizwar(cfg)
    release = threading.Event()
    started = threading.Event()

    def blocking_extract():
        started.set()
        release.wait(timeout=5)

    jobs = JobRunner()
    web = TestClient(
        create_app(
            cfg,
            client=unauthorized_client(tmp_path),
            stages={"extract": blocking_extract},
            jobs=jobs,
        )
    )
    assert web.post("/api/run/extract").status_code == 200
    assert started.wait(timeout=5)
    try:
        split = web.post(
            "/api/split",
            json={
                "title_raw": "Wiz-War",
                "source_photos": "shelf.jpg;shelf2.jpg",
                "row_ix": None,
            },
        )
        edit = web.post(
            "/api/edit-title",
            json={
                "title_raw": "Wiz-War",
                "source_photos": "shelf.jpg;shelf2.jpg",
                "title_new": "Wiz-War!",
            },
        )
    finally:
        release.set()
    assert split.status_code == 409
    assert edit.status_code == 409
    # neither curation store was written under the in-flight rewrite
    assert not cfg.title_splits_path.exists()
    assert not cfg.title_edits_path.exists()


def test_renaming_a_split_copy_keeps_split_protection(tmp_path):
    cfg = make_cfg(tmp_path)
    _rowless_wizwar(cfg)
    web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
    assert (
        web.post(
            "/api/split",
            json={
                "title_raw": "Wiz-War",
                "source_photos": "shelf.jpg;shelf2.jpg",
                "row_ix": None,
            },
        ).status_code
        == 200
    )
    # rename BOTH copies to the same corrected title, one at a time — the
    # store must keep protecting them or the rebuild re-merges the copies
    for photo in ("shelf.jpg", "shelf2.jpg"):
        assert (
            web.post(
                "/api/edit-title",
                json={
                    "title_raw": "Wiz-War",
                    "source_photos": photo,
                    "title_new": "Wiz War 2000",
                },
            ).status_code
            == 200
        )
    per_photo = [
        e["source_photos"]
        for e in json.loads(cfg.titles_path.read_text())
        if e["title_raw"] == "Wiz War 2000"
    ]
    assert per_photo == [["shelf.jpg"], ["shelf2.jpg"]]  # still two copies


def test_single_photo_rowless_entry_is_not_splittable(tmp_path):
    web, _ = make_client(tmp_path)
    line = next(
        c
        for c in web.get("/api/state").json()["catalog"]
        if c["title_raw"] == "Fresh Off The Shelf"
    )
    assert line["can_split"] is False


def test_remove_title_is_durable_and_drops_vetoed_rows(tmp_path):
    cfg = make_cfg(tmp_path)
    rows = read_matches(cfg.matches_path)
    rows.append(
        _row(
            title_raw="Citadels",
            match_status="approved",
            bgg_id="478",
            source_photos="shelf.jpg",
            dedupe_veto="1",
        )
    )
    write_matches(cfg.matches_path, rows)
    web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
    res = web.post(
        "/api/remove-title",
        json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
    )
    assert res.status_code == 200
    assert "Citadels" not in [c["title_raw"] for c in res.json()["catalog"]]
    # removal is explicit: the veto'd row goes too (unlike edits)
    assert not any(r["title_raw"] == "Citadels" for r in read_matches(cfg.matches_path))
    (record,) = json.loads(cfg.title_removals_path.read_text())
    assert record == {"title": "Citadels", "photos": ["shelf.jpg"]}
    # durable: a fresh replay keeps it gone; the sibling title is untouched
    from bggpipe.extract import replay_titles

    replay_titles(cfg)
    titles = {e["title_raw"] for e in json.loads(cfg.titles_path.read_text())}
    assert titles == {"Fresh Off The Shelf"}
    # unknown titles still 404
    assert (
        web.post(
            "/api/remove-title",
            json={"title_raw": "No Such Game", "source_photos": "x.jpg"},
        ).status_code
        == 404
    )


def test_help_page_serves_and_is_in_nav(tmp_path):
    web, _ = make_client(tmp_path)
    res = web.get("/help")
    assert res.status_code == 200
    assert "Fixing the titles" in res.text
    # every page's nav includes the Help entry
    assert 'href="/help"' in web.get("/").text


def test_shaky_reads_badge_counts_and_drains_on_edit(tmp_path):
    cfg = make_cfg(tmp_path)
    titles = json.loads(cfg.titles_path.read_text())
    titles.append(
        {
            "title_raw": "Hebarceos",
            "confidence": "low",
            "source_photos": ["shelf.jpg"],
        }
    )
    cfg.titles_path.write_text(json.dumps(titles))
    web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
    assert web.get("/api/pipeline").json()["shaky_reads"] == 1
    # an edit means a human read the line: it is verified, the badge drains
    assert (
        web.post(
            "/api/edit-title",
            json={
                "title_raw": "Hebarceos",
                "source_photos": "shelf.jpg",
                "title_new": "Herbaceous",
            },
        ).status_code
        == 200
    )
    assert web.get("/api/pipeline").json()["shaky_reads"] == 0


def test_nav_order_matches_workflow(tmp_path):
    web, _ = make_client(tmp_path)
    html = web.get("/").text
    order = [
        html.index(f'href="{href}"')
        for href in (
            "/",
            "/photos",
            "/titles",
            "/review",
            "/queue",
            "/library",
            "/help",
        )
    ]
    assert order == sorted(order)
    # the old address still lands on the page
    assert web.get("/catalog", follow_redirects=False).headers["location"] == "/titles"


def test_confirm_marks_shaky_read_verified_without_requeue(tmp_path):
    cfg = make_cfg(tmp_path)
    titles = json.loads(cfg.titles_path.read_text())
    titles.append(
        {
            "title_raw": "Blurry Spine",
            "confidence": "low",
            "source_photos": ["shelf.jpg"],
        }
    )
    cfg.titles_path.write_text(json.dumps(titles))
    web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
    line = next(
        c
        for c in web.get("/api/state").json()["catalog"]
        if c["title_raw"] == "Blurry Spine"
    )
    assert line["shaky"] is True
    rows_before = read_matches(cfg.matches_path)

    res = web.post(
        "/api/edit-title",
        json={
            "title_raw": "Blurry Spine",
            "source_photos": "shelf.jpg",
            "confirm": True,
        },
    )
    assert res.status_code == 200
    line = next(c for c in res.json()["catalog"] if c["title_raw"] == "Blurry Spine")
    assert line["shaky"] is False  # verified: chip and badge both clear
    assert web.get("/api/pipeline").json()["shaky_reads"] == 0
    # confirm changed no data, so nothing was re-queued
    assert read_matches(cfg.matches_path) == rows_before
    (record,) = json.loads(cfg.title_edits_path.read_text())
    assert record["confidence"] == "high"
    # a change-free save without confirm is still refused
    assert (
        web.post(
            "/api/edit-title",
            json={"title_raw": "Blurry Spine", "source_photos": "shelf.jpg"},
        ).status_code
        == 400
    )


def test_lan_allowed_hosts_admit_network_but_not_strangers(tmp_path):
    cfg = make_cfg(tmp_path)
    app = create_app(
        cfg,
        client=unauthorized_client(tmp_path),
        allowed_hosts={"192.168.1.5", "erics-mac.local"},
    )
    body = {"title_raw": "Citadels", "source_photos": "shelf.jpg", "confirm": True}
    lan = TestClient(app, base_url="http://192.168.1.5")
    assert lan.post("/api/edit-title", json=body).status_code == 200
    # a hostname NOT on the allowlist (DNS rebinding shape) is still refused
    stranger = TestClient(app, base_url="http://attacker.example")
    assert stranger.post("/api/edit-title", json=body).status_code == 403
    # and without the opt-in, the LAN host is refused too
    plain = create_app(cfg, client=unauthorized_client(tmp_path))
    assert (
        TestClient(plain, base_url="http://192.168.1.5")
        .post("/api/edit-title", json=body)
        .status_code
        == 403
    )


def test_lan_hosts_are_lowercase_nonempty_and_v4_only(monkeypatch):
    import socket as socket_mod

    from bggpipe.webreview import lan_hosts

    monkeypatch.setattr(socket_mod, "gethostname", lambda: "Erics-Mac.Example.COM")
    monkeypatch.setattr(
        socket_mod,
        "getaddrinfo",
        lambda *a, **k: (_ for _ in ()).throw(OSError("no dns")),
    )
    hosts = lan_hosts()
    assert "erics-mac.example.com" in hosts
    assert "erics-mac.local" in hosts
    assert "" not in hosts  # an empty entry would admit Host-less mutations
    assert all(":" not in h for h in hosts)  # no v6 forms, no ports


def test_lan_token_gates_every_request(tmp_path):
    cfg = make_cfg(tmp_path)
    app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret")
    phone = TestClient(app, base_url="http://192.168.1.99:8377")
    # reads are gated too: shelf photos and state are private
    assert phone.get("/api/state").status_code == 403
    assert phone.get("/api/state?k=wrong").status_code == 403
    first = phone.get("/titles?k=sekret")
    assert first.status_code == 200
    assert "bggpipe_key" in first.cookies  # the URL key becomes a cookie
    # cookie carries the session: mutations work from ANY host the phone
    # used (no allowlist dependence — DHCP/multi-interface safe), with a
    # same-origin Origin header and a port, like a real phone browser
    res = phone.post(
        "/api/edit-title",
        json={"title_raw": "Citadels", "source_photos": "shelf.jpg", "confirm": True},
        headers={"origin": "http://192.168.1.99:8377"},
    )
    assert res.status_code == 200
    # a foreign Origin is still refused even with the key
    assert (
        phone.post(
            "/api/dismiss",
            json={"photo": "shelf.jpg"},
            headers={"origin": "http://evil.example"},
        ).status_code
        == 403
    )


def test_lan_loopback_stays_keyless_but_guarded(tmp_path):
    cfg = make_cfg(tmp_path)
    app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret")
    local = TestClient(
        app, base_url="http://127.0.0.1:8377", client=("127.0.0.1", 50000)
    )
    # the desktop browser (old tabs, the auto-opened one) needs no key
    assert local.get("/api/pipeline").status_code == 200
    assert (
        local.post(
            "/api/edit-title",
            json={
                "title_raw": "Citadels",
                "source_photos": "shelf.jpg",
                "confirm": True,
            },
            headers={"origin": "http://127.0.0.1:8377"},
        ).status_code
        == 200
    )
    # but loopback keylessness never extends to foreign Hosts (rebinding)
    # or foreign Origins (classic CSRF from a local browser)
    rebound = TestClient(
        app, base_url="http://evil.example:8377", client=("127.0.0.1", 50000)
    )
    assert rebound.get("/api/state").status_code == 403
    assert (
        local.post(
            "/api/dismiss",
            json={"photo": "shelf.jpg"},
            headers={"origin": "http://evil.example"},
        ).status_code
        == 403
    )


def test_localhost_mutations_pass_with_origin_and_port(tmp_path):
    # the path every real browser takes: Origin present + port in Host —
    # regressing the header parsing must fail loudly here
    cfg = make_cfg(tmp_path)
    app = create_app(cfg, client=unauthorized_client(tmp_path))
    web = TestClient(app, base_url="http://localhost:8377")
    res = web.post(
        "/api/edit-title",
        json={"title_raw": "Citadels", "source_photos": "shelf.jpg", "confirm": True},
        headers={"origin": "http://localhost:8377"},
    )
    assert res.status_code == 200
    assert (
        TestClient(app, base_url="http://attacker.example:8377")
        .post(
            "/api/edit-title",
            json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
        )
        .status_code
        == 403
    )


def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys):
    import bggpipe.webreview as wr

    captured = {}
    monkeypatch.setattr("uvicorn.run", lambda app, **kw: captured.update(kw, app=app))
    cfg = make_cfg(tmp_path)
    monkeypatch.setattr(wr, "_route_ip", lambda: "192.168.1.5")
    monkeypatch.setattr(
        wr,
        "lan_hosts",
        lambda: {"127.0.0.1", "192.168.1.5", "192.168.64.1", "erics-mac.local"},
    )
    wr.run_web_review(cfg, port=9999, lan=True)
    out = capsys.readouterr().out
    assert captured["host"] == "0.0.0.0"
    # the default-route address leads; other interfaces are fallbacks
    assert "on your phone, open: http://192.168.1.5:9999/?k=" in out
    fallback = next(line for line in out.splitlines() if "try:" in line)
    assert "192.168.64.1" in fallback and "erics-mac.local" in fallback
    # no phone can reach loopback: only the desktop line mentions it
    assert out.count("http://127.0.0.1:9999") == 1
    assert "Use only on a network you trust" in out
    wr.run_web_review(cfg, port=9999, lan=False)
    assert captured["host"] == "127.0.0.1"
