"""Dataclasses + XML parsers for the BGG XML API2 responses we consume."""

from __future__ import annotations

import warnings
import xml.etree.ElementTree as ET  # parsing itself goes via defusedxml
from dataclasses import dataclass, field

from defusedxml.ElementTree import fromstring as _safe_fromstring


class BGGResponseError(Exception):
    """The API returned a well-formed error document (e.g. bad username)."""


# The two status predicates the whole pipeline shares: a version is trusted
# for diff/upload/enrich only when matching produced it confidently or a
# human approved it, and a row reaches diff/enrich only when its match did.
CONFIDENT_VERSION_STATUSES = ("version_auto", "version_approved")
RECOGNIZED_MATCH_STATUSES = ("auto", "approved")
PENDING_MATCH_STATUSES = ("ambiguous", "unmatched")
UNDECIDED_MATCH_STATUSES = ("ambiguous", "unmatched", "merged")


def is_recognized(row: dict) -> bool:
    """A row that reaches diff/enrich: matched and carrying a real id."""
    return row["match_status"] in RECOGNIZED_MATCH_STATUSES and bool(row["bgg_id"])


def is_confident_version(row: dict) -> bool:
    """A version trusted for upload: auto-scored or human-approved, with id."""
    return row["version_status"] in CONFIDENT_VERSION_STATUSES and bool(
        row["version_id"]
    )


@dataclass(frozen=True)
class SearchResult:
    bgg_id: int
    name: str
    name_type: str  # "primary" | "alternate"
    year: int | None
    type: str  # "boardgame" | "boardgameexpansion"


@dataclass(frozen=True)
class GameVersion:
    version_id: int
    name: str
    year: int | None
    publishers: tuple[str, ...]
    languages: tuple[str, ...]


@dataclass(frozen=True)
class ThingDetails:
    bgg_id: int
    name: str
    year: int | None
    type: str
    owned: int | None = None
    rank: int | None = None
    publishers: tuple[str, ...] = field(default=())
    versions: tuple[GameVersion, ...] = field(default=())


@dataclass(frozen=True)
class CollectionItem:
    object_id: int
    coll_id: int
    name: str
    subtype: str
    own: bool
    year: int | None
    version_id: int | None


def _root(xml_text: str) -> ET.Element:
    root = _safe_fromstring(xml_text)
    if root.tag == "errors":
        message = root.findtext("./error/message") or "unknown BGG error"
        raise BGGResponseError(message.strip())
    return root


def _attr_int(elem: ET.Element | None, attr: str = "value") -> int | None:
    if elem is None:
        return None
    raw = elem.get(attr)
    if raw is None or not raw.lstrip("-").isdigit():
        return None
    return int(raw)


def parse_search(xml_text: str) -> list[SearchResult]:
    results = []
    skipped = 0
    for item in _root(xml_text).findall("item"):
        name_elem = item.find("name")
        if name_elem is None or item.get("id") is None:
            skipped += 1  # tolerate stragglers; wholesale drift raises below
            continue
        results.append(
            SearchResult(
                bgg_id=int(item.get("id")),
                name=name_elem.get("value", ""),
                name_type=name_elem.get("type", "primary"),
                year=_attr_int(item.find("yearpublished")),
                type=item.get("type", "boardgame"),
            )
        )
    if skipped and results:
        warnings.warn(
            f"search: {skipped} unparseable item(s) tolerated — schema drift?",
            stacklevel=2,
        )
    if skipped and not results:
        raise BGGResponseError(
            f"search response had {skipped} item(s), none parseable — "
            "schema drift? Bad data must not look like no results."
        )
    return results


def _parse_version(item: ET.Element) -> GameVersion | None:
    version_id = item.get("id")
    name = item.find("name[@type='primary']")
    if version_id is None:
        return None
    return GameVersion(
        version_id=int(version_id),
        name=name.get("value", "") if name is not None else "",
        year=_attr_int(item.find("yearpublished")),
        publishers=tuple(
            link.get("value", "")
            for link in item.findall("link[@type='boardgamepublisher']")
        ),
        languages=tuple(
            link.get("value", "") for link in item.findall("link[@type='language']")
        ),
    )


def parse_things(xml_text: str) -> list[ThingDetails]:
    things = []
    for item in _root(xml_text).findall("item"):
        name = item.find("name[@type='primary']")
        rank_elem = item.find(".//ranks/rank[@name='boardgame']")
        versions = [
            v
            for v_item in item.findall("versions/item")
            if (v := _parse_version(v_item)) is not None
        ]
        things.append(
            ThingDetails(
                bgg_id=int(_required_attr(item, "id")),
                name=name.get("value", "") if name is not None else "",
                year=_attr_int(item.find("yearpublished")),
                type=item.get("type", "boardgame"),
                owned=_attr_int(item.find(".//ratings/owned")),
                rank=_attr_int(rank_elem),
                publishers=tuple(
                    link.get("value", "")
                    for link in item.findall("link[@type='boardgamepublisher']")
                ),
                versions=tuple(versions),
            )
        )
    return things


def _attr_float(elem: ET.Element | None, attr: str = "value") -> float | None:
    if elem is None:
        return None
    try:
        return float(elem.get(attr))
    except (TypeError, ValueError):
        return None


def parse_things_full(xml_text: str) -> list[dict]:
    """Full game metadata for the enrich stage (games.json). Returns plain
    dicts — this is artifact data, not pipeline logic."""
    games = []
    for item in _root(xml_text).findall("item"):

        def links(link_type: str, item: ET.Element = item) -> list[str]:
            return [
                link.get("value", "")
                for link in item.findall(f"link[@type='{link_type}']")
            ]

        name = item.find("name[@type='primary']")
        ratings = item.find("statistics/ratings")
        best_player_counts = []
        poll = item.find("poll[@name='suggested_numplayers']")
        if poll is not None:
            for results in poll.findall("results"):
                votes = {
                    r.get("value"): int(r.get("numvotes") or 0)
                    for r in results.findall("result")
                }
                best = votes.get("Best", 0)
                if (
                    best
                    and best >= votes.get("Recommended", 0)
                    and best > votes.get("Not Recommended", 0)
                ):
                    best_player_counts.append(results.get("numplayers"))
        games.append(
            {
                "bgg_id": int(_required_attr(item, "id")),
                "type": item.get("type", "boardgame"),
                "name": name.get("value", "") if name is not None else "",
                "year": _attr_int(item.find("yearpublished")),
                "description": (item.findtext("description") or "").strip(),
                "image": (item.findtext("image") or "").strip(),
                "thumbnail": (item.findtext("thumbnail") or "").strip(),
                "min_players": _attr_int(item.find("minplayers")),
                "max_players": _attr_int(item.find("maxplayers")),
                "best_player_counts": best_player_counts,
                "playtime": _attr_int(item.find("playingtime")),
                "min_playtime": _attr_int(item.find("minplaytime")),
                "max_playtime": _attr_int(item.find("maxplaytime")),
                "min_age": _attr_int(item.find("minage")),
                "designers": links("boardgamedesigner"),
                "artists": links("boardgameartist"),
                "publishers": links("boardgamepublisher"),
                "categories": links("boardgamecategory"),
                "mechanics": links("boardgamemechanic"),
                "rating": _attr_float(ratings.find("average"))
                if ratings is not None
                else None,
                "weight": _attr_float(ratings.find("averageweight"))
                if ratings is not None
                else None,
                "rank": _attr_int(item.find(".//ranks/rank[@name='boardgame']")),
                "users_owned": _attr_int(ratings.find("owned"))
                if ratings is not None
                else None,
            }
        )
    return games


def _required_attr(item: ET.Element, name: str) -> str:
    value = item.get(name)
    if not value:
        raise BGGResponseError(
            f"response item missing {name!r} — truncated or unexpected "
            "response; refusing to coerce a missing id"
        )
    return value


def validate_response(xml_text: str) -> None:
    """Raise BGGResponseError for error documents AND malformed XML — the
    client calls this before caching, so a torn or truncated 200 body can
    never poison the cache."""
    try:
        _root(xml_text)
    except ET.ParseError as err:
        raise BGGResponseError(f"malformed XML: {err}") from err


def parse_collection(xml_text: str) -> list[CollectionItem]:
    items = []
    for item in _root(xml_text).findall("item"):
        status = item.find("status")
        year_text = item.findtext("yearpublished")
        version_item = item.find("version/item")
        items.append(
            CollectionItem(
                # strict: a missing id coerced to 0 would collide in the
                # collid dedupe and silently drop owned games from the diff
                object_id=int(_required_attr(item, "objectid")),
                coll_id=int(_required_attr(item, "collid")),
                name=item.findtext("name", default=""),
                subtype=item.get("subtype", "boardgame"),
                own=status is not None and status.get("own") == "1",
                year=(
                    int(year_text)
                    if year_text and year_text.lstrip("-").isdigit()
                    else None
                ),
                version_id=(
                    int(version_item.get("id"))
                    if version_item is not None and version_item.get("id")
                    else None
                ),
            )
        )
    return items
