"""Stage 4 — diff approved/auto matches against the existing BGG collection.

Two collection sources:
- live API (default once BGG_API_TOKEN exists): /collection base + expansion
  subtype calls, merged by collid;
- snapshot files (fallback): data/collection_snapshot_base.xml +
  data/collection_snapshot_expansions.xml, pulled manually via the
  logged-in-user exemption.

Outputs both artifacts:
- to_add.csv    — recognized games not in the collection, plus additional
  copies once every owned copy is claimed by another match row;
- to_update.csv — owned, VERSION-LESS entries where matching produced a
  confident version. Strictly additive: entries that already carry a
  version are never touched — a version mismatch against an unclaimed
  copy is reported as a disagreement, nothing more.
"""

from __future__ import annotations

import os
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path

import typer

from bggpipe.bgg_client import (
    BGGAuthError,
    BGGClient,
    BGGQueueTimeout,
    client_for,
)
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_csv
from bggpipe.models import (
    RECOGNIZED_MATCH_STATUSES,
    CollectionItem,
    is_confident_version,
    parse_collection,
)
from bggpipe.resolve import read_matches

TO_ADD_COLUMNS = [
    "bgg_id",
    "bgg_name",
    "year",
    "type",
    "version_id",
    "version_name",
    "title_raw",
    "source_photos",
    "second_copy",  # "1": game already had copies — verify can't confirm it
]
TO_UPDATE_COLUMNS = ["collid", "bgg_id", "bgg_name", "version_id", "version_name"]

SNAPSHOT_FILES = ("collection_snapshot_base.xml", "collection_snapshot_expansions.xml")


@dataclass
class DiffResult:
    to_add: list[dict] = field(default_factory=list)
    to_update: list[dict] = field(default_factory=list)
    already_owned: list[str] = field(default_factory=list)  # title_raw
    local_only: list[str] = field(default_factory=list)  # RPGs etc: never uploaded
    second_copies: list[str] = field(default_factory=list)  # notes for adds
    disagreements: list[str] = field(default_factory=list)  # report-only
    unseen: list[CollectionItem] = field(default_factory=list)
    pending: list[str] = field(default_factory=list)  # ambiguous/unmatched titles
    rejected: int = 0
    merged: int = 0
    recognized: int = 0


def load_snapshot_collection(data_dir: Path) -> list[CollectionItem]:
    """Merge the base + expansions snapshot files, deduping by collid (the
    same physical copy can appear in both responses)."""
    items: list[CollectionItem] = []
    seen: set[int] = set()
    for path in Config(data_dir=data_dir).snapshot_paths:
        if not path.exists():
            raise FileNotFoundError(
                f"{path} not found — pull your collection while logged in "
                "(no registration needed for your own collection) or set "
                "BGG_API_TOKEN for live mode."
            )
        for item in parse_collection(path.read_text()):
            if item.own and item.coll_id not in seen:
                seen.add(item.coll_id)
                items.append(item)
    return items


def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResult:
    by_object: dict[int, list[CollectionItem]] = {}
    for item in collection:
        by_object.setdefault(item.object_id, []).append(item)

    result = DiffResult()
    seen_object_ids: set[int] = set()
    consumed_collids: set[int] = set()

    # photos of merged-away duplicate reads belong to their survivor
    merged_photos: dict[str, set[str]] = {}
    for row in rows:
        if row["match_status"] == "merged" and row.get("merged_into"):
            merged_photos.setdefault(row["merged_into"], set()).update(
                p for p in row["source_photos"].split(";") if p
            )

    queued_adds: Counter[int] = Counter()  # per game, across all passes

    def add_row(row: dict, confident: bool, second_copy: bool = False) -> dict:
        queued_adds[int(row["bgg_id"])] += 1
        photos = {p for p in row["source_photos"].split(";") if p}
        photos |= merged_photos.get(row["title_raw"], set())
        return {
            "bgg_id": row["bgg_id"],
            "bgg_name": row["bgg_name"],
            "year": row["year"],
            "type": row["type"],
            "version_id": row["version_id"] if confident else "",
            "version_name": row["version_name"] if confident else "",
            "title_raw": row["title_raw"],
            "source_photos": ";".join(sorted(photos)),
            "second_copy": "1" if second_copy else "",
        }

    recognized: list[dict] = []
    for row in rows:
        status = row["match_status"]
        if status == "rejected":
            result.rejected += 1
            continue
        if status == "merged":
            result.merged += 1  # represented by its survivor row
            continue
        if status not in RECOGNIZED_MATCH_STATUSES or not row["bgg_id"]:
            result.pending.append(row["title_raw"])
            continue
        if row["type"] == "rpgitem":
            # a real, identified game — but it lives on RPGGeek, not in the
            # BGG collection: a library citizen only, never queued
            result.local_only.append(row["title_raw"])
            continue
        result.recognized += 1
        recognized.append(row)
        if by_object.get(int(row["bgg_id"])):
            seen_object_ids.add(int(row["bgg_id"]))

    def unconsumed(bgg_id: int) -> list[CollectionItem]:
        return [
            c for c in by_object.get(bgg_id, []) if c.coll_id not in consumed_collids
        ]

    # Ordered sub-passes over the confident rows: greedy per-row handling
    # would let an earlier row's disagreement consume the exact-version copy
    # a later row matches, manufacturing a duplicate upload. Claims settle
    # strongest-first across all rows — exact version matches, then
    # versionless upgrades, then disagreement/second-copy handling.
    confident_rows = [r for r in recognized if is_confident_version(r)]
    leftover: list[dict] = []

    # 1a — exact (bgg_id, version) matches consume first
    for row in confident_rows:
        bgg_id = int(row["bgg_id"])
        if not by_object.get(bgg_id):
            result.to_add.append(add_row(row, True))
            continue
        matching = [
            c for c in unconsumed(bgg_id) if c.version_id == int(row["version_id"])
        ]
        if matching:
            # consume, so a SECOND row with the same version (a vetoed
            # duplicate = a real second copy) falls through to 1b/1c
            consumed_collids.add(matching[0].coll_id)
            result.already_owned.append(row["title_raw"])
        else:
            leftover.append(row)

    # 1b — versionless copies get upgraded. Guard: upload's row-edit flow
    # targets rows by game NAME (a collid can't drive the UI), so an update
    # is only safe when EVERY copy of the game is versionless — otherwise
    # the browser could open the versioned copy and overwrite it.
    still_left: list[dict] = []
    for row in leftover:
        bgg_id = int(row["bgg_id"])
        versionless = [c for c in unconsumed(bgg_id) if c.version_id is None]
        any_versioned = any(c.version_id is not None for c in by_object.get(bgg_id, []))
        if versionless and any_versioned:
            consumed_collids.add(versionless[0].coll_id)
            result.already_owned.append(row["title_raw"])
            result.disagreements.append(
                f"{row['title_raw']}: a versionless copy could take version "
                f"{row['version_name']!r}, but another copy already carries "
                "a version — set it by hand on BGG (the automated row edit "
                "can't safely target a specific copy)"
            )
            continue
        if versionless:
            target = versionless[0]
            consumed_collids.add(target.coll_id)
            result.already_owned.append(row["title_raw"])
            result.to_update.append(
                {
                    "collid": str(target.coll_id),
                    "bgg_id": row["bgg_id"],
                    "bgg_name": row["bgg_name"] or target.name,
                    "version_id": row["version_id"],
                    "version_name": row["version_name"],
                }
            )
        else:
            still_left.append(row)

    # 1c — what remains disagrees with an unclaimed copy (report-only) or
    # is an additional physical copy (every copy claimed by another row)
    for row in still_left:
        remaining = unconsumed(int(row["bgg_id"]))
        if remaining:
            # most likely the same physical box mis-scored — report, never
            # touch, never duplicate (spec: report the disagreement)
            consumed_collids.add(remaining[0].coll_id)
            result.already_owned.append(row["title_raw"])
            result.disagreements.append(
                f"{row['title_raw']}: photo suggests version "
                f"{row['version_name']!r} ({row['version_id']}) but the "
                "remaining collection entry carries a different version — "
                "left untouched"
            )
        else:
            result.to_add.append(add_row(row, True, second_copy=True))
            result.second_copies.append(
                f"{row['title_raw']}: adding as a NEW copy with version "
                f"{row['version_name']!r} ({row['version_id']}) — every "
                "existing entry of this game keeps its current version"
            )

    # Pass 2 — bare (version-unknown) rows. Spec: a bare id is owned if ANY
    # copy exists — only a human veto (dedupe_veto) makes an extra bare row
    # a genuine additional copy.
    for row in (r for r in recognized if not is_confident_version(r)):
        bgg_id = int(row["bgg_id"])
        copies = by_object.get(bgg_id, [])
        if not copies:
            if queued_adds[bgg_id] and not row.get("dedupe_veto"):
                # an unvetoed bare row is a typo-read sibling: a pass-1 add
                # for the same absent game already covers the physical box —
                # queueing it again would upload a duplicate entry
                result.already_owned.append(row["title_raw"])
            else:
                result.to_add.append(
                    add_row(row, False, second_copy=bool(queued_adds[bgg_id]))
                )
            continue
        remaining = unconsumed(bgg_id)
        if remaining:
            consumed_collids.add(remaining[0].coll_id)
            result.already_owned.append(row["title_raw"])
        elif row.get("dedupe_veto"):
            result.to_add.append(add_row(row, False, second_copy=True))
            result.second_copies.append(
                f"{row['title_raw']}: adding as a NEW version-less copy — "
                "human-vetoed duplicate, every existing entry claimed by "
                "another match row"
            )
        else:
            # unvetoed bare row, all copies claimed: per spec still owned
            # (a typo-read sibling of a confident row must not become a
            # spurious upload)
            result.already_owned.append(row["title_raw"])

    result.unseen = [
        item for item in collection if item.object_id not in seen_object_ids
    ]
    return result


def run_diff(cfg: Config, *, client: BGGClient | None = None) -> DiffResult:
    rows = read_matches(cfg.matches_path)
    if not rows:
        typer.echo(f"{cfg.matches_path} is empty — run `bggpipe resolve` first.")
        raise typer.Exit(code=1)

    has_token = bool(os.environ.get("BGG_API_TOKEN"))
    if has_token and cfg.bgg_username:
        typer.echo("Fetching live collection from BGG…")
        client = client or client_for(cfg)
        try:
            collection = client.collection_full(cfg.bgg_username, refresh=True)
        except (BGGAuthError, BGGQueueTimeout) as err:
            # a present-but-invalid token must not traceback when the
            # snapshot fallback is sitting right there
            typer.echo(f"Live fetch failed ({err}) — using snapshot files.")
            collection = load_snapshot_collection(cfg.data_dir)
    else:
        if has_token:
            # saying "No BGG_API_TOKEN" here would be false and misdirect
            # the user's debugging — the missing half is the username
            typer.echo(
                "BGG_API_TOKEN is set but BGG_USERNAME is not (is .env "
                "loaded?) — using snapshot files instead of live mode."
            )
        else:
            typer.echo(
                "No BGG_API_TOKEN — using collection snapshot files in "
                f"{cfg.data_dir}/ (live mode takes over once the token exists)."
            )
        collection = load_snapshot_collection(cfg.data_dir)

    result = compute_diff(rows, collection)

    # atomic: a killed diff never leaves a torn upload queue
    atomic_write_csv(cfg.to_add_path, TO_ADD_COLUMNS, result.to_add)
    atomic_write_csv(cfg.to_update_path, TO_UPDATE_COLUMNS, result.to_update)

    merged_note = f" · {result.merged} merged duplicate(s)" if result.merged else ""
    local_note = (
        f" · {len(result.local_only)} local-only (RPGs)" if result.local_only else ""
    )
    typer.echo(
        f"\n{result.recognized} recognized · {len(result.already_owned)} already "
        f"owned · {len(result.to_add)} to add · {len(result.to_update)} version "
        f"update(s) · {len(result.pending)} pending review · "
        f"{result.rejected} rejected{merged_note}{local_note}"
    )
    if result.second_copies:
        typer.echo("\nSecond copies (verify these on the dry run before upload):")
        for line in result.second_copies:
            typer.echo(f"  - {line}")
    if result.disagreements:
        typer.echo("\nVersion disagreements (reported only — nothing changed):")
        for line in result.disagreements:
            typer.echo(f"  - {line}")
    if result.pending:
        typer.echo("\nStill pending review: " + ", ".join(result.pending))
    if result.unseen:
        typer.echo(
            f"\nIn your collection but not seen in any photo "
            f"({len(result.unseen)} — informational only):"
        )
        for item in result.unseen:
            typer.echo(f"  - {item.name} ({item.object_id})")
    return result
