#!/usr/bin/python3
#
# Polychromatic is free software: you can redistribute it and/or modify
# it under the temms 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.
#
# Polychromatic 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 Polychromatic. If not, see <http://www.gnu.org/licenses/>.
#
# Copyright (C) 2015-2021 Luke Horwell <code@horwell.me>
#               2015-2016 Terry Cain <terry@terrys-home.co.uk>

"""
The primary "Controller" GUI for Polychromatic based on PyQt5.
"""
import argparse
import json
import os
import signal
import threading
import setproctitle
import time
import sys

from PyQt5 import uic, QtCore
from PyQt5.QtCore import Qt, QThread
from PyQt5.QtGui import QIcon, QFont, QFontDatabase
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QPushButton, \
                            QToolButton, QTabWidget, QAction, QMenu, \
                            QDesktopWidget, QLabel

VERSION = "0.7.3"

# Import modules if running relatively.
if os.path.exists(os.path.join(os.path.dirname(__file__), "pylib")):
    try:
        import pylib.preferences as pref
        import pylib.common as common
        import pylib.locales as locales
        import pylib.controller as controller
        import pylib.effects as effects
        import pylib.middleman as middleman
        import pylib.procpid as procpid
        import pylib.controller.shared as shared
    except (ImportError, Exception) as e:
        print("Failed to import modules relatively.\n")
        raise e

# Import modules if installed system-wide.
else:
    try:
        import polychromatic.preferences as pref
        import polychromatic.common as common
        import polychromatic.locales as locales
        import polychromatic.controller as controller
        import polychromatic.effects as effects
        import polychromatic.middleman as middleman
        import polychromatic.procpid as procpid
        import polychromatic.controller.shared as shared
    except (ImportError, Exception) as e:
        print("Polychromatic's modules could not be imported.")
        print("Check all dependencies are installed, the Python environment variables are correct, or try re-installing the application.\n")
        raise e


class ApplicationData(object):
    """
    Shared data that is globally accessible throughout the application.
    This includes save data variables and objects for each tab.
    """
    def __init__(self, qapp, dbg, _, paths, hidpi):
        """
        Prepare the object.
        """
        dbg.stdout("Initialising application...", dbg.action, 1)
        self.main_window = None
        self.main_app = qapp
        self.dbg = dbg
        self.locales = i18n
        self._ = _
        self.paths = paths
        self.exec_path = __file__
        self.exec_args = sys.argv
        self.hidpi = hidpi
        self.version = VERSION
        self.versions = VERSIONS

        # UI Colours (based on SCSS variables)
        self.normal_colour = "#DED9CB"      # $button-text-colour
        self.disabled_colour = "#575757"    # $text-disabled
        self.active_colour = "#00FF00"      # $primary
        self.selected_colour = "#00FF00"    # $primary
        self.secondary_colour_active = "#008000"    # $secondary
        self.secondary_colour_inactive = "#808080"  # $secondary (desaturated)

        # Assigned in load() later
        self.middleman = None
        self.ready = False
        self.device_list = []
        self.menubar = None
        self.tab_devices = None
        self.tab_effects = None
        self.tab_presets = None
        self.tab_triggers = None
        self.ui_preferences = None

        # Settings
        self.preferences = pref.load_file(self.paths.preferences)
        self.system_qt_theme = self.preferences["controller"]["system_qt_theme"]
        self.show_menu_bar = self.preferences["controller"]["show_menu_bar"]

    def load(self):
        """
        Show the main window and spawn a thread to load the rest of the application.
        """
        self.main_window = MainWindow()
        self.middleman = middleman.Middleman(dbg, common, _)

        # Menu bar & tabs
        self.tab_devices = controller.devices.DevicesTab(self)
        self.tab_effects = controller.effects.EffectsTab(self)
        self.tab_presets = controller.presets.PresetsTab(self)
        self.tab_triggers = controller.triggers.TriggersTab(self)
        self.menubar = controller.menubar.MenuBar(self)

        # Subwindows
        self.ui_preferences = controller.preferences.PreferencesWindow(self)

        self.main_window.findChild(QLabel, "GlobalStatus").setHidden(True)
        self.init_bg_thread()
        self.main_window.load()

    def init_bg_thread(self):
        """
        Backends will be initialised in this thread.
        """
        class BackgroundThreadTimeout(QThread):
            """
            Informs the user if the middleman is taking too long to load.
            """
            @staticmethod
            def run():
                # FIXME: It works, but isn't thread safe! [QObject:setParent]
                global_status_label = self.main_window.findChild(QLabel, "GlobalStatus")
                time.sleep(2)
                global_status_label.setText(self._("Waiting for backends to initialise..."))
                global_status_label.setHidden(False)
                time.sleep(10)
                global_status_label.setText(global_status_label.text() + ' ' + self._("(this is taking an unusually long time, try running the troubleshooter)"))
                time.sleep(30)
                global_status_label.setText(self._("Still waiting for backends to load, perhaps one of them is unresponsive?"))

        class BackgroundThreadInit(QThread):
            """
            Initialises the middleman in the background, so the application remains responsive.
            """
            @staticmethod
            def run():
                t_start = time.time()

                def _enable_action(action, enabled=True):
                    self.main_window.findChild(QAction, action).setEnabled(enabled)

                self.dbg.stdout("Initialising troubleshooters...", dbg.action, 1)
                self.middleman.init_troubleshooters()

                if "openrazer" in self.middleman.troubleshooters:
                    _enable_action("actionTroubleshootOpenRazer")

                # Misbehaving backends may take ages inside init()
                self.dbg.stdout("Initialising backends...", dbg.action, 1)
                self.middleman.init()
                self.device_list = self.middleman.get_device_list()

                # Enable OpenRazer functions if available
                # See also: pylib.controller.preferences.refresh_backend_status()
                if not "openrazer" in self.middleman.not_installed:
                    _enable_action("actionOpenRazerConfigure")
                    _enable_action("actionOpenRazerOpenLog")
                    _enable_action("actionOpenRazerRestartDaemon")
                    _enable_action("actionOpenRazerAbout")

                if "openrazer" in self.middleman.import_errors.keys():
                    _enable_action("actionOpenRazerAbout", False)

                t_end = time.time()
                self.dbg.stdout("Backends loaded in {0}s.".format(str(round(t_end - t_start, 3))), dbg.action, 1)
                self.ready = True

                app.ui_preferences.refresh_backend_status()

                # No longer need to inform user if initialisation took too long
                self.bg_thread_timeout.terminate()
                self.main_window.findChild(QLabel, "GlobalStatus").setHidden(True)

        self.bg_thread_init = BackgroundThreadInit()
        self.bg_thread_init.start()
        self.bg_thread_timeout = BackgroundThreadTimeout()
        self.bg_thread_timeout.start()


class MainWindow(QMainWindow):
    """
    The main window is the first window the user will use and interact with.
    """
    def __init__(self):
        dbg.stdout("Initialising window...", dbg.action, 1)
        super(MainWindow, self).__init__()

        # Check styles exist
        qt_style = os.path.join(common.paths.data_dir, "qt", "style.qss")
        if os.path.exists(qt_style) and os.path.getsize(qt_style) < 100:
            dbg.stdout("style.qss malformed! Maybe the application wasn't compiled properly?", dbg.error)
            dbg.stdout("Forcing native theme.", dbg.warning)
            app.system_qt_theme = True

        # Load UI and locales
        widget = uic.loadUi(os.path.join(common.paths.data_dir, "qt", "main.ui"), self)
        shared.translate_ui(app, widget)

        # Show the correct tab widgets
        if app.system_qt_theme:
            # User prefers own Qt theme, use native tabs.
            self.findChild(QWidget, "Header").hide()
            self.findChild(QWidget, "MainTabCustom").hide()
        else:
            # Custom Qt theme uses a different tab design. Hide native tabs.
            self.findChild(QWidget, "MainTabWidget").tabBar().hide()

        # Set window attributes
        self.setWindowTitle("Polychromatic")
        if common.paths.dev:
            self.setWindowTitle("Polychromatic {0} [dev]".format(get_versions()[0][1]))

        if self.windowIcon().isNull():
            self.setWindowIcon(QIcon(common.get_icon("general", "controller")))

        # Prepare Menu Bar
        self.findChild(QAction, "actionReinstateMenuBar").setVisible(False)
        if not app.preferences["controller"]["show_menu_bar"]:
            self.menuBar().hide()
            self.findChild(QAction, "actionReinstateMenuBar").setVisible(True)

        # CTRL+C'd
        signal.signal(signal.SIGINT, signal.SIG_DFL)

        self.CloseButton = self.findChild(QPushButton, "CloseApp")
        self.CloseButton.clicked.connect(self.quit_app)
        self.closeEvent = self.quit_app

    @staticmethod
    def _set_initial_window_position(qmainwindow, key_prefix):
        """
        Sets the initial placement of the window, according to user's preferences.

        Params:
            qmainwindow         QMainWindow() instance
            key_prefix          Name of prefix for save data. Should be initialised in preferences.
        """
        win_behaviour = app.preferences["controller"]["window_behaviour"]

        if win_behaviour == pref.WINDOW_BEHAVIOUR_IGNORE:
            return

        if win_behaviour in [pref.WINDOW_BEHAVIOUR_CENTER, pref.WINDOW_BEHAVIOUR_MAXIMIZED]:
            frame = qmainwindow.frameGeometry()

            # DEPRECATED: QDesktopWidget(), but some distros ship an older Qt version (5.8, 5.12)
            qt_ver = QtCore.PYQT_VERSION_STR.split(".")
            if qt_ver[0] == "5" and int(qt_ver[1]) <= 14:
                center = QDesktopWidget().availableGeometry().center()
            else:
                # For >= Qt 5.15
                center = qmainwindow.screen().availableGeometry().center()

            frame.moveCenter(center)
            qmainwindow.move(frame.topLeft())

        if win_behaviour == pref.WINDOW_BEHAVIOUR_MAXIMIZED:
            qmainwindow.setWindowState(Qt.WindowMaximized)

        if win_behaviour == pref.WINDOW_BEHAVIOUR_REMEMBER:
            dbg.stdout("Loading window geometry...", dbg.action, 1)
            pos_x = app.preferences["geometry"][key_prefix + "_window_pos_x"]
            pos_y = app.preferences["geometry"][key_prefix + "_window_pos_y"]
            size_x = app.preferences["geometry"][key_prefix + "_window_size_x"]
            size_y = app.preferences["geometry"][key_prefix + "_window_size_y"]
            qmainwindow.setGeometry(pos_x, pos_y, size_x, size_y)

    @staticmethod
    def _save_window_position(qmainwindow, key_prefix):
        """
        Saves the dimensions and position of the window, according to the user's
        preferences.
        """
        win_behaviour = app.preferences["controller"]["window_behaviour"]

        if win_behaviour == pref.WINDOW_BEHAVIOUR_REMEMBER:
            dbg.stdout("Saving window geometry...", dbg.action, 1)
            rect = qmainwindow.frameGeometry()
            app.preferences["geometry"][key_prefix + "_window_pos_x"] = rect.left()
            app.preferences["geometry"][key_prefix + "_window_pos_y"] = rect.top()
            app.preferences["geometry"][key_prefix + "_window_size_x"] = rect.width()
            app.preferences["geometry"][key_prefix + "_window_size_y"] = rect.height()
            pref.save_file(common.paths.preferences, app.preferences)

    def load(self):
        """
        Objects initialised. Proceed to load the main application window.
        """
        widgets = shared.PolychromaticWidgets(app)

        # Polychromatic's Qt theme is based on Fusion, uses the "Play" font.
        if not app.system_qt_theme:
            qapp.setStyle("Fusion")
            QFontDatabase.addApplicationFont(os.path.join(common.paths.data_dir, "qt", "fonts", "Play_regular.ttf"))
            qapp.setFont(QFont("Play", 10, 0))
            controller.shared.load_qt_theme(app, self)

        # Minimal modes
        if args.open:
            if args.open == "troubleshoot":
                # TODO: Should support multiple backends!
                return app.menubar.openrazer.troubleshoot()
            elif args.open == "colours":
                app.ui_preferences.modify_colours()
                exit()
            elif args.open == "preferences":
                app.ui_preferences.open_window()
                return

        # Prepare "native" tab widget and custom buttons acting as tabs for the design.
        tabs = self.findChild(QTabWidget, "MainTabWidget")
        tab_buttons = [
            self.findChild(QToolButton, "DevicesTabButton"),
            self.findChild(QToolButton, "EffectsTabButton"),
            self.findChild(QToolButton, "PresetsTabButton"),
            self.findChild(QToolButton, "TriggersTabButton")
        ]

        # Also in the menu bar's view menu
        tab_menu_items = [
            self.findChild(QAction, "actionDevices"),
            self.findChild(QAction, "actionEffects"),
            self.findChild(QAction, "actionPresets"),
            self.findChild(QAction, "actionTriggers")
        ]

        # Tab icons
        tab_buttons[0].setIcon(widgets.get_icon_qt("general", "devices"))
        tab_buttons[1].setIcon(widgets.get_icon_qt("general", "effects"))
        tab_buttons[2].setIcon(widgets.get_icon_qt("general", "presets"))
        tab_buttons[3].setIcon(widgets.get_icon_qt("general", "triggers"))

        # Each tab stores its logic in a separate module
        tab_objects = [
            app.tab_devices,
            app.tab_effects,
            app.tab_presets,
            app.tab_triggers
        ]

        def _change_tab():
            index = tabs.currentIndex()
            dbg.stdout("Opening tab: " + str(index), dbg.debug, 1)
            self.setCursor(QtCore.Qt.WaitCursor)

            for button in tab_buttons:
                button.setChecked(False)
            for item in tab_menu_items:
                item.setChecked(False)

            tab_buttons[index].setChecked(True)
            tab_menu_items[index].setChecked(True)
            try:
                tab_objects[index].set_tab()
            except Exception as e:
                traceback = common.get_exception_as_string(e)
                print(traceback)
                widgets.open_dialog(widgets.dialog_error,
                                    _("Polychromatic Error"),
                                    _("Unable to load the tab properly due to an error.") + "\n\n" + \
                                    _("See below for technical details. Consider reporting this as a bug on this project's issue tracker."),
                                    details=traceback)
            self.unsetCursor()

        def _change_tab_proxy(button, index):
            tabs.setCurrentIndex(index)

            # Clicking onto the 'button tab' should reload
            if button == False:
                _change_tab()

        tabs.currentChanged.connect(_change_tab)

        # Press F5 to refresh tab
        self.findChild(QAction, "actionRefreshTab").triggered.connect(_change_tab)

        # Connect custom tab buttons/view menu for tab switching
        tab_buttons[0].clicked.connect(lambda a: _change_tab_proxy(a, 0))
        tab_buttons[1].clicked.connect(lambda a: _change_tab_proxy(a, 1))
        tab_buttons[2].clicked.connect(lambda a: _change_tab_proxy(a, 2))
        tab_buttons[3].clicked.connect(lambda a: _change_tab_proxy(a, 3))
        tab_menu_items[0].triggered.connect(lambda a: _change_tab_proxy(a, 0))
        tab_menu_items[1].triggered.connect(lambda a: _change_tab_proxy(a, 1))
        tab_menu_items[2].triggered.connect(lambda a: _change_tab_proxy(a, 2))
        tab_menu_items[3].triggered.connect(lambda a: _change_tab_proxy(a, 3))

        # Reimplement ability to scroll over 'custom' tabs
        real_tabs = self.findChild(QWidget, "MainTabWidget").tabBar()
        custom_tabs = self.findChild(QWidget, "MainTabCustom")
        def _scroll_over_custom_tabs(evt):
            direction = 1 if evt.angleDelta().y() < 0 else -1
            real_tabs.setCurrentIndex(real_tabs.currentIndex() + direction)
        custom_tabs.wheelEvent = _scroll_over_custom_tabs

        # FIXME: Hide incomplete features
        tabs.removeTab(3)
        tabs.removeTab(2)
        for widget in [
            tab_buttons[2], tab_buttons[3],
            tab_menu_items[2], tab_menu_items[3],
            self.findChild(QAction, "actionImportEffect"),
            self.findChild(QAction, "actionNewPreset"),
            self.findChild(QAction, "actionNewPresetNow"),
            self.findChild(QAction, "actionDuplicate"),
            self.findChild(QAction, "actionDelete"),
        ]:
            widget.setDisabled(True)
            widget.setVisible(False)

        # Determine 'landing' tab to first open
        landing_tab = app.preferences["controller"]["landing_tab"]

        if args.open:
            try:
                param_to_index = {
                    "devices": 0,
                    "effects": 1,
                    "presets": 2,
                    "triggers": 3,
                }
                landing_tab = param_to_index[args.open]
            except KeyError:
                # Not applicable
                pass

        if landing_tab >= 0 and landing_tab <= len(tab_objects):
            # Signal triggered upon switching tab in the widget.
            _change_tab_proxy(None, landing_tab)

            # Signal may not trigger if the starting page is already 0.
            if landing_tab == 0:
                _change_tab()

        # Bind actions to menu bar
        self.findChild(QAction, "actionQuitApp").triggered.connect(self.quit_app)

        # Disable tray applet actions if not installed
        if not procpid.ProcessManager().is_component_installed("tray-applet"):
            self.findChild(QAction, "actionRestartTrayApplet").setDisabled(True)

        # Warn if configuration is newer then this version.
        pref_ver = pref.VERSION
        save_ver = app.preferences["config_version"]
        if save_ver > pref_ver:
            details = "Yours: {1}\nExpected: <={2}\nApplication Version: v{0}".format(VERSION, save_ver, pref_ver)
            widgets.open_dialog(widgets.dialog_warning,
                                _("Save Data Version Mismatch"),
                                _("Polychromatic's configuration (including your effects and presets) have been previously saved in a newer version of this software.") + \
                                _("While this older software version may run as expected, there is no guarantee everything will work as a result of this newer save data. This installation is unsupported.") + \
                                _("Consider updating the application, ignore this message or delete: ~/.config/polychromatic"),
                                details=details)

        self._set_initial_window_position(self, "main")

        # Showtime!
        self.show()

    def quit_app(self, event=None, b=None):
        """
        Closes the main application window. This won't stop the execution
        entirely until the last editor window is closed.
        """
        # Save window position if preference set.
        if app.preferences["controller"]["window_behaviour"] == pref.WINDOW_BEHAVIOUR_REMEMBER:
            self._save_window_position(self, "main")

        dbg.stdout("Main window closed, goodbye!", dbg.success, 1)
        self.close()

    def keyPressEvent(self, e):
        """
        Pressing 'Alt' will reveal the menu bar.
        """
        if e.key() == QtCore.Qt.Key_Alt:
            self.menuBar().show()


def get_versions():
    """
    Returns a list of the application version and its components.
    """
    app_version, git_commit, py_version = common.get_versions(VERSION)

    versions = [
        [_("Application"), app_version],
        [_("Save Data"), str(pref.VERSION)],
        ["Python", py_version],
        ["Qt", QtCore.PYQT_VERSION_STR],
    ]

    if git_commit:
        versions.insert(1, ["Commit", git_commit])

    return versions


def parse_parameters():
    """
    Process the parameters passed to the application.
    """
    global _

    open_choices = [
        "devices",
        "effects",
        # TODO: Hide unavailable features
        #"presets",
        #"triggers",
        "preferences",
        "troubleshoot",
        "colours"
    ]

    parser = argparse.ArgumentParser(add_help=False)
    parser._optionals.title = _("Optional arguments")
    parser.add_argument("-h", "--help", help=_("Show this help message and exit"), action="help")
    parser.add_argument("--version", help=_("Print program version and exit"), action="store_true")
    parser.add_argument("-v", "--verbose", help=_("Be verbose to stdout"), action="store_true")
    parser.add_argument("--locale", help=_("Force a specific language, e.g. de_DE"), action="store")
    parser.add_argument("--open", help=_("Open a specific tab or feature"), action="store", choices=open_choices)

    args = parser.parse_args()

    if args.version:
        versions = get_versions()
        print("Polychromatic " + versions[0][1])
        del(versions[0])
        for version in versions:
            print("{0}: {1}".format(version[0], version[1]))
        exit(0)

    if args.verbose:
        dbg.verbose_level = 1

    if args.locale:
        global i18n
        i18n = locales.Locales(__file__, args.locale)
        _ = i18n.init()

    return args


if __name__ == "__main__":
    setproctitle.setproctitle("polychromatic-controller")

    dbg = common.Debugging()
    i18n = locales.Locales(__file__)
    _ = i18n.init()
    VERSIONS = get_versions()

    args = parse_parameters()
    pref.init(_)

    # Improve resolution for HiDPI displays
    HIDPI = False
    if hasattr(QtCore.Qt, "AA_EnableHighDpiScaling"):
        QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True)
        HIDPI = True

    if hasattr(QtCore.Qt, "AA_UseHighDpiPixmaps"):
        QApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True)

    qapp = QApplication(sys.argv)
    app = ApplicationData(qapp, dbg, _, common.paths, HIDPI)
    app.load()
    qapp.exec_()
