#!/usr/bin/env python3
"""Puts the app's added games (Windows games in the user's own folders) into the Steam client's
library as non-Steam shortcuts, each mapped to the ARM64 Proton, before the client starts.

    bannerlator-steam-shortcuts <steam_root> <added-games.json>

The JSON is a list the app writes each session: {"name", "exe", "dir", "appid"} with guest paths.
The client keeps non-Steam games in userdata/<account>/config/shortcuts.vdf, a binary VDF it
reads at start-up and rewrites at exit, so this runs while it is down. Entries the app wrote
carry the tag "droiddeck-app" and are replaced wholesale; anything the user added inside the
client is kept as it is. The appid is the one the client itself derives (crc32 of exe+name with
the top bit set), so the launch URL the app uses matches what the client shows.
"""
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):
    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": "",
        "IsHidden": 0,
        "AllowDesktopConfig": 1,
        "AllowOverlay": 1,
        "OpenVR": 0,
        "Devkit": 0,
        "DevkitGameID": "",
        "DevkitOverrideAppID": 0,
        "LastPlayTime": 0,
        "FlatpakAppID": "",
        "tags": {"0": TAG},
    }


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("bannerlator-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("bannerlator-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("bannerlator-steam-shortcuts: no list (%s)" % e)
        return 0
    acct = account_dir(steam_root)
    if not acct:
        print("bannerlator-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) as e:
            print("bannerlator-steam-shortcuts: could not read the existing file (%s); starting over" % e)
    kept = [v for v in existing.values() if isinstance(v, dict) and TAG not in (v.get("tags") or {}).values()]
    ours = [shortcut(g) for g in games if g.get("exe") and g.get("name")]
    entries = {str(i): v for i, v in enumerate(kept + ours)}
    tmp = path + ".bannerlator"
    with open(tmp, "wb") as f:
        f.write(write({"shortcuts": entries}))
    os.replace(tmp, path)
    print("bannerlator-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/bannerlator-steam-compat"
        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:
            compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")])
            print("bannerlator-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("bannerlator-steam-shortcuts: compat mapping skipped (%s)" % e)
    return 0


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