#!/usr/bin/python3
# encoding=utf-8

# game-data-packager Gtk launcher stub. See doc/launcher.mdwn for design

# Copyright © 2015-2016 Simon McVittie <smcv@debian.org>
# SPDX-License-Identifier: GPL-2.0-or-later

import enum
import fnmatch
import logging
import os
import sys
import traceback
from contextlib import suppress
from typing import (Any, TextIO)

import gi

gi.require_version('Gtk', '4.0')

from gi.repository import (GLib, GObject)       # noqa
from gi.repository import Gtk                   # noqa

from gdp_launcher_base import (
    Launcher,
    RUNTIME_BUILT,
    prefered_langs,
)    # noqa

logger = logging.getLogger('game-data-packager.launcher')

if os.environ.get('GDP_DEBUG'):
    logger.setLevel(logging.DEBUG)
else:
    logger.setLevel(logging.INFO)


class LanguageCodes(enum.StrEnum):
    cz = 'Czech'
    de = 'German'
    en = 'English'
    es = 'Spanish'
    fr = 'French'
    it = 'Italian'
    nl = 'Dutch'
    pl = 'Polish'
    ru = 'Russian'


class IniEditor:
    def __init__(
        self,
        edits: list[dict[str, str | list[str] | dict[str, str]]]
    ) -> None:
        self.lines: list[str] = []
        self.edits = edits
        self.__section: str | None = None
        self.__section_lines: list[str] = []
        self.__sections: set[str] = set()

    def load(self, reader: TextIO) -> None:
        # Simple INI parser. Not using ConfigParser because Unreal
        # uses duplicate keys within sections, and we want to preserve
        # comments, blank lines etc.
        self.__section = None
        self.__section_lines = []
        self.__sections = set()

        for line in reader:
            line = line.rstrip('\r\n')

            if line.startswith('[') and line.endswith(']'):
                if self.__section is not None:
                    self.__end_section()
                self.__section = line[1:-1]

            self.__section_lines.append(line)

        self.__end_section()

        for edit in self.edits:
            if edit['section'] not in self.__sections:
                assert type(edit['section']) is str
                self.__section = edit['section']
                self.__section_lines = ['[%s]' % edit['section']]
                self.__end_section()

    def __end_section(self) -> None:
        assert type(self.__section) is str
        self.__sections.add(self.__section)

        for edit in self.edits:
            if edit['section'] != self.__section:
                continue

            logger.debug('editing %s', self.__section)
            extra_lines = []

            replace_key = edit.get('replace_key', {})
            assert type(replace_key) is dict
            for k, v in sorted(replace_key.items()):
                logger.debug('replacing %s with %s', k, v)
                self.__section_lines = [
                    line for line in self.__section_lines
                    if not line.startswith(k + '=')
                ]
                extra_lines.append('%s=%s' % (k, v))

            for pattern in sorted(edit.get('delete_matched', [])):
                logger.debug('deleting lines matching %s', pattern)
                self.__section_lines = [
                    line for line in self.__section_lines
                    if not fnmatch.fnmatchcase(line, pattern)
                ]

            for pattern in edit.get('comment_out_matched', []):
                logger.debug('commenting out lines matching %s', pattern)
                for i in range(len(self.__section_lines)):
                    if fnmatch.fnmatchcase(self.__section_lines[i], pattern):
                        assert type(edit['comment_out_reason']) is str
                        self.__section_lines[i] = ';' + self.__section_lines[i]
                        self.__section_lines.insert(
                            i, '; ' + edit['comment_out_reason'],
                        )

            for append in edit.get('append_unique', []):
                logger.debug('appending unique line %s', append)
                for line in self.__section_lines:
                    if line == append:
                        break
                else:
                    extra_lines.append(append)

            i = len(self.__section_lines) - 1

            while i >= 0:
                if self.__section_lines[i]:
                    # _s_l[i] is the last non-empty line, insert after it
                    self.__section_lines[i + 1:i + 1] = extra_lines
                    break
                i -= 1
            else:
                # no non-empty lines, insert after the section-opening heading
                self.__section_lines[1:1] = extra_lines

        self.lines.extend(self.__section_lines)
        self.__section_lines = []
        self.__section = None

    def save(self, writer: TextIO) -> None:
        for line in self.lines:
            print(line, file=writer)


class FullLauncher(Launcher):
    def set_id(self) -> None:
        self.keyfile = GLib.KeyFile()
        desktop = os.path.join(RUNTIME_BUILT, self.id + '.desktop')
        if os.path.exists(desktop):
            self.keyfile.load_from_file(desktop, GLib.KeyFileFlags.NONE)
        else:
            self.keyfile.load_from_data_dirs(
                    'applications/%s.desktop' % self.id,
                    GLib.KeyFileFlags.NONE)

        self.name = self.keyfile.get_string(
            GLib.KEY_FILE_DESKTOP_GROUP,
            GLib.KEY_FILE_DESKTOP_KEY_NAME,
        )
        logger.debug('Name: %s', self.name)
        GLib.set_application_name(self.name)

        self.icon_name = self.keyfile.get_string(
            GLib.KEY_FILE_DESKTOP_GROUP,
            GLib.KEY_FILE_DESKTOP_KEY_ICON,
        )
        logger.debug('Icon: %s', self.icon_name)

        try:
            override_id = self.keyfile.get_string(
                GLib.KEY_FILE_DESKTOP_GROUP,
                'X-GameDataPackager-ExpansionFor',
            )
        except GLib.Error:
            pass
        else:
            if self.expansion_name is None:
                self.expansion_name = self.id

            assert type(self.expansion_name) is str
            if self.expansion_name.startswith(override_id + '-'):
                self.expansion_name = self.expansion_name[
                    len(override_id) + 1:
                ]

            self.id = override_id

    def exec_game(self, _unused: Any = None) -> None:
        library_symlinks = self.data.get('library_symlinks', {})
        if 'destination_subdirectory' in library_symlinks:
            dest_subdir = library_symlinks.get('destination_subdirectory')
            os.makedirs(
                os.path.join(self.dot_directory, dest_subdir),
                exist_ok=True,
            )

            arch = self.data.get('arch', 'native')
            libdirs = [
                '/usr/lib/i386-linux-gnu',
                '/usr/lib32',
                '/usr/lib',
            ]
            if (
              (arch == 'native' and os.uname().machine == 'x86_64') or
              arch == 'amd64'
              ):
                libdirs = [
                    '/usr/lib/x86_64-linux-gnu',
                    '/usr/lib64',
                    '/usr/lib',
                ]
            elif (
              (arch == 'native' and os.uname().machine == 'aarch64') or
              arch == 'arm64'
              ):
                libdirs = [
                    '/usr/lib/aarch64-linux-gnu',
                    '/usr/lib64',
                    '/usr/lib',
                ]

            symlinks = library_symlinks.get('symlinks', {})
            for symlink_src, symlink_dst in symlinks.items():
                for d in libdirs:
                    f = os.path.join(d, symlink_src)

                    if os.path.exists(f):
                        dest = os.path.join(
                            self.dot_directory,
                            dest_subdir,
                            symlink_dst,
                        )

                        with suppress(FileNotFoundError):
                            os.remove(dest)

                        os.symlink(f, dest)
                        break

        if self.id == 'ut99':
            if self.expand_tokens.get('UT99System', '') == 'System64':
                dest = os.path.join(self.dot_directory, 'System64')
                with suppress(FileExistsError):
                    os.symlink('System', dest)
            elif self.expand_tokens.get('UT99System', '') == 'SystemARM64':
                dest = os.path.join(self.dot_directory, 'SystemARM64')
                with suppress(FileExistsError):
                    os.symlink('System', dest)

        # Edit before copying, so that we can detect whether this is
        # the first run or not
        for ini, details in self.data.get('edit_unreal_ini', {}).items():
            assert self.dot_directory is not None
            target = os.path.join(self.dot_directory, ini)
            encoding = details.get('encoding', 'windows-1252')

            if os.path.exists(target):
                first_time = False
                try:
                    reader = open(target, encoding='utf-16')
                    reader.readline()
                except UnicodeError:
                    reader = open(target, encoding=encoding)
                else:
                    reader.seek(0)
            else:
                first_time = True

                if os.path.lexists(target):
                    logger.info('Removing dangling symlink %s', target)
                    os.remove(target)

                for base in self.base_directories:
                    if ini in ['System/Rune.ini', 'System/UT2004.ini']:
                        source = os.path.join(base, 'System/Default.ini')
                    else:
                        source = os.path.join(base, ini)

                    if os.path.exists(source):
                        try:
                            reader = open(source, encoding='utf-16')
                            reader.readline()
                        except UnicodeError:
                            reader = open(source, encoding=encoding)
                        else:
                            reader.seek(0)
                        break
                else:
                    raise AssertionError('Required file %s not found', ini)

            if first_time:
                edits = details.get('once', []) + details.get('always', [])
            else:
                edits = details.get('always', [])

            logger.debug('%s', edits)
            editor = IniEditor(edits)

            with reader:
                editor.load(reader)

            d = os.path.dirname(target)

            if d:
                logger.info('Creating directory: %s', d)
                os.makedirs(d, exist_ok=True)

            with open(
                target, 'w', encoding=encoding,
                newline=details.get('newline', '\n')
            ) as writer:
                editor.save(writer)

        super().exec_game()

    def select_language(self) -> None:
        selected_language: str | None = None
        default_language: str | None = None
        start_game: bool = False

        for lang in prefered_langs():
            if lang in self.languages:
                default_language = lang
                break

        def toggle_lang_button(button: Gtk.Widget, data: str) -> None:
            nonlocal selected_language
            selected_language = data

        def click_play_button(button: Gtk.Widget, window: Gtk.Window) -> None:
            nonlocal start_game
            start_game = True
            window.close()

        window = Gtk.Window()
        window.set_default_size(400, 200)
        window.set_title(self.name)
        window.set_icon_name(self.icon_name)

        grid = Gtk.Grid(
            column_spacing=6,
            margin_top=12,
            margin_bottom=12,
            margin_start=12,
            margin_end=12,
        )

        image = Gtk.Image.new_from_icon_name(self.icon_name)
        image.set_pixel_size(128)
        image.set_valign(Gtk.Align.START)
        grid.attach(image, 0, 0, 1, 10)

        subgrid = Gtk.Grid(row_spacing=6)

        langlabel = Gtk.Label(label="Choose your language:")
        langlabel.set_hexpand(True)
        subgrid.attach(langlabel, 0, 0, 1, 1)

        lang = self.languages[0]
        langradio = Gtk.CheckButton(label=getattr(LanguageCodes, lang))
        langradio.connect('toggled', toggle_lang_button, lang)
        if lang == default_language:
            langradio.set_active(True)
        subgrid.attach(langradio, 0, 1, 1, 1)

        i = 2
        for lang in self.languages[1:]:
            radiobutton = Gtk.CheckButton(label=getattr(LanguageCodes, lang))
            radiobutton.set_group(langradio)
            radiobutton.connect('toggled', toggle_lang_button, lang)
            if lang == default_language:
                radiobutton.set_active(True)
            subgrid.attach(radiobutton, 0, i, 1, 1)
            i += 1
        play_button = Gtk.Button.new_with_label('Play')
        play_button.connect('clicked', click_play_button, window)
        subgrid.attach(play_button, 0, i, 1, 1)

        grid.attach(subgrid, 1, 0, 1, 1)

        window.set_child(grid)
        window.present()
        play_button.grab_focus()

        context = GLib.MainContext.default()
        while len(Gtk.Window.get_toplevels()) > 1:
            context.iteration(True)

        if not start_game:
            # Closing the window without clicking on "Play"
            sys.exit(0)

        self.argv.insert(1, self.data.get("languages").get('arg'))
        assert selected_language
        self.argv.insert(2, selected_language)

    def write_confirm_binary_only_stamp(self) -> None:
        assert type(self.warning_stamp) is str
        open(self.warning_stamp, 'a').close()

    def __init__(self) -> None:
        super().__init__()

        # Migrate old directories to new, e.g. ~/.loki/ut -> ~/.utpg
        for old in self.old_dot_directories:
            new = self.dot_directory
            assert new is not None
            os.makedirs(os.path.dirname(new), exist_ok=True)

            have_old = os.path.exists(old)
            have_new = os.path.exists(new)

            if have_old and not have_new:
                logger.debug('Migrating from %s to %s', old, new)
                try:
                    os.rename(old, new)
                except OSError as e:
                    logger.debug(
                        'Unable to rename, falling back to symlink: %s',
                        e,
                    )
                    rel = os.path.relpath(old, start=os.path.dirname(new))
                    logger.debug('Creating symlink %s -> %s', new, rel)
                    os.symlink(rel, new)

        self.window = Gtk.Window()
        self.window.set_default_size(600, 300)
        self.window.set_title(self.name)
        self.window.set_icon_name(self.icon_name)

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

        image = Gtk.Image.new_from_icon_name(self.icon_name)
        image.set_pixel_size(48)
        image.set_valign(Gtk.Align.START)
        self.grid.attach(image, 0, 0, 1, 1)

        self.text_view = Gtk.TextView(
            editable=False,
            cursor_visible=False,
            hexpand=True,
            vexpand=True,
            wrap_mode=Gtk.WrapMode.WORD,
            left_margin=6,
            right_margin=6,
        )
        try:
            self.text_view.set_bottom_margin(6)
            self.text_view.set_top_margin(6)
        except AttributeError:
            logger.warn('You are using an old version of pygobject')
        self.grid.attach(self.text_view, 1, 0, 1, 1)

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

        cancel_button = Gtk.Button.new_with_label('Cancel')
        cancel_button.connect('clicked', lambda _: self.window.close())
        subgrid.attach(cancel_button, 0, 0, 1, 1)

        self.check_box = Gtk.CheckButton.new_with_label("I'll be careful")
        self.check_box.set_hexpand(True)
        self.grid.attach(self.check_box, 0, 1, 2, 1)

        self.ok_button = Gtk.Button.new_with_label('Run')
        self.ok_button.set_sensitive(False)
        subgrid.attach(self.ok_button, 1, 0, 1, 1)

        self.grid.attach(subgrid, 0, 2, 2, 1)

    def run_error(self, message: str) -> None:
        self.show_error(message)

        context = GLib.MainContext.default()
        while len(Gtk.Window.get_toplevels()) > 0:
            context.iteration(True)

    def show_error(self, message: str) -> None:
        self.text_view.get_buffer().set_text(message)
        self.ok_button.set_sensitive(False)
        self.check_box.set_visible(False)
        self.window.present()

    def run_confirm_binary_only(self) -> None:
        self.text_view.get_buffer().set_text(
            self.load_text(
                'confirm-binary-only.txt',
                'Binary-only game, we cannot fix bugs or security '
                'vulnerabilities!'
            )
        )
        self.check_box.bind_property(
            'active', self.ok_button, 'sensitive',
            GObject.BindingFlags.SYNC_CREATE,
        )
        self.ok_button.connect(
            'clicked',
            lambda _: self.__confirm_binary_only_cb(),
        )
        self.window.present()

        context = GLib.MainContext.default()
        while len(Gtk.Window.get_toplevels()) > 0:
            context.iteration(True)

    def __confirm_binary_only_cb(self) -> None:
        try:
            self.write_confirm_binary_only_stamp()
            self.exec_game()
        except Exception:
            self.show_error(traceback.format_exc())


if __name__ == '__main__':
    logging.basicConfig()
    FullLauncher().main()
