#!/usr/bin/python3
r"""Before Proton starts a store game, asks the app for what that store needs at launch.

Run by droiddeck-proton / droiddeck-proton-wrap (steam-compatibility) for every real game launch,
whoever started it: the Games tab, a pinned shortcut, or the Steam client's own Play button. It
looks for the store launcher DroidDeck wrote (.droiddeck-launch.bat) among the arguments; beside it
.droiddeck-store.json says which store and game it is.

For an Epic game, once the Epic Games Launcher is installed (droiddeck-epic), the game is started
through it: the hidden launcher signs the game in and hands its arguments back in
.droiddeck-epic-args. Without the launcher, or when it cannot, the game needs a fresh exchange code
to sign in, which only the app can mint. The request goes through files under $BL_LAUNCH_DIR/stores (the session root, the same path on both
sides):

    req/<id>.json    {"op": "epic-code", "store": "epic", "id": "<app name>"}   (tmp + rename)
    resp/<id>.json   {"ok": true, "code": true|false, "reason": "..."}

The app writes .droiddeck-epic-code into the game's folder itself - the code never passes through
here - and the launcher .bat reads and deletes it. No answer within TIMEOUT seconds: the game starts
with its offline identity. A code file older than STALE_SECONDS is removed first, so a code left by
an earlier launch is never used.

The game's prefix gets Wine's browser set to droiddeck-open-url, which hands web addresses to
Android: an EOS sign-in step that falls back to a browser (a consent Epic asks for once per product)
is finished there. An EOS overlay pointer (HKCU\Software\Epic Games\EOS "OverlayPath") an earlier
build wrote is removed - with it the game exited while loading.

    droiddeck-store-launch <the arguments Steam gave the compat tool>
"""

import json
import os
import sys
import time
import subprocess
import tempfile
import hashlib
from pathlib import Path

LAUNCHER = ".droiddeck-launch.bat"
SIDECAR = ".droiddeck-store.json"
EPIC_CODE = ".droiddeck-epic-code"
EPIC_ARGS = ".droiddeck-epic-args"
EPIC_CLIENT = "/usr/local/bin/droiddeck-epic"
EPIC_CLIENT_CURRENT = ".local/share/droiddeck-epic/current.json"
EPIC_HANDLED = "DROIDDECK_EPIC_HANDLED"
CLIENT_TIMEOUT = 150.0
TIMEOUT = 5.0
STALE_SECONDS = 300
SERVER_WAIT = 8.0
BROWSER = "/usr/local/bin/droiddeck-open-url"
EOS_KEY = "[Software\\\\Epic Games\\\\EOS]"
BROWSER_KEY = "[Software\\\\Wine\\\\WineBrowser]"


def log(line):
    print("droiddeck-store-launch: " + line, file=sys.stderr, flush=True)


MAX_DEPTH = 6


def sidecar_at(folder):
    try:
        sidecar = json.loads((folder / SIDECAR).read_text())
    except (OSError, ValueError):
        return None
    if not isinstance(sidecar, dict) or not sidecar.get("store") or not sidecar.get("id"):
        return None
    return sidecar


def find_store_game(argv):
    """
    The (folder, sidecar) of a store game among [argv], or None when this is not one. Steam passes
    the shortcut's target: the launcher .bat when the game has one (Epic, Amazon, a GOG game with
    arguments), else the game's exe itself (most GOG games), which may sit in a subfolder - so the
    folders above it are looked through too, up to MAX_DEPTH, for the sidecar.
    """
    for raw in argv:
        arg = raw.strip().strip('"').strip("'").replace("\\", "/")
        low = arg.lower()
        if not (low.endswith(LAUNCHER) or low.endswith(".exe") or low.endswith(".bat")) or "/" not in arg:
            continue
        folder = Path(arg).parent
        for _ in range(MAX_DEPTH):
            sidecar = sidecar_at(folder)
            if sidecar is not None:
                return folder, sidecar
            if folder.parent == folder:
                break
            folder = folder.parent
    return None


def drop_stale(folder, now=None):
    """Removes a code file older than STALE_SECONDS; True when one was removed."""
    code = folder / EPIC_CODE
    try:
        if (now if now is not None else time.time()) - code.stat().st_mtime > STALE_SECONDS:
            code.unlink()
            return True
    except OSError:
        pass
    return False


def client_wanted():
    home = os.environ.get("HOME") or "/root"
    return os.access(EPIC_CLIENT, os.X_OK) and os.path.isfile(os.path.join(home, EPIC_CLIENT_CURRENT))


def through_client(folder, sidecar, timeout=CLIENT_TIMEOUT):
    extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
    namespace, item = str(extra.get("namespace") or ""), str(extra.get("catalogItemId") or "")
    if not namespace or not item:
        return "no-catalog-ids"
    command = [EPIC_CLIENT, "launch", "--app", str(sidecar.get("id")), "--namespace", namespace, "--item", item,
               "--folder", str(folder), "--owner", str(os.getppid()), "--timeout", str(int(timeout))]
    try:
        run = subprocess.run(command, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, timeout=timeout + 30)
        answer = json.loads(run.stdout.decode("utf-8", "replace").strip().splitlines()[-1])
    except (OSError, ValueError, IndexError, subprocess.TimeoutExpired) as error:
        return "error-" + type(error).__name__
    if not isinstance(answer, dict):
        return "no-answer"
    if answer.get("ok") and (folder / EPIC_ARGS).is_file():
        return "ok"
    return str(answer.get("reason") or "failed")


def ask(channel, request, timeout=TIMEOUT):
    """Writes [request] under channel/req and waits for channel/resp; the answer, or None on timeout."""
    req_dir, resp_dir = channel / "req", channel / "resp"
    req_dir.mkdir(parents=True, exist_ok=True)
    resp_dir.mkdir(parents=True, exist_ok=True)
    rid = "%d-%d" % (os.getpid(), int(time.time() * 1000))
    tmp = req_dir / (rid + ".json.tmp")
    tmp.write_text(json.dumps(request))
    os.rename(tmp, req_dir / (rid + ".json"))
    answer = resp_dir / (rid + ".json")
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            value = json.loads(answer.read_text())
            answer.unlink()
            return value
        except (OSError, ValueError):
            time.sleep(0.05)
    try:
        (req_dir / (rid + ".json")).unlink()
    except OSError:
        pass
    return None


def windows_path(unix):
    """A guest path as Wine names it through Z: (the whole filesystem)."""
    return "Z:" + unix.replace("/", "\\")


def reg_escape(value):
    return value.replace("\\", "\\\\").replace('"', '\\"')


def set_value(reg, key, name, value, now=None):
    """
    Appends `key` with `"name"="value"` to a prefix's user.reg unless the last word on it already
    says so. Appended rather than edited in place, as DirectAudio's key is: Wine reads later sections
    over earlier ones, and nothing of the file is rewritten under a wineserver that might hold it.
    False when there is no user.reg yet (Proton creates the prefix after this; the next launch has it).
    """
    if not reg.is_file():
        return False
    line = '"%s"="%s"' % (name, reg_escape(value))
    inside, last = False, None
    for row in reg.read_text(errors="replace").splitlines():
        if row.startswith("["):
            inside = row.startswith(key + " ") or row == key
        elif inside and row.startswith('"%s"=' % name):
            last = row
    if last == line:
        return True
    with open(reg, "a") as out:
        out.write("\n%s %d\n%s\n" % (key, int(now if now is not None else time.time()), line))
    return True


def remove_value(reg, key, name):
    """Drops every `"name"=` line under `key` in user.reg; True when one was there."""
    if not reg.is_file():
        return False
    rows, inside, dropped = [], False, False
    for row in reg.read_text(errors="replace").splitlines(keepends=True):
        if row.startswith("["):
            inside = row.startswith(key + " ") or row.rstrip("\n") == key
        elif inside and row.startswith('"%s"=' % name):
            dropped = True
            continue
        rows.append(row)
    if dropped:
        tmp = reg.with_name(reg.name + ".dd-new")
        tmp.write_text("".join(rows))
        os.replace(tmp, reg)
    return dropped


def options(sidecar):
    """An Epic game's launch choices from its sidecar, as the Games tab's Epic card sets them."""
    e = sidecar.get("epic") if isinstance(sidecar.get("epic"), dict) else {}
    return {"eos": e.get("eos", True) is not False, "offline": e.get("offline", False) is True}


def server_socket(pfx):
    """The wineserver socket for prefix [pfx], as Wine names it (server-<dev>-<inode> under /tmp/.wine-<uid>)."""
    try:
        st = os.stat(pfx)
    except OSError:
        return None
    base = os.environ.get("BL_WINE_SERVER_BASE") or "/tmp/.wine-%d" % os.getuid()
    return Path(base) / ("server-%x-%x" % (st.st_dev, st.st_ino)) / "socket"


def wait_for_server(pfx, timeout=SERVER_WAIT):
    """
    Waits for a wineserver still holding this prefix (an earlier verb's, or the last launch's) to
    exit: it rewrites user.reg from its own memory when it does, which drops a line appended
    meanwhile. True when none is left.
    """
    sock = server_socket(pfx)
    if sock is None:
        return True
    deadline = time.monotonic() + timeout
    while sock.exists() and time.monotonic() < deadline:
        time.sleep(0.1)
    return not sock.exists()


def has_value(reg, key, name, value=None):
    """Whether the last word on `key` in user.reg sets "name" (to [value], when given)."""
    if not reg.is_file():
        return False
    inside, last = False, None
    for row in reg.read_text(errors="replace").splitlines():
        if row.startswith("["):
            inside = row.startswith(key + " ") or row == key
        elif inside and row.startswith('"%s"=' % name):
            last = row
    if last is None:
        return False
    return value is None or last == '"%s"="%s"' % (name, reg_escape(value))


def provision_prefix(compat_data, wait=SERVER_WAIT):
    """Wine's browser set to the hand-off, and an earlier build's EOS overlay pointer removed."""
    if not compat_data:
        return "no-prefix"
    pfx = Path(compat_data) / "pfx"
    reg = pfx / "user.reg"
    if not reg.is_file():
        return "prefix-not-created"
    busy = not wait_for_server(pfx, wait)
    set_value(reg, BROWSER_KEY, "Browsers", BROWSER + ",xdg-open")
    removed = remove_value(reg, EOS_KEY, "OverlayPath")
    state = "browser-set" if has_value(reg, BROWSER_KEY, "Browsers", BROWSER + ",xdg-open") else "browser-not-written"
    if removed:
        state += " old-overlay-removed"
    return state + (" server-running" if busy else "")


CLOUD_TIMEOUT = 15.0
CLOUD_STORES = ("gog", "epic")
STORE_GAME = 10


def cloud_on(sidecar):
    """Cloud saves for this game (the game card's switch; on unless turned off)."""
    return str(sidecar.get("store")) in CLOUD_STORES and sidecar.get("cloud", True) is not False


def exited(argv, code=0):
    """After the game: ask the app to upload what changed, without waiting for it."""
    found = find_store_game(argv)
    launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
    if found is not None and str(found[1].get("store")) == "epic" and os.environ.get(EPIC_HANDLED) != "1" \
            and os.access(EPIC_CLIENT, os.X_OK):
        try:
            subprocess.run([EPIC_CLIENT, "exited", "--code", str(code)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, timeout=10)
        except (OSError, subprocess.TimeoutExpired):
            pass
    if found is None or not launch_dir or not cloud_on(found[1]):
        return 0
    store, game = str(found[1].get("store")), str(found[1].get("id"))
    ask(Path(launch_dir) / "stores", {"op": "cloud-up", "store": store, "id": game}, timeout=1.0)
    log("cloud %s %s up requested" % (store, game))
    return 0


COMET_DIR = Path("/usr/local/lib/droiddeck-comet")


def provision_galaxy(compat_data, proton, timeout=90):
    """Proton creates the prefix and registers Galaxy before the first game runs."""
    if not compat_data or not proton or not Path(proton).is_file():
        return "no-proton"
    data = Path(compat_data)
    data.mkdir(parents=True, exist_ok=True)
    marker = data / "pfx/.droiddeck-comet-service"
    try:
        version = (COMET_DIR / "version").read_text().strip()
        tools = [COMET_DIR / "GalaxyCommunication.exe", COMET_DIR / "update-permissions.exe"]
        if not all(p.is_file() for p in tools):
            return "not-installed"
        stamp = version + " " + " ".join(hashlib.sha256(p.read_bytes()).hexdigest() for p in tools)
        if marker.is_file() and marker.read_text() == stamp and (data / "pfx/system.reg").is_file():
            return "ready"
        with tempfile.TemporaryDirectory(prefix="droiddeck-galaxy-") as tmp:
            folder = Path(tmp)
            result = folder / "result"
            bat = folder / "install.bat"
            src = windows_path(str(COMET_DIR))
            answer = windows_path(str(result))
            target = r"C:\ProgramData\GOG.com\Galaxy\redists"
            lines = ["@echo off", "setlocal", 'set "DD_DIR=%s"' % target,
                     "sc query GalaxyCommunication >nul 2>&1",
                     'if not errorlevel 1 if not exist "%DD_DIR%\\droiddeck-comet" goto existing',
                     'if not exist "%DD_DIR%" mkdir "%DD_DIR%"',
                     'copy /y "%s\\GalaxyCommunication.exe" "%s\\GalaxyCommunication.exe" >nul' % (src, target),
                     'if errorlevel 1 exit /b 1',
                     'sc query GalaxyCommunication >nul 2>&1',
                     'if errorlevel 1 sc create GalaxyCommunication binpath= "%s\\GalaxyCommunication.exe"' % target,
                     'if errorlevel 1 exit /b 1',
                     '"%s\\update-permissions.exe"' % src,
                     'if errorlevel 1 exit /b 1',
                     'sc query GalaxyCommunication >nul 2>&1',
                     'if errorlevel 1 exit /b 1',
                     'echo managed>"%s\\droiddeck-comet"' % target,
                     ':existing', 'echo ready>"%s"' % answer, 'exit /b 0']
            bat.write_bytes(("\r\n".join(lines) + "\r\n").encode())
            env = os.environ.copy()
            env["BL_COMET_BOOTSTRAP"] = "1"
            run = subprocess.run([proton, "run", "cmd.exe", "/d", "/c", windows_path(str(bat))],
                                 env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
            if run.returncode or not result.is_file() or result.read_text().strip() != "ready":
                return "service-unavailable"
        marker.write_text(stamp)
        return "ready"
    except subprocess.TimeoutExpired:
        return "timeout"
    except (OSError, ValueError):
        return "service-unavailable"


def prepare_gog(sidecar, compat_data, launch_dir):
    if not launch_dir:
        log("gog achievements unavailable: no-channel")
        return False
    answer = ask(Path(launch_dir) / "stores", {"op": "gog-achievements", "store": "gog", "id": str(sidecar.get("id"))}, timeout=25.0)
    if not isinstance(answer, dict) or not answer.get("ready"):
        log("gog achievements: " + (str(answer.get("reason", "unavailable")) if isinstance(answer, dict) else "timeout"))
        return False
    state = provision_galaxy(compat_data, os.environ.get("BL_STORE_PROTON", ""))
    log("gog achievements prefix: " + state)
    return state == "ready"


def main(argv):
    if argv and argv[0] == "--exited":
        rest, code = argv[1:], 0
        if rest[:1] == ["--code"] and len(rest) > 1:
            try:
                code = int(rest[1])
            except ValueError:
                pass
            rest = rest[2:]
        return exited(rest, code)
    found = find_store_game(argv)
    if found is None:
        return 0
    folder, sidecar = found
    store, game = str(sidecar.get("store")), str(sidecar.get("id"))
    compat_data = os.environ.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
    prefix_id = os.path.basename(compat_data) if compat_data else ""
    launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
    if store == "gog":
        prepare_gog(sidecar, compat_data, launch_dir)
    if store == "epic":
        handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file()
        if not handled:
            drop_stale(folder)
            try:
                (folder / EPIC_ARGS).unlink()
            except OSError:
                pass
        choice = options(sidecar)
        client = None
        if handled:
            client = "ok"
        elif choice["eos"] and not choice["offline"] and client_wanted():
            client = through_client(folder, sidecar)
            log("epic launch id=%s client=%s" % (game, client))
        if client == "ok":
            try:
                (folder / EPIC_CODE).unlink()
            except OSError:
                pass
            log("epic launch id=%s through=client" % game)
        elif not (choice["eos"] and not choice["offline"]):
            try:
                (folder / EPIC_CODE).unlink()
            except OSError:
                pass
            log("epic launch id=%s code=no reason=%s" % (game, "offline" if choice["offline"] else "eos-off"))
        elif not launch_dir:
            log("epic launch id=%s code=no reason=no-channel" % game)
        else:
            answer = ask(Path(launch_dir) / "stores", {"op": "epic-code", "store": store, "id": game, "prefix": prefix_id})
            if answer is None:
                log("epic launch id=%s code=no reason=timeout" % game)
            else:
                got = bool(answer.get("code"))
                log("epic launch id=%s code=%s reason=%s" % (game, "yes" if got else "no", answer.get("reason", "")))
        log("epic prefix=%s %s" % (prefix_id or "?", provision_prefix(compat_data)))
    if not cloud_on(sidecar):
        if store in CLOUD_STORES:
            log("cloud %s %s down result=skipped reason=off" % (store, game))
        return 0
    if launch_dir:
        answer = ask(Path(launch_dir) / "stores", {"op": "cloud-down", "store": store, "id": game}, timeout=CLOUD_TIMEOUT)
        if answer is None:
            log("cloud %s %s down result=timeout" % (store, game))
        else:
            log("cloud %s %s down result=%s files=%s reason=%s" % (store, game, answer.get("result", "?"), answer.get("files", 0), answer.get("reason", "")))
    else:
        log("cloud %s %s down result=skipped reason=no-channel" % (store, game))
    return STORE_GAME


if __name__ == "__main__":
    try:
        sys.exit(main(sys.argv[1:]))
    except Exception as e:
        log("skipped: %s" % type(e).__name__)
        sys.exit(0)
