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

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


@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 _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, list[UploadJob]]:
    """Turn the diff outputs into pending jobs, minus work the log says is
    done. Returns (jobs, skipped_done, skipped_failed, deferred) — deferred
    being same-game updates held for a later run.

    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
    deferred: list[UploadJob] = []
    update_game_seen: set[str] = set()
    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"  note: {job.name} was previously {job.action}ed with a "
                f"different version ({', '.join(sorted(prior)) or 'none'}) — "
                "if re-review changed the version, the BGG entry needs a "
                "manual correction (additive-only rule)"
            )
        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
        elif job.action == "update" and job.bgg_id in update_game_seen:
            # The row-edit flow finds rows by game name, not collid — a
            # second same-game update this run could reopen the copy the
            # first one just versioned and overwrite it. One per run; the
            # next run (after --verify) picks up the rest.
            deferred.append(job)
        else:
            if job.action == "update":
                update_game_seen.add(job.bgg_id)
            jobs.append(job)
    return jobs, skipped_done, skipped_failed, deferred


def _scrub(text: str) -> str:
    """Credentials must never reach the log, even via a selector error that
    echoes filled form values."""
    for key in ("BGG_PASSWORD", "BGG_USERNAME"):
        value = os.environ.get(key)
        if value:
            text = text.replace(value, "***")
    return text


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.

    Verified selectors (2026-08-01): the login form (#inputUsername /
    #inputPassword / "Sign In") and the Add-to-Collection dialog structure.
    UNVERIFIED: version-picker pagination, post-save behavior, and the whole
    collection-row edit flow for updates — expect first-real-run adjustments.
    """

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

    def _signed_out(self) -> bool:
        # Heuristic: the header shows a "Sign In" link only when logged out.
        return (
            self._page.get_by_role("link", name=re.compile(r"^sign in$", re.I)).count()
            > 0
        )

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

    def _select_version(self, dialog, version_name: str) -> bool:
        """Page through the version sub-view matching the canonical version
        NAME (the list has no search box). Returns False — with the sub-view
        cancelled — when the name never shows up."""
        dialog.get_by_role("button", name="Set version/edition").click()
        pattern = re.compile(re.escape(version_name), re.I)
        try:
            dialog.get_by_role("listitem").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
        for _ in range(MAX_VERSION_PAGES):
            items = dialog.get_by_role("listitem").filter(has_text=pattern)
            if items.count():
                items.first.click()
                return True
            # Pagination controls are UNVERIFIED (docs/bgg-upload-flow.md);
            # best guess is a next-page button, stopping when absent/disabled.
            nxt = dialog.get_by_role("button", name=re.compile("next|›|»", re.I)).first
            if nxt.count() == 0 or nxt.is_disabled():
                break  # genuine end of list: added_no_version is honest
            nxt.click()
            self._page.wait_for_timeout(2_000)  # etiquette: paginating hits BGG too
        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"
            )
        # 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()
        return False

    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").first
        dialog = self._open_dialog(add_btn)
        # Content settles when the game-name heading replaces "Loading...".
        dialog.get_by_role(
            "heading", name=re.compile(re.escape(job.name), re.I)
        ).wait_for(timeout=15_000)
        dialog.get_by_label("Own").check()
        status, note = "added", ""
        if job.version_name and not self._select_version(dialog, job.version_name):
            status = "added_no_version"
            note = f"version {job.version_name!r} not in picker; added without version"
        dialog.get_by_role("button", name="Save").click()
        # The dialog is hidden after save, not removed from the DOM.
        dialog.wait_for(state="hidden", timeout=15_000)
        return status, note

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

        UNVERIFIED FLOW: the collection table can't target a collid directly,
        so this filters by game and opens the row's status link. With several
        copies of one game the wrong row could open — acceptable only while
        every 2018 entry is version-less; revisit after the first real run.
        """
        self._ensure_logged_in()
        page = self._page
        self._goto(
            f"{BGG}/collection/user/{self._username}?objectid={job.bgg_id}&own=1"
        )
        row = (
            page.get_by_role("row")
            .filter(has_text=re.compile(re.escape(job.name), re.I))
            .first
        )
        opener = row.get_by_role("link", name=re.compile("own", re.I)).first
        dialog = self._open_dialog(opener)
        if not self._select_version(dialog, job.version_name):
            # Never guess a version: close without touching the entry.
            dialog.get_by_role("button", name="Cancel").first.click()
            raise RuntimeError(
                f"version {job.version_name!r} not in picker — entry left untouched"
            )
        dialog.get_by_role("button", name="Save").click()
        dialog.wait_for(state="hidden", timeout=15_000)
        return "updated", "row-edit flow is unverified; confirm 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.split(":", 1)[0]  # exception type from _scrub format
            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)

    jobs, skipped_done, skipped_failed, deferred = 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)"
        )
    if deferred:
        typer.echo(
            f"{len(deferred)} version update(s) deferred: one update per game "
            "per run (the row-edit flow can't target a collid) — re-run "
            "upload after --verify confirms this batch."
        )

    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.")
