#!/usr/bin/env python3
"""
    This script handles installing system dependencies for games using the
    Steam runtime.  It is intended to be customized by other distributions
    to "do the right thing"

    Usage: steamdeps dependencies.txt
"""

import argparse
import os
import re
import shlex
import stat
import subprocess
import sys
import tempfile

try:
    import typing
except ImportError:
    pass

try:
    import apt
except ImportError:
    sys.stderr.write("Couldn't import apt, please install python3-apt or "
                     "update steamdeps for your distribution.\n")
    sys.exit(3)

# This is the set of supported Steam runtime environments
SUPPORTED_STEAM_RUNTIME = ['1']

# This is the set of supported dependency formats
SUPPORTED_STEAM_DEPENDENCY_VERSION = ['1']

_arch = None


class OsRelease:
    def __init__(self):
        # type: () -> None
        self._load_any()
        self._is_like = self._data.get('ID_LIKE', '').split()

    def _load_any(self):
        # type: () -> None
        for path in ('/etc/os-release', '/usr/lib/os-release'):
            self._data = self._load(path)

            if self._data:
                return

    def _load(
        self,
        path        # type: str
    ):
        # type: (...) -> typing.Dict[str, str]

        data = {}       # type: typing.Dict[str, str]

        try:
            with open(path, 'r', encoding='utf-8', errors='replace') as reader:
                for line in reader:
                    try:
                        line = line.strip()

                        if not line or line.startswith('#'):
                            continue

                        key, value = line.split('=', 1)

                        data[key] = ''.join(shlex.split(value))

                    except Exception as e:
                        print(
                            'Warning: error parsing %s: line %r: %s'
                            % (path, line, e),
                            file=sys.stderr
                        )
                        continue
        except OSError:
            return {}
        else:
            return data

    def is_os(self, os_id):
        # type: (str) -> bool
        if self._data.get('ID') == os_id:
            return True

        return os_id in self._is_like

    def dump(self):
        print(self._data, file=sys.stderr)


#
# Get the current package architecture
# This may be different than the actual architecture for the case of i386
# chroot environments on amd64 hosts.
#
def get_arch():
    """
    Get the current architecture
    """
    global _arch

    if _arch is None:
        _arch = subprocess.check_output(
            ['dpkg', '--print-architecture']).decode("utf-8").strip()
    return _arch


###
def get_full_package_name(name):
    """
    Get the full name of a package, qualified by architecture
    """
    if name.find(":") < 0:
        return name + ":" + get_arch()
    else:
        return name


# N.B. Version checks are not supported on virtual packages
#
def is_provided(pkgname):
    """
    Check to see if another package Provides this package
    """
    cache = apt.Cache()
    pkgs = cache.get_providing_packages(pkgname)
    for pkg in pkgs:
        if pkg.is_installed:
            return True
    return False


###
class Package:
    """
    Package definition class
    """

    def __init__(self, name, version_conditions):
        self.name = name
        self.version_conditions = version_conditions
        self.installed = None

    def set_installed(self, version):
        self.installed = version

    def is_available(self):
        if self.installed is None:
            # check to see if another package is providing this virtual package
            return is_provided(self.name)

        for (op, version) in self.version_conditions:
            if subprocess.call(['dpkg', '--compare-versions', self.installed,
                                op, version]) != 0:
                return False

        return True

    def __str__(self):
        text = self.name
        for (op, version) in self.version_conditions:
            text += " (%s %s)" % (op, version)
        return text


def is_glvnd():
    try:
        with subprocess.Popen(['apt-cache', 'pkgnames', 'libgl1'],
                              stdout=subprocess.PIPE,) as process:
            for line in process.stdout:
                line = line.decode('utf-8').strip()

                if line == 'libgl1':
                    return True
            return False
    except (OSError, FileNotFoundError):
        return False


def is_package_available(package_name):
    try:
        apt_output = subprocess.check_output(
            ['apt-cache', '-q', 'show', package_name])
        # On SteamOS "xdg-desktop-portal-gtk" is recommended by the backported
        # version of flatpak, even if it is not available in the repositories.
        # For this reason we also check the apt-cache -q show output before
        # asserting that a package is really available.
        return bool(apt_output)
    except (OSError, FileNotFoundError, subprocess.CalledProcessError):
        return False


def choose_xdg_portal_backend(default_backend="xdg-desktop-portal-gtk"):
    gtk_desktop = ["Cinnamon", "GNOME", "LXDE", "MATE", "Unity", "XFCE"]
    kde_desktop = ["KDE"]
    gtk_backend = "xdg-desktop-portal-gtk"
    kde_backend = "xdg-desktop-portal-kde"

    current_desktops = os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")

    for c in current_desktops:
        if c in gtk_desktop:
            if is_package_available(gtk_backend):
                return gtk_backend
        elif c in kde_desktop:
            if is_package_available(kde_backend):
                return kde_backend

    if is_package_available(default_backend):
        return default_backend
    else:
        sys.stderr.write(
            "There isn't a known XDG portal backend that can be installed\n")
        return None


def remap_package(name):
    if name in (
            'python-apt',
    ):
        # Steam claims it needs python-apt, but it doesn't really
        return None

    # Ubuntu 12.04.2, 12.04.3, and 12.04.4 introduce new X stacks which require
    # different sets of incompatible glx packages depending on which X
    # is currently installed.

    cache = apt.Cache()
    for lts in ('quantal', 'raring', 'saucy', 'trusty', 'xenial'):
        xserver = 'xserver-xorg-core-lts-' + lts
        if xserver in cache and cache[xserver].is_installed:
            if name in (
                    'libgl1-mesa-glx',
                    'libgl1-mesa-dri',
            ):
                return name + '-lts-' + lts

    if name == 'libgl1-mesa-glx':
        if is_glvnd():
            return 'libgl1'

    return name


###
def create_package(description):
    """
    Create a package object based on a description.
    This can return None if the package isn't meaningful on this platform.
    """
    # Look for architecture conditions, e.g. foo [i386]
    match = re.match(r"(.*) \[([^\]]+)\]", description)
    if match is not None:
        description = match.group(1).strip()
        condition = match.group(2)
        if condition[0] == '!':
            if get_arch() == condition[1:]:
                return None
        else:
            if get_arch() != condition:
                return None

    # Look for version requirements, e.g. foo (>= 1.0)
    version_conditions = []
    while True:
        match = re.search(r"\s*\(\s*([<>=]+)\s*([\w\-.:]+)\s*\)\s*",
                          description)
        if match is None:
            break

        version_conditions.append((match.group(1), match.group(2)))
        description = description[:match.start()] + description[match.end():]

    description = description.strip()

    if ':' in description:
        name, multiarch = description.rsplit(':', 1)
    else:
        name = description
        multiarch = None

    name = remap_package(name)

    if name in (
            "xdg-desktop-portal-gtk",
            "xdg-desktop-portal-kde",
    ):
        name = choose_xdg_portal_backend(name)
        # Skip version conditions for portal backends because we don't know
        # in advance which backend we will need to install.
        # In the future, if necessary, we can enhance this check and, for
        # example, embed a minimum version for each possible backed.
        version_conditions = []

    if name is None:
        return None
    elif multiarch is not None:
        description = name + ':' + multiarch
    else:
        description = name

    return Package(description, version_conditions)


###
def get_terminal_command_line(title):
    """
    Function to find a useful terminal like xterm or compatible
    """
    if "DISPLAY" in os.environ:
        gnome_wait_option = None
        try:
            # Use the new '--wait' option if available
            terminal_out = subprocess.check_output(
                ["gnome-terminal", "--help-terminal-options"]).decode("utf-8")
            if "--wait" in terminal_out:
                gnome_wait_option = "--wait"
            else:
                # If the old '--disable-factory' is supported we use it
                terminal_out = subprocess.check_output(
                    ["gnome-terminal", "--help"]).decode("utf-8")
                if "--disable-factory" in terminal_out:
                    gnome_wait_option = "--disable-factory"

            if gnome_wait_option is not None:
                # If 'gnome-terminal' with the right options is available, we
                # just use it
                return ["gnome-terminal", gnome_wait_option, "-t", title, "--"]
        except FileNotFoundError:
            pass

        programs = [
            ("konsole",
             ["konsole", "--nofork", "-p", "tabtitle=" + title, "-e"]),
            ("xterm",
             ["xterm", "-bg", "#383635", "-fg", "#d1cfcd", "-T", title, "-e"]),
            ("x-terminal-emulator",
             ["x-terminal-emulator", "-T", title, "-e"]),
            # If we reach this point either 'gnome-terminal' is not available
            # or the current version is too old for the new '--wait' option.
            # Anyway we can't know for sure if '--disable-factory' option
            # is supported until we try it because, for example,
            # on Ubuntu 16.04 '--disable-factory' is available but it doesn't
            # show up with '--help'. Leave this 'gnome-terminal' test as the
            # last resort because it's highly likely to fail.
            ("gnome-terminal",
             ["gnome-terminal", "--disable-factory", "-t", title, "--"]),
        ]
        for (program, commandLine) in programs:
            if subprocess.call(['which', program],
                               stdout=subprocess.PIPE) == 0:
                return commandLine

    # Fallback if no GUI terminal program is available
    return ['/bin/sh']


###
def update_packages(packages):
    """
    Function to install or update package dependencies
    Ideally we would call some sort of system UI that users were familiar with
    to do this, but nothing that exists yet does what we need.
    """

    package_list = " ".join([package.name for package in packages])

    # Create a temporary file to hold the installation completion status
    (fd, status_file) = tempfile.mkstemp()
    os.close(fd)

    # Create a script to run, in a secure way
    (fd, script_file) = tempfile.mkstemp()
    script = """#!/bin/sh
check_sudo()
{
    # If your host file is misconfigured in certain circumstances this
    # can cause sudo to block for a while, which causes gksudo to go into
    # limbo and never return.
    timeout --signal=9 5 sudo -v -S </dev/null 2>/dev/null
    if [ $? -eq 124 -o $? -eq 137 ]; then
        # sudo timed out or was killed due to timeout
        cat <<__EOF__
sudo timed out, your hostname may be missing from /etc/hosts.

See https://support.steampowered.com/kb_article.php?ref=7493-ADXN-9620
for more details.
__EOF__
        return 1
    else
        return 0
    fi
}

cat <<__EOF__
Steam needs to install these additional packages:
    %s
__EOF__
check_sudo

# Check to make sure 64-bit systems can get 32-bit packages
if [ "$(dpkg --print-architecture)" = "amd64" ] && \
   ! dpkg --print-foreign-architectures | grep i386 >/dev/null; then
    sudo dpkg --add-architecture i386
fi

# Update the package list, showing progress
sudo apt-get update | while read line; do echo -n "."; done
echo

# Install the packages!
sudo apt-get install %s
echo $? >%s
echo -n "Press return to continue: "
read line
""" % (", ".join([package.name for package in packages]), package_list,
       status_file)
    os.write(fd, script.encode("utf-8"))
    os.close(fd)
    os.chmod(script_file, (stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR))

    try:
        subprocess.call(
            get_terminal_command_line("Package Install") + [script_file])
    except KeyboardInterrupt:
        pass
    os.unlink(script_file)

    # Read the status out of the file, since if we ran the script in a
    # terminal the process status will be whether the terminal started
    try:
        status = int(open(status_file).read())
    except ValueError:
        # The status wasn't written to the file
        status = 255

    os.unlink(status_file)

    return status


###
def check_config(path, config):
    if "STEAM_RUNTIME" not in config:
        sys.stderr.write(
            "Missing STEAM_RUNTIME definition in %s\n" % path)
        return False

    if config["STEAM_RUNTIME"] not in SUPPORTED_STEAM_RUNTIME:
        sys.stderr.write(
            "Unsupported Steam runtime: %s\n" % config["STEAM_RUNTIME"])
        return False

    if "STEAM_DEPENDENCY_VERSION" not in config:
        sys.stderr.write(
            "Missing STEAM_DEPENDENCY_VERSION definition in %s\n" % path)
        return False

    if config["STEAM_DEPENDENCY_VERSION"]\
            not in SUPPORTED_STEAM_DEPENDENCY_VERSION:
        sys.stderr.write("Unsupported dependency version: %s\n" % config[
            "STEAM_DEPENDENCY_VERSION"])
        return False

    # Make sure we can use dpkg on this system.
    try:
        subprocess.call(['dpkg', '--version'], stdout=subprocess.PIPE)
    except FileNotFoundError:
        sys.stderr.write("Couldn't find dpkg, please update steamdeps for "
                         "your distribution.\n")
        return False

    return True


###
def main():
    config = {}
    os_release = OsRelease()
    missing_packages = []    # type: List[str]

    parser = argparse.ArgumentParser(description='Install Steam dependencies')
    parser.add_argument(
        '--dry-run',
        action='store_true',
        help="Don't install anything, just report",
    )
    parser.add_argument(
        '--debug-dump-os-release',
        action='store_true',
        help=argparse.SUPPRESS,
    )
    parser.add_argument(
        'dependencies',
        metavar='$HOME/.steam/root/steamdeps.txt',
        help='Path to steamdeps.txt',
    )
    args = parser.parse_args()

    if args.debug_dump_os_release:
        os_release.dump()
        return 0

    # Make sure we can open the file
    try:
        fp = open(args.dependencies)
    except Exception as e:
        sys.stderr.write("Couldn't open file: %s\n" % e)
        return 2

    # Look for configuration variables
    config_pattern = re.compile(r"(\w+)\s*=\s*(\w+)")
    for line in fp:
        line = line.strip()
        if line == "" or line[0] == '#':
            continue

        match = re.match(config_pattern, line)
        if match is not None:
            config[match.group(1)] = match.group(2)

    # Check to make sure we have a valid config
    if not check_config(args.dependencies, config):
        return 3

    # Seek back to the beginning of the file
    fp.seek(0)

    # Load the package dependency information
    packages = {}
    dependencies = []
    for line in fp:
        line = line.strip()
        if line == "" or line[0] == '#':
            continue

        match = re.match(config_pattern, line)
        if match is not None:
            continue

        row = []
        for section in line.split("|"):
            package = create_package(section)
            if package is None:
                continue

            packages[package.name] = package
            row.append(package)

        dependencies.append(row)

    if get_arch() == 'amd64':
        for synthetic in (
                'libc6',
                'libgl1-mesa-dri',
                'libgl1-mesa-glx',
        ):
            package = create_package(synthetic + ':amd64')
            if package is not None:
                packages[package.name] = package
                dependencies.append([package])

    # Try to install these packages, even if they are not
    # listed in the steamdeps.txt file. If they are not available we
    # just inform the user about it and continue.
    for additional_pkg in (
        'steam-libs-amd64:amd64',
        'steam-libs-i386:i386',
        'xdg-desktop-portal',
        choose_xdg_portal_backend() or 'xdg-desktop-portal-gtk',
    ):
        if additional_pkg not in packages:
            if is_package_available(additional_pkg):
                package = Package(additional_pkg, [])
                packages[package.name] = package
                dependencies.append([package])
            else:
                missing_packages.append(additional_pkg)

    # The Steam container runtime (pressure-vessel) requires a setuid
    # bubblewrap executable on some kernel configurations. Steam is
    # unprivileged, so we have to get it from the host OS.
    if (
        # Debian's kernel doesn't allow unprivileged users to create
        # new namespaces (https://bugs.debian.org/898446) so we need the
        # setuid bubblewrap
        os_release.is_os('debian')
        # Ubuntu's kernel does allow that. We assume Ubuntu derivatives
        # like Linux Mint will inherit that, rather than reverting to the
        # Debian behaviour
        and not os_release.is_os('ubuntu')
    ):
        package = Package('bubblewrap', [])
        packages[package.name] = package
        dependencies.append([package])

    if missing_packages:
        print("These packages are not available:\n")

        for p in sorted(missing_packages):
            print("- %s" % p)

        print("\nThe Steam client may have limited functionality.")

    # Print package dependency information for debug
    """
    for row in dependencies:
        print " | ".join( [ str(package) for package in row ] )
    """

    # Get the installed package versions
    # Make sure COLUMNS isn't set, or dpkg will truncate its output
    if "COLUMNS" in os.environ:
        del os.environ["COLUMNS"]

    process = subprocess.Popen(['dpkg', '-l'] + list(packages.keys()),
                               stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    installed_pattern = re.compile(r"^\Si\s+([^\s]+)\s+([^\s]+)")
    for line in process.stdout:
        line = line.decode("utf-8").strip()
        match = re.match(installed_pattern, line)
        if match is None:
            continue

        name = match.group(1)
        if name not in packages:
            name = get_full_package_name(name)
        packages[name].set_installed(match.group(2))

    # See which ones need to be installed
    needed = []
    for row in dependencies:
        if len(row) == 0:
            continue

        satisfied = False
        for dep in row:
            if dep.is_available():
                satisfied = True
                break
        if not satisfied:
            needed.append(row[0])

    # If we have anything to install, do it!
    if len(needed) > 0:
        for package in needed:
            if package.installed:
                print("Package %s is installed with version '%s' but doesn't "
                      "match requirements: %s" % (
                        package.name, package.installed, package),
                      file=sys.stderr)
            else:
                print("Package %s needs to be installed" % package.name,
                      file=sys.stderr)

        if args.dry_run:
            return 1
        else:
            return update_packages(needed)
    else:
        return 0


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