#!/usr/bin/env python3

# Copyright © 2019 Collabora Ltd.
#
# SPDX-License-Identifier: MIT
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import logging
import os
import shlex
import subprocess
import sys

try:
    import typing
except ImportError:
    pass
else:
    typing      # silence pyflakes

import gi
gi.require_version('Gtk', '3.0')

from gi.repository import GLib
from gi.repository import Gtk

logger = logging.getLogger('pressure-vessel-test-ui')

assert sys.version_info >= (3, 4), 'Python 3.4+ is required for this script'


def tristate_environment(name):
    # type: (str) -> typing.Optional[bool]
    value = os.getenv(name)

    if value is None or value == '':
        return None

    if value == '1':
        return True

    if value == '0':
        return False

    logger.warning('Unrecognised value %r for $%s', value, name)
    return None


def boolean_environment(name, default):
    # type: (str, bool) -> bool
    value = os.getenv(name)

    if value is None:
        return default

    if value == '1':
        return True

    if value in ('', '0'):
        return False

    logger.warning('Unrecognised value %r for $%s', value, name)
    return default


class Gui:
    def __init__(self):
        # type: (...) -> None

        self.failed = False
        self.home = GLib.get_home_dir()

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

        for search in (
            os.getenv('PRESSURE_VESSEL_RUNTIME_BASE'),
            '..',
            '../..',
        ):
            if search is None:
                continue

            source_of_runtimes = os.path.join(
                os.path.dirname(__file__),
                search,
            )

            if not os.path.isdir(source_of_runtimes):
                continue

            for member in os.listdir(source_of_runtimes):
                path = os.path.realpath(
                    os.path.join(source_of_runtimes, member)
                )
                files = os.path.join(path, 'files')

                if os.path.isdir(files):
                    description = self._describe_runtime(path)
                    self.container_runtimes[path] = description

        self.window = Gtk.Window()
        self.window.set_default_size(600, 300)
        self.window.connect('delete-event', Gtk.main_quit)
        self.window.set_title('pressure-vessel options')

        self.grid = Gtk.Grid(
            row_spacing=6,
            column_spacing=6,
            margin_top=12,
            margin_bottom=12,
            margin_start=12,
            margin_end=12,
        )
        self.window.add(self.grid)

        row = 0

        label = Gtk.Label.new('')
        label.set_markup(
            'This is a test UI for developers. '
            '<b>'
            'Some options are known to break games and Steam features.'
            '</b>'
            ' Use at your own risk!'
        )
        label.set_line_wrap(True)
        self.grid.attach(label, 0, row, 2, 1)
        row += 1

        label = Gtk.Label.new('Container runtime')
        self.grid.attach(label, 0, row, 1, 1)

        self.container_runtime_combo = Gtk.ComboBoxText.new()
        self.container_runtime_combo.append(
            '/',
            'None (use host system and traditional LD_LIBRARY_PATH runtime)'
        )

        for path, description in sorted(self.container_runtimes.items()):
            self.container_runtime_combo.append(path, description)

        if self.container_runtimes:
            self.container_runtime_combo.set_active(1)
        else:
            self.container_runtime_combo.set_active(0)

        self.grid.attach(self.container_runtime_combo, 1, row, 1, 1)

        row += 1

        var_path = os.getenv('PRESSURE_VESSEL_COPY_RUNTIME_INTO', None)

        if var_path is None:
            value = False
            var_path = os.getenv(
                'PRESSURE_VESSEL_RUNTIME_BASE',
                os.path.dirname(__file__) + '/../..',
            ) + '/var'
            assert var_path is not None
        else:
            value = True

        self.var_path = os.path.realpath(var_path)
        self.copy_runtime_into_check = Gtk.CheckButton.new_with_label(
            'Create temporary runtime copy on disk'
        )
        self.copy_runtime_into_check.set_active(value)
        self.grid.attach(self.copy_runtime_into_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            'The copy will be in {} and can be modified while the '
            'container is running. It will be deleted next time you '
            'run a game in this mode.'
            '</i></small>'.format(
                GLib.markup_escape_text(self.var_path),
            )
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self.host_graphics_check = Gtk.CheckButton.new_with_label(
            'Use host-system graphics stack'
        )
        value = boolean_environment('PRESSURE_VESSEL_HOST_GRAPHICS', True)
        self.host_graphics_check.set_active(value)
        self.grid.attach(self.host_graphics_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            "Most games and GPUs won't work when this is disabled."
            '</i></small>'
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self._container_runtime_changed(self.container_runtime_combo)
        self.container_runtime_combo.connect(
            'changed',
            self._container_runtime_changed)

        self.unshare_home_check = Gtk.CheckButton.new_with_label(
            'Use a separate home directory per game'
        )
        share_home = tristate_environment('PRESSURE_VESSEL_SHARE_HOME')
        self.unshare_home_check.set_active(
            share_home is not None and not share_home
        )
        self.grid.attach(self.unshare_home_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            'Creating a separate home directory is experimental, '
            'and is likely to break Steam Cloud Auto-Sync, Steam Workshop '
            'and probably other features.'
            '</i></small>'
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self.unshare_pid_check = Gtk.CheckButton.new_with_label(
            'Create a new process ID namespace'
        )
        share_pid = boolean_environment('PRESSURE_VESSEL_SHARE_PID', True)
        self.unshare_pid_check.set_active(not share_pid)
        self.grid.attach(self.unshare_pid_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            "Creating a new process ID namespace is very experimental, "
            "and is known to break Steam's tracking of running games."
            '</i></small>'
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self.keep_game_overlay_check = Gtk.CheckButton.new_with_label(
            'Allow Steam Overlay'
        )
        remove = boolean_environment(
            'PRESSURE_VESSEL_REMOVE_GAME_OVERLAY', False
        )
        self.keep_game_overlay_check.set_active(not remove)
        self.grid.attach(self.keep_game_overlay_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            'Disabling this seems to work around some of the issues with '
            'process ID namespaces, but will break various Steam features.'
            '</i></small>'
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self.import_vulkan_layers_check = Gtk.CheckButton.new_with_label(
            'Import Vulkan layers'
        )
        import_val = boolean_environment(
            'PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS', True
        )
        self.import_vulkan_layers_check.set_active(import_val)
        self.grid.attach(self.import_vulkan_layers_check, 1, row, 1, 1)
        row += 1

        label = Gtk.Label.new('')
        label.set_markup(
            '<small><i>'
            'If you are experiencing crashes, or other issues, you may try '
            'disabling this and check whether it helps.'
            '</i></small>'
        )
        label.set_halign(Gtk.Align.START)
        label.set_line_wrap(True)
        self.grid.attach(label, 1, row, 1, 1)
        row += 1

        self.xterm_check = Gtk.CheckButton.new_with_label('Run in an xterm')
        self.xterm_check.set_active(False)
        self.grid.attach(self.xterm_check, 1, row, 1, 1)

        env = os.getenv('PRESSURE_VESSEL_TERMINAL')

        if env is not None:
            if env == 'xterm':
                self.xterm_check.set_active(True)

        row += 1

        shell_label = Gtk.Label.new('Run an interactive shell')
        self.grid.attach(shell_label, 0, row, 1, 1)

        self.shell_combo = Gtk.ComboBoxText.new()
        self.shell_combo.append('', 'No')
        self.shell_combo.append('--shell-after', 'After running the command')
        self.shell_combo.append('--shell-fail', 'If the command fails')
        self.shell_combo.append(
            '--shell-instead', 'Instead of running the command')
        self.shell_combo.set_active(0)

        env = os.getenv('PRESSURE_VESSEL_SHELL')

        if env is not None:
            if env == 'after':
                self.shell_combo.set_active(1)
            elif env == 'fail':
                self.shell_combo.set_active(2)
            elif env == 'instead':
                self.shell_combo.set_active(3)

        self._shell_changed(self.shell_combo)
        self.shell_combo.connect('changed', self._shell_changed)
        self.grid.attach(self.shell_combo, 1, row, 1, 1)

        row += 1

        try:
            subproc = subprocess.Popen(
                [
                    os.path.join(
                        os.path.dirname(__file__),
                        'pressure-vessel-wrap'
                    ),
                    '--version-only',
                ],
                stdout=subprocess.PIPE,
            )
            stdout, _ = subproc.communicate()
            version = stdout.decode('utf-8', errors='replace')
        except Exception:
            version = ''

        if version:
            label = Gtk.Label.new('')
            label.set_markup(
                '<small>v{}</small>'.format(
                    GLib.markup_escape_text(version),
                )
            )
            label.set_line_wrap(True)
            label.set_halign(Gtk.Align.START)
            label.set_sensitive(False)
            self.grid.attach(label, 0, row, 1, 1)

        buttons_grid = Gtk.Grid(
            column_spacing=6,
            column_homogeneous=True,
            halign=Gtk.Align.END,
        )

        cancel_button = Gtk.Button.new_with_label('Cancel')
        cancel_button.connect('clicked', Gtk.main_quit)
        buttons_grid.attach(cancel_button, 0, 0, 1, 1)

        run_button = Gtk.Button.new_with_label('Run')
        run_button.connect('clicked', self.run_cb)
        buttons_grid.attach(run_button, 1, 0, 1, 1)

        self.grid.attach(buttons_grid, 1, row, 2, 1)

        row += 1

    def _shell_changed(self, shell_combo):
        if shell_combo.get_active_id():
            self.xterm_check.set_active(True)
            self.xterm_check.set_sensitive(False)
        else:
            self.xterm_check.set_sensitive(True)

    def _container_runtime_changed(self, combo):
        if combo.get_active_id() == '/':
            self.copy_runtime_into_check.set_sensitive(False)
            self.host_graphics_check.set_sensitive(False)
        else:
            self.copy_runtime_into_check.set_sensitive(True)
            self.host_graphics_check.set_sensitive(True)

    def _describe_runtime(
        self,
        path        # type: str
    ):
        # type: (...) -> str

        description = path
        files = os.path.join(path, 'files')
        metadata = os.path.join(path, 'metadata')

        if os.path.islink(files):
            description = os.path.realpath(files)

        if description.startswith(self.home + '/'):
            description = '~' + description[len(self.home):]

        name = None             # type: typing.Optional[str]
        pretty_name = None      # type: typing.Optional[str]
        build_id = None         # type: typing.Optional[str]
        variant = None          # type: typing.Optional[str]

        try:
            keyfile = GLib.KeyFile.new()
            keyfile.load_from_file(
                metadata, GLib.KeyFileFlags.NONE)
            try:
                build_id = keyfile.get_string('Runtime', 'x-flatdeb-build-id')
            except GLib.Error:
                pass

            try:
                name = keyfile.get_string('Runtime', 'runtime')
            except GLib.Error:
                pass
            else:
                assert name is not None
                variant = name.split('.')[-1]
        except GLib.Error:
            pass

        try:
            with open(
                os.path.join(files, 'lib', 'os-release')
            ) as reader:
                for line in reader:
                    if line.startswith('PRETTY_NAME='):
                        pretty_name = line.split('=', 1)[1].strip()
                        pretty_name = GLib.shell_unquote(pretty_name)
                    elif line.startswith('BUILD_ID='):
                        build_id = line.split('=', 1)[1].strip()
                        build_id = GLib.shell_unquote(build_id)
                    elif line.startswith('VARIANT='):
                        variant = line.split('=', 1)[1].strip()
                        variant = GLib.shell_unquote(variant)
        except (GLib.Error, EnvironmentError):
            pass

        if pretty_name is None:
            pretty_name = name

        if pretty_name is None:
            pretty_name = os.path.basename(path)

        if build_id is None:
            build_id = ''
        else:
            build_id = ' build {}'.format(build_id)

        if variant is None:
            variant = ''
        else:
            variant = ' {}'.format(variant)

        description = '{}{}{}\n({})'.format(
            pretty_name,
            variant,
            build_id,
            description,
        )

        return description

    def run_cb(self, _ignored=None):
        # type: (typing.Any) -> None

        argv = [
            os.path.join(
                os.path.dirname(__file__),
                'pressure-vessel-wrap'
            ),
        ]

        id = self.container_runtime_combo.get_active_id()

        if id is None:
            argv.append('--runtime=')
        elif id == '/':
            argv.append('--runtime=')
        else:
            argv.append('--runtime')
            argv.append(os.path.join(id, 'files'))

        if self.host_graphics_check.get_active():
            if os.path.isdir('/run/host'):
                argv.append('--graphics-provider=/run/host')
            else:
                argv.append('--graphics-provider=/')
        else:
            argv.append('--graphics-provider=')

        if self.copy_runtime_into_check.get_active():
            os.makedirs(self.var_path, mode=0o755, exist_ok=True)
            argv.append('--copy-runtime-into=' + self.var_path)
        else:
            argv.append('--copy-runtime-into=')

        if self.unshare_home_check.get_active():
            argv.append('--unshare-home')
        else:
            argv.append('--share-home')

        if self.unshare_pid_check.get_active():
            argv.append('--unshare-pid')
        else:
            argv.append('--share-pid')

        if self.keep_game_overlay_check.get_active():
            argv.append('--keep-game-overlay')
        else:
            argv.append('--remove-game-overlay')

        if self.import_vulkan_layers_check.get_active():
            argv.append('--import-vulkan-layers')
        else:
            argv.append('--no-import-vulkan-layers')

        if self.xterm_check.get_active():
            argv.append('--terminal=xterm')
        else:
            argv.append('--terminal=none')

        id = self.shell_combo.get_active_id()

        if id is not None and id != '':
            argv.append(id)
        else:
            argv.append('--shell=none')

        argv.append('--verbose')
        argv.extend(sys.argv[1:])

        os.environ['G_MESSAGES_DEBUG'] = 'all'

        try:
            os.execvp(argv[0], argv)
        except OSError:
            logger.error('Unable to run: %s', ' '.join(map(shlex.quote, argv)))
            Gtk.main_quit()
            self.failed = True
            raise

    def run(self):
        # type: (...) -> None
        self.window.show_all()
        Gtk.main()

        if self.failed:
            sys.exit(1)


if __name__ == '__main__':
    if '--check-gui-dependencies' not in sys.argv:
        Gui().run()