#!/usr/bin/python3

import base64
import binascii
import json
import os
import re
import shlex
import shutil
import sys
import time
import subprocess
import tempfile
import hashlib
import threading
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):
    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):
    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):
    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):
    return "Z:" + unix.replace("/", "\\")


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


def set_value(reg, key, name, value, now=None):
    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):
    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):
    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):
    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):
    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):
    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):
    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 "")


def create_prefix(compat_data, proton, timeout=120):
    if not compat_data or not proton or not Path(proton).is_file():
        return "no-proton"
    reg = Path(compat_data) / "pfx/user.reg"
    if reg.is_file():
        return "present"
    try:
        Path(compat_data).mkdir(parents=True, exist_ok=True)
        subprocess.run([proton, "run", "cmd.exe", "/d", "/c", "exit"], stdin=subprocess.DEVNULL,
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
    except subprocess.TimeoutExpired:
        return "timeout"
    except OSError:
        return "failed"
    return "created" if reg.is_file() else "failed"


CLOUD_TIMEOUT = 15.0
CLOUD_STORES = ("gog", "epic")
STORE_GAME = 10
RAN = 11
RAN_FAILED = 12
BATTLENET = "/usr/local/bin/droiddeck-battlenet"
BATTLENET_EXE = re.compile(r"(?i)/compatdata/2596113170/pfx/drive_c/Program Files \(x86\)/Battle\.net/Battle\.net\.exe$")
BATTLENET_LAUNCH = re.compile(r'(?i)^--exec=\s*"?launch\s+([A-Za-z0-9_]{1,16})"?$')


def battlenet(argv, env=os.environ):
    if env.get("BL_BATTLENET"):
        return None
    exe, code = False, None
    for raw in argv:
        arg = raw.strip().strip('"').strip("'")
        if BATTLENET_EXE.search(arg.replace("\\", "/")):
            exe = True
        found = BATTLENET_LAUNCH.match(arg)
        if found:
            code = found.group(1)
    if not exe:
        return None
    return [BATTLENET, "launch", code] if code else [BATTLENET, "open"]


def cloud_on(sidecar):
    return str(sidecar.get("store")) in CLOUD_STORES and sidecar.get("cloud", True) is not False


def exited(argv, code=0):
    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 not None and launch_dir and str(found[1].get("store")) == "ea":
        ask(Path(launch_dir) / "stores", {"op": "ea-exited", "store": "ea", "id": str(found[1].get("id"))}, timeout=1.0)
        log("ea %s exited" % found[1].get("id"))
        return 0
    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


UBISOFT_DIR = "drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher"
UBISOFT_WINDOWS_DIR = "C:\\Program Files (x86)\\Ubisoft\\Ubisoft Game Launcher\\"
UBISOFT_INSTALLER_URL = "https://static3.cdn.ubi.com/orbit/launcher_installer/UbisoftConnectInstaller.exe"
UBISOFT_CACHE = Path("/root/.cache/droiddeck/ubisoft")
UBISOFT_LAUNCH = ".droiddeck-ubisoft.json"
UBISOFT_SERVICE = Path("/usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe")
UBISOFT_ASK_WAIT = 45.0
UBISOFT_EXTRACT_WAIT = 300
# Ubisoft's own game-side runtime from the Connect installer, unmodified: the API and aux DLLs the
# game's loader opens from InstallDir, the starter it re-executes into and its imports, and the overlay
# DLLs uplay_r1.dll loads even with the overlay off (its startup fails without them).
UBISOFT_RUNTIME = ("uplay_r1.dll", "uplay_r164.dll", "uplay_aux_r1.dll", "uplay_aux_r164.dll",
                   "uplay_r2.dll", "uplay_r264.dll", "ubiorbitapi_r2.dll", "ubiorbitapi_r264.dll",
                   "UbisoftGameLauncher.exe", "UbisoftGameLauncher64.exe", "discord-rpc.x86.dll",
                   "discord-rpc.x64.dll", "steam_api.dll", "steam_api64.dll", "overlay.dll", "overlay64.dll")
UBISOFT_VIEWS = ("[Software\\\\Ubisoft\\\\Launcher%s]", "[Software\\\\Wow6432Node\\\\Ubisoft\\\\Launcher%s]")
MIN_INSTALLER = 1 << 20


def ubisoft_runtime_dir(compat_data):
    return Path(compat_data) / "pfx" / UBISOFT_DIR


def ubisoft_runtime_ready(compat_data):
    folder = ubisoft_runtime_dir(compat_data)
    return all((folder / name).is_file() for name in UBISOFT_RUNTIME)


def fetch_installer(url=UBISOFT_INSTALLER_URL, folder=UBISOFT_CACHE):
    target = folder / "UbisoftConnectInstaller.exe"
    if target.is_file() and target.stat().st_size >= MIN_INSTALLER:
        return target
    import urllib.request
    folder.mkdir(parents=True, exist_ok=True)
    part = target.with_name(target.name + ".part")
    request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(request, timeout=60) as reply, open(part, "wb") as out:
        shutil.copyfileobj(reply, out)
    if part.stat().st_size < MIN_INSTALLER:
        part.unlink()
        raise OSError("installer too small")
    os.replace(part, target)
    return target


def install_runtime(compat_data, wait=UBISOFT_EXTRACT_WAIT):
    if ubisoft_runtime_ready(compat_data):
        return "present"
    if not (Path(compat_data) / "pfx").is_dir():
        return "no-prefix"
    try:
        installer = fetch_installer()
    except OSError as error:
        return "download-failed-" + type(error).__name__
    target = ubisoft_runtime_dir(compat_data)
    target.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix="droiddeck-ubisoft-", dir=str(UBISOFT_CACHE)) as tmp:
        try:
            subprocess.run(["7z", "e", "-y", "-o" + tmp, str(installer)] + list(UBISOFT_RUNTIME), stdin=subprocess.DEVNULL,
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=wait)
        except subprocess.TimeoutExpired:
            return "extract-timeout"
        except OSError:
            return "no-7z"
        missing = [name for name in UBISOFT_RUNTIME if not (Path(tmp) / name).is_file()]
        if missing:
            return "extract-missing-" + ",".join(missing)
        for name in UBISOFT_RUNTIME:
            shutil.copyfile(Path(tmp) / name, target / name)
    if not ubisoft_runtime_ready(compat_data):
        return "copy-failed"
    try:
        installer.unlink()
    except OSError:
        pass
    return "installed"


def ubisoft_product(sidecar):
    extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
    try:
        return int(str(extra.get("productId") or ""))
    except ValueError:
        return None


def ubisoft_install_dir(folder):
    return windows_path(str(folder)).replace("\\", "/").rstrip("/") + "/"


def register_ubisoft(folder, product, compat_data):
    reg = Path(compat_data) / "pfx" / "system.reg"
    if not reg.is_file():
        return "no-registry"
    if not wait_for_server(reg.parent):
        return "server-running"
    for view in UBISOFT_VIEWS:
        set_value(reg, view % "", "InstallDir", UBISOFT_WINDOWS_DIR)
        set_value(reg, view % ("\\\\Installs\\\\%d" % product), "InstallDir", ubisoft_install_dir(folder))
    return "registered %d" % product


UBISOFT_HIVES = {"HKEY_LOCAL_MACHINE": "system.reg", "HKEY_CURRENT_USER": "user.reg"}


def manifest_value(values, folder, language):
    """The value Connect writes for a manifest registry entry: the install folder, the entry for the
    language being played, or the only entry there is."""
    entries = [v for v in values if isinstance(v, dict)]
    if not entries:
        return None
    names = [str(v.get("name") or "") for v in entries]
    if "$INSTALL_DIR" in names:
        return windows_path(str(folder)).rstrip("\\") + "\\"
    for v in entries:
        if str(v.get("value") or "").lower() == language.lower():
            return str(v.get("value"))
    prefix = language.split("-")[0].lower()
    for v in entries:
        if str(v.get("value") or "").lower().split("-")[0] == prefix:
            return str(v.get("value"))
    return str(entries[0].get("value") or entries[0].get("name") or "")


def apply_manifest_registry(compat_data, registry, folder, language):
    written = 0
    for item in registry or []:
        if not isinstance(item, dict):
            continue
        path = str(item.get("key") or "").replace("/", "\\")
        hive, _, rest = path.partition("\\")
        target = UBISOFT_HIVES.get(hive.upper())
        if not target or "\\" not in rest:
            continue
        subkey, _, name = rest.rpartition("\\")
        value = manifest_value(item.get("values"), folder, language)
        if value is None or not name:
            continue
        reg = Path(compat_data) / "pfx" / target
        parts = subkey.split("\\")
        views = [parts]
        if target == "system.reg" and len(parts) > 1 and parts[0].lower() == "software" and parts[1].lower() != "wow6432node":
            views.append(parts[:1] + ["Wow6432Node"] + parts[1:])
        for view in views:
            key = "[" + "\\\\".join(view) + "]"
            if set_value(reg, key, name, value):
                written += 1
    return written


def ubisoft_launch(folder, sidecar, product, compat_data, answer):
    """The launch file droiddeck-upc.exe reads: the game, the account and this launch's ticket."""
    account = answer.get("account") if isinstance(answer.get("account"), dict) else {}
    user = str(account.get("userId") or "user")
    saves = Path(compat_data) / "pfx" / "drive_c/users/steamuser/Saved Games/Ubisoft" / user / str(product)
    saves.mkdir(parents=True, exist_ok=True)
    exe = str(sidecar.get("exe") or "").replace("\\", "/")
    workdir = folder / str(sidecar.get("workdir")) if sidecar.get("workdir") else folder
    UBISOFT_CACHE.mkdir(parents=True, exist_ok=True)
    return {
        "productId": product,
        "exe": windows_path(str(folder / exe)),
        "args": " ".join(str(a) for a in sidecar.get("args") or []),
        "workdir": windows_path(str(workdir)),
        "account": account,
        "ticket": str(answer.get("ticket") or ""),
        "owned": [int(i) for i in answer.get("owned") or [] if str(i).isdigit()],
        "products": [p for p in answer.get("products") or [] if isinstance(p, dict) and isinstance(p.get("id"), int)],
        "appId": str(answer.get("appId") or ""),
        "chunks": [c for c in answer.get("chunks") or [] if isinstance(c, dict) and isinstance(c.get("id"), int)],
        "saves": "C:/users/steamuser/Saved Games/Ubisoft/%s/%d/" % (user, product),
        "language": str(answer.get("language") or "en-US"),
        "bridge": windows_path(str(UBISOFT_CACHE / ("bridge-%d" % product))),
        "log": windows_path(str(UBISOFT_CACHE / ("upc-%d.log" % product))),
    }


def prepare_ubisoft(folder, sidecar, compat_data, launch_dir, proton="", ask_app=None):
    launch = folder / UBISOFT_LAUNCH
    try:
        launch.unlink()
    except OSError:
        pass
    if not compat_data:
        return "no-prefix"
    product = ubisoft_product(sidecar)
    if product is None:
        return "no-product"
    if not UBISOFT_SERVICE.is_file():
        return "no-service"
    made = create_prefix(compat_data, proton) if not (Path(compat_data) / "pfx/user.reg").is_file() else "present"
    if made not in ("present", "created"):
        return "prefix " + made
    runtime = install_runtime(compat_data)
    if not ubisoft_runtime_ready(compat_data):
        return "runtime " + runtime
    registry = register_ubisoft(folder, product, compat_data)
    if not launch_dir:
        return "runtime %s, %s, no-channel" % (runtime, registry)
    answer = (ask_app or ask)(Path(launch_dir) / "stores", {"op": "ubisoft-launch", "store": "ubisoft", "id": str(sidecar.get("id"))},
                              timeout=UBISOFT_ASK_WAIT)
    if not isinstance(answer, dict) or not answer.get("ok") or not answer.get("ticket"):
        reason = "timeout" if answer is None else str(answer.get("reason") or "refused")
        return "runtime %s, %s, session %s" % (runtime, registry, reason)
    written = 0
    if answer.get("registry") and wait_for_server(Path(compat_data) / "pfx"):
        written = apply_manifest_registry(compat_data, answer.get("registry"), folder, str(answer.get("language") or "en-US"))
    tmp = launch.with_name(launch.name + ".tmp")
    tmp.write_text(json.dumps(ubisoft_launch(folder, sidecar, product, compat_data, answer)))
    os.replace(tmp, launch)
    return "runtime %s, %s, %d manifest value(s), session ready" % (runtime, registry, written)

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


def provision_galaxy(compat_data, proton, timeout=90):
    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"


GOG_SCRIPTS = "goggame-*.script"
GOG_REGISTRY_MARK = ".droiddeck-gog-registry"
GOG_SUPPORT_MARK = ".droiddeck-gog-support"
GOG_ROOTS = {"HKEY_LOCAL_MACHINE": "HKEY_LOCAL_MACHINE", "HKLM": "HKEY_LOCAL_MACHINE",
             "HKEY_CURRENT_USER": "HKEY_CURRENT_USER", "HKCU": "HKEY_CURRENT_USER",
             "HKEY_CLASSES_ROOT": "HKEY_CLASSES_ROOT", "HKCR": "HKEY_CLASSES_ROOT"}
WINE_USER = "C:\\users\\steamuser"

# GOG completes some installs by running a support installer from the game folder (the
# goggame-<id>.script "Execute" action). Under ARM64 FEX that 32-bit self-extractor stops at
# "Path not found", so the values it would write are applied here instead. Only products GOG
# ships no setRegistry action for are listed; a script's own entries always win.
# The Witcher: Enhanced Edition (#694) exits during startup unless the installer's
# HKLM\Software\CD Projekt Red\The Witcher keys exist - the same ones The Witcher's own
# setup writes (InstallFolder, RegionVersion, IsDjinniInstalled) plus its language.
GOG_INSTALL_REGISTRY = {
    "1207658924": [
        ("HKLM", "Software\\CD Projekt Red\\The Witcher", "InstallFolder", "string", "{app}\\"),
        ("HKLM", "Software\\CD Projekt Red\\The Witcher", "RegionVersion", "string", "US"),
        ("HKLM", "Software\\CD Projekt Red\\The Witcher", "IsDjinniInstalled", "dword", 1),
        ("HKLM", "Software\\CD Projekt Red\\The Witcher", "Language", "string", "{language}"),
        ("HKCU", "Software\\CD Projekt RED\\Witcher\\Settings", "TextLanguage", "string", "{language}"),
        ("HKCU", "Software\\CD Projekt RED\\Witcher\\Settings", "VoiceLanguage", "string", "{language}"),
    ],
}
# The Witcher's text and voice language IDs, from its installer's registry data.
GOG_LANGUAGE_IDS = {"en": 3, "fr": 11, "de": 10, "es": 12, "it": 13, "ru": 14, "cs": 15, "hu": 16,
                    "zh": 21, "pl": 5}


def gog_paths(folder, product):
    app = windows_path(str(folder))
    support = app + "\\support\\" + product
    return {"app": app, "productid": product, "support": support, "supportdir": support,
            "localappdata": WINE_USER + "\\AppData\\Local", "userdocs": WINE_USER + "\\Documents"}


def gog_expand(text, paths):
    return re.sub(r"\{([A-Za-z]+)\}", lambda m: paths[m.group(1).lower()], text)


def gog_number(data):
    if isinstance(data, bool):
        raise ValueError
    if isinstance(data, int):
        return data
    text = str(data).strip()
    try:
        return int(text, 0)
    except ValueError:
        return int(text, 10)


def gog_value(kind, data):
    kind = str(kind).lower()
    if kind == "string":
        text = str(data)
        if any(c in text for c in "\r\n\0"):
            return None
        return '"%s"' % reg_escape(text)
    if kind == "dword":
        return "dword:%08x" % (gog_number(data) & 0xFFFFFFFF)
    if kind == "binary":
        return "hex:" + ",".join("%02x" % b for b in base64.b64decode(str(data), validate=True))
    return None


def gog_entry(arguments, paths):
    if not isinstance(arguments, dict):
        return None
    try:
        root = GOG_ROOTS.get(str(arguments.get("root") or "").upper())
        subkey = gog_expand(str(arguments.get("subkey") or ""), paths).strip("\\")
        if root is None or not subkey or any(c in subkey for c in "\r\n\0"):
            return None
        section = "[%s\\%s]" % (root, subkey)
        if not arguments.get("valueType"):
            return section
        data = arguments.get("valueData")
        value = gog_value(arguments["valueType"], gog_expand(data, paths) if isinstance(data, str) else data)
        name = gog_expand(str(arguments.get("valueName") or ""), paths)
        if value is None or any(c in name for c in "\r\n\0"):
            return None
        return section + "\r\n" + ('"%s"' % reg_escape(name) if name else "@") + "=" + value
    except (KeyError, ValueError, TypeError, binascii.Error):
        return None


def gog_installed_products(folder):
    products = {}
    for info in sorted(Path(folder).glob("goggame-*.info")):
        product = info.name[len("goggame-"):-len(".info")]
        if not product.isdigit():
            continue
        try:
            products[product] = json.loads(info.read_text(encoding="utf-8-sig"))
        except (OSError, ValueError):
            products[product] = {}
    return products


def gog_language_id(info):
    languages = info.get("languages") if isinstance(info, dict) else None
    if isinstance(languages, str):
        languages = [languages]
    code = str(languages[0]).lower()[:2] if isinstance(languages, list) and languages else ""
    return GOG_LANGUAGE_IDS.get(code, GOG_LANGUAGE_IDS["en"])


GOG_VALUE_HIVES = {"hklm": "local_machine", "hkey_local_machine": "local_machine",
                   "hkcu": "current_user", "hkey_current_user": "current_user",
                   "hkcr": "classes_root", "hkey_classes_root": "classes_root"}


def gog_value_key(root, section, name, view32=True):
    """A registry value's identity, keeping the root and the 32-bit view apart.

    The fallback import always targets the 32-bit view (`/reg:32`), which Wine keeps under
    `Software\\Wow6432Node` for HKLM: a native HKLM value must not pass for the 32-bit one.
    HKCU\\Software is shared by both views, so its values read the same either way.
    """
    text = section.replace("\\\\", "\\").strip("\\").lower()
    hive = GOG_VALUE_HIVES.get(root.lower(), root.lower())
    if text.startswith("software\\wow6432node\\"):
        text = text[len("software\\wow6432node\\"):]
    elif hive == "local_machine" and not view32 and text.startswith("software\\"):
        text = "native\\" + text
    elif text.startswith("software\\"):
        text = text[len("software\\"):]
    return "%s|%s|%s" % (hive, text, name.lower())


def gog_registry_values(pfx):
    """Values already in the prefix, so install defaults never overwrite a player's choice."""
    values = set()
    for file, root in (("system.reg", "hklm"), ("user.reg", "hkcu")):
        try:
            lines = (pfx / file).read_text(encoding="utf-8", errors="ignore").splitlines()
        except OSError:
            continue
        section = ""
        for line in lines:
            if line.startswith("["):
                end = line.find("]")
                section = line[1:end] if end > 0 else line[1:]
                continue
            match = re.match(r'^"([^"]+)"=', line)
            if match and section:
                values.add(gog_value_key(root, section, match.group(1), view32=False))
    return values


def gog_scripted_products(folder):
    products = set()
    for script in sorted(Path(folder).glob(GOG_SCRIPTS)):
        try:
            actions = json.loads(script.read_text(encoding="utf-8-sig")).get("actions")
        except (OSError, ValueError, AttributeError):
            continue
        if isinstance(actions, list) and any(
                isinstance(action, dict) and isinstance(action.get("install"), dict)
                and action["install"].get("action") == "setRegistry" for action in actions):
            products.add(script.name[len("goggame-"):-len(".script")])
    return products


def gog_expected_values(folder):
    """The fallback values a launch must find, so the marker never hides one that was deleted."""
    scripted = gog_scripted_products(folder)
    return [gog_value_key(root, subkey, name) for product in gog_installed_products(folder)
            if product in GOG_INSTALL_REGISTRY and product not in scripted
            for root, subkey, name, _kind, _value in GOG_INSTALL_REGISTRY[product]]


def gog_registry(folder, existing=()):
    entries, skipped, scripted = [], 0, set()
    for script in sorted(Path(folder).glob(GOG_SCRIPTS)):
        product = script.name[len("goggame-"):-len(".script")]
        try:
            actions = json.loads(script.read_text(encoding="utf-8-sig")).get("actions")
        except (OSError, ValueError, AttributeError):
            continue
        paths = gog_paths(folder, product)
        for action in actions if isinstance(actions, list) else []:
            install = action.get("install") if isinstance(action, dict) else None
            if not isinstance(install, dict) or install.get("action") != "setRegistry":
                continue
            entry = gog_entry(install.get("arguments"), paths)
            if entry is None:
                skipped += 1
            else:
                entries.append(entry)
                scripted.add(product)
    for product, info in gog_installed_products(folder).items():
        if product in scripted or product not in GOG_INSTALL_REGISTRY:
            continue
        paths = dict(gog_paths(folder, product), language=str(gog_language_id(info)))
        for root, subkey, name, kind, value in GOG_INSTALL_REGISTRY[product]:
            if gog_value_key(root, subkey, name) in existing:
                continue
            entry = gog_entry({"root": root, "subkey": subkey, "valueName": name,
                               "valueType": kind, "valueData": value}, paths)
            if entry is None:
                skipped += 1
            else:
                entries.append(entry)
    return entries, skipped


def apply_gog_registry(folder, compat_data, proton, timeout=90, installation=None):
    pfx = Path(compat_data) / "pfx" if compat_data else None
    existing = gog_registry_values(pfx) if pfx else set()
    entries, skipped = gog_registry(folder, existing)
    expected = gog_expected_values(folder)
    if installation:
        product = str(installation["id"])
        if not product.isdigit():
            raise ValueError("Invalid GOG product identifier")
        values = {"PATH": windows_path(str(folder))}
        identity = Path(folder) / ("goggame-%s.id" % product)
        if identity.is_file():
            actual = json.loads(identity.read_text(encoding="utf-8-sig")).get("buildId")
            if actual and installation.get("build") and str(actual) != str(installation["build"]):
                raise ValueError("GOG build metadata changed before patch setup")
        if installation.get("build"):
            values["BUILDID"] = installation["build"]
        if installation.get("version"):
            values["VER"] = installation["version"]
        for key, value in values.items():
            entry = gog_entry({"root": "HKLM", "subkey": "Software\\GOG.com\\Games\\" + product,
                               "valueName": key, "valueData": value, "valueType": "string"}, {})
            if entry:
                entries.append(entry)
    note = " skipped=%d" % skipped if skipped else ""
    if not entries:
        return "none" + note
    if not compat_data or not proton or not Path(proton).is_file():
        return "no-proton" + note
    text = "Windows Registry Editor Version 5.00\r\n\r\n" + "".join(entry + "\r\n\r\n" for entry in entries)
    stamp = hashlib.sha256(text.encode("utf-8")).hexdigest()
    marker = pfx / GOG_REGISTRY_MARK
    try:
        if (marker.is_file() and marker.read_text() == stamp and (pfx / "system.reg").is_file()
                and set(expected) <= existing):
            return "ready" + note
        Path(compat_data).mkdir(parents=True, exist_ok=True)
        with tempfile.TemporaryDirectory(prefix="droiddeck-gog-") as tmp:
            source = Path(tmp) / "registry.reg"
            source.write_bytes(b"\xff\xfe" + text.encode("utf-16-le"))
            executable = pfx / "drive_c/windows/system32/reg.exe"
            run = subprocess.run([proton, "run", str(executable) if executable.is_file() else "reg.exe", "import", windows_path(str(source)), "/reg:32"],
                                 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
        if run.returncode or not (pfx / "system.reg").is_file():
            return "import-failed" + note
        marker.write_text(stamp)
        return "written=%d%s" % (len(entries), note)
    except subprocess.TimeoutExpired:
        return "timeout" + note
    except OSError:
        return "import-failed" + note


EA_HARDWARE = ".droiddeck-ea-hw.json"
EA_CLIENT = "C:\\Program Files (x86)\\Origin\\Origin.exe"
EA_WMI = (
    ("boardManufacturer", "Win32_BaseBoard", "Manufacturer"),
    ("boardSerial", "Win32_BaseBoard", "SerialNumber"),
    ("biosManufacturer", "Win32_BIOS", "Manufacturer"),
    ("biosSerial", "Win32_BIOS", "SerialNumber"),
    ("osInstallDate", "Win32_OperatingSystem", "InstallDate"),
    ("osSerial", "Win32_OperatingSystem", "SerialNumber"),
    ("gpuPnpId", "Win32_VideoController", "PNPDeviceID"),
    ("cpuManufacturer", "Win32_Processor", "Manufacturer"),
    ("processorId", "Win32_Processor", "ProcessorId"),
    ("brand", "Win32_Processor", "Name"),
)
EA_ROOTS = {"HKLM": "HKEY_LOCAL_MACHINE", "HKCU": "HKEY_CURRENT_USER"}


def decode_output(data):
    if data[:2] in (b"\xff\xfe", b"\xfe\xff") or (len(data) > 1 and data[1:2] == b"\x00"):
        return data.decode("utf-16", "replace")
    return data.decode("utf-8", "replace")


def wmi_value(text):
    rows = [row.strip() for row in text.replace("\r", "\n").split("\n") if row.strip()]
    return rows[1] if len(rows) > 1 else ""


CPUID_LEAVES = (0, 1, 0x80000002, 0x80000003, 0x80000004)


def cpuid_exe():
    """A 32-bit console program that writes the raw CPUID leaves an x86 game sees under the emulator."""
    import struct
    base, rva = 0x400000, 0x1000
    buf, written = base + rva + 0x180, base + rva + 0x1D0
    iat = base + rva + 0x138
    code = b"\x53\x57\xbf" + struct.pack("<I", buf)
    for leaf in CPUID_LEAVES:
        code += b"\xb8" + struct.pack("<I", leaf) + b"\x31\xc9\x0f\xa2\x89\x07\x89\x5f\x04\x89\x4f\x08\x89\x57\x0c\x83\xc7\x10"
    code += b"\x6a\xf5\xff\x15" + struct.pack("<I", iat)
    code += b"\x6a\x00\x68" + struct.pack("<I", written) + b"\x6a\x50\x68" + struct.pack("<I", buf) + b"\x50\xff\x15" + struct.pack("<I", iat + 4)
    code += b"\x6a\x00\xff\x15" + struct.pack("<I", iat + 8)
    section = bytearray(0x200)
    section[:len(code)] = code
    names = [(0x148, b"GetStdHandle"), (0x158, b"WriteFile"), (0x164, b"ExitProcess")]
    struct.pack_into("<IIIII", section, 0x100, rva + 0x128, 0, 0, rva + 0x172, rva + 0x138)
    for i, (at, name) in enumerate(names):
        struct.pack_into("<I", section, 0x128 + 4 * i, rva + at)
        struct.pack_into("<I", section, 0x138 + 4 * i, rva + at)
        section[at + 2:at + 2 + len(name)] = name
    section[0x172:0x172 + 12] = b"KERNEL32.dll"
    header = bytearray(0x200)
    header[0:2] = b"MZ"
    struct.pack_into("<I", header, 0x3C, 0x40)
    header[0x40:0x44] = b"PE\0\0"
    struct.pack_into("<HHIIIHH", header, 0x44, 0x14C, 1, 0, 0, 0, 0xE0, 0x0103)
    opt = struct.pack("<HBBIIIIIIIIIHHHHHHIIIIHHIIIIII", 0x10B, 0, 0, 0x200, 0, 0, rva, rva, rva, base, 0x1000, 0x200,
                      4, 0, 0, 0, 4, 0, 0, 0x2000, 0x200, 0, 3, 0, 0x100000, 0x1000, 0x100000, 0x1000, 0, 16)
    dirs = bytearray(128)
    struct.pack_into("<II", dirs, 8, rva + 0x100, 40)
    struct.pack_into("<II", dirs, 96, rva + 0x138, 16)
    header[0x58:0x58 + 0xE0] = opt + bytes(dirs)
    header[0x138:0x138 + 40] = struct.pack("<8sIIIIIIHHI", b".text", 0x1000, rva, 0x200, 0x200, 0, 0, 0, 0, 0xE0000060)
    return bytes(header) + bytes(section)


EA_STUB_DIR = "Program Files (x86)/Origin"
EA_STUB = "C:\\Program Files (x86)\\Origin\\Origin.exe"


def origin_stub_exe():
    """A 32-bit program that only sleeps: the process older EA SDKs look for while a game runs."""
    import struct
    base, rva = 0x400000, 0x1000
    iat = base + rva + 0x138
    code = b"\x6a\xff\xff\x15" + struct.pack("<I", iat) + b"\xeb\xf6"
    section = bytearray(0x200)
    section[:len(code)] = code
    struct.pack_into("<IIIII", section, 0x100, rva + 0x128, 0, 0, rva + 0x172, rva + 0x138)
    struct.pack_into("<I", section, 0x128, rva + 0x148)
    struct.pack_into("<I", section, 0x138, rva + 0x148)
    section[0x14A:0x14A + 5] = b"Sleep"
    section[0x172:0x172 + 12] = b"KERNEL32.dll"
    header = bytearray(cpuid_exe()[:0x200])
    struct.pack_into("<H", header, 0x58 + 68, 2)
    return bytes(header) + bytes(section)


def install_origin_stub(compat_data):
    if not compat_data:
        return "no-prefix"
    target = Path(compat_data) / "pfx/drive_c" / EA_STUB_DIR / "Origin.exe"
    data = origin_stub_exe()
    try:
        if target.is_file() and target.read_bytes() == data:
            return "ready"
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(data)
        return "written"
    except OSError:
        return "failed"


def cpuid_values(raw):
    import struct
    if len(raw) < 80:
        return {}
    regs = [struct.unpack_from("<IIII", raw, 16 * i) for i in range(5)]
    vendor = struct.pack("<III", regs[0][1], regs[0][3], regs[0][2]).split(b"\0")[0].decode("latin-1")
    brand = b"".join(struct.pack("<IIII", *r) for r in regs[2:]).split(b"\0")[0].decode("latin-1")
    return {"cpuVendor": vendor, "cpuEax": "%08X" % regs[1][0], "cpuEdx": "%08X" % regs[1][3],
            "cpuEcx": "%08x" % regs[1][2], "cpuBrand": brand}


EA_PROBE_FIELDS = ("boardManufacturer", "biosManufacturer", "osInstallDate", "osSerial", "volumeSerial", "hostname",
                   "cpuVendor", "cpuEax", "cpuEdx", "cpuEcx")


def probe_complete(values):
    return all(str(values.get(key) or "").strip() for key in EA_PROBE_FIELDS)


def ea_hardware(compat_data, proton, timeout=240):
    pfx = Path(compat_data) / "pfx"
    cache = pfx / EA_HARDWARE
    try:
        cached = json.loads(cache.read_text())
        if isinstance(cached, dict) and probe_complete(cached):
            return cached, "cached"
    except (OSError, ValueError):
        pass
    if not proton or not Path(proton).is_file():
        return {}, "no-proton"
    values = {}
    with tempfile.TemporaryDirectory(prefix="droiddeck-ea-") as tmp:
        folder = Path(tmp)
        lines = ["@echo off"]
        for key, cls, prop in EA_WMI:
            lines.append('wmic path %s get %s > "%s"' % (cls, prop, windows_path(str(folder / (key + ".txt")))))
        lines.append('vol C: > "%s"' % windows_path(str(folder / "volume.txt")))
        lines.append('hostname > "%s"' % windows_path(str(folder / "hostname.txt")))
        (folder / "cpuid.exe").write_bytes(cpuid_exe())
        lines.append('"%s" > "%s"' % (windows_path(str(folder / "cpuid.exe")), windows_path(str(folder / "cpuid.bin"))))
        bat = folder / "probe.bat"
        bat.write_bytes(("\r\n".join(lines) + "\r\n").encode())
        try:
            subprocess.run([proton, "run", "cmd.exe", "/d", "/c", windows_path(str(bat))], stdin=subprocess.DEVNULL,
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
        except subprocess.TimeoutExpired:
            return {}, "timeout"
        except OSError:
            return {}, "failed"
        for key, _, _ in EA_WMI:
            try:
                values[key] = wmi_value(decode_output((folder / (key + ".txt")).read_bytes()))
            except OSError:
                values[key] = ""
        try:
            found = re.search(r"([0-9A-Fa-f]{4})-([0-9A-Fa-f]{4})", decode_output((folder / "volume.txt").read_bytes()))
            values["volumeSerial"] = (found.group(1) + found.group(2)).lower() if found else ""
        except OSError:
            values["volumeSerial"] = ""
        try:
            values["hostname"] = decode_output((folder / "hostname.txt").read_bytes()).strip().splitlines()[0].strip()
        except (OSError, IndexError):
            values["hostname"] = ""
        try:
            values.update(cpuid_values((folder / "cpuid.bin").read_bytes()))
        except OSError:
            pass
    if probe_complete(values):
        try:
            cache.write_text(json.dumps(values))
        except OSError:
            pass
        return values, "probed"
    return values, "incomplete"


def ea_registry_entries(folder, sidecar):
    install = windows_path(str(folder)).rstrip("\\") + "\\"
    extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
    title = str(extra.get("displayName") or sidecar.get("title") or folder.name).strip()
    locale = str(extra.get("locale") or "en_US")
    entries = [
        ("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\EA Desktop", "InstallSuccessful", "true"),
        ("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\Origin", "InstallSuccessful", "true"),
        ("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\Origin", "ClientPath", EA_CLIENT),
        ("HKEY_LOCAL_MACHINE\\SOFTWARE\\Origin", "ClientPath", EA_CLIENT),
    ]
    for content in [c.strip() for c in str(extra.get("contentIds") or extra.get("contentId") or "").split(",")]:
        if re.fullmatch(r"[A-Za-z0-9._-]{1,64}", content):
            key = "HKEY_LOCAL_MACHINE\\SOFTWARE\\Origin Games\\" + content
            entries += [(key, "DisplayName", title), (key, "Install Dir", install), (key, "Locale", locale)]
    try:
        keys = json.loads(extra.get("registry") or "[]")
    except ValueError:
        keys = []
    for pair in keys if isinstance(keys, list) else []:
        if not isinstance(pair, list) or len(pair) != 2:
            continue
        key, name = str(pair[0]), str(pair[1])
        root, _, rest = key.partition("\\")
        key = EA_ROOTS.get(root.upper(), root) + ("\\" + rest if rest else "")
        if key.upper().startswith(("HKEY_LOCAL_MACHINE\\", "HKEY_CURRENT_USER\\")) and name and "\n" not in key + name:
            entries.append((key, name, install))
            if title:
                entries.append((key, "DisplayName", title))
            entries.append((key, "Locale", locale))
    return entries


def ea_registry(folder, sidecar, compat_data, proton, timeout=90):
    if not compat_data or not proton or not Path(proton).is_file():
        return "no-proton"
    pfx = Path(compat_data) / "pfx"
    text = "Windows Registry Editor Version 5.00\r\n\r\n" + "".join(
        '[%s]\r\n"%s"="%s"\r\n\r\n' % (key, reg_escape(name), reg_escape(value)) for key, name, value in ea_registry_entries(folder, sidecar))
    stamp = hashlib.sha256(text.encode("utf-8")).hexdigest()
    marker = pfx / (".droiddeck-ea-registry-" + re.sub(r"[^A-Za-z0-9._-]", "_", str(sidecar.get("id"))))
    try:
        if marker.is_file() and marker.read_text() == stamp and (pfx / "system.reg").is_file():
            return "ready"
        with tempfile.TemporaryDirectory(prefix="droiddeck-ea-") as tmp:
            source = Path(tmp) / "registry.reg"
            source.write_bytes(b"\xff\xfe" + text.encode("utf-16-le"))
            executable = pfx / "drive_c/windows/system32/reg.exe"
            for view in ("/reg:32", "/reg:64"):
                run = subprocess.run([proton, "run", str(executable) if executable.is_file() else "reg.exe", "import", windows_path(str(source)), view],
                                     stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
                if run.returncode:
                    return "import-failed"
        marker.write_text(stamp)
        return "written"
    except subprocess.TimeoutExpired:
        return "timeout"
    except OSError:
        return "import-failed"


def find_case(folder, relative):
    current = Path(folder)
    for part in [p for p in relative.replace("\\", "/").split("/") if p and p != "."]:
        exact = current / part
        if exact.exists():
            current = exact
            continue
        try:
            current = next(c for c in current.iterdir() if c.name.lower() == part.lower())
        except (OSError, StopIteration):
            return None
    return current if current != Path(folder) else None


def touchup_args(raw, locale, install):
    raw = raw.replace("{locale}", locale)
    raw = raw.replace('"{installLocation}"', '"%s"' % install).replace("{installLocation}", install)
    try:
        return shlex.split(raw.replace("\\", "\\\\"))
    except ValueError:
        return raw.split()


def ea_touchup(folder, sidecar, compat_data, proton, timeout=900):
    extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
    relative = str(extra.get("touchup") or "")
    if not relative:
        return "none"
    exe = find_case(folder, relative)
    if exe is None or not exe.is_file():
        return "missing"
    if not compat_data or not proton or not Path(proton).is_file():
        return "no-proton"
    pfx = Path(compat_data) / "pfx"
    stamp = str(sidecar.get("installVersion") or "") + " " + str(sidecar.get("installedAt") or "")
    marker = pfx / (".droiddeck-ea-touchup-" + re.sub(r"[^A-Za-z0-9._-]", "_", str(sidecar.get("id"))))
    try:
        if marker.is_file() and marker.read_text() == stamp:
            return "done"
    except OSError:
        pass
    args = touchup_args(str(extra.get("touchupArgs") or ""), str(extra.get("locale") or "en_US"), windows_path(str(folder)).rstrip("\\"))
    try:
        run = subprocess.run([proton, "run", str(exe)] + args, cwd=str(exe.parent), stdin=subprocess.DEVNULL,
                             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
        result = "ok" if run.returncode == 0 else "exit-%d" % run.returncode
    except subprocess.TimeoutExpired:
        result = "timeout"
    except OSError:
        return "failed"
    try:
        marker.write_text(stamp)
    except OSError:
        pass
    return result


def write_licenses(compat_data, licenses):
    if not compat_data:
        return 0
    root = Path(compat_data) / "pfx/drive_c"
    written = 0
    for item in licenses or []:
        relative = str(item.get("path") or "") if isinstance(item, dict) else ""
        if not relative or relative.startswith("/") or ".." in relative.split("/"):
            continue
        target = root / relative
        try:
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_bytes(base64.b64decode(item.get("data") or ""))
            written += 1
        except (OSError, binascii.Error):
            pass
    return written


def prepare_ea(folder, sidecar, compat_data, launch_dir):
    game = str(sidecar.get("id"))
    proton = os.environ.get("BL_STORE_PROTON", "")
    made = create_prefix(compat_data, proton)
    ready = made in ("present", "created") or (compat_data and (Path(compat_data) / "pfx/user.reg").is_file())
    stub = install_origin_stub(compat_data) if ready else "no-prefix"
    hardware, probe = ea_hardware(compat_data, proton) if ready else ({}, "no-prefix")
    answer = None
    if launch_dir:
        answer = ask(Path(launch_dir) / "stores", {"op": "ea-prepare", "store": "ea", "id": game, "hardware": hardware}, timeout=40.0)
    sidecar = sidecar_at(Path(folder)) or sidecar
    log("ea %s prefix=%s stub=%s hardware=%s registry=%s touchup=%s" % (
        game, made, stub, probe, ea_registry(folder, sidecar, compat_data, proton), ea_touchup(folder, sidecar, compat_data, proton)))
    if answer is None:
        log("ea %s session=no reason=%s" % (game, "timeout" if launch_dir else "no-channel"))
        return
    written = write_licenses(compat_data, answer.get("licenses"))
    log("ea %s session=%s reason=%s lsx=%s licence=%s files=%d" % (
        game, "yes" if answer.get("ok") else "no", answer.get("reason", ""), answer.get("lsx"), answer.get("license", ""), written))


def gog_support_actions(folder):
    found = []
    for script in sorted(Path(folder).glob(GOG_SCRIPTS)):
        product = script.name[len("goggame-"):-len(".script")]
        try:
            actions = json.loads(script.read_text(encoding="utf-8-sig")).get("actions")
        except (OSError, ValueError, AttributeError):
            continue
        for action in actions if isinstance(actions, list) else []:
            install = action.get("install") if isinstance(action, dict) else None
            if isinstance(install, dict) and install.get("action") == "supportData" \
                    and isinstance(install.get("arguments"), dict):
                found.append((product, install["arguments"]))
    return found


def gog_support_bases(folder, product, pfx):
    user = Path(pfx) / "drive_c" / "users" / "steamuser"
    support = Path(folder) / "support" / product
    return {"app": Path(folder), "supportdir": support, "support": support,
            "userdocs": user / "Documents", "localappdata": user / "AppData" / "Local"}


def gog_inside(path, base):
    root = os.path.realpath(base)
    real = os.path.realpath(path)
    return real == root or real.startswith(root + os.sep)


def gog_support_path(text, bases):
    match = re.fullmatch(r"\{(\w+)\}(.*)", text) if isinstance(text, str) else None
    if not match or match.group(1).lower() not in bases:
        return None
    parts = [part for part in re.split(r"[\\/]+", match.group(2)) if part]
    if any(part in (".", "..") for part in parts):
        return None
    base = bases[match.group(1).lower()]
    path = base
    for part in parts:
        exact = path / part
        if not exact.exists() and path.is_dir():
            exact = next((child for child in path.iterdir() if child.name.lower() == part.lower()), exact)
        path = exact
    if path != base and not gog_inside(path.parent, base):
        return None
    return path, base


def gog_copy_file(source, target, root):
    if not gog_inside(target.parent, root):
        raise OSError("outside " + str(root))
    target.parent.mkdir(parents=True, exist_ok=True)
    if target.is_symlink():
        target.unlink()
    shutil.copy2(source, target, follow_symlinks=False)


def gog_support_data(folder, compat_data, proton):
    actions = gog_support_actions(folder)
    if not actions:
        return "none"
    pfx = Path(compat_data) / "pfx" if compat_data else None
    if not pfx or not (pfx / "drive_c" / "users" / "steamuser").is_dir():
        made = create_prefix(compat_data, proton)
        if made not in ("present", "created") or not (pfx / "drive_c" / "users" / "steamuser").is_dir():
            return "no-prefix"
    stamp = hashlib.sha256(json.dumps(actions, sort_keys=True).encode("utf-8")).hexdigest()
    marker = pfx / GOG_SUPPORT_MARK
    try:
        if marker.is_file() and marker.read_text() == stamp:
            return "ready"
    except OSError:
        pass
    copied = skipped = missing = 0
    for product, arguments in actions:
        bases = gog_support_bases(folder, product, pfx)
        found = gog_support_path(arguments.get("target"), bases)
        origin = gog_support_path(arguments["source"], bases) if "source" in arguments else None
        if found is None or ("source" in arguments and origin is None):
            skipped += 1
            continue
        target, root = found
        source = origin[0] if origin else None
        try:
            if source is None:
                target.mkdir(parents=True, exist_ok=True)
            elif not source.exists():
                missing += 1
            elif source.is_dir() and not source.is_symlink():
                for top, dirs, files in os.walk(source):
                    for name in sorted(files + [d for d in dirs if (Path(top) / d).is_symlink()]):
                        item = Path(top) / name
                        destination = target / item.relative_to(source)
                        if (destination.exists() or destination.is_symlink()) and not arguments.get("overwrite"):
                            continue
                        gog_copy_file(item, destination, root)
                        copied += 1
                target.mkdir(parents=True, exist_ok=True)
            elif (target.exists() or target.is_symlink()) and not arguments.get("overwrite"):
                continue
            else:
                gog_copy_file(source, target, root)
                copied += 1
        except OSError:
            skipped += 1
    note = "".join(" %s=%d" % (name, count) for name, count in (("skipped", skipped), ("missing", missing)) if count)
    if not skipped:
        try:
            marker.write_text(stamp)
        except OSError:
            pass
    return "copied=%d%s" % (copied, note)


def main(argv):
    if os.environ.get("DROIDDECK_GOG_MAINTENANCE") == "1":
        return 0
    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)
    command = battlenet(argv)
    if command is not None:
        log("battlenet " + " ".join(command[1:]))
        return RAN if subprocess.call(command) == 0 else RAN_FAILED
    found = find_store_game(argv)
    if found is None:
        return 0
    folder, sidecar = found
    store, game = str(sidecar.get("store")), str(sidecar.get("id"))
    if store == "gog":
        helper = Path(__file__).resolve().with_name("droiddeck-gog-maintenance")
        if helper.is_file():
            child = subprocess.Popen([sys.executable, str(helper), "lease", str(folder), str(os.getppid())],
                                     stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
                                     start_new_session=True)
            ready = child.stdout.readline().decode().strip()
            child.stdout.close()
            threading.Thread(target=child.wait, daemon=True).start()
            if ready != "ready":
                log("gog: close or recover the maintenance operation before playing")
                return RAN_FAILED
    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 launch_dir and cloud_on(sidecar):
        made = create_prefix(compat_data, os.environ.get("BL_STORE_PROTON", ""))
        if made != "present":
            log("cloud %s %s prefix=%s" % (store, game, made))
    if store == "gog":
        prepare_gog(sidecar, compat_data, launch_dir)
        log("gog registry: " + apply_gog_registry(folder, compat_data, os.environ.get("BL_STORE_PROTON", "")))
        log("gog support data: " + gog_support_data(folder, compat_data, os.environ.get("BL_STORE_PROTON", "")))
    if store == "ubisoft":
        try:
            log("ubisoft %s: %s" % (game, prepare_ubisoft(folder, sidecar, compat_data, launch_dir, os.environ.get("BL_STORE_PROTON", ""))))
        except OSError as e:
            try:
                (folder / UBISOFT_LAUNCH).unlink()
            except OSError:
                pass
            log("ubisoft %s: not prepared (%s), starting directly" % (game, e))
    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 store == "ea":
        prepare_ea(folder, sidecar, compat_data, launch_dir)
        return STORE_GAME
    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)
