#!/usr/bin/python3
# Registers the card the app installs to (bound at /mnt/droiddeck-sd) as a library folder of the
# native Steam client, so they show as installed there instead of being downloaded a second time.
#
#   droiddeck-steam-library <steam root>
#
# Ported from WinNative's gamescope runtime (GPL-3.0).
import json
import os
import re
import sys

# The client's own library is internal storage (its steamapps/common is bound to the app's root);
# the card is the one extra library folder.
# The app binds the storage the user chose (Steam cog -> Game storage) at this path and names it
# in BL_LIBRARY_LABEL; nothing bound = internal only, and the entry is removed so the client does
# not keep offering a place that is not there.
LIBRARIES = (("/mnt/droiddeck-sd", os.environ.get("BL_LIBRARY_LABEL", "SD Card")),)
# The client calls its own library "Local Drive (/)", which says nothing about where that is.
# It is the phone's internal storage, so say so.
MAIN_LABEL = "Internal Storage"


def register(steam_root, library, label):
    """Adds one library folder to the client's list; the client then offers it as an install
    location as well as reading the games already in it. Registered even when empty: a new user
    should be able to install straight into it."""
    steamapps = os.path.join(library, "steamapps")
    if not os.path.isdir(steamapps):
        return 0
    apps = sorted(
        int(m.group(1))
        for m in (re.fullmatch(r"appmanifest_(\d+)\.acf", n) for n in os.listdir(steamapps))
        if m
    )
    marker = os.path.join(library, "libraryfolder.vdf")
    if not os.path.isfile(marker):
        with open(marker, "w", encoding="utf-8") as f:
            f.write('"libraryfolder"\n{\n\t"contentid"\t\t"%d"\n\t"label"\t\t"%s"\n}\n'
                    % (int.from_bytes(os.urandom(8), "little"), label))
    steamapps_dir = os.path.join(steam_root, "steamapps")
    path = os.path.join(steamapps_dir, "libraryfolders.vdf")
    text = ""
    if os.path.isfile(path):
        with open(path, encoding="utf-8", errors="replace") as f:
            text = f.read()
    if re.search(r'"path"\s+"%s"' % re.escape(library), text):
        return len(apps)
    indexes = [int(i) for i in re.findall(r'^\t"(\d+)"\s*$', text, re.M)]
    if not text.strip():
        text = ('"libraryfolders"\n{\n\t"0"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"label"\t\t""\n\t}\n}\n'
                % steam_root)
        indexes = [0]
    entry = ['\t"%d"' % (max(indexes) + 1), "\t{", '\t\t"path"\t\t"%s"' % library,
             '\t\t"label"\t\t"%s"' % label, '\t\t"apps"', "\t\t{"]
    entry += ['\t\t\t"%d"\t\t"0"' % app for app in apps]
    entry += ["\t\t}", "\t}"]
    closing = text.rstrip().rfind("}")
    if closing < 0:
        return len(apps)
    text = text[:closing] + "\n".join(entry) + "\n" + text[closing:]
    os.makedirs(steamapps_dir, exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write(text)
    print("droiddeck-steam-library: registered %s with %d game(s)" % (library, len(apps)))
    return len(apps)


RETIRED = ("/mnt/bannerlator",)
RENAMED = (("/mnt/bannerlator-sd", "/mnt/droiddeck-sd"),)


def rename(steam_root, old, new):
    """Moves a library registered under an earlier mount point to the current one, keeping its
    entry (content id, app sizes) rather than registering it anew."""
    path = os.path.join(steam_root, "steamapps", "libraryfolders.vdf")
    if not os.path.isfile(path):
        return
    with open(path, encoding="utf-8", errors="replace") as f:
        text = f.read()
    old_path = re.compile(r'("path"\s+")%s(")' % re.escape(old))
    if not old_path.search(text):
        return
    if re.search(r'"path"\s+"%s"' % re.escape(new), text):
        forget(steam_root, old)
        return
    with open(path, "w", encoding="utf-8") as f:
        f.write(old_path.sub(lambda m: m.group(1) + new + m.group(2), text))
    print("droiddeck-steam-library: moved %s to %s" % (old, new))


def forget(steam_root, library):
    """Removes a library folder earlier builds registered and this one no longer binds, so the
    client does not keep offering a location that is not there."""
    path = os.path.join(steam_root, "steamapps", "libraryfolders.vdf")
    if not os.path.isfile(path):
        return
    with open(path, encoding="utf-8", errors="replace") as f:
        lines = f.read().split("\n")
    out, block, depth = [], None, 0
    for line in lines:
        if depth == 1 and re.fullmatch(r'\t"\d+"', line):
            block = [line]
            continue
        if block is not None:
            block.append(line)
            depth += line.count("{") - line.count("}")
            if depth == 1:
                if not re.search(r'"path"\s+"%s"' % re.escape(library), "\n".join(block)):
                    out.extend(block)
                block = None
            continue
        depth += line.count("{") - line.count("}")
        out.append(line)
    text = "\n".join(out)
    if text != "\n".join(lines):
        with open(path, "w", encoding="utf-8") as f:
            f.write(text)
        print("droiddeck-steam-library: forgot %s" % library)


def relabel(steam_root, target, label):
    """Names a library for what it is. The client writes its own entry with an empty label and
    falls back to showing the mount point - "Local Drive (/)" - so the install dialog gives no
    clue which of the two places is the phone and which is the card. A library registered on an
    earlier run keeps whatever label it was given then, so both are named here every time.""" 
    path = os.path.join(steam_root, "steamapps", "libraryfolders.vdf")
    if not os.path.isfile(path):
        return
    with open(path, encoding="utf-8", errors="replace") as f:
        lines = f.read().split("\n")
    out, depth, block, changed = [], 0, None, False
    for line in lines:
        if depth == 1 and re.fullmatch(r'\t"\d+"', line):
            block = [line]
            continue
        if block is not None:
            block.append(line)
            depth += line.count("{") - line.count("}")
            if depth == 1:
                text = "\n".join(block)
                if re.search(r'"path"\s+"%s"' % re.escape(target), text):
                    new = re.sub(r'("label"\s+")[^"]*(")', r"\g<1>%s\g<2>" % label, text, count=1)
                    if new != text:
                        changed = True
                        text = new
                out.extend(text.split("\n"))
                block = None
            continue
        depth += line.count("{") - line.count("}")
        out.append(line)
    if changed:
        with open(path, "w", encoding="utf-8") as f:
            f.write("\n".join(out))
        print("droiddeck-steam-library: labelled %s %r" % (target, label))
    # A library also carries its own libraryfolder.vdf, and the client takes the label from there
    # back into the list above - so editing the list alone is undone on the next start. The
    # client's own library has no such file, which is why that one kept the new name and the card
    # went back to the name it was first registered with.
    marker = os.path.join(target, "libraryfolder.vdf")
    if not os.path.isfile(marker):
        return
    with open(marker, encoding="utf-8", errors="replace") as f:
        text = f.read()
    new = re.sub(r'("label"\s+")[^"]*(")', r"\g<1>%s\g<2>" % label, text, count=1)
    if new != text:
        with open(marker, "w", encoding="utf-8") as f:
            f.write(new)
        print("droiddeck-steam-library: labelled %s in its own marker too" % target)


GAMES_DIR = "/root/Games/"


def wanted():
    listing = os.environ.get("BL_STEAM_LIBRARIES")
    if not listing:
        return None
    try:
        with open(listing, encoding="utf-8") as f:
            entries = json.load(f)
        return [(str(e["path"]), str(e["label"]).replace('"', " ").replace("\\", " ")) for e in entries]
    except (OSError, ValueError, KeyError, TypeError):
        return None


def registered(steam_root):
    try:
        with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
            return re.findall(r'"path"\s+"([^"]*)"', f.read())
    except OSError:
        return []


def main():
    steam_root = sys.argv[1]
    for library in RETIRED:
        forget(steam_root, library)
    for old, new in RENAMED:
        rename(steam_root, old, new)
    relabel(steam_root, steam_root, MAIN_LABEL)
    libraries = wanted()
    managed = libraries is not None
    for library, label in libraries if managed else LIBRARIES:
        if os.path.isdir(os.path.join(library, "steamapps")):
            register(steam_root, library, label)
            relabel(steam_root, library, label)
        else:
            forget(steam_root, library)
    if managed:
        keep = {library for library, _ in libraries}
        for library in registered(steam_root):
            if library not in keep and (library == LIBRARIES[0][0] or library.startswith(GAMES_DIR)):
                forget(steam_root, library)


if __name__ == "__main__":
    main()
