"""Stage 5 — upload: add games / set versions on boardgamegeek.com via Playwright.

BGG has no write API, so this stage drives the real website with a logged-in
browser session. Etiquette (spec + bgg-api skill):
- 2-4 s randomized delay between games;
- credentials only from BGG_USERNAME / BGG_PASSWORD env vars, never disk/logs;
- browser storage state persists locally (gitignored) so login is rare;
- every attempt is appended to data/upload_log.csv immediately, so a killed
  run loses nothing and re-runs skip completed work;
- refuses to touch the site while either provenance marker exists —
  data/bgg_cache/STUB_FIXTURES.marker (gitignored) or data/STUB_DATA.marker
  (committed, so fresh clones stay guarded); --dry-run still works, loudly
  labeled as synthetic.

Cloudflare: BGG fronts the site with a Turnstile check that blocks headless
browsers outright (verified 2026-08-01 — headless shell never gets past
"Just a moment..."). The stage therefore runs HEADED by default; a first
login may need one human click on the challenge widget. BGG pages also never
reach Playwright's networkidle (ad/analytics polling), so navigation waits on
domcontentloaded plus explicit element waits.
"""

from __future__ import annotations

import csv
import os
import random
import re
import time
from collections import Counter
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Protocol

import typer

from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
from bggpipe.models import CollectionItem, is_recognized
from bggpipe.resolve import read_matches

BGG = "https://boardgamegeek.com"
UPLOAD_LOG_COLUMNS = [
    "action",
    "bgg_id",
    "collid",
    "name",
    "version_id",
    "second_copy",
    "status",
    "timestamp",
    "error",
]
DONE_STATUSES = {"added", "added_no_version", "updated", "already_present"}
MAX_VERSION_PAGES = 40


_YEARISH = re.compile(r"[\s(]*\d{4}(?:-\d+)?[\s)]*")


def _loose_version_text(text: str) -> str:
    """Version names as displayed differ from the API's by punctuation,
    parens and year qualifiers — compare only the words that name it."""
    text = _YEARISH.sub(" ", text or "")
    return " ".join(re.sub(r"[^\w\s]", " ", text.casefold()).split())


@dataclass(frozen=True)
class UploadJob:
    action: str  # "add" | "update"
    bgg_id: str
    name: str
    version_id: str = ""
    version_name: str = ""
    collid: str = ""
    second_copy: bool = False  # game had prior copies: verify can't confirm

    @property
    def key(self) -> tuple[str, str, str]:
        return _key(self.action, self.bgg_id, self.collid, self.version_id)


def _key(
    action: str, bgg_id: str, collid: str, version_id: str
) -> tuple[str, str, str]:
    # A second copy of the same game (different version) is a distinct add;
    # updates are keyed by the physical copy they amend. NOTE: two copies
    # with the SAME (bgg_id, version) — vetoed duplicates — share a key, so
    # build_queue counts completions per key instead of treating the key as
    # unique.
    if action == "update":
        return ("update", collid, "")
    return ("add", bgg_id, version_id)


def _job_key(row: dict) -> tuple[str, str, str]:
    return _key(row["action"], row["bgg_id"], row["collid"], row["version_id"])


def annotate_queue(
    queue_rows: list[dict], action: str, log_rows: list[dict]
) -> list[dict]:
    """Each queue row plus the outcome of its LAST upload attempt, in a
    `state` field: "" (pending), "done", or "failed". to_add.csv and
    to_update.csv are diff-time snapshots — nothing removes a row once its
    job succeeds — so a reader without the log sees finished work as
    outstanding forever."""
    last: dict[tuple[str, str, str], str] = {}
    done_count: Counter[tuple[str, str, str]] = Counter()
    for row in log_rows:
        last[_job_key(row)] = row["status"]
        if row["status"] in DONE_STATUSES:
            done_count[_job_key(row)] += 1
    # vetoed duplicate copies share a key: one success must mark ONE row
    # done, not both, or the queue reports a copy uploaded that never was
    claimed: Counter[tuple[str, str, str]] = Counter()
    out = []
    for row in queue_rows:
        key = _key(
            action,
            row.get("bgg_id", ""),
            row.get("collid", ""),
            row.get("version_id", ""),
        )
        status = last.get(key, "")
        if status in DONE_STATUSES and claimed[key] >= done_count[key]:
            status = ""  # completions exhausted: this copy is still pending
        if status in DONE_STATUSES:
            claimed[key] += 1
        out.append(
            {
                **row,
                "state": "done"
                if status in DONE_STATUSES
                else "failed"
                if status == "failed"
                else "",
                "last_status": status,
            }
        )
    return out


def stale_jobs(queue_rows: list[dict], match_rows: list[dict]) -> dict[str, str]:
    """bgg_id -> why, for queued games the CURRENT matches.csv no longer
    endorses. to_add.csv is a snapshot from the last diff; a review
    decision taken afterwards (marking a game local, rejecting it, calling
    a match wrong) must not still upload."""
    if not match_rows:
        # nothing to compare against (no matches.csv, or a test harness
        # driving the queue directly): absence is not a verdict
        return {}
    live: dict[str, list[dict]] = {}
    endorsed: Counter[tuple[str, str]] = Counter()
    for row in match_rows:
        if row.get("bgg_id"):
            live.setdefault(row["bgg_id"], []).append(row)
            if is_recognized(row):
                endorsed[(row["bgg_id"], row.get("version_id", ""))] += 1
    queued: Counter[tuple[str, str]] = Counter()
    for row in queue_rows:
        queued[(row.get("bgg_id") or "", row.get("version_id", ""))] += 1
    stale = {}
    for row in queue_rows:
        bgg_id = row.get("bgg_id") or ""
        rows = live.get(bgg_id, [])
        if not rows:
            stale[bgg_id] = "no longer matched to this game in matches.csv"
            continue
        if not any(is_recognized(r) for r in rows):
            statuses = sorted({r["match_status"] for r in rows})
            stale[bgg_id] = f"now {', '.join(statuses)} in matches.csv"
            continue
        # the game survives, but does THIS copy? Rejecting one of two
        # editions must not ride along on the other's endorsement.
        pair = (bgg_id, row.get("version_id", ""))
        if queued[pair] > endorsed[pair]:
            stale[bgg_id] = (
                f"queued with version {row.get('version_id') or '(none)'} "
                "but matches.csv no longer endorses that copy — re-run diff"
            )
    return stale


def outstanding_failures(
    log_rows: list[dict],
    queue_rows: list[dict] | None = None,
    match_rows: list[dict] | None = None,
) -> int:
    """Failures still worth retrying: the job's LATEST attempt failed AND
    it is still queued and still endorsed by matches.csv.

    Two ways to over-count. upload_log.csv is append-only, so a successful
    retry leaves its old 'failed' line behind; and a failed job whose
    review decision has since changed (marked local, rejected) is not
    pending work at all — it will never run again, so offering to retry it
    is a lie."""
    last: dict[tuple[str, str, str], tuple[str, str]] = {}
    for row in log_rows:
        last[_job_key(row)] = (row["status"], row.get("bgg_id", ""))
    failed = [bgg_id for status, bgg_id in last.values() if status == "failed"]
    if queue_rows is None:
        return len(failed)
    live = {r.get("bgg_id", "") for r in queue_rows} - set(
        stale_jobs(queue_rows, match_rows or [])
    )
    return sum(1 for bgg_id in failed if bgg_id in live)


def _read_csv(path: Path) -> list[dict]:
    if not path.exists():
        return []
    with path.open(newline="") as f:
        return list(csv.DictReader(f))


def append_log_row(path: Path, row: dict) -> None:
    """Append one attempt. One row per attempt, flushed immediately — the
    log is the resume point, so its header is created ATOMICALLY first (a
    torn header line would become DictReader's fieldnames and misparse
    every logged success on the next run)."""
    if not path.exists():
        atomic_write_text(path, ",".join(UPLOAD_LOG_COLUMNS) + "\r\n")
    with path.open("a", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=UPLOAD_LOG_COLUMNS, extrasaction="ignore")
        writer.writerow(row)


def build_queue(
    to_add: list[dict],
    to_update: list[dict],
    log_rows: list[dict],
    *,
    retry_failed: bool = False,
) -> tuple[list[UploadJob], int, int]:
    """Turn the diff outputs into pending jobs, minus work the log says is
    done. Returns (jobs, skipped_done, skipped_failed).

    Completions are COUNTED per key, not looked up: two vetoed duplicate
    copies share a key, and one logged success must complete exactly one
    of them."""
    done_count: Counter[tuple[str, str, str]] = Counter()
    last_status: dict[tuple[str, str, str], str] = {}
    for row in log_rows:
        k = _job_key(row)
        if row["status"] in DONE_STATUSES:
            done_count[k] += 1
        last_status[k] = row["status"]

    candidates = [
        UploadJob(
            action="add",
            bgg_id=row["bgg_id"],
            name=row["bgg_name"],
            version_id=row.get("version_id", ""),
            version_name=row.get("version_name", ""),
            second_copy=bool(row.get("second_copy")),
        )
        for row in to_add
    ] + [
        UploadJob(
            action="update",
            bgg_id=row["bgg_id"],
            name=row["bgg_name"],
            version_id=row["version_id"],
            version_name=row["version_name"],
            collid=row["collid"],
        )
        for row in to_update
    ]

    done_versions: dict[tuple[str, str], set[str]] = {}
    for row in log_rows:
        if row["status"] in DONE_STATUSES:
            done_versions.setdefault(
                (row["action"], row["collid"] or row["bgg_id"]), set()
            ).add(row["version_id"])

    jobs: list[UploadJob] = []
    skipped_done = skipped_failed = 0
    seen: Counter[tuple[str, str, str]] = Counter()
    queued_versions: dict[str, set[str]] = {}
    for job in candidates:
        if job.action == "add":
            queued_versions.setdefault(job.bgg_id, set()).add(job.version_id)
    for job in candidates:
        prior = done_versions.get(
            ("update", job.collid) if job.action == "update" else ("add", job.bgg_id),
            set(),
        )
        if job.action == "add" and prior & queued_versions.get(job.bgg_id, set()):
            # the done version is still queued alongside this one: a
            # multi-edition game partway through, not a re-review drift
            prior = set()
        if prior and job.version_id not in prior:
            typer.echo(
                f"  skipping {job.name}: previously {job.action}ed with a "
                f"different version ({', '.join(sorted(prior)) or 'none'}) — "
                "the entry exists on BGG, so re-adding can only duplicate "
                "it; correct the version by hand (additive-only rule)"
            )
            skipped_done += 1
            continue
        occurrence = seen[job.key]
        seen[job.key] += 1
        if not job.name:
            # an empty name (manual id whose lookup failed) would make the
            # name-driven selectors match ANY heading/row — refuse loudly
            typer.echo(
                f"  refusing to queue bgg_id {job.bgg_id}: empty game name "
                "— re-review this match so the name resolves"
            )
            skipped_failed += 1
        elif occurrence < done_count[job.key]:
            skipped_done += 1
        elif last_status.get(job.key) == "failed" and not retry_failed:
            skipped_failed += 1
        else:
            # same-game updates coexist in one run: update_entry addresses
            # the copy by collid and the edition by radio value, so a
            # second update cannot reopen what the first just saved
            jobs.append(job)
    return jobs, skipped_done, skipped_failed


def _scrub(text: str) -> str:
    """Credentials must never reach the log, even via a selector error that
    echoes filled form values. Playwright errors carry multi-line call
    logs; a CSV cell keeps one line."""
    for key in ("BGG_PASSWORD", "BGG_USERNAME"):
        value = os.environ.get(key)
        if value:
            text = text.replace(value, "***")
    return " ".join(text.split())


class LoginError(RuntimeError):
    """Authentication is broken (bad credentials, Cloudflare block, changed
    login page). Systemic by definition: retrying per-game would hammer the
    login endpoint and poison upload_log.csv with misleading failures."""


class Uploader(Protocol):
    def add_game(self, job: UploadJob) -> tuple[str, str]: ...

    def update_entry(self, job: UploadJob) -> tuple[str, str]: ...


class PlaywrightUploader:
    """Drives the real site. Selector documentation: docs/bgg-upload-flow.md.

    All flows verified against the live site 2026-08-06 (adds, the version
    picker with pagination, and the collection-row version edit). The
    remaining unknown is the second-copy add: BGG may edit the existing
    entry instead of creating one, which run_upload reports as a copy-count
    shortfall during --verify.
    """

    def __init__(
        self,
        username: str,
        storage_state: Path | None = None,
        headless: bool = False,
    ) -> None:
        self._username = username
        self._storage_state = storage_state or Config().storage_state_path
        self._headless = headless
        self._authed = False

    def __enter__(self) -> PlaywrightUploader:
        from playwright.sync_api import TimeoutError as PWTimeoutError
        from playwright.sync_api import sync_playwright

        self._timeout_error = PWTimeoutError
        self._pw = sync_playwright().start()
        self._browser = self._pw.chromium.launch(headless=self._headless)
        state = str(self._storage_state) if self._storage_state.exists() else None
        self._context = self._browser.new_context(storage_state=state)
        self._page = self._context.new_page()
        return self

    def __exit__(self, *exc: object) -> None:
        self._context.close()
        self._browser.close()
        self._pw.stop()

    def _goto(self, url: str) -> None:
        # networkidle never arrives on BGG (ad polling) — domcontentloaded
        # plus per-element auto-waiting is the reliable pattern.
        self._page.goto(url, wait_until="domcontentloaded")

    # BGG's Sign In control is an <a class="btn"> with NO href, so it has
    # no implicit link role — role-based queries cannot find it.
    _SIGN_IN = 'a:has-text("Sign In"), button:has-text("Sign In")'
    _SIGNED_IN = ':text-is("Sign Out"), :text-is("Log Out")'

    def _signed_out(self) -> bool:
        """True = signed out. Waits for the header to prove ONE state or
        the other: BGG hydrates it after domcontentloaded, so an early read
        finds NEITHER control — and any check resting on a single absence
        guesses, silently, in the unsafe direction. Undeterminable state
        raises rather than assumes."""
        page = self._page
        deadline = time.monotonic() + 30
        while time.monotonic() < deadline:
            out = page.locator(self._SIGN_IN).count()
            inside = page.locator(self._SIGNED_IN).count()
            if out and not inside:
                return True
            if inside and not out:
                return False
            page.wait_for_timeout(250)
        raise LoginError(
            "could not tell whether this browser is signed in (neither a "
            "Sign In nor a Sign Out control appeared in 30s) — BGG's header "
            "may have changed; see docs/bgg-upload-flow.md"
        )

    def _ensure_logged_in(self) -> None:
        if self._authed:
            return
        page = self._page
        self._goto(f"{BGG}/")
        if "just a moment" in (page.title() or "").casefold():
            raise LoginError(
                "Cloudflare is challenging this browser ('Just a moment...') "
                "— run headed (drop --headless) and click the widget once"
            )
        if self._signed_out():
            user = os.environ.get("BGG_USERNAME", "")
            password = os.environ.get("BGG_PASSWORD", "")
            if not (user and password):
                raise LoginError(
                    "BGG_USERNAME and BGG_PASSWORD env vars are required to log in"
                )
            self._goto(f"{BGG}/login")
            # Generous timeout: in headed mode a human may need to click the
            # Cloudflare Turnstile widget before the form renders.
            page.locator("#inputUsername").wait_for(timeout=120_000)
            page.locator("#inputUsername").fill(user)
            page.locator("#inputPassword").fill(password)
            page.get_by_role("button", name="Sign In").click()
            try:
                page.wait_for_url(lambda url: "/login" not in url, timeout=120_000)
            except self._timeout_error as err:
                raise LoginError(
                    "login did not complete (wrong credentials, or the login "
                    "page changed — see docs/bgg-upload-flow.md)"
                ) from err
            # Verify the TRANSITION: the Sign In control was visible a
            # moment ago, so its disappearance is evidence, not inference.
            self._goto(f"{BGG}/")
            if self._signed_out():
                raise LoginError(
                    "the login form was submitted but the site still offers "
                    "Sign In — credentials rejected, or a challenge is "
                    "pending in the browser window"
                )
            self._context.storage_state(path=str(self._storage_state))
            self._storage_state.chmod(0o600)  # session cookies: owner-only
        self._authed = True

    def _open_dialog(self, opener):
        """Click an opener that may no-op right after page load (hydration
        race) and wait for the dialog to actually show. (Params/return are
        Playwright Locators; untyped because the import is lazy.)"""
        dialog = self._page.get_by_role("dialog")
        for attempt in (1, 2):
            opener.click()
            try:
                dialog.wait_for(state="visible", timeout=5_000)
                break
            except self._timeout_error:
                if attempt == 2:
                    raise
        return dialog

    # Verified 2026-08-06 against the live picker: version rows are the
    # <li>s carrying a thumbnail (the paging <li>s are not), and paging is
    # an AngularJS <ul class="pagination"> of anchors, not buttons.
    _VERSION_ROWS = "li:has(.summary-item-thumbnail)"
    # Paging controls render TWICE — a desktop set and a mobile set marked
    # visible-xs-* — so every one of these selectors matches hidden nodes
    # too; clicking one waits forever. Always pick the visible match.
    _NEXT_PAGE = 'ul.pagination a[title="Next Page"]'
    _PAGE_LINKS = "ul.pagination a"
    # Rows read "<game name> (<version name>) (<year>)", so the version name
    # is matched inside its parentheses — bare substrings would let
    # "English edition" match "English edition, second printing".
    _TRAILING_YEAR = re.compile(r"\s+\d{4}(?:-\d+)?$")

    def _rows_on_page(self, dialog) -> list[str]:
        return [
            " ".join(t.split())
            for t in dialog.locator(self._VERSION_ROWS).all_text_contents()
        ]

    def _visible(self, dialog, selector):
        """The first VISIBLE match, or None — see _NEXT_PAGE."""
        loc = dialog.locator(selector)
        for i in range(loc.count()):
            candidate = loc.nth(i)
            if candidate.is_visible():
                return candidate
        return None

    def _has_next_page(self, dialog) -> bool:
        nxt = self._visible(dialog, self._NEXT_PAGE)
        if nxt is None:
            return False  # single-page list: the container is ng-show'd off
        return "disabled" not in (nxt.evaluate("e => e.closest('li').className") or "")

    def _select_version(self, dialog, version_name: str) -> tuple[bool, str]:
        """Pick the version whose name matches, paging the whole list first
        so a decision is made against every candidate. Returns (picked,
        reason); on failure the sub-view is cancelled and the caller adds
        the game version-less — never a guessed edition (spec)."""
        dialog.get_by_role("button", name="Set version/edition").click()
        try:
            dialog.locator(self._VERSION_ROWS).first.wait_for(timeout=15_000)
        except self._timeout_error as err:
            # a version resolve found on BGG can't be missing from the
            # picker: an unrendered list means a slow page or changed markup
            raise RuntimeError(
                "version picker never rendered — site slow or markup "
                "changed; attempt is retryable"
            ) from err

        wanted = f"({version_name})".casefold()
        # BGG's API name can carry a printing qualifier the picker omits
        # ("English edition 2018-2" vs "(English edition) (2018)")
        relaxed = f"({self._TRAILING_YEAR.sub('', version_name).strip()})".casefold()
        self._goto_first_page(dialog)  # a fresh sub-view opens on page 1,
        # but never assume it: paging state outlives a close/reopen
        exact: list[tuple[int, str]] = []
        loose: list[tuple[int, str]] = []
        pages = 0
        for page_ix in range(MAX_VERSION_PAGES):
            pages = page_ix + 1
            for text in self._rows_on_page(dialog):
                folded = text.casefold()
                if wanted in folded:
                    exact.append((page_ix, text))
                elif relaxed != wanted and relaxed in folded:
                    loose.append((page_ix, text))
            if not self._has_next_page(dialog):
                break
            self._visible(dialog, self._NEXT_PAGE).click()
            self._page.wait_for_timeout(400)  # client-side paging: no request
        else:
            # never saw the end of the list: "not in picker" would be a false
            # verdict frozen into DONE_STATUSES
            raise RuntimeError(
                f"hit MAX_VERSION_PAGES ({MAX_VERSION_PAGES}) without "
                "finding the version or the end of the list — retryable"
            )

        hits = exact or loose
        if len(hits) == 1:
            _, text = hits[0]
            if not self._click_row(dialog, text):
                raise RuntimeError(
                    f"{text!r} was listed a moment ago but vanished on the "
                    "second pass — retryable"
                )
            how = "" if exact else f" (matched loosely as {text!r})"
            return True, how

        # Two-level dismissal: the sub-view has its own Cancel distinct from
        # the main dialog's.
        dialog.get_by_role("button", name="Cancel").first.click()
        if not hits:
            return False, f"not offered in the picker ({pages} page(s) scanned)"
        return False, (
            f"{len(hits)} versions match {version_name!r} "
            f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess"
        )

    def _goto_first_page(self, dialog) -> None:
        """Back to page 1 via the numbered "1" anchor. Closing and
        reopening the sub-view does NOT reset Angular's paging state (it
        reopens wherever it was left), and First/Prev render in a
        mobile-only variant that a desktop viewport can never click."""
        pager = dialog.locator(self._PAGE_LINKS)
        for i in range(pager.count()):
            anchor = pager.nth(i)
            if anchor.is_visible() and (
                " ".join((anchor.text_content() or "").split()) == "1"
            ):
                anchor.click()
                self._page.wait_for_timeout(400)
                return
        # no pagination rendered: a single-page list is already page 1

    def _click_row(self, dialog, text: str) -> bool:
        """Find the row again by its NORMALIZED text and click it by index.
        Playwright's has_text regex matches raw textContent — whose tabs and
        newlines a normalized capture will never equal — so the comparison
        happens in Python, and the click addresses a position."""
        self._goto_first_page(dialog)
        for _ in range(MAX_VERSION_PAGES):
            for i, row_text in enumerate(self._rows_on_page(dialog)):
                if row_text == text:
                    dialog.locator(self._VERSION_ROWS).nth(i).click()
                    return True
            if not self._has_next_page(dialog):
                return False
            self._visible(dialog, self._NEXT_PAGE).click()
            self._page.wait_for_timeout(400)
        return False

    def _owned_button(self):
        """The owned-game page replaces "Add To" with "In Collections" —
        positive evidence the collection already holds this game."""
        return self._page.get_by_role("button", name="In Collections")

    def add_game(self, job: UploadJob) -> tuple[str, str]:
        self._ensure_logged_in()
        page = self._page
        # /boardgame/<id> redirects to the canonical slug for any subtype.
        self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
        add_btn = page.get_by_role("button", name="Add To")
        for _ in range(60):  # the header hydrates late: poll for EITHER state
            if add_btn.count() and add_btn.first.is_visible():
                break
            owned = self._owned_button()
            if owned.count() and owned.first.is_visible():
                if job.second_copy:
                    # the second-copy path goes through the In Collections
                    # dialog, which this code has never driven live
                    raise RuntimeError(
                        "second copy of an owned game — the In Collections "
                        "add-a-copy flow is unverified; add this copy by hand"
                    )
                return (
                    "already_present",
                    "the site already lists this game as owned — a previous "
                    "attempt likely landed without reaching the log",
                )
            page.wait_for_timeout(500)
        dialog = self._open_dialog(add_btn.first)
        # Wait for the form itself, NOT for a heading matching our stored
        # name: a match made through an ALTERNATE name (BGG 140509 is
        # "Dungeons & Dragons" to search, "Dragones Y Mazmorras" on the
        # page) would never show it. The /boardgame/<id>/ URL already
        # guarantees which game this is.
        dialog.get_by_role("checkbox", name="Own", exact=True).wait_for(timeout=15_000)
        # exact: "Own" is a substring of "Prev. Owned", and a loose label
        # match resolves to both checkboxes (strict-mode violation)
        dialog.get_by_role("checkbox", name="Own", exact=True).check()
        status, note = "added", ""
        if job.version_name:
            picked, why = self._select_version(dialog, job.version_name)
            if picked:
                note = why.strip()
            else:
                status = "added_no_version"
                note = f"version {job.version_name!r}: {why}; added without version"
        dialog.get_by_role("button", name="Save").click()
        # The dialog is hidden after save, not removed from the DOM.
        try:
            dialog.wait_for(state="hidden", timeout=15_000)
        except self._timeout_error:
            # a slow hide is not a failed save: reload and ask the page.
            # Logging "failed" for a landed add would double-add on retry.
            self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
            owned = self._owned_button()
            try:
                owned.first.wait_for(timeout=15_000)
            except self._timeout_error as err:
                raise RuntimeError(
                    "the save dialog never closed and the page does not "
                    "show the game as owned — the add may not have landed"
                ) from err
            note = (note + "; " if note else "") + "save confirmed via page reload"
        return status, note

    def update_entry(self, job: UploadJob) -> tuple[str, str]:
        """Set the version on an EXISTING entry — strictly additive.

        Verified 2026-08-06. The collection table's version cell carries its
        own collid in an onclick and opens an inline editor whose radios
        carry VERSION IDS as their values, so both the copy and the edition
        are addressed exactly — no name matching, no dialog, no pagination.
        Clicking a radio fires CE_SaveData itself; there is no Save button.
        """
        self._ensure_logged_in()
        page = self._page
        self._goto(
            f"{BGG}/collection/user/{self._username}?objectid={job.bgg_id}&own=1"
        )
        cell = page.locator(f'td.collection_version[onclick*="{job.collid}"]')
        if cell.count() == 0:
            raise RuntimeError(
                f"collid {job.collid} has no version cell on the collection "
                "page — the entry may have been removed; re-run diff"
            )
        cell.first.click()
        radio = page.locator(
            f'form[id^="form_version"] input[type="radio"][value="{job.version_id}"]'
        )
        try:
            radio.first.wait_for(timeout=15_000)
        except self._timeout_error as err:
            # the editor lists every version of the game: an absent id means
            # the wrong game's editor opened, or BGG retired that version
            page.keyboard.press("Escape")
            raise RuntimeError(
                f"version {job.version_id} not offered for collid "
                f"{job.collid} — entry left untouched"
            ) from err
        radio.first.click()  # fires CE_SaveData: no separate Save button
        want = _loose_version_text(job.version_name)
        for _ in range(40):
            settled = " ".join((cell.first.text_content() or "").split())
            if settled and "editing" not in settled.casefold():
                got = _loose_version_text(settled)
                if want and want not in got and got not in want:
                    # the editor closed but re-rendered its OLD content:
                    # the AJAX save failed server-side. "updated" here
                    # would mark the job done forever without evidence.
                    raise RuntimeError(
                        f"the version cell settled on {settled!r}, not the "
                        f"chosen {job.version_name!r} — the save did not "
                        "land; safe to retry (same radio, same result)"
                    )
                return "updated", ""
            page.wait_for_timeout(500)
        raise RuntimeError(
            "the version cell never left its editing state — the save may "
            "not have landed; re-run with --verify"
        )


def _process(
    uploader: Uploader,
    jobs: list[UploadJob],
    log_path: Path,
    *,
    sleep: Callable[[float], None],
    rng: random.Random,
    now: Callable[[], str],
) -> list[dict]:
    results = []
    consecutive: tuple[str, int] = ("", 0)
    for i, job in enumerate(jobs):
        if i:
            sleep(rng.uniform(2.0, 4.0))  # polite pacing between games (spec)
        try:
            if job.action == "add":
                status, note = uploader.add_game(job)
            else:
                status, note = uploader.update_entry(job)
        except LoginError as exc:
            # Systemic: every remaining job would fail identically. Abort
            # WITHOUT logging failures, so the next run just retries.
            typer.echo(f"  aborting — login is broken: {_scrub(str(exc))}")
            typer.echo(f"  {len(jobs) - i} job(s) left untouched for the next run.")
            break
        except Exception as exc:  # per-game isolation: log it, keep going
            status, note = "failed", _scrub(f"{type(exc).__name__}: {exc}")
        row = {
            "action": job.action,
            "bgg_id": job.bgg_id,
            "collid": job.collid,
            "name": job.name,
            "version_id": job.version_id,
            "second_copy": "1" if job.second_copy else "",
            "status": status,
            "timestamp": now(),
            # the column carries degradation notes on successes too
            # (e.g. added_no_version), not just failure text
            "error": note,
        }
        append_log_row(log_path, row)
        results.append(row)
        suffix = f" — {note}" if note else ""
        typer.echo(f"  {job.name}: {status}{suffix}")
        if status == "failed":
            kind = note  # IDENTICAL means the whole message, not the class
            consecutive = (kind, consecutive[1] + 1 if kind == consecutive[0] else 1)
            if consecutive[1] >= 3:
                typer.echo(
                    "  aborting — 3 identical consecutive failures look "
                    "systemic, not per-game; remaining jobs left for the "
                    "next run"
                )
                break
        else:
            consecutive = ("", 0)
    return results


def verify_uploads(log_rows: list[dict], collection: list[CollectionItem]) -> list[str]:
    """Cross-check the log's successes against a fresh collection fetch."""
    by_object: dict[int, list[CollectionItem]] = {}
    by_collid: dict[int, CollectionItem] = {}
    for item in collection:
        by_object.setdefault(item.object_id, []).append(item)
        by_collid[item.coll_id] = item

    problems = []
    added_copies: Counter[int] = Counter()
    shortfall_reported: set[int] = set()
    latest: dict[tuple[str, str, str], dict] = {}
    for row in log_rows:
        k = _job_key(row)
        latest[k] = row
        if row["action"] == "add" and row["status"] in DONE_STATUSES:
            added_copies[int(row["bgg_id"])] += 1
    for row in latest.values():
        if row["status"] in ("added", "added_no_version"):
            if row.get("second_copy"):
                # the game had copies before this add: collection state can't
                # distinguish "new entry created" from "existing entry
                # edited" (the failure the unverified dialog could produce)
                problems.append(
                    f"{row['name']}: second-copy add can't be verified from "
                    "collection counts — confirm by eye on BGG"
                )
                continue
            copies = by_object.get(int(row["bgg_id"]), [])
            if not copies:
                problems.append(f"{row['name']}: logged added but not in collection")
            elif (
                int(row["bgg_id"]) not in shortfall_reported
                and len(copies) < added_copies[int(row["bgg_id"])]
            ):
                # the unverified second-copy dialog may EDIT the existing
                # entry instead of creating one — a count shortfall is the
                # only externally visible symptom; report once per GAME
                shortfall_reported.add(int(row["bgg_id"]))
                problems.append(
                    f"{row['name']}: {added_copies[int(row['bgg_id'])]} "
                    f"add(s) logged but only {len(copies)} cop"
                    f"{'y' if len(copies) == 1 else 'ies'} in the collection"
                    " — a second-copy add may have edited an existing entry"
                )
            elif (
                row["status"] == "added"  # no_version: absence is expected
                and row["version_id"]
                and not any(
                    str(c.version_id or "") == row["version_id"] for c in copies
                )
            ):
                problems.append(
                    f"{row['name']}: in collection but no copy has "
                    f"version {row['version_id']}"
                )
        elif row["status"] == "updated":
            item = by_collid.get(int(row["collid"]))
            if item is None:
                problems.append(
                    f"{row['name']}: collid {row['collid']} not in collection"
                )
            elif str(item.version_id or "") != row["version_id"]:
                problems.append(
                    f"{row['name']}: collid {row['collid']} does not carry "
                    f"version {row['version_id']}"
                )
    return problems


def run_upload(
    cfg: Config,
    *,
    dry_run: bool = False,
    verify: bool = False,
    retry_failed: bool = False,
    limit: int | None = None,
    headless: bool = False,
    uploader: Uploader | None = None,
    client: BGGClient | None = None,
    storage_state: Path | None = None,
    sleep: Callable[[float], None] = time.sleep,
    rng: random.Random | None = None,
    now: Callable[[], str] | None = None,
) -> list[dict]:
    rng = rng or random.Random()
    now = now or (lambda: datetime.now(UTC).isoformat(timespec="seconds"))

    # Two provenance markers guard the same fact from different angles: the
    # cache marker travels with the stub XML (gitignored, so a fresh clone
    # loses it), while data/STUB_DATA.marker is COMMITTED alongside the
    # stub-derived CSVs — so a clone can never upload placeholder ids.
    marker = next((m for m in cfg.stub_marker_paths if m.exists()), None)
    if marker is not None:
        if not dry_run:
            typer.echo(
                f"Refusing to upload: {marker} exists — every resolved "
                "version_id is a synthetic stub placeholder. Once "
                "BGG_API_TOKEN arrives: delete both cache dirs, re-record "
                "fixtures, `resolve --force`, re-review, re-diff, then "
                "delete the marker(s)."
            )
            raise typer.Exit(code=1)
        typer.echo(
            "WARNING: stub fixtures active — the ids below are SYNTHETIC "
            "placeholders, not real BGG data.\n"
        )

    log_path = cfg.upload_log_path
    if not cfg.to_add_path.exists():
        typer.echo(f"{cfg.to_add_path} not found — run `bggpipe diff` first.")
        raise typer.Exit(code=1)
    to_add = _read_csv(cfg.to_add_path)
    if not cfg.to_update_path.exists():
        typer.echo(
            f"note: {cfg.to_update_path} not found — no version updates "
            "queued (re-run `bggpipe diff` if that's unexpected)"
        )
    to_update = _read_csv(cfg.to_update_path)
    log_rows = _read_csv(log_path)

    # The queue is a snapshot; review decisions since the last diff win.
    stale = stale_jobs(to_add + to_update, read_matches(cfg.matches_path))
    if stale:
        to_add = [r for r in to_add if r.get("bgg_id") not in stale]
        to_update = [r for r in to_update if r.get("bgg_id") not in stale]
        typer.echo(
            f"Skipping {len(stale)} queued game(s) whose review decision "
            "changed since the last diff — re-run diff to refresh the queue:"
        )
        for bgg_id, why in stale.items():
            typer.echo(f"  {bgg_id}: {why}")

    jobs, skipped_done, skipped_failed = build_queue(
        to_add, to_update, log_rows, retry_failed=retry_failed
    )
    if limit is not None:
        jobs = jobs[:limit]

    skip_note = ""
    if skipped_done or skipped_failed:
        skip_note = (
            f" (skipping {skipped_done} already done, {skipped_failed} "
            "previously failed — use --retry-failed)"
        )

    results: list[dict] = []
    if dry_run:
        typer.echo(f"Dry run: {len(jobs)} job(s) pending{skip_note}")
        for job in jobs:
            if job.action == "add":
                version = f" [version {job.version_name}]" if job.version_name else ""
                typer.echo(f"  would add {job.name} ({job.bgg_id}){version}")
            else:
                typer.echo(
                    f"  would set version {job.version_name!r} on existing "
                    f"entry collid {job.collid} ({job.name})"
                )
    elif jobs:
        typer.echo(f"Uploading {len(jobs)} job(s){skip_note}")
        if uploader is None:
            if not (os.environ.get("BGG_USERNAME") and os.environ.get("BGG_PASSWORD")):
                typer.echo(
                    "BGG_USERNAME and BGG_PASSWORD env vars are required "
                    "(direnv loads them from .env)."
                )
                raise typer.Exit(code=1)
            with PlaywrightUploader(
                cfg.bgg_username,
                storage_state=storage_state or cfg.storage_state_path,
                headless=headless,
            ) as real:
                results = _process(real, jobs, log_path, sleep=sleep, rng=rng, now=now)
        else:
            results = _process(uploader, jobs, log_path, sleep=sleep, rng=rng, now=now)
        counts = Counter(row["status"] for row in results)
        summary = " · ".join(f"{n} {status}" for status, n in sorted(counts.items()))
        typer.echo(f"\n{summary or 'nothing to do'}")
    else:
        typer.echo(f"Nothing to upload{skip_note}.")

    if verify and not dry_run:
        _run_verify(cfg, client, log_path)
    return results


def _run_verify(cfg: Config, client: BGGClient | None, log_path: Path) -> None:
    if not cfg.bgg_username:
        typer.echo("--verify needs BGG_USERNAME in the environment.")
        return
    client = client or client_for(cfg)
    try:
        collection = client.collection_full(cfg.bgg_username, refresh=True)
    except BGGAuthError as exc:
        typer.echo(f"--verify skipped: {exc}")
        return
    problems = verify_uploads(_read_csv(log_path), collection)
    if problems:
        typer.echo("\nVerification problems:")
        for line in problems:
            typer.echo(f"  - {line}")
    else:
        typer.echo("\nVerification OK: every logged success is in the collection.")
