"""Free, local MTG Arena collection exporter (memory-based).

Modern MTG Arena no longer writes the player's collection to ``Player.log``
(verified: a fresh login logs only home-screen calls — no
``PlayerInventory.GetPlayerCards``), so log-scraping tools cannot read it. This
tool reads the collection straight out of the running ``MTGA.exe`` process,
which is log-independent and always current.

How it works
------------
MTGA holds the collection in memory as a flat array of
``(grpId: uint32, quantity: uint32)`` little-endian pairs. We:

1. Build a ``grpId -> {name, set, collector}`` map from the client's OWN local
   card database (``MTGA_Data/Downloads/Raw/*.mtga`` SQLite — ``Cards`` +
   ``Localizations``), so names always match the installed client (incl. the
   newest sets). Falls back to Scryfall ``arena_id`` bulk data if needed.
2. Walk every committed, writable memory region of ``MTGA.exe`` looking for the
   longest contiguous run of *valid* ``(grpId, qty)`` pairs, and disambiguate
   by which candidate block's grpIds best resolve against the name map. That
   block is the collection.
3. Write ``mtga_collection.txt`` — bare ``N CardName`` lines, ready to paste
   into Tutorbox's "Import Arena List" (set/collector are intentionally omitted;
   Arena ownership is per-card). Also writes ``.csv``.

Only reads memory + local files (and optionally Scryfall for names); writes only
local output files. It does not modify the game. Run it with Arena open and on
the Decks/Collection screen.

NOTE: reading game memory is against MTGA's ToS in the strict letter (like every
collection tracker). It only ever reads YOUR OWN collection for personal use.

Usage::

    pip install pymem requests
    python tools/arena_collection_exporter.py
    # optional disambiguation if auto-detect picks the wrong block:
    python tools/arena_collection_exporter.py --anchor "Sol Ring:1" --anchor "Llanowar Elves:4"

The pure parsing helpers (``parse_card_pairs``, ``pick_best_block``,
``format_txt``) are import-safe on any OS and unit-tested; the memory + SQLite
parts import their Windows-only deps lazily.
"""
from __future__ import annotations

import argparse
import json
import struct
import sys
from pathlib import Path

# Valid bounds for a (grpId, quantity) pair in the collection array. Arena grpIds
# are ~16xxx..1xxxxx; a singleton format caps real ownership ~4, but basics and
# some promos go higher — keep generous to avoid dropping the array's tail.
_GRPID_MIN, _GRPID_MAX = 1000, 999_999
_QTY_MIN, _QTY_MAX = 1, 1000
# A run shorter than this is noise, not a collection (a real account owns 100s).
_MIN_BLOCK = 60
# Tolerate this many invalid pairs inside a run before splitting it (the array
# can be interleaved with small gaps / alignment slack).
_MISS_TOLERANCE = 8


def parse_card_pairs(data: bytes) -> list[dict[int, int]]:
    """Find runs of valid ``(grpId, qty)`` uint32 pairs in a raw byte buffer.

    Returns a list of ``{grpId: qty}`` blocks. Tries both 4-byte alignments so
    a pair that starts on an odd word boundary is still found. Pure function —
    no process access — so it is unit-testable with synthetic bytes.
    """
    n = len(data) // 4
    if n < 2:
        return []
    ints = struct.unpack(f"<{n}I", data[: n * 4])

    blocks: list[dict[int, int]] = []
    for offset in (0, 1):
        current: dict[int, int] = {}
        misses = 0
        for i in range(offset, n - 1, 2):
            k, v = ints[i], ints[i + 1]
            if _GRPID_MIN <= k <= _GRPID_MAX and _QTY_MIN <= v <= _QTY_MAX:
                current[k] = v
                misses = 0
            else:
                misses += 1
                if misses > _MISS_TOLERANCE:
                    if len(current) >= _MIN_BLOCK:
                        blocks.append(current)
                    current = {}
                    misses = 0
        if len(current) >= _MIN_BLOCK:
            blocks.append(current)
    return blocks


def pick_best_block(
    blocks: list[dict[int, int]],
    name_map: dict[int, dict] | None = None,
    anchors: dict[int, int] | None = None,
) -> dict[int, int]:
    """Choose the block most likely to be the real collection.

    Priority:
      1. If ``anchors`` (grpId->qty the user confirmed owning) are given, prefer
         a block that contains every anchor at the right quantity.
      2. Else score by ``size * name-resolution-rate`` — the collection's grpIds
         resolve against ``name_map`` far better than a random int array does.
      3. Else the largest block.
    """
    if not blocks:
        return {}
    if anchors:
        hits = [b for b in blocks
                if all(b.get(a) == q for a, q in anchors.items())]
        if hits:
            return max(hits, key=len)
    if name_map:
        def score(b: dict[int, int]) -> float:
            known = sum(1 for g in b if g in name_map)
            return known * (known / max(1, len(b)))  # size * hit-rate
        return max(blocks, key=score)
    return max(blocks, key=len)


def aggregate(collection: dict[int, int], name_map: dict[int, dict]) -> list[dict]:
    """Map grpId->name and sum quantities per (name, set). Unknown grpIds are
    kept under a ``grp:<id>`` placeholder name so nothing is silently dropped."""
    out: dict[tuple, dict] = {}
    for grp, qty in collection.items():
        info = name_map.get(grp)
        name = info["name"] if info else f"grp:{grp}"
        setc = (info.get("set") if info else "") or ""
        key = (name, setc)
        row = out.setdefault(key, {"count": 0, "name": name, "set": setc,
                                   "cn": (info or {}).get("collector_number", ""),
                                   "known": info is not None})
        row["count"] += qty
    return sorted(out.values(), key=lambda r: (r["name"], r["set"]))


def format_txt(rows: list[dict], *, include_unknown: bool = False) -> str:
    """Bare ``N CardName`` per line — the format Tutorbox's Import Arena List
    accepts. Set/collector dropped on purpose (Arena ownership is per-card), and
    quantities are summed across printings so each card appears once."""
    by_name: dict[str, int] = {}
    for r in rows:
        if not r["known"] and not include_unknown:
            continue
        by_name[r["name"]] = by_name.get(r["name"], 0) + r["count"]
    return "\n".join(f"{q} {n}" for n, q in sorted(by_name.items()))


# --------------------------------------------------------------------------
# Windows-only: local card DB + process memory. Lazy imports so the module
# (and its pure helpers above) import cleanly on Linux/CI.
# --------------------------------------------------------------------------

def _local_db_paths() -> list[Path]:
    return [
        Path(r"C:\Program Files\Wizards of the Coast\MTGA\MTGA_Data\Downloads\Raw"),
        Path(r"C:\Program Files (x86)\Wizards of the Coast\MTGA\MTGA_Data\Downloads\Raw"),
        Path(r"C:\Program Files (x86)\Steam\steamapps\common\MTGA\MTGA_Data\Downloads\Raw"),
    ]


def load_local_card_map() -> dict[int, dict]:
    """grpId -> {name, set, collector_number} from the client's local SQLite.

    Modern MTGA splits the data: ``Cards`` (GrpId, TitleId, ExpansionCode) lives
    in ``Raw_CardDatabase_*.mtga`` alongside per-language name tables
    ``Localizations_enUS`` (LocId, Formatted, Loc). A LocId can have both a
    Formatted=1 (markup) and Formatted=0 (plain) row; for names they're equal,
    so we prefer Formatted=0 and accept 1 as a fallback.
    """
    import sqlite3

    raw = next((p for p in _local_db_paths() if p.exists()), None)
    if not raw:
        return {}
    db = next(iter(sorted(raw.glob("Raw_CardDatabase_*.mtga"),
                          key=lambda f: f.stat().st_size, reverse=True)), None)
    if db is None:
        return {}
    lookup: dict[int, dict] = {}
    try:
        conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
        cur = conn.cursor()
        tables = {r[0] for r in cur.execute(
            "SELECT name FROM sqlite_master WHERE type='table'")}
        loc_table = ("Localizations_enUS" if "Localizations_enUS" in tables
                     else None)
        if "Cards" not in tables or not loc_table:
            conn.close()
            return {}
        loc: dict[int, str] = {}
        for lid, fmt, text in cur.execute(
                f"SELECT LocId, Formatted, Loc FROM {loc_table}"):
            if text and (lid not in loc or fmt == 0):
                loc[lid] = text
        cols = {r[1] for r in cur.execute("PRAGMA table_info(Cards)")}
        sset = "ExpansionCode" if "ExpansionCode" in cols else "NULL"
        for grp, title, setc in cur.execute(
                f"SELECT GrpId, TitleId, {sset} FROM Cards"):
            nm = loc.get(title)
            if nm:
                lookup[grp] = {"name": nm, "set": setc or "",
                               "collector_number": ""}
        conn.close()
    except sqlite3.Error:
        return lookup
    return lookup


def fetch_scryfall_card_map() -> dict[int, dict]:
    """Fallback grpId(arena_id) -> name from Scryfall bulk data."""
    import requests

    # Scryfall now requires a descriptive User-Agent + Accept header.
    hdrs = {"User-Agent": "TutorboxArenaExporter/1.0", "Accept": "application/json"}
    meta = requests.get(
        "https://api.scryfall.com/bulk-data/default-cards",
        headers=hdrs, timeout=30).json()
    cards = requests.get(meta["download_uri"], headers=hdrs, timeout=180).json()
    out: dict[int, dict] = {}
    for c in cards:
        aid = c.get("arena_id")
        if aid:
            out[int(aid)] = {"name": c.get("name", "Unknown"),
                             "set": (c.get("set") or "").upper(),
                             "collector_number": c.get("collector_number", "")}
    return out


def _iter_readable_regions(handle):
    """Yield (base, size) for committed, writable, non-guard regions."""
    import ctypes
    from ctypes import wintypes

    class MBI(ctypes.Structure):
        _fields_ = [
            ("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
            ("AllocationProtect", wintypes.DWORD), ("__align", wintypes.DWORD),
            ("RegionSize", ctypes.c_size_t), ("State", wintypes.DWORD),
            ("Protect", wintypes.DWORD), ("Type", wintypes.DWORD),
        ]

    VirtualQueryEx = ctypes.windll.kernel32.VirtualQueryEx
    VirtualQueryEx.restype = ctypes.c_size_t
    MEM_COMMIT = 0x1000
    PAGE_GUARD = 0x100
    # Writable protections — the collection lives on the managed heap (RW).
    WRITABLE = {0x04, 0x08, 0x40, 0x80}  # READWRITE/WRITECOPY/EXEC_RW/EXEC_WC
    addr = 0
    mbi = MBI()
    while VirtualQueryEx(handle, ctypes.c_void_p(addr), ctypes.byref(mbi),
                         ctypes.sizeof(mbi)):
        base = mbi.BaseAddress or 0
        size = mbi.RegionSize
        if (mbi.State == MEM_COMMIT and (mbi.Protect & 0xFF) in WRITABLE
                and not (mbi.Protect & PAGE_GUARD)):
            yield base, size
        nxt = base + size
        if nxt <= addr:
            break
        addr = nxt


def scan_process_collection(proc_name: str = "MTGA.exe",
                            name_map: dict[int, dict] | None = None,
                            anchors: dict[int, int] | None = None,
                            max_region: int = 256 * 1024 * 1024) -> dict[int, int]:
    """Attach to the process and return the best ``{grpId: qty}`` block."""
    import pymem

    pm = pymem.Pymem(proc_name)
    candidates: list[dict[int, int]] = []
    for base, size in _iter_readable_regions(pm.process_handle):
        if size > max_region:
            continue
        try:
            data = pm.read_bytes(base, size)
        except Exception:
            continue
        candidates.extend(parse_card_pairs(data))
    return pick_best_block(candidates, name_map=name_map, anchors=anchors)


def _parse_anchor(spec: str, name_index: dict[str, int]) -> tuple[int, int] | None:
    name, _, qty = spec.rpartition(":")
    if not name or not qty.strip().isdigit():
        return None
    grp = name_index.get(name.strip().lower())
    return (grp, int(qty)) if grp else None


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description="Export your MTG Arena collection.")
    ap.add_argument("--process", default="MTGA.exe")
    ap.add_argument("--out", default=".", help="output directory")
    ap.add_argument("--anchor", action="append", default=[],
                    help='"Card Name:Quantity" you own, to disambiguate (repeatable)')
    ap.add_argument("--include-unknown", action="store_true",
                    help="also emit cards whose grpId didn't resolve to a name")
    args = ap.parse_args(argv)

    print("Loading card database (local client DB)…")
    name_map = load_local_card_map()
    if not name_map:
        print("Local DB not found — downloading names from Scryfall…")
        try:
            name_map = fetch_scryfall_card_map()
        except Exception as e:
            print(f"Scryfall fetch failed: {e}")
            name_map = {}
    print(f"  {len(name_map)} card names loaded.")

    name_index = {v["name"].lower(): k for k, v in name_map.items()}
    anchors: dict[int, int] = {}
    for spec in args.anchor:
        pa = _parse_anchor(spec, name_index)
        if pa:
            anchors[pa[0]] = pa[1]
        else:
            print(f"  (ignored unrecognized anchor: {spec!r})")

    print(f"Attaching to {args.process} and scanning memory…")
    try:
        collection = scan_process_collection(
            args.process, name_map=name_map, anchors=anchors or None)
    except Exception as e:
        print(f"ERROR: could not read {args.process}: {e}")
        print("Is Arena running? Open it to the Decks/Collection screen, then retry.")
        return 2

    if not collection:
        print("No collection block found. Try adding --anchor \"Card You Own:Qty\".")
        return 3

    rows = aggregate(collection, name_map)
    known = [r for r in rows if r["known"]]
    txt = format_txt(rows, include_unknown=args.include_unknown)

    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "mtga_collection.txt").write_text(txt + "\n", encoding="utf-8")
    (out_dir / "mtga_collection.json").write_text(
        json.dumps(rows, indent=2), encoding="utf-8")

    total = sum(r["count"] for r in known)
    print(f"\nDone: {len(known)} distinct cards ({total} total), "
          f"{len(rows) - len(known)} unresolved grpIds.")
    print(f"Wrote {out_dir / 'mtga_collection.txt'}")
    print("Upload that .txt via Tutorbox -> 'Import Arena List'.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
