"""Diff-stage tests: pure compute_diff cases plus the real 2018 collection
snapshots as parsing fixtures. No network anywhere."""

from __future__ import annotations

import shutil
from pathlib import Path

from bggpipe.config import Config
from bggpipe.diff import (
    SNAPSHOT_FILES,
    compute_diff,
    load_snapshot_collection,
    run_diff,
)
from bggpipe.models import CollectionItem
from bggpipe.resolve import write_matches
from bggpipe.upload import run_upload

FIXTURES = Path(__file__).parent / "fixtures"


def _item(object_id, coll_id, name="Game", version_id=None, own=True):
    return CollectionItem(
        object_id=object_id,
        coll_id=coll_id,
        name=name,
        subtype="boardgame",
        own=own,
        year=None,
        version_id=version_id,
    )


def _match(
    title, bgg_id="", status="auto", vstatus="version_unknown", vid="", vname=""
):
    return {
        "title_raw": title,
        "bgg_id": bgg_id,
        "bgg_name": title.title(),
        "year": "2000",
        "type": "boardgame",
        "match_status": status,
        "version_id": vid,
        "version_name": vname,
        "version_status": vstatus,
        "candidates_json": "[]",
        "version_candidates_json": "[]",
        "source_photos": "x.jpg",
    }


# -- snapshot loading ---------------------------------------------------


def test_load_real_snapshots_merges_and_dedupes():
    collection = load_snapshot_collection(FIXTURES)
    # 79 base + 3 expansions, but all 3 expansion collids also appear in
    # the base file -> 79 unique physical copies
    assert len(collection) == 79
    assert len({c.coll_id for c in collection}) == 79
    by_id = {c.object_id: c for c in collection}
    assert by_id[207830].name == "5-Minute Dungeon"
    assert by_id[177].name == "Advanced Civilization"
    # hand-entered in 2018: every entry is version-less (parsed, not assumed)
    assert all(c.version_id is None for c in collection)


# -- compute_diff -------------------------------------------------------


def test_new_game_goes_to_add_with_version():
    result = compute_diff(
        [
            _match(
                "Cat Crimes",
                "235096",
                vstatus="version_auto",
                vid="360982",
                vname="ThinkFun edition",
            )
        ],
        [_item(13, 1)],
    )
    (row,) = result.to_add
    assert row["bgg_id"] == "235096"
    assert row["version_id"] == "360982"
    assert not result.to_update


def test_owned_versionless_plus_confident_version_goes_to_update():
    result = compute_diff(
        [
            _match(
                "Britannia",
                "240",
                vstatus="version_auto",
                vid="55555",
                vname="AH English edition",
            )
        ],
        [_item(240, 900001, name="Britannia")],
    )
    assert result.already_owned == ["Britannia"]
    (row,) = result.to_update
    assert row == {
        "collid": "900001",
        "bgg_id": "240",
        "bgg_name": "Britannia",
        "version_id": "55555",
        "version_name": "AH English edition",
    }
    assert not result.to_add


def test_owned_with_matching_version_is_just_owned():
    result = compute_diff(
        [_match("Wingspan", "266192", vstatus="version_auto", vid="465063")],
        [_item(266192, 5, version_id=465063)],
    )
    assert result.already_owned == ["Wingspan"]
    assert not result.to_update and not result.to_add


def test_version_mismatch_with_unclaimed_copy_is_report_only():
    # ONE row, ONE copy with a different version: most likely the same
    # physical box mis-scored. Spec: report the disagreement, touch nothing,
    # and never risk uploading a duplicate entry.
    result = compute_diff(
        [
            _match(
                "Wingspan",
                "266192",
                vstatus="version_auto",
                vid="521212",
                vname="fourth printing",
            )
        ],
        [_item(266192, 5, version_id=465063)],
    )
    assert not result.to_update and not result.to_add
    assert result.already_owned == ["Wingspan"]
    assert "fourth printing" in result.disagreements[0]


def test_vetoed_duplicate_of_same_version_is_a_real_second_copy():
    # Two rows, same confident version, ONE owned copy with that version:
    # a human vetoed the merge ("these ARE two boxes"), so the exact-version
    # match must consume the copy and the second row must become an add.
    rows = [
        _match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd ed."),
        _match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd ed."),
    ]
    result = compute_diff(rows, [_item(13, 900, version_id=123)])
    assert result.already_owned == ["Catan"]
    assert [r["version_id"] for r in result.to_add] == ["123"]
    assert len(result.second_copies) == 1


def test_vetoed_bare_duplicate_beyond_owned_count_is_added_versionless():
    # Two version-unknown rows, one owned copy: only a HUMAN VETO makes the
    # extra bare row a genuine second copy (spec: a bare id is owned if any
    # copy exists — an unvetoed typo-read sibling must not upload).
    vetoed = {**_match("Catan", "13"), "dedupe_veto": "1"}
    result = compute_diff([_match("Catan", "13"), vetoed], [_item(13, 900)])
    assert result.already_owned == ["Catan"]
    (added,) = result.to_add
    assert added["version_id"] == ""
    assert len(result.second_copies) == 1


def test_unvetoed_bare_duplicate_stays_owned():
    # same shape WITHOUT the veto: both rows owned, nothing uploaded
    rows = [_match("Catan", "13"), _match("Catan", "13")]
    result = compute_diff(rows, [_item(13, 900)])
    assert result.already_owned == ["Catan", "Catan"]
    assert result.to_add == []


def test_earlier_disagreement_cannot_steal_a_later_rows_exact_match():
    # row A (v3, no match) must not consume the v2 copy that row B exactly
    # matches — exact-version claims settle before disagreements
    rows = [
        _match("Catan", "13", vstatus="version_auto", vid="3", vname="v3"),
        _match("Catan", "13", vstatus="version_auto", vid="2", vname="v2"),
    ]
    result = compute_diff(rows, [_item(13, 900, version_id=2)])
    assert result.already_owned == ["Catan"]  # B's exact match claims the copy
    # A's v3 box matches no collection entry: a genuine new copy
    assert [r["version_id"] for r in result.to_add] == ["3"]
    assert result.disagreements == []


def test_update_withheld_when_another_copy_is_versioned():
    # upload's row edit targets by NAME: an update is only safe when every
    # copy is versionless, else it could overwrite the versioned copy
    rows = [_match("Catan", "13", vstatus="version_auto", vid="5", vname="5th")]
    result = compute_diff(rows, [_item(13, 900, version_id=7), _item(13, 901)])
    assert result.to_update == []
    assert "set it by hand" in result.disagreements[0]


def test_bare_row_does_not_steal_versionless_copy_from_confident_update():
    # ordering independence: the confident row upgrades the versionless
    # copy even when a bare row of the same game appears first in the file
    rows = [
        _match("Catan", "13"),
        _match("Catan", "13", vstatus="version_auto", vid="55", vname="5th ed."),
    ]
    result = compute_diff(rows, [_item(13, 900), _item(13, 901)])
    assert [u["version_id"] for u in result.to_update] == ["55"]
    assert result.to_add == []
    assert result.already_owned.count("Catan") == 2


def test_version_unknown_owned_by_bare_id():
    result = compute_diff(
        [_match("Catan", "13")],
        [_item(13, 1, name="Catan")],
    )
    assert result.already_owned == ["Catan"]
    assert not result.to_add and not result.to_update


def test_two_photo_editions_consume_distinct_collids():
    matches = [
        _match("Cosmic A", "39", vstatus="version_auto", vid="111"),
        _match("Cosmic B", "39", vstatus="version_auto", vid="222"),
    ]
    collection = [_item(39, 701), _item(39, 702)]
    result = compute_diff(matches, collection)
    assert {r["collid"] for r in result.to_update} == {"701", "702"}
    assert {r["version_id"] for r in result.to_update} == {"111", "222"}


def test_pending_rejected_and_unseen_are_reported():
    matches = [
        _match("Mystery Spine", status="ambiguous"),
        _match("Junk", status="rejected"),
        _match("Catan", "13"),
    ]
    collection = [_item(13, 1, name="Catan"), _item(9209, 2, name="Ticket to Ride")]
    result = compute_diff(matches, collection)
    assert result.pending == ["Mystery Spine"]
    assert result.rejected == 1
    assert [c.object_id for c in result.unseen] == [9209]  # informational
    assert result.recognized == 1


def test_merged_rows_are_skipped_but_photos_carry_to_survivor():
    matches = [
        _match("Joking Hazard", "193621"),
        {
            **_match("Jokin Ha...", "193621", status="merged"),
            "merged_into": "Joking Hazard",
            "source_photos": "other.jpg",
        },
    ]
    result = compute_diff(matches, [])  # empty collection -> to_add
    assert result.merged == 1
    assert result.pending == []  # merged is not "needs review"
    (row,) = result.to_add
    assert row["title_raw"] == "Joking Hazard"
    assert row["source_photos"] == "other.jpg;x.jpg"  # combined


def test_versionless_copies_exhaust_then_second_copy_becomes_add():
    # two confident-version matches, ONE versionless copy: the first consumes
    # it (to_update), the second is an additional physical copy (to_add)
    result = compute_diff(
        [
            _match("Sorcerer", "39", vstatus="version_auto", vid="111", vname="1st"),
            _match("Sorcerer", "39", vstatus="version_auto", vid="222", vname="2nd"),
        ],
        [_item(39, 701)],
    )
    assert [u["version_id"] for u in result.to_update] == ["111"]
    assert [a["version_id"] for a in result.to_add] == ["222"]
    assert len(result.second_copies) == 1


def test_run_diff_outputs_feed_upload_unchanged(tmp_path, monkeypatch):
    # the cross-stage contract: whatever run_diff writes, run_upload must
    # read — a column rename on either side has to fail HERE

    monkeypatch.delenv("BGG_API_TOKEN", raising=False)
    cfg = Config(data_dir=tmp_path)
    fixtures = Path(__file__).parent / "fixtures"
    for name in SNAPSHOT_FILES:
        shutil.copy(fixtures / name, tmp_path / name)
    write_matches(
        cfg.matches_path,
        [
            _match("Wingspan", "266192", status="auto"),  # not in the snapshots
            _match("5 MINUTE DUNGEON", "207830", status="auto"),  # owned
        ],
    )
    result = run_diff(cfg)
    assert [r["bgg_id"] for r in result.to_add] == ["266192"]

    from test_upload import FakeUploader

    fake = FakeUploader()
    run_upload(cfg, uploader=fake, sleep=lambda s: None, now=lambda: "t")
    assert [(j.action, j.bgg_id, j.name) for j in fake.calls] == [
        ("add", "266192", "Wingspan")
    ]


def test_token_without_username_says_so(tmp_path, monkeypatch, capsys):

    monkeypatch.setenv("BGG_API_TOKEN", "tok")
    monkeypatch.delenv("BGG_USERNAME", raising=False)
    cfg = Config(data_dir=tmp_path)  # bgg_username defaults to ""
    fixtures = Path(__file__).parent / "fixtures"
    for name in SNAPSHOT_FILES:
        shutil.copy(fixtures / name, tmp_path / name)
    write_matches(cfg.matches_path, [_match("Catan", "13")])
    run_diff(cfg)
    out = capsys.readouterr().out
    assert "BGG_API_TOKEN is set but BGG_USERNAME is not" in out
    assert "No BGG_API_TOKEN" not in out  # the token IS set; blame the username


class _LiveClient:
    """Fake client recording collection_full calls for the live branch."""

    def __init__(self, collection, fail_auth=False):
        self.collection = collection
        self.fail_auth = fail_auth
        self.calls: list[dict] = []

    def collection_full(self, username, *, refresh=False):
        from bggpipe.bgg_client import BGGAuthError

        self.calls.append({"username": username, "refresh": refresh})
        if self.fail_auth:
            raise BGGAuthError("token rejected")
        return self.collection


def test_live_diff_fetches_fresh_collection(tmp_path, monkeypatch):
    # the branch that runs the day the token arrives: must call
    # collection_full with refresh=True, not serve resolve-era cache

    monkeypatch.setenv("BGG_API_TOKEN", "tok")
    monkeypatch.setenv("BGG_USERNAME", "eric")
    cfg = Config(bgg_username="eric", data_dir=tmp_path)
    write_matches(cfg.matches_path, [_match("Catan", "13")])
    client = _LiveClient([_item(13, 1, name="Catan")])
    result = run_diff(cfg, client=client)
    assert client.calls == [{"username": "eric", "refresh": True}]
    assert result.already_owned == ["Catan"]


def test_live_diff_falls_back_to_snapshots_on_auth_failure(tmp_path, monkeypatch):

    monkeypatch.setenv("BGG_API_TOKEN", "bad")
    monkeypatch.setenv("BGG_USERNAME", "eric")
    cfg = Config(bgg_username="eric", data_dir=tmp_path)
    for name in SNAPSHOT_FILES:
        shutil.copy(FIXTURES / name, tmp_path / name)
    write_matches(cfg.matches_path, [_match("5 MINUTE DUNGEON", "207830")])
    result = run_diff(cfg, client=_LiveClient([], fail_auth=True))
    assert result.already_owned == ["5 MINUTE DUNGEON"]  # snapshots served


def test_bare_sibling_of_confident_add_is_not_a_duplicate_upload():
    # photo A reads "Wingspan" (confident version), photo B misreads
    # "Wingspam" (bare) resolving to the same absent game: ONE add, not two
    rows = [
        _match("Wingspan", "266192", vstatus="version_auto", vid="1", vname="1st"),
        _match("Wingspam", "266192"),
    ]
    result = compute_diff(rows, [])
    assert len(result.to_add) == 1
    assert result.already_owned == ["Wingspam"]


def test_vetoed_bare_sibling_still_adds_for_absent_game():
    vetoed = {**_match("Wingspan", "266192"), "dedupe_veto": "1"}
    rows = [
        _match("Wingspan", "266192", vstatus="version_auto", vid="1", vname="1st"),
        vetoed,
    ]
    result = compute_diff(rows, [])
    assert len(result.to_add) == 2
    assert result.to_add[1]["second_copy"] == "1"


def test_second_copy_flag_travels_on_exhausted_adds():
    rows = [
        _match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd"),
        _match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd"),
    ]
    result = compute_diff(rows, [_item(13, 900, version_id=123)])
    (added,) = result.to_add
    assert added["second_copy"] == "1"


def test_rpgitem_rows_are_local_only_never_queued():
    rpg = {**_match("ALICE IS MISSING", "400001"), "type": "rpgitem"}
    result = compute_diff([rpg, _match("Catan", "13")], [])
    assert result.local_only == ["ALICE IS MISSING"]
    assert [r["bgg_id"] for r in result.to_add] == ["13"]  # RPG never queued
    assert result.recognized == 1  # RPGs aren't counted against the queue


def test_split_copies_become_extra_collection_entries():
    # three vetoed per-photo copies, one owned: one claims the copy, the
    # other two are queued as new entries
    base = _match("Wiz-War", "104710")
    copies = [
        {**base, "source_photos": p, "dedupe_veto": "1"}
        for p in ("a.jpg", "b.jpg", "c.jpg")
    ]
    result = compute_diff(copies, [_item(104710, 900)])
    assert result.already_owned == ["Wiz-War"]
    assert len(result.to_add) == 2
    assert all(r["second_copy"] == "1" for r in result.to_add)
