#!/usr/bin/python3
import fcntl
import errno
import json
import os
from pathlib import Path
import runpy
import subprocess
import sys
import tempfile
import time

BIN = Path(__file__).resolve().parent
IDLE = {"wineserver", "services.exe", "winedevice.exe", "explorer.exe", "rpcss.exe", "plugplay.exe",
        "conhost.exe", "lsass.exe", "svchost.exe", "steam.exe", "wineboot.exe", "winemenubuilder.exe"}


def control(folder):
    folder = Path(folder).resolve(strict=True)
    root = folder / ".droiddeck-gog"
    if root.is_symlink():
        raise ValueError("GOG control directory is a symbolic link")
    root.mkdir(exist_ok=True)
    return root


def read(path):
    if path.is_symlink():
        raise ValueError("GOG metadata is a symbolic link")
    return json.loads(path.read_text(encoding="utf-8"))


def write(path, value):
    if path.is_symlink():
        raise ValueError("GOG metadata is a symbolic link")
    descriptor, name = tempfile.mkstemp(prefix=path.name + ".", suffix=".new", dir=path.parent)
    temporary = Path(name)
    try:
        with os.fdopen(descriptor, "w") as stream:
            json.dump(value, stream)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
        directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
        try:
            try:
                os.fsync(directory)
            except OSError as error:
                if error.errno not in (errno.EINVAL, errno.ENOTSUP):
                    raise
        finally:
            os.close(directory)
    finally:
        temporary.unlink(missing_ok=True)


def lock_file(root):
    return os.fdopen(os.open(root / "lease", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), "r+")


def generation(pid):
    try:
        return Path("/proc/%s/stat" % pid).read_text().rsplit(")", 1)[1].split()[19]
    except (OSError, IndexError):
        return None


def lease(folder, owner):
    root = control(folder)
    with lock_file(root) as stream:
        fcntl.lockf(stream, fcntl.LOCK_SH | fcntl.LOCK_NB)
        sidecar = read(Path(folder) / ".droiddeck-store.json")
        if sidecar.get("state", "installed") != "installed":
            raise ValueError("Resume or recover the GOG update before playing")
        journal = root / "operation.json"
        if journal.exists() and read(journal).get("phase") != "download":
            raise ValueError("GOG maintenance is incomplete")
        start = generation(owner)
        if start is None:
            raise ValueError("Game launcher has exited")
        print("ready", flush=True)
        sys.stdout.close()
        while generation(owner) == start:
            time.sleep(0.25)
    return 0


def desktop_environment(base, proc=Path("/proc")):
    env = dict(base)
    if env.get("DROIDDECK_GOG_DESKTOP") != "1":
        return env
    deadline = time.monotonic() + 45
    while True:
        for entry in proc.iterdir():
            if not entry.name.isdigit():
                continue
            try:
                command = (entry / "cmdline").read_bytes().split(b"\0")[0]
                if command != b"/usr/bin/plasmashell":
                    continue
                values = dict(os.fsdecode(item).split("=", 1) for item in (entry / "environ").read_bytes().split(b"\0") if b"=" in item)
                if values.get("BL_DESKTOP") != "1" or values.get("XDG_RUNTIME_DIR") != env.get("XDG_RUNTIME_DIR"):
                    continue
                if not values.get("DISPLAY") or values.get("WAYLAND_DISPLAY") == env.get("WAYLAND_DISPLAY"):
                    continue
                for key in ("DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "BL_DESKTOP"):
                    if key in values:
                        env[key] = values[key]
                return env
            except (OSError, ValueError):
                continue
        if time.monotonic() >= deadline:
            raise ValueError("The desktop display is not ready; resume the Linux session and verify recovery before retrying")
        time.sleep(0.25)


def target_for(folder, appid, game):
    run = runpy.run_path(str(BIN / "droiddeck-proton-run"))["main"].__globals__
    root = os.environ.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or run["STEAM_ROOT"]
    target = run["shortcut_target"](str(int(appid) & 0xffffffff), str(game), str(folder), root)
    target.install = str(folder)
    return run, root, target


def runner(folder, appid, game, graphical=False):
    run, root, target = target_for(folder, appid, game)
    available = run["tools"](root)
    name, _ = run["choose_tool"](target.appid, run["mapping"](root), available)
    if name is None:
        raise ValueError("No Proton tool is installed for this game")
    tool, command = available[name]
    base = desktop_environment(os.environ) if graphical else dict(os.environ)
    env = run["environment"](base, target, tool, root, window=base.get("BL_DESKTOP") == "1")
    env["DROIDDECK_GOG_MAINTENANCE"] = "1"
    env["FEX_X87REDUCEDPRECISION"] = "0"
    env.pop("BL_STORE_EXIT", None)
    return str(Path(tool) / command), env, target.compatdata


def active(prefix, proc=Path("/proc")):
    result = []
    prefix = os.path.realpath(os.path.join(prefix, "pfx"))
    for entry in proc.iterdir():
        if not entry.name.isdigit():
            continue
        try:
            env = dict(part.split(b"=", 1) for part in (entry / "environ").read_bytes().split(b"\0") if b"=" in part)
            if os.path.realpath(os.fsdecode(env.get(b"WINEPREFIX", b""))) != prefix:
                continue
            args = (entry / "cmdline").read_bytes().split(b"\0")
            name = os.fsdecode(args[0]).replace("\\", "/").rsplit("/", 1)[-1].lower()
            if name not in IDLE:
                result.append(int(entry.name))
        except (OSError, ValueError):
            continue
    return result


def idle(folder, appid, game):
    _, _, target = target_for(folder, appid, game)
    if active(target.compatdata):
        raise ValueError("Close the GOG game before maintenance")
    return 0


def validate_installer(folder, installer):
    root = control(folder)
    path = Path(installer)
    if path.is_symlink() or not path.resolve(strict=True).is_relative_to(root / "downloads") or path.suffix.lower() != ".exe":
        raise ValueError("The patch executable is outside GOG download storage")
    with path.open("rb") as stream:
        if stream.read(2) != b"MZ":
            raise ValueError("The GOG patch is not a Windows executable")
    return path


def installer_arguments(path):
    with path.open("rb") as stream:
        data = stream.read(4 * 1024 * 1024)
    return ["/NOGUI"] if b"Inno Setup Setup Data" in data else []


def start(folder, appid, installer, game):
    root = control(folder)
    validate_installer(folder, installer)
    with lock_file(root) as stream:
        fcntl.lockf(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
        journal = read(root / "operation.json")
        if journal.get("phase") != "patch-ready":
            raise ValueError("The patch must be selected again before it can run")
        idle(folder, appid, game)
        journal["phase"] = "patch-starting"
        write(root / "operation.json", journal)
        write(root / "patch-status.json", {"state": "starting", "startedAt": time.time()})
        with (root / "patch.log").open("ab") as log:
            subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "run", folder, appid, installer, game],
                             stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True, close_fds=True)
    return 0


def status(folder):
    root = control(folder)
    path = root / "patch-status.json"
    result = read(path)
    ended = result.get("state") == "running" and generation(result.get("pid")) != result.get("generation")
    abandoned = result.get("state") == "starting" and time.time() - result.get("startedAt", 0) > 60
    if ended or abandoned:
        result = {"state": "uncertain", "reason": "The patch helper ended without a confirmed result; verify recovery before retrying"}
        write(path, result)
        journal = read(root / "operation.json")
        journal["phase"] = "patch-uncertain"
        write(root / "operation.json", journal)
    print(json.dumps(result), flush=True)
    return 0


def run_patch(folder, appid, installer, game):
    root = control(folder)
    try:
        with lock_file(root) as stream:
            fcntl.lockf(stream, fcntl.LOCK_EX)
            path = validate_installer(folder, installer)
            command, env, prefix = runner(folder, appid, game, True)
            if active(prefix):
                raise ValueError("Close the GOG game before applying the patch")
            journal = read(root / "operation.json")
            if journal.get("phase") != "patch-starting":
                raise ValueError("The patch is already running or needs recovery")
            journal["phase"] = "patch-applying"
            write(root / "operation.json", journal)
            sidecar_path = Path(folder) / ".droiddeck-store.json"
            sidecar = read(sidecar_path)
            sidecar["state"] = "updating"
            write(sidecar_path, sidecar)
            installed_path = root / "installed.json"
            if installed_path.is_file():
                installed = read(installed_path)
                installed.update(verified=False, manifest={}, files=[])
                write(installed_path, installed)
            write(root / "patch-status.json", {"state": "running", "pid": os.getpid(), "generation": generation(os.getpid())})
            os.makedirs(prefix, exist_ok=True)
            setup = runpy.run_path(str(BIN / "droiddeck-store-launch"))["main"].__globals__
            previous = dict(os.environ)
            try:
                os.environ.update(env)
                pfx = Path(prefix) / "pfx"
                if not (pfx / "system.reg").is_file() or not (pfx / "drive_c/windows/system32/reg.exe").is_file():
                    subprocess.run([command, "run", "wineboot.exe", "-u"], env=env, timeout=90, check=True)
                if not (pfx / "drive_c/windows/system32/reg.exe").is_file():
                    raise ValueError("Proton did not initialize the game's registry tool")
                result = setup["apply_gog_registry"](Path(folder), prefix, command, installation=journal.get("before"))
                if not result.startswith(("ready", "written=")):
                    raise ValueError("GOG registry preparation failed; the patch was not applied")
                prepare_setup(folder, command, env)
            finally:
                os.environ.clear(); os.environ.update(previous)
            proc = subprocess.Popen([command, "run", str(path), *installer_arguments(path)], cwd=str(path.parent), env=env)
            code = proc.wait()
            quiet_at = time.monotonic()
            while True:
                if active(prefix):
                    quiet_at = time.monotonic()
                elif time.monotonic() - quiet_at >= 2:
                    break
                time.sleep(0.25)
            journal["phase"] = "patch-uncertain"
            write(root / "operation.json", journal)
            if code:
                raise ValueError("GOG patch installer exited with code %d; inspect patch.log" % code)
            write(root / "patch-status.json", {"state": "done", "exitCode": code})
        return 0
    except Exception as error:
        write(root / "patch-status.json", {"state": "failed", "reason": str(error)})
        return 1


def prepare_setup(folder, command, env):
    root = control(folder)
    record_path = root / "installed.json"
    if not record_path.is_file():
        return
    journal = read(root / "operation.json")
    record = journal.get("before") or read(record_path)
    manifest = record.get("manifest", {})
    if not record.get("build"):
        return
    info_path = Path(folder) / ("goggame-%s.info" % record["id"])
    language = read(info_path).get("language", "") if info_path.is_file() else ""
    legacy = manifest.get("product", {})
    products = manifest.get("products", []) if not legacy else [
        {"productId": str(item["gameID"]), "temp_executable": item["executable"].lstrip("/\\")}
        for item in legacy.get("support_commands", [])
        if "Neutral" in item.get("languages", []) or record.get("language") in item.get("languages", []) or language in item.get("languages", [])]
    for product in products:
        if str(product.get("productId")) not in record.get("products", []):
            continue
        program = None
        if manifest.get("scriptInterpreter"):
            if not (Path(folder) / ("goggame-%s.script" % product["productId"])).is_file():
                continue
            program = Path(folder) / "support/__redist/ISI/scriptinterpreter.exe"
        elif product.get("temp_executable"):
            relative = product["temp_executable"].replace("\\", "/")
            for candidate in (Path(folder) / "support" / str(product["productId"]) / relative, Path(folder) / relative):
                if candidate.is_file():
                    program = candidate
                    break
        if program is None:
            continue
        if not program.resolve(strict=True).is_relative_to(Path(folder).resolve()):
            raise ValueError("GOG setup executable leaves the installation")
        windows = "Z:" + str(Path(folder).resolve()).replace("/", "\\")
        args = ["/VERYSILENT", "/DIR=" + windows, "/ProductId=" + str(product["productId"]),
                "/galaxyclient", "/buildId=" + str(legacy.get("timestamp", record["build"])), "/versionName=" + record.get("version", ""),
                "/lang-code=" + record.get("language", "en-US"), "/supportDir=" + windows + "\\support", "/nodesktopshortcut"]
        if language:
            args += ["/Language=" + language, "/LANG=" + language.lower()]
        result = subprocess.run([command, "run", str(program), *args], env=env, cwd=folder, timeout=120)
        if result.returncode:
            raise ValueError("GOG installation setup failed; the patch was not applied")


def main(argv):
    if len(argv) == 3 and argv[0] == "lease":
        return lease(argv[1], int(argv[2]))
    if len(argv) == 4 and argv[0] == "idle":
        return idle(*argv[1:])
    if len(argv) == 2 and argv[0] == "status":
        return status(argv[1])
    if len(argv) == 5 and argv[0] == "start":
        return start(*argv[1:])
    if len(argv) == 5 and argv[0] == "run":
        return run_patch(*argv[1:])
    raise ValueError("Invalid GOG maintenance command")


if __name__ == "__main__":
    try:
        sys.exit(main(sys.argv[1:]))
    except Exception as error:
        print("GOG maintenance: " + str(error), file=sys.stderr)
        sys.exit(1)
