# -*- coding: utf-8 -*-

##########################################################################
# OpenLP - Open Source Lyrics Projection                                 #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2024 OpenLP Developers                              #
# ---------------------------------------------------------------------- #
# 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 <https://www.gnu.org/licenses/>. #
##########################################################################
"""
The :mod:`~openlp.core.display.window` module contains the display window
"""
import json
import logging
import os
import copy
import re

from PyQt5 import QtCore, QtWebChannel, QtWidgets

from openlp.core.common.enum import ServiceItemType
from openlp.core.common.i18n import translate
from openlp.core.common.mixins import LogMixin, RegistryProperties
from openlp.core.common.path import path_to_str
from openlp.core.common.platform import is_win, is_macosx
from openlp.core.common.registry import Registry
from openlp.core.common.utils import wait_for
from openlp.core.display.screens import ScreenList
from openlp.core.ui import HideMode


FONT_FOUNDRY = re.compile(r'(.*?) \[(.*?)\]')
TRANSITION_END_EVENT_NAME = 'transparent_transition_end'
log = logging.getLogger(__name__)


class DisplayWatcher(QtCore.QObject):
    """
    This facilitates communication from the Display object in the browser back to the Python
    """
    initialised = QtCore.pyqtSignal(bool)

    def __init__(self, parent, window_title=None):
        super().__init__()
        self._display_window = parent
        self._transient_dispatch_events = {}
        self._permanent_dispatch_events = {}
        self._event_counter = 0
        self._window_title = window_title

    @QtCore.pyqtSlot(result=str)
    def getWindowTitle(self):
        return self._window_title

    @QtCore.pyqtSlot(bool)
    def setInitialised(self, is_initialised):
        """
        This method is called from the JS in the browser to set the _is_initialised attribute
        """
        log.info('Display is initialised: {init}'.format(init=is_initialised))
        self.initialised.emit(is_initialised)

    @QtCore.pyqtSlot()
    def pleaseRepaint(self):
        """
        Called from the js in the webengine view when it's requesting a repaint by Qt
        """
        self._display_window.webview.update()

    @QtCore.pyqtSlot(str, 'QJsonObject')
    def dispatchEvent(self, event_name, event_data):
        """
        Called from the js in the webengine view for event dispatches
        """
        transient_dispatch_events = self._transient_dispatch_events
        permanent_dispatch_events = self._permanent_dispatch_events
        if event_name in transient_dispatch_events:
            event = transient_dispatch_events[event_name]
            del transient_dispatch_events[event_name]
            event(event_data)
        if event_name in permanent_dispatch_events:
            permanent_dispatch_events[event_name](event_data)

    def register_event_listener(self, event_name, callback, transient=True):
        """
        Register an event listener from webengine view
        :param event_name: Event name
        :param callback: Callback listener when event happens
        :param transient: If the event listener should be unregistered after being run
        """
        if transient:
            events = self._transient_dispatch_events
        else:
            events = self._permanent_dispatch_events

        events[event_name] = callback

    def unregister_event_listener(self, event_name, transient=True):
        """
        Unregisters an event listener from webengine view
        :param event_name: Event name
        :param transient: If the event listener was registered as transient
        """
        if transient:
            events = self._transient_dispatch_events
        else:
            events = self._permanent_dispatch_events

        if event_name in events:
            del events[event_name]

    def get_unique_event_name(self):
        """
        Generates an unique event name
        :returns: Unique event name
        """
        event_count = self._event_counter
        self._event_counter += 1
        return 'event_' + str(event_count)


class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin):
    """
    This is a window to show the output
    """
    def __init__(self, parent=None, screen=None, can_show_startup_screen=True, start_hidden=False,
                 after_loaded_callback=None, window_title=None):
        """
        Create the display window
        """
        super(DisplayWindow, self).__init__(parent)
        self.after_loaded_callback = after_loaded_callback
        # Gather all flags for the display window
        flags = QtCore.Qt.FramelessWindowHint | QtCore.Qt.Tool | QtCore.Qt.WindowStaysOnTopHint
        if self.settings.value('advanced/x11 bypass wm'):
            flags |= QtCore.Qt.X11BypassWindowManagerHint
        else:
            # This helps the window not being hidden by KDE's "Hide utility windows for inactive applications" option
            self.setAttribute(QtCore.Qt.WidgetAttribute.WA_X11NetWmWindowTypeDialog)
        if is_macosx():
            self.setAttribute(QtCore.Qt.WA_MacAlwaysShowToolWindow, True)
        self._is_initialised = False
        self._is_manual_close = False
        self._can_show_startup_screen = can_show_startup_screen
        self._fbo = None
        self.window_title = window_title
        self.setWindowTitle(translate('OpenLP.DisplayWindow', 'Display Window'))
        self.setWindowFlags(flags)
        self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
        self.setAutoFillBackground(True)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
        self.layout = QtWidgets.QVBoxLayout(self)
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.webview = self.init_webengine()
        self.webview.setAttribute(QtCore.Qt.WA_TranslucentBackground)
        self.webview.page().setBackgroundColor(QtCore.Qt.transparent)
        self.webview.display_clicked = self.disable_display
        self.layout.addWidget(self.webview)
        self.webview.loadFinished.connect(self.after_loaded)
        self.channel = QtWebChannel.QWebChannel(self)
        if not window_title and screen and screen.is_display:
            window_title = 'Display Window'
        self.display_watcher = DisplayWatcher(self, window_title)
        self.channel.registerObject('displayWatcher', self.display_watcher)
        self.webview.page().setWebChannel(self.channel)
        self.display_watcher.initialised.connect(self.on_initialised)
        self.openlp_splash_screen_path = 'openlp://display/openlp-splash-screen.png'
        # Using custom display if provided
        if Registry().has('display_custom_url') and Registry().get('display_custom_url') is not None:
            display_custom_url = Registry().get('display_custom_url')
            self.display_path = display_custom_url + '/display.html'
            self.checkerboard_path = display_custom_url + '/checkerboard.png'
            qUrl = QtCore.QUrl(display_custom_url)
        else:
            self.display_path = 'openlp://display/display.html'
            self.checkerboard_path = 'openlp://display/checkerboard.png'
            qUrl = QtCore.QUrl(self.display_path)
        self.set_url(qUrl)
        self.is_display = False
        self.scale = 1
        self.hide_mode = None
        self.__script_done = True
        self.__script_result = None
        if screen and screen.is_display:
            # use log_debug to set up function wrapping before registering functions
            self.log_debug('registering live display show/hide functions')
            Registry().register_function('live_display_hide', self.hide_display)
            Registry().register_function('live_display_show', self.show_display)
            self.update_from_screen(screen)
            self.is_display = True
            # Only make visible on single monitor setup if setting enabled.
            if not start_hidden and (len(ScreenList()) > 1 or self.settings.value('core/display on monitor')):
                self.show()

    def closeEvent(self, event):
        """
        Override the closeEvent method to prevent the window from being closed by the user
        """
        if not self._is_manual_close:
            event.ignore()

    def init_webengine(self):
        # Need to import this inline to get around a QtWebEngine issue
        from openlp.core.display.webengine import WebEngineView, WebViewSchemes, OpenLPScheme, OpenLPLibraryScheme
        webview = WebEngineView(self)
        profile = webview.page().profile()
        WebViewSchemes().get_scheme(OpenLPScheme.scheme_name).init_handler(profile)
        WebViewSchemes().get_scheme(OpenLPLibraryScheme.scheme_name).init_handler(profile)

        return webview

    def _fix_font_name(self, font_name):
        """
        Do some font machinations to see if we can fix the font name
        """
        # Some fonts on Windows that end in "Bold" are made into a base font that is bold
        if is_win() and font_name.endswith(' Bold'):
            font_name = font_name.split(' Bold')[0]
        # Some fonts may have the Foundry name in their name. Remove the foundry name
        match = FONT_FOUNDRY.match(font_name)
        if match:
            font_name = match.group(1)
        return font_name

    def deregister_display(self):
        """
        De-register this displays callbacks in the registry to be able to remove it
        """
        if self.is_display:
            Registry().remove_function('live_display_hide', self.hide_display)
            Registry().remove_function('live_display_show', self.show_display)
        self._is_manual_close = True

    @property
    def is_initialised(self):
        return self._is_initialised

    def on_initialised(self, is_initialised):
        """
        Update the initialised status
        """
        self._is_initialised = is_initialised

    def update_from_screen(self, screen):
        """
        Update the number and the geometry from the screen.

        :param Screen screen: A `~openlp.core.display.screens.Screen` instance
        """
        self.setGeometry(screen.display_geometry)
        self.screen_number = screen.number

    def set_background_image(self, image_path):
        image_uri = image_path.as_uri()
        self.run_in_display('setBackgroundImage', image_uri)

    def set_single_image(self, bg_color, image_path):
        """
        :param str bg_color: Background color
        :param Path image_path: Path to the image
        """
        image_uri = image_path.as_uri()
        self.run_in_display('setFullscreenImage', bg_color, image_uri)

    def set_single_image_data(self, bg_color, image_data):
        self.run_in_display('setFullscreenImageFromData', bg_color, image_data)

    def set_startup_screen(self):
        bg_color = self.settings.value('core/logo background color')
        image = self.settings.value('core/logo file')
        if path_to_str(image).startswith(':'):
            image_uri = self.openlp_splash_screen_path
        else:
            try:
                image_uri = image.as_uri().replace('file://', 'openlp-library://local-file/')
            except Exception:
                image_uri = ''
        # if set to hide logo on startup, do not send the logo
        if self.settings.value('core/logo hide on startup'):
            image_uri = ''
        self.run_in_display('setStartupSplashScreen', bg_color, image_uri)

    def set_url(self, url):
        """
        Set the URL of the webview

        :param QtCore.QUrl | str url: The URL to set
        """
        if not isinstance(url, QtCore.QUrl):
            url = QtCore.QUrl(url)
        self.webview.setUrl(url)

    def set_html(self, html):
        """
        Set the html
        """
        self.webview.setHtml(html)

    def after_loaded(self):
        """
        Add stuff after page initialisation
        """
        item_transitions = self.settings.value('themes/item transitions')
        hide_mouse = (self.settings.value('advanced/hide mouse') and self.is_display)
        slide_numbers_in_footer = self.settings.value('advanced/slide numbers in footer')
        self.run_in_display('init', {
                            'isDisplay': self.is_display,
                            'doItemTransitions': item_transitions,
                            'slideNumbersInFooter': slide_numbers_in_footer,
                            'hideMouse': hide_mouse,
                            'displayTitle': self.window_title
                            })
        wait_for(lambda: self._is_initialised)
        if self.scale != 1:
            self.set_scale(self.scale)
        if self._can_show_startup_screen:
            self.set_startup_screen()
        if self.after_loaded_callback:
            self.after_loaded_callback()

    def run_in_display(self, action, *parameters, raw_parameters=None, is_sync=False, return_event_name=None):
        if len(parameters):
            raw_parameters = ''
            first = True
            for parameter in parameters:
                if not first:
                    raw_parameters += ', '
                else:
                    first = False
                raw_parameters += json.dumps(parameter)
        action_name = 'requestAction'
        action_async = ''
        if return_event_name:
            action_name = 'requestActionAsync'
            action_async = ', \'{event_name}\''.format(event_name=return_event_name)
        if not raw_parameters:
            return self._run_javascript('{action_name}(\'{action}\'{action_async})'.format(action_name=action_name,
                                                                                           action=action,
                                                                                           action_async=action_async),
                                        is_sync)
        else:
            return self._run_javascript('{action_name}(\'{action}\'{action_async}, {raw_parameters})'
                                        .format(action_name=action_name, action=action, action_async=action_async,
                                                raw_parameters=raw_parameters),
                                        is_sync)

    def _run_javascript(self, script, is_sync=False):
        """
        Run some Javascript in the WebView

        :param script: The script to run, a string
        :param is_sync: Run the script synchronously. Defaults to False
        """
        log.debug((script[:80] + '..') if len(script) > 80 else script)
        # Wait for previous scripts to finish
        wait_for(lambda: self.__script_done)
        if is_sync:
            self.__script_done = False
            self.__script_result = None

            def handle_result(result):
                """
                Handle the result from the asynchronous call
                """
                self.__script_done = True
                self.__script_result = result

            self.webview.page().runJavaScript(script, handle_result)
            # Wait for script to finish
            if not wait_for(lambda: self.__script_done):
                self.__script_done = True
            return self.__script_result
        else:
            self.webview.page().runJavaScript(script)
        self.raise_()

    def go_to_slide(self, verse):
        """
        Go to a particular slide.

        :param str verse: The verse to go to, e.g. "V1" for songs, or just "0" for other types
        """
        self.run_in_display('goToSlide', verse)

    def load_verses(self, verses, is_sync=False):
        """
        Set verses in the display
        """
        self.run_in_display('setTextSlides', verses, is_sync=is_sync)

    def load_images(self, images):
        """
        Set images in the display
        """
        imagesr = copy.deepcopy(images)
        for image in imagesr:
            image['path'] = image['path'].as_uri()
            # Not all images has a dedicated thumbnail (such as images loaded from old or local servicefiles),
            # in that case reuse the image
            if image.get('thumbnail', None):
                image['thumbnail'] = image['thumbnail'].as_uri()
            else:
                image['thumbnail'] = image['path']
        self.run_in_display('setImageSlides', imagesr)

    def save_screenshot(self, fname=None):
        """
        Save a screenshot, either returning it or saving it to file
        """
        pixmap = self.grab()
        if fname:
            ext = os.path.splitext(fname)[-1][1:]
            pixmap.save(fname, ext)
        else:
            return pixmap

    def set_theme(self, theme, is_sync=False, service_item_type=False):
        """
        Set the theme of the display
        """
        theme_copy = copy.deepcopy(theme)
        if self.is_display:
            if service_item_type == ServiceItemType.Text:
                if theme.background_type == 'video' or theme.background_type == 'stream':
                    theme_copy.background_type = 'transparent'
        else:
            # If preview Display with media background we just show the background color, no media
            if theme.background_type == 'stream' or theme.background_type == 'video':
                theme_copy.background_type = 'solid'
                theme_copy.background_start_color = theme.background_border_color
                theme_copy.background_end_color = theme.background_border_color
                theme_copy.background_main_color = theme.background_border_color
                theme_copy.background_footer_color = theme.background_border_color
                theme_copy.background_color = theme.background_border_color
            # If preview Display for media so we need to display black box.
            elif service_item_type == ServiceItemType.Command or theme.background_type == 'live':
                theme_copy.background_type = 'solid'
                theme_copy.background_start_color = '#590909'
                theme_copy.background_end_color = '#590909'
                theme_copy.background_main_color = '#090909'
                theme_copy.background_footer_color = '#090909'
        # Do some font-checking, see https://gitlab.com/openlp/openlp/-/issues/39
        theme_copy.font_main_name = self._fix_font_name(theme.font_main_name)
        theme_copy.font_footer_name = self._fix_font_name(theme.font_footer_name)
        exported_theme = theme_copy.export_theme(is_js=True)
        self.run_in_display('setTheme', raw_parameters=exported_theme, is_sync=is_sync)

    def reload_theme(self):
        """
        Applies the set theme
        DO NOT use this when changing slides. Only use this if you need to force an update
        to the current visible slides.
        """
        self.run_in_display('resetTheme')

    def get_video_types(self):
        """
        Get the types of videos playable by the embedded media player
        """
        return self.run_in_display('getVideoTypes', is_sync=True)

    def show_display(self):
        """
        Show the display
        """
        if self.is_display:
            # Only make visible on single monitor setup if setting enabled.
            if len(ScreenList()) == 1 and not self.settings.value('core/display on monitor'):
                return
        # Aborting setVisible(False) call in case the display modes are changed quickly
        self.display_watcher.unregister_event_listener(TRANSITION_END_EVENT_NAME)
        if self.isHidden():
            self.setVisible(True)
        self.run_in_display('show')
        self.hide_mode = None

    def hide_display(self, mode=HideMode.Screen):
        """
        Hide the display by making all layers transparent Store the images so they can be replaced when required

        :param mode: How the screen is to be hidden
        """
        log.debug('hide_display mode = {mode:d}'.format(mode=mode))
        if self.is_display:
            # Only make visible on single monitor setup if setting enabled.
            if len(ScreenList()) == 1 and not self.settings.value('core/display on monitor'):
                return
        # Aborting setVisible(False) call in case the display modes are changed quickly
        self.display_watcher.unregister_event_listener(TRANSITION_END_EVENT_NAME)
        # Update display to the selected mode
        if mode != HideMode.Screen:
            if self.isHidden():
                self.setVisible(True)
        if mode == HideMode.Screen:
            if self.settings.value('advanced/disable transparent display'):
                # Hide window only after all webview CSS ransitions are done
                self.display_watcher.register_event_listener(TRANSITION_END_EVENT_NAME,
                                                             lambda _: self.setVisible(False))
                self.run_in_display('toTransparent', return_event_name=TRANSITION_END_EVENT_NAME)
            else:
                self.run_in_display('toTransparent')
        elif mode == HideMode.Blank:
            self.run_in_display('toBlack')
        elif mode == HideMode.Theme:
            self.run_in_display('toTheme')
        self.hide_mode = mode

    def disable_display(self):
        """
        Removes the display if showing desktop
        This allows users to click though the screen even if the screen is only transparent
        """
        if self.is_display and self.hide_mode == HideMode.Screen:
            self.setVisible(False)

    def finish_with_current_item(self):
        """
        This is called whenever the song/image display is followed by eg a presentation or video which
        has its own display.
        This function ensures that the current item won't flash momentarily when the webengineview
        is displayed for a subsequent song or image.
        """
        self.run_in_display('finishWithCurrentItem', is_sync=True)
        self.webview.update()

    def set_scale(self, scale):
        """
        Set the HTML scale
        """
        self.scale = scale
        # Only scale if initialised (scale run again once initialised)
        if self._is_initialised:
            self.run_in_display('setScale', scale * 100)

    def alert(self, text, settings):
        """
        Set an alert
        """
        self._run_javascript('Display.alert("{text}", {settings});'.format(text=text, settings=settings))

    @QtCore.pyqtSlot(result='QPixmap')
    def _grab_screenshot_safe_signal(self):
        return self.save_screenshot()

    def grab_screenshot_safe(self):
        # Using internal Qt's messaging/event system to invoke the function.
        return QtCore.QMetaObject.invokeMethod(self, '_grab_screenshot_safe_signal',
                                               QtCore.Qt.ConnectionType.AutoConnection,
                                               QtCore.Q_RETURN_ARG('QPixmap'))
