###################################################################################################
#                              MIT Licence (C) 2022 Cubicpath@Github                              #
###################################################################################################
"""Module for the main application classes."""
from __future__ import annotations

__all__ = (
    'app',
    'GetterApp',
    'Theme',
    'tr',
)

import json
import shutil
import subprocess
import sys
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from typing import Final
from typing import NamedTuple

import toml
from PySide6.QtCore import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *

from ..constants import *
from ..events import EventBus
from ..lang import Translator
from ..models import DeferredCallable
from ..models import DistributedCallable
from ..models import Singleton
from ..network.client import Client
from ..network.client import WEB_DUMP_PATH
from ..network.manager import NetworkSession
from ..tomlfile import *
from ..utils.gui import icon_from_bytes
from ..utils.gui import set_or_swap_icon
from ..utils.network import http_code_map
from ..utils.package import has_package
from ..utils.system import hide_windows_file

_DEFAULTS_FILE: Final[Path] = HI_RESOURCE_PATH / 'default_settings.toml'
_LAUNCHED_FILE: Final[Path] = HI_CONFIG_PATH / '.LAUNCHED'
_SETTINGS_FILE: Final[Path] = HI_CONFIG_PATH / 'settings.toml'


def app() -> GetterApp:
    """:return: GetterApp.instance()"""
    return GetterApp.instance()


def tr(key: str, *args: Any, **kwargs: Any) -> str:
    """Alias for app().translator().

    :param key: Translation keys to translate.
    :param args: Arguments to format key with.
    :keyword default: Default value to return if key is not found.
    :return: Translated text.
    """
    return app().translator(key, *args, **kwargs)


class _DialogResponse(NamedTuple):
    """Response object for GetterApp.show_dialog()."""
    button: QAbstractButton = QMessageBox.NoButton
    role:   QMessageBox.ButtonRole = QMessageBox.NoRole


class Theme(NamedTuple):
    """Object containing data about a Theme."""
    id:           str
    style:        str
    display_name: str


class GetterApp(Singleton, QApplication):
    """The main HaloInfiniteGetter PySide application that runs in the background and manages the process.

    :py:class:`GetterApp` is a singleton and can be accessed via the class using the GetterApp.instance() class method or the app() function.
    """
    _singleton_base_type = QApplication
    _singleton_check_ref = False

    # PyCharm detects dict literals in __init__ as a dict[str, EventBus[TomlEvent]], for no explicable reason.
    # noinspection PyTypeChecker
    def __init__(self, *args, **kwargs) -> None:
        """Create a new app with the given arguments and settings."""
        super().__init__(list(args))  # Despite documentation saying it takes in a Sequence[str], it only accepts lists

        self._first_launch: bool = not _LAUNCHED_FILE.is_file()  # Check if launched marker exists
        self._legacy_style: str = self.styleSheet()              # Set legacy style before it is overridden
        self._thread_pool:  QThreadPool = QThreadPool.globalInstance()

        self._setting_defaults: TomlTable = toml.loads(_DEFAULTS_FILE.read_text(encoding='utf8'), decoder=PathTomlDecoder())
        self._registered_translations: DistributedCallable[set[Callable[DeferredCallable[str]]]] = DistributedCallable(set())
        self._windows: dict[str, QWidget] = {}

        # Create all files/directories that are needed for the app to run
        self._create_paths()

        # Must have themes up before load_env
        self.icon_store:      defaultdict[str, QIcon] = defaultdict(QIcon)  # Null icon generator
        self.session:         NetworkSession = NetworkSession(self)
        self.settings:        TomlFile = TomlFile(_SETTINGS_FILE, default=self._setting_defaults)
        self.themes:          dict[str, Theme] = {}
        self.theme_index_map: dict[str, int] = {}
        self.translator:      Translator = Translator(self.settings['language'])

        # Load resources from disk
        self.load_themes()  # Depends on icon_store, settings, themes, theme_index_map
        self.load_icons()   # Depends on icon_store, session

        # Register callables to events
        EventBus['settings'] = self.settings.event_bus
        EventBus['settings'].subscribe(DeferredCallable(self.load_themes), TomlEvents.Import)
        EventBus['settings'].subscribe(DeferredCallable(self.update_language), TomlEvents.Import)
        EventBus['settings'].subscribe(DeferredCallable(self.update_stylesheet), TomlEvents.Set, event_predicate=lambda e: e.key == 'gui/themes/selected')

        # Register formats for use in shutil
        self._register_archive_formats()

        # Must load client last, but before windows
        self.load_env(verbose=True)
        self.client = Client(self)

        # Create window instances
        self._create_windows(**kwargs)

    @property
    def first_launch(self) -> bool:
        """Return whether this is the first launch of the application.

        This is determined by checking if the .LAUNCHED file exists in the user's config folder.
        """
        return self._first_launch

    @property
    def windows(self) -> dict[str, QWidget]:
        """Return a copy of the self._windows dictionary."""

        return self._windows.copy()

    def _create_paths(self) -> None:
        """Create files and directories if they do not exist."""
        for dir_path in (HI_CACHE_PATH, WEB_DUMP_PATH, HI_CONFIG_PATH):
            if not dir_path.is_dir():
                dir_path.mkdir(parents=True)

        if self.first_launch:
            # Create first-launch marker
            _LAUNCHED_FILE.touch()
            hide_windows_file(_LAUNCHED_FILE)

        if not _SETTINGS_FILE.is_file():
            # Write default_settings to user's SETTINGS_FILE
            with _SETTINGS_FILE.open(mode='w', encoding='utf8') as file:
                toml.dump(self._setting_defaults, file, encoder=PathTomlEncoder())

    def _create_windows(self, **kwargs) -> None:
        """Create window instances."""
        from .windows import AppWindow
        from .windows import LicenseViewer
        from .windows import ReadmeViewer
        from .windows import SettingsWindow

        SettingsWindow.create(QSize(420, 600))
        AppWindow.create(QSize(
            # Size to use, with a minimum of 100x100
            max(kwargs.pop('x_size', self.settings['gui/window/x_size']), 100),
            max(kwargs.pop('y_size', self.settings['gui/window/y_size']), 100)
        ))

        self._windows['license_viewer'] = LicenseViewer()
        self._windows['readme_viewer'] = ReadmeViewer()
        self._windows['settings'] = SettingsWindow.instance()
        self._windows['app'] = AppWindow.instance()

    def _translate_http_code_map(self) -> None:
        """Translate the HTTP code map to the current language."""
        for code in (400, 401, 403, 404, 405, 406):
            http_code_map[code] = (http_code_map[code][0], self.translator(f'network.http.codes.{code}.description'))

    def _register_archive_formats(self) -> None:
        if not has_package('py7zr'):
            self.missing_package_dialog('py7zr', 'Importing/Exporting 7Zip Archives')
        if not has_package('py7zr'):  # Check again, during the dialog, the package may be dynamically installed by user.
            return

        from py7zr import pack_7zarchive
        from py7zr import unpack_7zarchive

        # Register .7z archive support
        shutil.register_archive_format('7z', pack_7zarchive, description='7Zip Archive File')
        shutil.register_unpack_format('7z', ['7z'], unpack_7zarchive, description='7Zip Archive File')

    def init_translations(self, translation_calls: dict[Callable, str]) -> None:
        """Initialize the translation of all objects.

        Register functions to call with their respective translation keys.
        This is used to translate everything in the GUI.
        """

        for func, key in translation_calls.items():
            # Call the function with the deferred translation of the given key.
            translate = DeferredCallable(func, DeferredCallable(self.translator, key))

            # Register the object for dynamic translation
            self._registered_translations.callables.add(translate)
            translate()

    def update_language(self) -> None:
        """Set the application language to the one currently selected in settings.

        This method dynamically translates all registered text in the GUI to the given language using translation keys.
        """
        self.translator.language = self.settings['language']
        self._translate_http_code_map()
        self._registered_translations()

    def update_stylesheet(self) -> None:
        """Set the application stylesheet to the one currently selected in settings."""
        try:
            self.setStyleSheet(self.themes[self.settings['gui/themes/selected']].style)
        except KeyError:
            self.settings['gui/themes/selected'] = 'legacy'
            self.setStyleSheet(self._legacy_style)

    def show_dialog(self, key: str, parent: QWidget | None = None,
                    buttons: Sequence[tuple[QAbstractButton, QMessageBox.ButtonRole] | QMessageBox.StandardButton] | QMessageBox.StandardButtons | None = None,
                    default_button: QAbstractButton | QMessageBox.StandardButton | None = None,
                    title_args: Sequence | None = None,
                    description_args: Sequence | None = None) -> _DialogResponse | None:
        """Show a dialog. This is a wrapper around QMessageBox creation.

        The type of dialog icon depends on the key's first section.
        The following sections are supported::
            - 'about':      -> QMessageBox.about
            - 'questions'   -> QMessageBox.Question
            - 'information' -> QMessageBox.Information
            - 'warnings'    -> QMessageBox.Warning
            - 'errors'      -> QMessageBox.Critical

        The dialog title and description are determined from the "title" and "description" child sections of the given key.
        Example with given key as "questions.key"::
            "questions.key.title": "Question Title"
            "questions.key.description": "Question Description"

        WARNING: If a StandardButton is clicked, the button returned is NOT a StandardButton enum, but a QPushButton.

        :param key: The translation key to use for the dialog.
        :param parent: The parent widget to use for the dialog. If not supplied, a dummy widget is temporarily created.
        :param buttons: The buttons to use for the dialog. If button is not a StandardButton, it should be a tuple containing the button and its role.
        :param default_button: The default button to use for the dialog.
        :param description_args: The translation arguments used to format the description.
        :param title_args: The translation arguments used to format the title.
        :return: The button that was clicked, as well as its role. None if the key's first section is "about".
        """
        if parent is None:
            dummy_widget = QWidget()
            parent = dummy_widget

        title_args:       Sequence = () if title_args is None else title_args
        description_args: Sequence = () if description_args is None else description_args

        icon: QMessageBox.Icon
        first_section: str = key.split('.')[0]
        match first_section:
            case 'questions':
                icon = QMessageBox.Question
            case 'information':
                icon = QMessageBox.Information
            case 'warnings':
                icon = QMessageBox.Warning
            case 'errors':
                icon = QMessageBox.Critical
            case _:
                icon = QMessageBox.NoIcon

        title_text:       str = self.translator(f'{key}.title', *title_args)
        description_text: str = self.translator(f'{key}.description', *description_args)

        msg_box = QMessageBox(icon, title_text, description_text, parent=parent)

        if first_section == 'about':
            return msg_box.about(parent, title_text, description_text)

        standard_buttons = None
        if buttons is not None:
            if isinstance(buttons, Sequence):
                for button in buttons:
                    if isinstance(button, tuple):
                        msg_box.addButton(*button)
                    else:
                        # If the button is not a tuple, assume it's a QMessageBox.StandardButton.
                        # Build a StandardButtons from all StandardButton objects in buttons.
                        if standard_buttons is None:
                            standard_buttons = button
                        else:
                            standard_buttons |= button
            else:
                # If the buttons is not a sequence, assume it's QMessageBox.StandardButtons.
                standard_buttons = buttons

        if standard_buttons:
            msg_box.setStandardButtons(standard_buttons)

        if default_button is not None:
            msg_box.setDefaultButton(default_button)

        msg_box.buttonClicked.connect((result := set()).add)
        msg_box.exec()

        result_button: QAbstractButton = next(iter(result)) if result else QMessageBox.NoButton
        result_role:   QMessageBox.ButtonRole = msg_box.buttonRole(result_button) if result else QMessageBox.NoRole

        if parent is None:
            # noinspection PyUnboundLocalVariable
            dummy_widget.deleteLater()

        return _DialogResponse(button=result_button, role=result_role)

    def missing_package_dialog(self, package: str, reason: str | None = None, parent: QObject | None = None) -> None:
        """Show a dialog informing the user that a package is missing and asks to install said package.

        If a user presses the "Install" button, the package is installed.

        :param package: The name of the package that is missing.
        :param reason: The reason why the package is attempting to be used.
        :param parent: The parent widget to use for the dialog. If not supplied, a dummy widget is temporarily created.
        """
        exec_path = Path(sys.executable)

        install_button = QPushButton(self.get_theme_icon('dialog_ok'), self.translator('errors.missing_package.install'))

        consent_to_install: bool = self.show_dialog(
            'errors.missing_package', parent, (
                (install_button, QMessageBox.AcceptRole),
                QMessageBox.Cancel
            ),
            default_button=QMessageBox.Cancel,
            description_args=(package, reason, exec_path)
        ).role == QMessageBox.AcceptRole

        if consent_to_install:
            try:
                # Install the package
                subprocess.run([exec_path, '-m', 'pip', 'install', package], check=True)
            except (OSError, subprocess.SubprocessError) as e:
                self.show_dialog(
                    'errors.package_install_failure', parent,
                    description_args=(package, e)
                )
            else:
                self.show_dialog(
                    'information.package_installed', parent,
                    description_args=(package,)
                )

    def load_env(self, verbose: bool = True) -> None:
        """Load environment variables from .env file."""
        if not has_package('python-dotenv'):
            self.missing_package_dialog('python-dotenv', 'Loading environment variables')
        if has_package('python-dotenv'):  # Check again, during the dialog, the package may be dynamically installed by user.
            return

        from dotenv import load_dotenv
        load_dotenv(verbose=verbose)

    def load_icons(self) -> None:
        """Load all icons needed for the application.

        Fetch locally stored icons from the HI_RESOURCE_PATH/icons directory

        Asynchronously fetch externally stored icons from urls defined in HI_RESOURCE_PATH/external_icons.json
        """
        # Load locally stored icons
        self.icon_store.update({
            filename.stem: QIcon(str(filename)) for
            filename in (HI_RESOURCE_PATH / 'icons').iterdir() if filename.is_file()
        })

        # Load external icon links
        external_icon_links: dict[str, str] = json.loads((HI_RESOURCE_PATH / 'external_icons.json').read_text(encoding='utf8'))

        # Load externally stored icons
        # pylint: disable=cell-var-from-loop
        for key, url in external_icon_links.items():
            # Create a new handler for every key being requested.
            def handle_reply(reply):
                icon = icon_from_bytes(reply.readAll())
                set_or_swap_icon(self.icon_store, key, icon)
                reply.deleteLater()

            self.session.get(url, finished=handle_reply)

        # Set the default icon for all windows.
        self.setWindowIcon(self.icon_store['hi'])

    def get_theme_icon(self, icon: str) -> QIcon:
        """Return the icon for the given theme.

        :param icon: Icon name for given theme.
        :return: QIcon for the given theme or a null QIcon if not found. Null icons are falsy.
        """
        current_theme = self.settings['gui/themes/selected']
        return self.icon_store[f'hi_theme+{current_theme}+{icon}']

    def add_theme(self, theme: Theme) -> None:
        """Add a theme to the application.

        Overwrites previous theme if the ids are the same.
        """
        self.themes[theme.id] = theme

    def load_themes(self) -> None:
        """Load all theme locations from settings and store them in self.themes.

        Also set current theme from settings.
        """
        self.add_theme(Theme('legacy', self._legacy_style, 'Legacy (Default Qt)'))

        try:
            themes = self.settings['gui/themes']
        except KeyError:
            self.settings['gui/themes'] = themes = {}

        for id, theme in themes.items():
            if not isinstance(theme, dict):
                continue

            theme: dict = theme.copy()
            if isinstance((path := theme.pop('path')), CommentValue):
                path = path.val

            # Translate builtin theme locations
            if isinstance(path, str) and path.startswith('builtin::'):
                path = HI_RESOURCE_PATH / f'themes/{path.removeprefix("builtin::")}'

            # Ensure path is a Path value that exists
            if (path := Path(path)).is_dir():
                QDir.addSearchPath(f'hi_theme+{id}', path)

                for theme_resource in path.iterdir():
                    if theme_resource.is_file():
                        if theme_resource.name == 'stylesheet.qss':
                            # Load stylesheet file
                            theme['id'] = id
                            theme['style'] = theme_resource.read_text(encoding='utf8')
                            theme['display_name'] = theme.get('display_name') or id
                        elif theme_resource.suffix.lstrip('.') in SUPPORTED_IMAGE_EXTENSIONS:
                            # Load all images in the theme directory into the icon store.
                            self.icon_store[f'hi_theme+{id}+{theme_resource.stem}'] = QIcon(str(theme_resource.resolve()))

                self.add_theme(Theme(**theme))

        # noinspection PyUnresolvedReferences
        self.theme_index_map = {theme_id: i for i, theme_id in enumerate(theme.id for theme in self.sorted_themes())}
        self.update_stylesheet()

    def sorted_themes(self) -> list[Theme]:
        """List of themes sorted by their display name."""
        return sorted(self.themes.values(), key=lambda theme: theme.display_name)

    def start_worker(self, runnable: Callable | QRunnable, priority: int = 0) -> None:
        """Start a runnable from the application :py:class:`QThreadPool`."""
        self._thread_pool.start(runnable, priority)
