# pylint: disable=wrong-import-position
# pylint: disable=too-many-lines
#
# Copyright (C) 2009 Mathieu Comandon <mathieucomandon@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import json
import logging
import os
import signal
import sys
import tempfile
import types
from collections.abc import Callable
from datetime import datetime, timedelta
from gettext import gettext as _
from typing import TYPE_CHECKING, Any, Type, TypeVar, cast

from gi.repository import Gio, GLib, Gtk

from lutris import settings
from lutris.api import get_runners, parse_installer_url
from lutris.database import games as games_db
from lutris.database.services import ServiceGameCollection
from lutris.exception_backstops import init_exception_backstops
from lutris.game import GAME_START, GAME_STOPPED, Game, export_game, import_game
from lutris.gui.dialogs import ErrorDialog, InstallOrPlayDialog, NoticeDialog, display_error
from lutris.gui.dialogs.delegates import CommandLineUIDelegate, InstallUIDelegate, LaunchUIDelegate
from lutris.gui.dialogs.issue import IssueReportWindow
from lutris.gui.download_queue import DOWNLOAD_QUEUE_COMPLETED
from lutris.gui.installerwindow import INSTALLATION_COMPLETED, INSTALLATION_FAILED, InstallerWindow
from lutris.gui.widgets.status_icon import LutrisStatusIcon
from lutris.installer import InstallationKind, get_installers
from lutris.migrations import migrate
from lutris.monitored_command import exec_command
from lutris.runners import InvalidRunnerError, RunnerInstallationError, get_runner_names, import_runner
from lutris.services import get_enabled_services
from lutris.startup import init_lutris, run_all_checks
from lutris.style_manager import StyleManager
from lutris.util import datapath, log, resources, system
from lutris.util.http import HTTPError, Request
from lutris.util.log import file_handler, logger
from lutris.util.savesync import save_check, show_save_stats, upload_save
from lutris.util.steam.appmanifest import AppManifest, get_appmanifests
from lutris.util.steam.config import get_steamapps_dirs

from ..util.busy import BusyAsyncCall
from ..util.standalone_scripts import generate_script
from .lutriswindow import LutrisWindow

LUTRIS_EXPERIMENTAL_FEATURES_ENABLED = os.environ.get("LUTRIS_EXPERIMENTAL_FEATURES_ENABLED") == "1"

if TYPE_CHECKING:
    from lutris.api import InstallerInfoDict
    from lutris.database.games import DbGameDict
    from lutris.database.services import DBServiceGame
    from lutris.services.base import BaseService

    GtkWindowType = TypeVar("GtkWindowType", bound=Gtk.Window)


class LutrisApplication(Gtk.Application):
    def __init__(self) -> None:
        super().__init__(
            application_id="net.lutris.Lutris",
            flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
            register_session=True,
        )

        # Prepare the backstop logic just before the first emission hook (or connection) is
        # established; this will apply to all connections from this point forward.
        init_exception_backstops()

        GAME_START.register(self.on_game_start)
        GAME_STOPPED.register(self.on_game_stopped)
        settings.SETTINGS_CHANGED.register(self.on_settings_changed)
        INSTALLATION_COMPLETED.register(self.on_install_ended)
        INSTALLATION_FAILED.register(self.on_install_ended)
        DOWNLOAD_QUEUE_COMPLETED.register(self.on_download_queue_completed)

        GLib.set_application_name(_("Lutris"))
        GLib.set_prgname("net.lutris.Lutris")
        self.force_updates = False
        self.css_provider = Gtk.CssProvider.new()
        self.window: LutrisWindow | None = None
        self.launch_ui_delegate = LaunchUIDelegate()
        self.install_ui_delegate = InstallUIDelegate()

        self._running_games = []
        self.app_windows: dict[str, Gtk.Window] = {}
        self.tray = None

        self.quit_on_game_exit = False
        self.style_manager = StyleManager()

        if os.geteuid() == 0:
            NoticeDialog(_("Do not run Lutris as root."))
            sys.exit(2)

        try:
            self.css_provider.load_from_path(os.path.join(datapath.get(), "ui", "lutris.css"))
        except GLib.Error as e:
            logger.exception(e)

        if hasattr(self, "add_main_option"):
            self.add_arguments()
        else:
            ErrorDialog(_("Your Linux distribution is too old. Lutris won't function properly."))

    def add_arguments(self) -> None:
        if hasattr(self, "set_option_context_summary"):
            self.set_option_context_summary(
                _(
                    "Run a game directly by adding the parameter lutris:rungame/game-identifier.\n"
                    "If several games share the same identifier you can use the numerical ID "
                    "(displayed when running lutris --list-games) and add "
                    "lutris:rungameid/numerical-id.\n"
                    "To install a game, add lutris:install/game-identifier."
                )
            )
        else:
            logger.warning("GLib.set_option_context_summary missing, was added in GLib 2.56 (Released 2018-03-12)")
        self.add_main_option(
            "version",
            ord("v"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("Print the version of Lutris and exit"),
            None,
        )
        self.add_main_option(
            "debug",
            ord("d"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("Show debug messages"),
            None,
        )
        self.add_main_option(
            "install",
            ord("i"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            _("Install a game from a yml file"),
            None,
        )
        self.add_main_option(
            "force",
            ord("f"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("Force updates"),
            None,
        )
        self.add_main_option(
            "output-script",
            ord("b"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            _("Generate a bash script to run a game without the client"),
            None,
        )
        self.add_main_option(
            "exec",
            ord("e"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            _("Execute a program with the Lutris Runtime"),
            None,
        )
        self.add_main_option(
            "list-games",
            ord("l"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("List all games in database"),
            None,
        )
        self.add_main_option(
            "installed",
            ord("o"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("Only list installed games"),
            None,
        )
        self.add_main_option(
            "list-steam-games",
            ord("s"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("List available Steam games"),
            None,
        )
        self.add_main_option(
            "list-steam-folders",
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("List all known Steam library folders"),
            None,
        )
        self.add_main_option(
            "list-runners",
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("List all known runners"),
            None,
        )
        self.add_main_option(
            "list-wine-versions",
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("List all known Wine versions"),
            None,
        )
        self.add_main_option(
            "list-all-service-games",
            ord("a"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("List all games for all services in database"),
            None,
        )
        self.add_main_option(
            "list-service-games",
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            _("List all games for provided service in database"),
            None,
        )
        self.add_main_option(
            "install-runner",
            ord("r"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            _("Install a Runner"),
            None,
        )
        self.add_main_option(
            "uninstall-runner",
            ord("u"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            _("Uninstall a Runner"),
            None,
        )
        self.add_main_option(
            "export",
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            _("Export a game"),
            None,
        )
        self.add_main_option(
            "import",
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            _("Import a game"),
            None,
        )
        if LUTRIS_EXPERIMENTAL_FEATURES_ENABLED:
            self.add_main_option(
                "save-stats",
                0,
                GLib.OptionFlags.NONE,
                GLib.OptionArg.STRING,
                _("Show statistics about a game saves"),
                None,
            )
            self.add_main_option(
                "save-upload",
                0,
                GLib.OptionFlags.NONE,
                GLib.OptionArg.STRING,
                _("Upload saves"),
                None,
            )
            self.add_main_option(
                "save-check",
                0,
                GLib.OptionFlags.NONE,
                GLib.OptionArg.STRING,
                _("Verify status of save syncing"),
                None,
            )

        self.add_main_option(
            "dest",
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            _("Destination path for export"),
            None,
        )
        self.add_main_option(
            "json",
            ord("j"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("Display the list of games in JSON format"),
            None,
        )
        self.add_main_option(
            "reinstall",
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("Reinstall game"),
            None,
        )
        self.add_main_option("submit-issue", 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("Submit an issue"), None)
        self.add_main_option(
            GLib.OPTION_REMAINING,
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING_ARRAY,
            _("URI to open"),
            "URI",
        )

    def do_startup(self) -> None:  # pylint: disable=arguments-differ
        """Sets up the application on first start."""
        Gtk.Application.do_startup(self)
        file_handler.doRollover()
        signal.signal(signal.SIGINT, signal.SIG_DFL)

        action = Gio.SimpleAction.new("quit")
        action.connect("activate", lambda *x: self.quit())
        self.add_action(action)
        self.add_accelerator("<Primary>q", "app.quit")

    def do_activate(self) -> None:  # pylint: disable=arguments-differ
        if not self.window:
            self.window = LutrisWindow(application=self)
            screen = self.window.props.screen  # pylint: disable=no-member
            Gtk.StyleContext.add_provider_for_screen(screen, self.css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    def start_runtime_updates(self) -> None:
        if os.environ.get("LUTRIS_SKIP_INIT"):
            logger.debug("Skipping initialization")
        elif not self.window:
            logger.error("Runtime updates can't be started with a LutrisWindow.")
        else:
            self.window.start_runtime_updates(self.force_updates)

    def get_window_key(self, **kwargs: Any) -> str:
        if kwargs.get("appid"):
            return str(kwargs["appid"])
        if kwargs.get("runner"):
            return str(kwargs["runner"].name)
        if kwargs.get("installers"):
            installer: dict[str, Any] = kwargs["installers"][0]
            return installer.get("slug") or installer.get("game_slug") or "Malformed script"
        if kwargs.get("game"):
            return str(kwargs["game"].id)
        return str(kwargs)

    def show_window(
        self,
        window_class: Type["GtkWindowType"],
        update_function: Callable[["GtkWindowType"], None] | None = None,
        **kwargs: Any,
    ) -> "GtkWindowType":
        """Instantiate a window keeping 1 instance max

        Params:
            window_class (Gtk.Window): class to create the instance from
            update_function (Callable): Function to initialize or update the window (if possible before being shown)
            kwargs (dict): Additional arguments to pass to the instanciated window

        Returns:
            Gtk.Window: the existing window instance or a newly created one
        """
        window_key = str(window_class.__name__) + self.get_window_key(**kwargs)
        if self.app_windows.get(window_key):
            self.app_windows[window_key].present()
            window_inst = cast("GtkWindowType", self.app_windows[window_key])
            if update_function:
                update_function(window_inst)
            return window_inst
        if issubclass(window_class, Gtk.Dialog):
            if "parent" in kwargs or not self.window:
                window_inst: "GtkWindowType" = window_class(**kwargs)
            else:
                window_inst: "GtkWindowType" = window_class(parent=self.window, **kwargs)
            window_inst.set_application(self)
        else:
            window_inst = window_class(application=self, **kwargs)
        window_inst.connect("destroy", self.on_app_window_destroyed, self.get_window_key(**kwargs))
        if update_function:
            update_function(window_inst)
        self.app_windows[window_key] = window_inst
        window_inst.show()
        window_inst.present()
        return window_inst

    def show_installer_window(
        self,
        installers: list[dict[str, Any]],
        service: "BaseService" = None,
        appid: str | None = None,
        installation_kind: InstallationKind = InstallationKind.INSTALL,
    ) -> None:
        self.show_window(
            InstallerWindow, installers=installers, service=service, appid=appid, installation_kind=installation_kind
        )

    def show_lutris_installer_window(self, game_slug: str) -> None:
        def on_installers_ready(installers: list[dict[str, Any]], error: BaseException) -> None:
            if error and self.window:
                display_error(error, parent=self.window)
            elif installers:
                self.show_installer_window(installers)
            else:
                ErrorDialog(_("No installer available."), parent=self.window)

        BusyAsyncCall(get_installers, on_installers_ready, game_slug=game_slug)

    def on_app_window_destroyed(self, app_window: Gtk.Window, window_key: str) -> bool:
        """Remove the reference to the window when it has been destroyed"""
        window_key = str(app_window.__class__.__name__) + window_key
        try:
            del self.app_windows[window_key]
        except KeyError:
            logger.warning("Failed to remove window %s", window_key)
            logger.info("Available windows: %s", ", ".join(self.app_windows.keys()))

        return True  # stop signal propagation

    @staticmethod
    def _print(command_line: Gio.ApplicationCommandLine, string: str) -> None:
        # Workaround broken pygobject bindings
        command_line.do_print_literal(command_line, string + "\n")  # type: ignore

    def do_handle_local_options(self, options: GLib.VariantDict) -> int:
        # Text only commands

        # Print Lutris version and exit
        if options.contains("version"):
            executable_name = os.path.basename(sys.argv[0])
            print(executable_name + "-" + settings.VERSION)
            logger.setLevel(logging.NOTSET)
            return 0
        return -1  # continue command line processes

    def do_command_line(self, command_line: Gio.ApplicationCommandLine) -> int:  # noqa: C901  # pylint: disable=arguments-differ
        # pylint: disable=too-many-locals,too-many-return-statements,too-many-branches
        # pylint: disable=too-many-statements
        options = command_line.get_options_dict()

        # Use stdout to output logs, only if no command line argument is
        # provided.
        argc = len(sys.argv) - 1
        if "-d" in sys.argv or "--debug" in sys.argv:
            argc -= 1
        if not argc:
            # Switch back the log output to stderr (the default in Python)
            # to avoid messing with any output from command line options.
            log.console_handler.setStream(sys.stderr)

        # Set up logger
        if options.contains("debug"):
            log.console_handler.setFormatter(log.DEBUG_FORMATTER)
            logger.setLevel(logging.DEBUG)

        if options.contains("force"):
            self.force_updates = True

        logger.info("Starting Lutris %s", settings.VERSION)
        init_lutris()
        migrate()

        run_all_checks(async_ops=not argc)
        if option := options.lookup_value("dest"):
            dest_dir = option.get_string()
        else:
            dest_dir = None

        if option := options.lookup_value("output-script"):
            searchstring = option.get_string()
            export_script_game = (
                games_db.get_game_by_field(searchstring, "id")
                or games_db.get_game_by_field(searchstring, "slug")
                or games_db.get_game_by_field(searchstring, "installer_slug")
            )

            if not export_script_game or not export_script_game["id"]:
                logger.warning(
                    "No valid game (%s) provided to generate the script. Use the -l option to find suitable games!",
                    searchstring,
                )
                return 1
            generate_script(logger, self.launch_ui_delegate, export_script_game, f"{export_script_game['slug']}.sh")
            return 0

        # List game
        if options.contains("list-games"):
            game_list = games_db.get_games(filters=({"installed": 1} if options.contains("installed") else None))
            if options.contains("json"):
                self.print_game_json(command_line, game_list)
            else:
                self.print_game_list(command_line, game_list)
            return 0

        # List specified service games
        if option := options.lookup_value("list-service-games"):
            service = option.get_string()
            game_list = games_db.get_games(filters={"installed": 1, "service": service})
            service_game_list = ServiceGameCollection.get_for_service(service)
            for db_game in service_game_list:
                db_game["installed"] = any(("service_id", db_game["appid"]) in item.items() for item in game_list)
            if options.contains("installed"):
                service_game_list = [d for d in service_game_list if d["installed"]]
            if options.contains("json"):
                self.print_service_game_json(command_line, service_game_list)
            else:
                self.print_service_game_list(command_line, service_game_list)
            return 0

        # List all service games
        if options.contains("list-all-service-games"):
            game_list = games_db.get_games(filters={"installed": 1})
            service_game_list = ServiceGameCollection.get_service_games()
            for db_game in service_game_list:
                db_game["installed"] = any(
                    ("service_id", db_game["appid"]) in item.items()
                    for item in game_list
                    if item["service"] == db_game["service"]
                )
            if options.contains("installed"):
                service_game_list = [d for d in service_game_list if d["installed"]]
            if options.contains("json"):
                self.print_service_game_json(command_line, service_game_list)
            else:
                self.print_service_game_list(command_line, service_game_list)
            return 0

        # List Steam games
        if options.contains("list-steam-games"):
            self.print_steam_list(command_line)
            return 0

        # List Steam folders
        if options.contains("list-steam-folders"):
            self.print_steam_folders(command_line)
            return 0

        # List Runners
        if options.contains("list-runners"):
            self.print_runners()
            return 0

        # List Wine Runners
        if options.contains("list-wine-versions"):
            self.print_wine_runners()
            return 0

        # install Runner
        if option := options.lookup_value("install-runner"):
            runner = option.get_string()
            self.install_runner(runner)
            return 0

        # Uninstall Runner
        if option := options.lookup_value("uninstall-runner"):
            runner = option.get_string()
            self.uninstall_runner(runner)
            return 0

        if option := options.lookup_value("export"):
            slug = option.get_string()
            if not dest_dir:
                print("No destination dir given")
            else:
                export_game(slug, dest_dir)
            return 0

        if option := options.lookup_value("import"):
            filepath = option.get_string()
            if not dest_dir:
                print("No destination dir given")
            else:
                import_game(filepath, dest_dir)
            return 0

        if LUTRIS_EXPERIMENTAL_FEATURES_ENABLED:

            def get_game_match(slug: str) -> Game | None:
                # First look for an exact match
                games = games_db.get_games_by_slug(slug)
                if not games:
                    # Then look for matches
                    games = games_db.get_games(searches={"slug": slug})
                if len(games) > 1:
                    self._print(
                        command_line,
                        "Multiple games matching %s: %s" % (slug, ",".join(game["slug"] for game in games)),
                    )
                    return None
                if not games:
                    self._print(command_line, "No matching game for %s" % slug)
                    return None
                return Game(games[0]["id"])

            if option := options.lookup_value("save-stats"):
                game = get_game_match(option.get_string())
                if game:
                    show_save_stats(game, output_format="json" if options.contains("json") else "text")
                return 0
            if option := options.lookup_value("save-upload"):
                game = get_game_match(option.get_string())
                if game:
                    upload_save(game)
                return 0
            if option := options.lookup_value("save-check"):
                game = get_game_match(option.get_string())
                if game:
                    save_check(game)
                return 0

        # Execute command in Lutris context
        if option := options.lookup_value("exec"):
            command = option.get_string()
            self.execute_command(command)
            return 0

        if options.contains("submit-issue"):
            IssueReportWindow(application=self)
            return 0

        url = options.lookup_value(GLib.OPTION_REMAINING)
        try:
            installer_info = self.get_lutris_action(url)
        except ValueError:
            self._print(command_line, _("%s is not a valid URI") % (url.get_strv() if url else "''"))
            return 1

        game_slug = installer_info["game_slug"]
        action = installer_info["action"]
        service = installer_info["service"]
        appid = installer_info["appid"]
        launch_config_name = installer_info["launch_config_name"]

        self.launch_ui_delegate = CommandLineUIDelegate(launch_config_name)

        revision = installer_info["revision"]

        installer_file = None
        if option := options.lookup_value("install"):
            installer_file = option.get_string()
            if installer_file.startswith(("http:", "https:")):
                try:
                    request = Request(installer_file).get()
                except HTTPError:
                    self._print(command_line, _("Failed to download %s") % installer_file)
                    return 1
                try:
                    headers = dict(request.response_headers or {})
                    file_name = headers["Content-Disposition"].split("=", 1)[-1]
                except (KeyError, IndexError):
                    file_name = os.path.basename(installer_file)
                file_path = os.path.join(tempfile.gettempdir(), file_name)
                self._print(
                    command_line, _("download {url} to {file} started").format(url=installer_file, file=file_path)
                )
                with open(file_path, "wb") as dest_file:
                    dest_file.write(request.content)
                installer_file = file_path
                action = "install"
            else:
                # Use the invoking process's cwd for relative paths, not the running instance's
                if not os.path.isabs(installer_file):
                    cwd = command_line.get_cwd()
                    if cwd:
                        installer_file = os.path.join(cwd, installer_file)
                installer_file = os.path.abspath(installer_file)
                action = "install"

            if not os.path.isfile(installer_file):
                self._print(command_line, _("No such file: %s") % installer_file)
                return 1

        db_game = None
        if game_slug and not service:
            if action == "rungameid":
                # Force db_game to use game id
                if not self.has_tray_icon() and (not self.window or not self.window.is_visible()):
                    self.quit_on_game_exit = True
                db_game = games_db.get_game_by_field(game_slug, "id")
            elif action == "rungame":
                # Force db_game to use game slug
                if not self.has_tray_icon() and (not self.window or not self.window.is_visible()):
                    self.quit_on_game_exit = True
                db_game = games_db.get_game_by_field(game_slug, "slug")
            elif action == "install":
                # Installers can use game or installer slugs
                if not self.has_tray_icon() and (not self.window or not self.window.is_visible()):
                    self.quit_on_game_exit = True
                db_game = games_db.get_game_by_field(game_slug, "slug") or games_db.get_game_by_field(
                    game_slug, "installer_slug"
                )
            else:
                # Dazed and confused, try anything that might works
                db_game = (
                    games_db.get_game_by_field(game_slug, "id")
                    or games_db.get_game_by_field(game_slug, "slug")
                    or games_db.get_game_by_field(game_slug, "installer_slug")
                )

        # If reinstall flag is passed, force the action to install
        if options.contains("reinstall"):
            action = "install"

        # Graphical commands
        self.set_tray_icon()
        self.activate()

        if not action:
            if db_game and db_game["installed"]:
                # Game found but no action provided, ask what to do
                dlg = InstallOrPlayDialog(str(db_game["name"]))
                if not dlg.action:
                    action = "cancel"
                elif dlg.action == "play":
                    action = "rungame"
                elif dlg.action == "install":
                    action = "install"
            elif game_slug or installer_file or service:
                # No game found, default to install if a game_slug or
                # installer_file is provided
                action = "install"

        if service and appid:
            service_game = ServiceGameCollection.get_game(service, appid)
            if service_game:
                service = get_enabled_services()[service]()
                service.install(service_game)
                return 0

        if action == "cancel":
            if self.window and not self.window.is_visible():
                self.quit()
            return 0

        if action == "install":
            installers = get_installers(
                game_slug=game_slug,
                installer_file=installer_file,
                revision=revision,
            )
            if installers:
                self.show_installer_window(installers)
        elif action in ("rungame", "rungameid"):
            if not db_game or not db_game["id"]:
                logger.warning("No game found in library")
                if self.window and not self.window.is_visible():
                    self.quit()
                return 0

            def on_error(error: BaseException) -> None:
                logger.exception("Unable to launch game: %s", error)

            game = Game(str(db_game["id"]))
            game.game_error.register(on_error)
            game.launch(self.launch_ui_delegate)

            if game.state == game.STATE_STOPPED and self.window and not self.window.is_visible():
                self.quit()

            if self.quit_on_game_exit:

                def game_stop_signal_handler(signum: int, _frame: types.FrameType | None) -> None:
                    logger.debug("signal handler called with signal: %d", signum)
                    game.stop()

                signal.signal(signal.SIGTERM, game_stop_signal_handler)
                signal.signal(signal.SIGINT, game_stop_signal_handler)
        elif self.window:
            # If we're showing the window, it will handle the delegated UI
            # from here on out, no matter what command line we got.
            self.launch_ui_delegate = self.window
            self.install_ui_delegate = self.window
            self.window.present()
            self.start_runtime_updates()
            # If the Lutris GUI is started by itself, don't quit it when a game stops
            self.quit_on_game_exit = False
        return 0

    def on_settings_changed(self, setting_key: str, new_value: Any, section: str) -> bool:
        if section == "lutris":
            if setting_key == "preferred_theme":
                self.style_manager.preferred_theme = new_value
            elif setting_key == "show_tray_icon" and self.window:
                if self.window.get_visible():
                    self.set_tray_icon()
        return True  # stop signal propagation

    def on_game_start(self, game: Game) -> None:
        self._running_games.append(game)
        if self.window and settings.read_bool_setting("hide_client_on_game_start"):
            self.window.hide()  # Hide launcher window

    def on_game_stopped(self, game: Game) -> None:
        """Callback to quit Lutris is last game stops while the window is hidden."""
        running_game_ids = [g.id for g in self._running_games]
        if game.id in running_game_ids:
            logger.debug("Removing %s from running IDs", game.id)
            try:
                del self._running_games[running_game_ids.index(game.id)]
            except ValueError:
                pass
        elif running_game_ids:
            logger.warning("%s not in %s", game.id, running_game_ids)
        else:
            logger.debug("Game has already been removed from running IDs?")

        if self.window and settings.read_bool_setting("hide_client_on_game_start") and not self.quit_on_game_exit:
            self.window.show()  # Show launcher window
        else:
            self._quit_if_hidden_and_idle()

    def on_install_ended(self) -> None:
        self._quit_if_hidden_and_idle()

    def on_download_queue_completed(self, _widget: Gtk.Widget = None) -> None:
        self._quit_if_hidden_and_idle()

    def _quit_if_hidden_and_idle(self) -> None:
        """Quits Lutris if the window is not visible and there is no ongoing activity
        (running games or active downloads) keeping it alive."""
        if not self.window or not self.window.is_visible():
            if not self.has_running_games and not self.has_active_downloads:
                if self.quit_on_game_exit or not self.has_tray_icon():
                    self.quit()

    @property
    def has_active_downloads(self) -> bool:
        """True if the download queue has active operations."""
        if self.window:
            return not self.window.is_download_queue_empty
        return False

    def get_launch_ui_delegate(self) -> LaunchUIDelegate:
        return self.launch_ui_delegate

    def get_running_games(self) -> list[Game]:
        # This method reflects games that have stopped even if the 'game-stopped' signal
        # has not been handled yet; that handler will still clean up the list though.
        return [g for g in self._running_games if g.state != g.STATE_STOPPED]

    @property
    def has_running_games(self) -> bool:
        return bool(self.get_running_games())

    def get_running_game_ids(self) -> list[str]:
        """Returns the ids of the games presently running."""
        return [game.id for game in self.get_running_games()]

    def is_game_running_by_id(self, game_id: str) -> bool:
        """True if the ID is the ID of a game that is running."""
        return bool(game_id and str(game_id) in self.get_running_game_ids())

    def get_game_by_id(self, game_id: str) -> Game:
        """Returns the game with the ID given; if it's running this is the running
        game instance, and if not it's a fresh copy off the database."""
        for game in self.get_running_games():
            if game.id == str(game_id):
                return game

        return Game(game_id)

    @staticmethod
    def get_lutris_action(url: GLib.Variant | None) -> "InstallerInfoDict | dict[str, None]":
        installer_info = {
            "game_slug": None,
            "revision": None,
            "action": None,
            "service": None,
            "appid": None,
            "launch_config_name": None,
        }

        if url:
            urls = url.get_strv()

            if urls:
                url_str = urls[0]
                installer_info = parse_installer_url(url_str)
        return installer_info

    def print_game_list(self, command_line: Gio.ApplicationCommandLine, game_list: list["DbGameDict"]) -> None:
        for game in game_list:
            self._print(
                command_line,
                "{:4} | {:<40} | {:<40} | {:<15} | {:<64}".format(
                    game["id"],
                    game["name"][:40],
                    game["slug"][:40],
                    game["runner"] or "-",
                    game["directory"] or "-",
                ),
            )

    def print_game_json(self, command_line: Gio.ApplicationCommandLine, game_list: list["DbGameDict"]) -> None:
        games = []

        for game in game_list:
            playtime = timedelta(hours=game["playtime"]) if game["playtime"] else None
            cover_path = resources.get_cover_path(game["slug"]) if game["slug"] else None

            if cover_path and not os.path.exists(cover_path):
                cover_path = None

            game_obj = {
                "id": game["id"],
                "slug": game["slug"],
                "name": game["name"],
                "runner": game["runner"],
                "platform": game["platform"] or None,
                "year": game["year"] or None,
                "directory": game["directory"] or None,
                "playtime": str(playtime) if playtime else None,
                "playtimeSeconds": playtime.total_seconds() if playtime else None,
                "lastplayed": (str(datetime.fromtimestamp(game["lastplayed"])) if game["lastplayed"] else None),
                "coverPath": cover_path,
            }

            games.append(game_obj)

        self._print(command_line, json.dumps(games, indent=2))

    def print_service_game_list(
        self, command_line: Gio.ApplicationCommandLine, game_list: list["DBServiceGame"]
    ) -> None:
        for game in game_list:
            self._print(
                command_line,
                "{:4} | {:<40} | {:<40} | {:<15} | {:<15}".format(
                    game["id"],
                    str(game["name"])[:40],
                    str(game["slug"])[:40],
                    str(game["service"])[:15],
                    "true" if game["installed"] else "false"[:15],
                ),
            )

    def print_service_game_json(
        self, command_line: Gio.ApplicationCommandLine, game_list: list["DBServiceGame"]
    ) -> None:
        games = [
            {
                "id": game["id"],
                "slug": game["slug"],
                "name": game["name"],
                "service": game["service"],
                "appid": game["appid"],
                "installed": game["installed"],
                "details": game["details"],
            }
            for game in game_list
        ]
        self._print(command_line, json.dumps(games, indent=2))

    def print_steam_list(self, command_line: Gio.ApplicationCommandLine) -> None:
        steamapps_paths = get_steamapps_dirs()
        for path in steamapps_paths if steamapps_paths else []:
            appmanifest_files = get_appmanifests(path)
            for appmanifest_file in appmanifest_files:
                appmanifest = AppManifest(os.path.join(path, appmanifest_file))
                self._print(
                    command_line,
                    " {:8} | {:<60} | {}".format(
                        appmanifest.steamid,
                        appmanifest.name or "-",
                        ", ".join(appmanifest.states),
                    ),
                )

    @staticmethod
    def execute_command(command: str) -> None:
        """Execute an arbitrary command in a Lutris context
        with the runtime enabled and monitored by a MonitoredCommand
        """
        logger.info("Running command '%s'", command)
        monitored_command = exec_command(command)
        try:
            GLib.MainLoop().run()
        except KeyboardInterrupt:
            monitored_command.stop()

    def print_steam_folders(self, command_line: Gio.ApplicationCommandLine) -> None:
        steamapps_paths = get_steamapps_dirs()
        if steamapps_paths:
            for path in steamapps_paths:
                self._print(command_line, path)

    def print_runners(self) -> None:
        runner_names = get_runner_names()
        sorted_names = sorted(runner_names, key=lambda x: x.lower())
        for name in sorted_names:
            print(name)

    def print_wine_runners(self) -> None:
        runnersName = get_runners("wine")
        for i in runnersName["versions"]:
            if i["version"]:
                print(i)

    def install_runner(self, runner: str) -> None:
        if runner.startswith("lutris"):
            self.install_wine_cli(runner)
        else:
            self.install_cli(runner)

    def uninstall_runner(self, runner: str) -> None:
        if "wine" in runner:
            print("Are sure you want to delete Wine and all of the installed runners?[Y/N]")
            ans = input()
            if ans.lower() in ("y", "yes"):
                self.uninstall_runner_cli(runner)
            else:
                print("Not Removing Wine")
        elif runner.startswith("lutris"):
            self.wine_runner_uninstall(runner)
        else:
            self.uninstall_runner_cli(runner)

    def install_wine_cli(self, version: str) -> None:
        """
        Downloads wine runner using lutris -r <runner>
        """

        WINE_DIR = os.path.join(settings.RUNNER_DIR, "wine")
        runner_path = os.path.join(WINE_DIR, f"{version}{'' if '-x86_64' in version else '-x86_64'}")
        if os.path.isdir(runner_path):
            print(f"Wine version '{version}' is already installed.")
        else:
            try:
                runner = import_runner("wine")
                runner().install(self.install_ui_delegate, version=version)
                print(f"Wine version '{version}' has been installed.")
            except (InvalidRunnerError, RunnerInstallationError) as ex:
                print(ex.message)

    def wine_runner_uninstall(self, version: str) -> None:
        version = f"{version}{'' if '-x86_64' in version else '-x86_64'}"
        WINE_DIR = os.path.join(settings.RUNNER_DIR, "wine")
        runner_path = os.path.join(WINE_DIR, version)
        if os.path.isdir(runner_path):
            system.remove_folder(runner_path)
            print(f"Wine version '{version}' has been removed.")
        else:
            print(
                f"""
Specified version of Wine is not installed: {version}.
Please check if the Wine Runner and specified version are installed (for that use --list-wine-versions).
Also, check that the version specified is in the correct format.
                """
            )

    def install_cli(self, runner_name: str) -> None:
        """
        install the runner provided in prepare_runner_cli()
        """

        try:
            runner = import_runner(runner_name)()
            if runner.is_installed():
                print(f"'{runner_name}' is already installed.")
            else:
                runner.install(self.install_ui_delegate, version=None, callback=None)
                print(f"'{runner_name}' has been installed")
        except (InvalidRunnerError, RunnerInstallationError) as ex:
            print(ex.message)

    def uninstall_runner_cli(self, runner_name: str) -> None:
        """
        uninstall the runner given in application file located in lutris/gui/application.py
        provided using lutris -u <runner>
        """
        try:
            runner_class = import_runner(runner_name)
            runner = runner_class()
        except InvalidRunnerError:
            logger.error("Failed to import Runner: %s", runner_name)
            return
        if not runner.is_installed():
            print(f"Runner '{runner_name}' is not installed.")
            return
        if runner.can_uninstall():
            runner.uninstall()
            print(f"'{runner_name}' has been uninstalled.")
        else:
            print(f"Runner '{runner_name}' cannot be uninstalled.")

    def do_shutdown(self) -> None:  # pylint: disable=arguments-differ
        logger.info("Shutting down Lutris")
        if self.window:
            selected_category = "%s:%s" % self.window.selected_category
            settings.write_setting("selected_category", selected_category)
            self.window.destroy()
        Gtk.Application.do_shutdown(self)

    def set_tray_icon(self) -> None:
        """Creates or destroys a tray icon for the application"""
        active = settings.read_setting("show_tray_icon", default="false").lower() == "true"
        if active and not self.tray:
            self.tray = LutrisStatusIcon(application=self)
        if self.tray:
            self.tray.set_visible(active)

    def has_tray_icon(self) -> bool:
        return bool(self.tray and self.tray.is_visible())
