#!/usr/bin/env python3
"""Import added games while Steam is closed, preserving user-created shortcuts.

Usage: droiddeck-steam-shortcuts <steam_root> <added-games.json>
"""
import importlib.machinery
import importlib.util
import json
import os
import re
import shutil
import struct
import sys

TAG = "droiddeck-app"


def account_dir(steam_root):
    """The most recent account's userdata folder, else the only one that is not 0."""
    users = os.path.join(steam_root, "config", "loginusers.vdf")
    recent = None
    try:
        with open(users, encoding="utf-8", errors="replace") as f:
            text = f.read()
        for block in re.finditer(r'"(\d{5,})"\s*\{([^}]*)\}', text):
            if re.search(r'"MostRecent"\s*"1"', block.group(2)):
                recent = int(block.group(1))
    except OSError:
        pass
    userdata = os.path.join(steam_root, "userdata")
    if recent is not None:
        # loginusers holds the 64-bit id; userdata folders use the 32-bit account id.
        acct = recent - 76561197960265728
        return os.path.join(userdata, str(acct))
    try:
        dirs = [d for d in os.listdir(userdata) if d.isdigit() and d != "0"]
    except OSError:
        dirs = []
    return os.path.join(userdata, dirs[0]) if len(dirs) == 1 else None


# ---- binary VDF: 0x00 map, 0x01 string, 0x02 int32, 0x08 end ----

def parse(data):
    pos = [0]

    def cstr():
        end = data.index(b"\x00", pos[0])
        s = data[pos[0]:end].decode("utf-8", "replace")
        pos[0] = end + 1
        return s

    def node():
        out = {}
        while True:
            t = data[pos[0]]
            pos[0] += 1
            if t == 0x08:
                return out
            key = cstr()
            if t == 0x00:
                out[key] = node()
            elif t == 0x01:
                out[key] = cstr()
            elif t == 0x02:
                out[key] = struct.unpack_from("<i", data, pos[0])[0]
                pos[0] += 4
            else:
                raise ValueError("unknown vdf type 0x%02x" % t)

    t = data[pos[0]]
    pos[0] += 1
    key = cstr()
    return {key: node()}


def write(obj):
    out = bytearray()

    def emit(key, value):
        if isinstance(value, dict):
            out.append(0x00)
            out.extend(key.encode() + b"\x00")
            for k, v in value.items():
                emit(k, v)
            out.append(0x08)
        elif isinstance(value, int):
            out.append(0x02)
            out.extend(key.encode() + b"\x00" + struct.pack("<i", value))
        else:
            out.append(0x01)
            out.extend(key.encode() + b"\x00" + str(value).encode() + b"\x00")

    for k, v in obj.items():
        emit(k, v)
    out.append(0x08)
    return bytes(out)


def shortcut(game, launch_options=""):
    appid = int(game["appid"]) & 0xFFFFFFFF
    return {
        "appid": struct.unpack("<i", struct.pack("<I", appid))[0],
        "AppName": game["name"],
        "Exe": '"%s"' % game["exe"],
        "StartDir": '"%s"' % game["dir"],
        "icon": (game.get("art") or {}).get("icon", "") or "",
        "ShortcutPath": "",
        "LaunchOptions": launch_options,
        "IsHidden": 0,
        "AllowDesktopConfig": 1,
        "AllowOverlay": 1,
        "OpenVR": 0,
        "Devkit": 0,
        "DevkitGameID": "",
        "DevkitOverrideAppID": 0,
        "LastPlayTime": 0,
        "FlatpakAppID": "",
        "tags": {"0": TAG},
    }


RECORD = ".droiddeck-shortcuts.json"
AWAY_LIMIT = 256
FIELDS = ("AppName", "Exe", "StartDir", "icon")


def unquoted(value):
    return value.strip().strip('"') if isinstance(value, str) else ""


def read_record(path):
    try:
        with open(path, encoding="utf-8") as f:
            record = json.load(f)
    except (OSError, ValueError):
        record = {}
    if not isinstance(record, dict):
        record = {}
    games, away = record.get("games"), record.get("away")
    return {
        "games": {k: v for k, v in games.items() if isinstance(v, dict)} if isinstance(games, dict) else {},
        "away": {k: v for k, v in away.items() if isinstance(v, dict) and isinstance(v.get("entry"), dict)} if isinstance(away, dict) else {},
    }


def merge(fresh, current, state, seen):
    state = state if isinstance(state, dict) else {}
    rev = state.get("rev") if isinstance(state.get("rev"), int) else 0
    if current is None:
        return fresh, rev
    base = state.get("app") if isinstance(state.get("app"), dict) else {field: current.get(field) for field in FIELDS}
    before = state.get("steam") if isinstance(state.get("steam"), dict) else {"Exe": current.get("Exe")}
    if unquoted(current.get("Exe")) != unquoted(before.get("Exe")):
        rev += 1
    entry = dict(current)
    entry["appid"] = fresh["appid"]
    exe = unquoted(fresh["Exe"])
    if exe != unquoted(current.get("Exe")) and (exe != unquoted(base.get("Exe")) or seen == rev):
        entry["Exe"], entry["StartDir"] = fresh["Exe"], fresh["StartDir"]
    for field in ("AppName", "icon"):
        if current.get(field) == base.get(field):
            entry[field] = fresh[field]
    tags = dict(current["tags"]) if isinstance(current.get("tags"), dict) else {}
    if TAG not in tags.values():
        slot = 0
        while str(slot) in tags:
            slot += 1
        tags[str(slot)] = TAG
    entry["tags"] = tags
    for key, value in fresh.items():
        entry.setdefault(key, value)
    return entry, rev


def reconcile(existing, wanted, seen, record):
    games, away = record["games"], dict(record["away"])
    kept, ours, placed = [], [], {}
    for value in existing.values():
        if not isinstance(value, dict):
            continue
        tags = value.get("tags")
        if not isinstance(tags, dict) or TAG not in tags.values():
            kept.append(value)
            continue
        appid = value.get("appid")
        if not isinstance(appid, int) or appid in placed:
            continue
        key = str(appid & 0xFFFFFFFF)
        fresh = wanted.get(appid)
        if fresh is None:
            away.pop(key, None)
            away[key] = {"entry": value, "state": games.get(key)}
            continue
        entry, rev = merge(fresh, value, games.get(key), seen.get(appid, 0))
        placed[appid] = (len(ours), rev)
        ours.append(entry)
    for appid, fresh in wanted.items():
        if appid in placed:
            continue
        key = str(appid & 0xFFFFFFFF)
        stashed = away.get(key)
        if stashed:
            entry, rev = merge(fresh, stashed["entry"], stashed.get("state"), seen.get(appid, 0))
        else:
            entry, rev = merge(fresh, None, games.get(key), seen.get(appid, 0))
        placed[appid] = (len(ours), rev)
        ours.append(entry)
    for appid in placed:
        away.pop(str(appid & 0xFFFFFFFF), None)
    while len(away) > AWAY_LIMIT:
        away.pop(next(iter(away)))
    state = {}
    for appid, (index, rev) in placed.items():
        entry = ours[index]
        state[str(appid & 0xFFFFFFFF)] = {
            "app": {field: wanted[appid][field] for field in FIELDS},
            "steam": {"Exe": entry.get("Exe", ""), "StartDir": entry.get("StartDir", "")},
            "rev": rev,
        }
    return kept, ours, {"games": state, "away": away}


def save_record(path, record):
    tmp = path + ".droiddeck"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(record, f)
    os.replace(tmp, path)


ART_MANIFEST = ".droiddeck-art.json"
# listing key -> the client's grid file suffix: <appid>p = portrait capsule, <appid> = header,
# <appid>_hero, <appid>_logo.
ART_SLOTS = (("p", "p"), ("header", ""), ("hero", "_hero"), ("logo", "_logo"))


def install_art(acct, games):
    """Copies each game's art into the client's grid folder under the names it looks for. A file
    the client already has that this script did not write (art the user set in the client) is
    left alone; ours are replaced when the source changed and removed when the game lost its art."""
    grid = os.path.join(acct, "config", "grid")
    os.makedirs(grid, exist_ok=True)
    manifest_path = os.path.join(grid, ART_MANIFEST)
    try:
        with open(manifest_path, encoding="utf-8") as f:
            manifest = json.load(f)
    except (OSError, ValueError):
        manifest = {}
    wanted = {}
    copied = 0
    for game in games:
        art = game.get("art") or {}
        appid = str(int(game["appid"]) & 0xFFFFFFFF)
        for key, suffix in ART_SLOTS:
            src = art.get(key)
            if not src or not os.path.isfile(src):
                continue
            ext = os.path.splitext(src)[1].lower() or ".png"
            if ext == ".jpeg":
                ext = ".jpg"
            name = appid + suffix + ext
            dst = os.path.join(grid, name)
            size = os.path.getsize(src)
            if os.path.exists(dst) and name not in manifest:
                continue  # the user's own choice inside the client; never ours to touch
            wanted[name] = size
            if os.path.exists(dst) and manifest.get(name) == size:
                continue
            try:
                shutil.copyfile(src, dst + ".tmp")
                os.replace(dst + ".tmp", dst)
                copied += 1
            except OSError as e:
                print("droiddeck-steam-shortcuts: could not copy art %s (%s)" % (name, e))
    for name in list(manifest):
        if name not in wanted:
            try:
                os.remove(os.path.join(grid, name))
            except OSError:
                pass
    with open(manifest_path, "w", encoding="utf-8") as f:
        json.dump(wanted, f)
    if copied:
        print("droiddeck-steam-shortcuts: %d piece(s) of art placed" % copied)


def main():
    if len(sys.argv) < 3:
        print(__doc__)
        return 2
    steam_root, listing = sys.argv[1], sys.argv[2]
    try:
        with open(listing, encoding="utf-8") as f:
            games = json.load(f)
    except (OSError, ValueError) as e:
        print("droiddeck-steam-shortcuts: no list (%s)" % e)
        return 0
    acct = account_dir(steam_root)
    if not acct:
        print("droiddeck-steam-shortcuts: no signed-in account yet; nothing written")
        return 0
    config = os.path.join(acct, "config")
    os.makedirs(config, exist_ok=True)
    path = os.path.join(config, "shortcuts.vdf")
    existing = {}
    if os.path.isfile(path):
        try:
            with open(path, "rb") as f:
                existing = parse(f.read()).get("shortcuts", {})
        except (ValueError, IndexError, struct.error) as e:
            print("droiddeck-steam-shortcuts: could not read the existing file (%s); keeping it unchanged" % e)
            return 1
    helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "droiddeck-steam-games")
    loader = importlib.machinery.SourceFileLoader("steam_games", helper)
    spec = importlib.util.spec_from_loader(loader.name, loader)
    routing = importlib.util.module_from_spec(spec)
    loader.exec_module(routing)
    games, routes = routing.route(steam_root, acct, games)
    wanted, seen = {}, {}
    for game in games:
        if game.get("exe") and game.get("name"):
            entry = shortcut(game)
            if entry["appid"] not in wanted:
                wanted[entry["appid"]] = entry
                seen[entry["appid"]] = game["seen"] if isinstance(game.get("seen"), int) else 0
    record_path = os.path.join(config, RECORD)
    kept, ours, record = reconcile(existing, wanted, seen, read_record(record_path))
    entries = {str(i): v for i, v in enumerate(kept + ours)}
    tmp = path + ".droiddeck"
    with open(tmp, "wb") as f:
        f.write(write({"shortcuts": entries}))
    os.replace(tmp, path)
    save_record(record_path, record)
    print("droiddeck-steam-shortcuts: %d added game(s) written, %d of the client's own kept" % (len(ours), len(kept)))
    install_art(acct, [g for g in games if g.get("exe") and g.get("name")])
    # Each added game runs under the ARM64 Proton: the compat registrar's own mapping writer.
    try:
        # No .py suffix on the registrar, so the loader has to be named.
        compat_path = "/usr/local/bin/steam-compatibility"
        spec = importlib.util.spec_from_file_location("compat", compat_path, loader=importlib.machinery.SourceFileLoader("compat", compat_path))
        compat = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(compat)
        cfg = os.path.join(steam_root, "config", "config.vdf")
        if os.path.isfile(cfg) and (ours or routes):
            compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")] + [str(appid) for appid in set(routes.values())],
                                    protect=compat.adopt_extras(os.path.join(steam_root, "compatibilitytools.d")))
            print("droiddeck-steam-shortcuts: mapped them to %s" % compat.TOOL)
    except Exception as e:  # noqa: BLE001 - the games still show; the client falls back to the default tool
        print("droiddeck-steam-shortcuts: compat mapping skipped (%s)" % e)
    return 0


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