#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Author: Bertrand256
# Created on: 2017-04
import logging
import time
from PyQt5 import QtWidgets, QtCore
from PyQt5.QtCore import Qt, QEventLoop, QPoint
from PyQt5.QtCore import QThread
from PyQt5.QtWidgets import QDialog, QLabel
from typing import Optional, Callable

from common import CancelException
from ui import ui_thread_fun_dlg


class ThreadFunDlg(QtWidgets.QDialog, ui_thread_fun_dlg.Ui_ThreadFunDlg):
    """
    Some of the DMT's features require quite a long time to complete. Performing this in a main thread
    causes the app to behave as if it hung. In such situations it is better to display a dialog with
    some information about the progress of the task.
    This class is such a dialog window - it takes a reference to function/method performing a long-running task. 
    That function is executed inside a thread, controlled by a dialog and has the possibility of updating dialog's 
    text and/or progressbar through a special control object passed to it as an argument.
    
    Creating dialog for long running function:
    arg1 = 'test'
    ui = ThreadFunDlg(long_running_function, (arg1,), close_after_finish=True, 
        buttons=[{'caption': 'Break', 'role': QtWidgets.QDialogButtonBox.RejectRole}])
    ui.exec_()
    res = ui.getResult()

    Example of a worker function:
        def long_running_function(ctrl, arg1):
            ctrl.dlg_config_fun(dlg_title="Long running task...", show_message=True, show_progress_bar=True)
            ctrl.display_msg_fun('test %d' % i)
            ctrl.set_progress_value_fun(50)
            time.sleep(10)
            if ctrl.finish:  # if using a loop you should periodically check if the user is willing to breake the task
                return
            return 'return value'
    """

    # signal for display message, args: message text:
    display_msg_signal = QtCore.pyqtSignal(str)

    # sets a dialog's progress bar's value
    set_progress_value_signal = QtCore.pyqtSignal(int)

    show_window_signal = QtCore.pyqtSignal(bool)

    # signal to configure dialog, args: (bool) show message text (default True), (bool) show progress bar,
    # (int) window maximum width
    # (default false):
    dlg_config_signal = QtCore.pyqtSignal(object, object, object, object)

    def __init__(self, worker_fun, worker_args, close_after_finish=True, buttons=None, title='', text=None,
                 center_by_window=None,
                 force_close_dlg_callback: Optional[Callable[[None], bool]]=None,
                 show_window_delay_ms: Optional[int] = 0):
        """
        Constructor.
        :param worker_fun: reference to an external method which is to be run in background
        :param worker_args: tuple with arguments passed to worker_fun
        :param close_after_finish: True, if dialog has to be closed after finishing worker_fun
        :param buttons: list of button definition to be created on the bottom of the dialog;
            Each of the elements can be:
              - a dict  {'std_btn': QtWidgets.QDialogButtonBox.StandardButton for example: QDialogButtonBox.Cancel,
                         'callback': callback_function (not mandatory)}
              - a dict {'caption': "Button caption", 
                        'role': QtWidgets.QDialogButtonBox.ButtonRole,
                         'callback': callback_function (not mandatory)}
            'callback': function executed on specific button click event; id not specified, click event 
                results in 'accept' or 'reject' event, depending on butotn's role. 
        :param title: title of the dialog
        :param text: initial text to display
        :param center_by_window: True, if this dialog is to be centered by window 'center_by_window'
        :param force_close_dlg_callback: non mandatory callback function called when a user tries to close window
            while the associated thread hasn't finished yet; the callback function can for example ask the user if
            he/she really wants to break the underlying process (this means leaving the thread alone and closing the
            window) or apply a little more civilized approach like causing the termination ofthe underlying thread if
            possible; if the callback function returns True it means that there is consent to abandon thread and
            close the window
        :param show_window_delay_ms:
           -1: the window is initially hidden; can be shown only by emitting the 'show_window_signal' signal
           or explicitly calling the 'show' method
           >=0 the will be shown after the 'value' miliseconds after calling the 'wait_for_worker_completion'
           method
        """
        QtWidgets.QDialog.__init__(self, parent=center_by_window)
        ui_thread_fun_dlg.Ui_ThreadFunDlg.__init__(self)
        self.worker_fun = worker_fun
        self.worker_args = worker_args
        self.close_after_finish = close_after_finish
        self.force_close_dlg_callback = force_close_dlg_callback
        self.buttons = buttons
        self.setTextCalled = False
        self.title = title
        self.text = text
        self.show_window_delay_ms = show_window_delay_ms
        self.max_width = None
        self.worker_thread: 'WorkerDlgThread' = None
        self.center_by_window = center_by_window
        self.setupUi()

    def setupUi(self):
        ui_thread_fun_dlg.Ui_ThreadFunDlg.setupUi(self, self)
        self.setWindowFlags(self.windowFlags() | Qt.CustomizeWindowHint)
        # self.setWindowFlags(self.windowFlags() & ~Qt.WindowCloseButtonHint)
        self.setWindowTitle(self.title)
        self.display_msg_signal.connect(self.setText)
        self.dlg_config_signal.connect(self.onConfigureDialog)
        self.show_window_signal.connect(self.onShowWindow)
        self.set_progress_value_signal.connect(self.setProgressValue)
        self.closeEvent = self.closeEvent
        # self.btnBox.accepted.connect(self.accept)
        # self.btnBox.rejected.connect(self.reject)
        self.progressBar.setVisible(False)
        self.btnBox.clear()
        self.btnBox.setCenterButtons(True)

        if self.buttons:
            for btn in self.buttons:
                assert isinstance(btn, dict)
                if btn.get('std_btn'):
                    b = self.btnBox.addButton(btn.get('std_btn'))
                elif btn.get('caption'):
                    if not btn.get('role'):
                        raise Exception("Button's role is mandatory")
                    b = self.btnBox.addButton(btn.get('caption'), btn.get('role'))
                else:
                    continue
                if btn.get('callback'):
                    b.clicked.connect(btn.get('callback'))
        if self.worker_fun:
            self.worker_thread = WorkerDlgThread(self, self.worker_fun, self.worker_args,
                                                 display_msg_signal=self.display_msg_signal,
                                                 set_progress_value_signal=self.set_progress_value_signal,
                                                 dlg_config_signal=self.dlg_config_signal,
                                                 show_dialog_signal=self.show_window_signal)
            self.worker_thread.finished.connect(self.threadFinished)

            # the user method controlled by the worker thread may need to access the widget
            # displaying a feedback for its non-standard purposes, so we expose it through
            # control object which is passed to that method
            self.worker_thread.ctrl_obj.set_msg_label(self.lblText)

        self.worker_result = None
        self.worker_exception = None
        if self.text:
            self.setText(self.text)
        if self.center_by_window:
            self.centerByWindow(self.center_by_window)
        if self.worker_fun:
            self.thread_running = True
            self.worker_thread.start()
        else:
            self.thread_running = False

    def getResult(self):
        return self.worker_result

    def setText(self, text):
        """
        Displays text on dialog.
        :param text: Text to be displayed.
        """
        if not self.setTextCalled:
            self.layout().setSizeConstraint(3)  # QLayout::SetFixedSize
            self.setTextCalled = True
        self.lblText.setText(text)

        # width = self.lblText.fontMetrics().boundingRect(text).width()
        # if self.max_width and width > self.max_width:
        #     width = self.max_width
        # self.lblText.setFixedWidth(width)

        # QtWidgets.qApp.processEvents(QEventLoop.ExcludeUserInputEvents)
        self.centerByWindow(self.center_by_window)

    def setProgressValue(self, value):
        self.progressBar.setValue(value)

    def onShowWindow(self, show: bool):
        if show:
            self.show()
        else:
            self.hide()

    def centerByWindow(self, center_by_window: QDialog):
        """
        Centers this window by window given by attribute 'center_by_window'
        :param center_by_window: Reference to (parent) window by wich this window will be centered.
        :return: None
        """
        if self.center_by_window:
            pg: QPoint = center_by_window.frameGeometry().topLeft()
            size_diff = center_by_window.rect().center() - self.rect().center()
            pg.setX( pg.x() + int((size_diff.x())))
            pg.setY( pg.y() + int((size_diff.y())))
            self.move(pg)

    def onConfigureDialog(self, show_message=None, show_progress_bar=None, dlg_title=None, max_width=None):
        """
        Configure visibility of this dialog's elements. This method can be called from inside a thread by calling
        signal dlg_config_signal passed inside control dicttionary.
        :param show_message: True if text area is to be shown
        :param show_progress_bar: True if progress bar is to be shown
        """
        if show_message:
            self.lblText.setVisible(show_message)
        if show_progress_bar:
            self.progressBar.setVisible(show_progress_bar)
        if dlg_title:
            self.setWindowTitle(dlg_title)
        if max_width is not None:
            self.max_width = max_width
            self.lblText.setWordWrap(True)
        else:
            self.lblText.setWordWrap(False)

    def setWorkerResults(self, result, exception):
        self.worker_result = result
        self.worker_exception = exception

    def threadFinished(self):
        self.thread_running = False
        work = self.worker_thread
        self.worker_thread = None
        del work
        if self.close_after_finish:
            self.accept()

    def waitForTerminate(self):
        if self.thread_running:
            self.worker_thread.stop()
            if self.force_close_dlg_callback is not None:
                try:
                    finish = self.force_close_dlg_callback()
                    if finish:
                        # user's decision to force close the window; probably something went wrong
                        # and some underlying process hung
                        self.thread_running = False
                        return
                except CancelException as e:
                    self.worker_exception = e
                    return
                except Exception as e:
                    self.worker_exception = e
                    return
            self.worker_thread.wait()

    def closeEvent(self, event):
        if self.thread_running:
            self.worker_thread.currentThreadId()
            self.waitForTerminate()

    def accept(self):
        self.waitForTerminate()
        self.close()

    def reject(self):
        self.waitForTerminate()
        self.close()

    def wait_for_worker_completion(self):
        start_time = time.time()
        shown = False
        while self.thread_running:
            if not shown and self.show_window_delay_ms >= 0 and (time.time() - start_time) * 1000 >= self.show_window_delay_ms:
                self.show()
                shown = True
            QtWidgets.qApp.processEvents()
            if self.worker_thread:
                self.worker_thread.wait(100)
            else:
                break


class CtrlObject(object):
    def __init__(self):
        self.display_msg_fun: Callable[[str], None] = None
        self.set_progress_value_fun: Callable[[int], None] = None
        self.dlg_config_fun: Callable[[bool, bool, str, int],None] = None
        self.show_dialog_fun: Callable[[bool], None] = None
        self.finish: bool = False
        self.__msg_label = None

    def get_msg_label_control(self) -> QLabel:
        return self.__msg_label

    def set_msg_label(self, label: QLabel):
        self.__msg_label = label


class WorkerDlgThread(QThread):
    """
    Class dedicated for running external method (worker_fun) in the background with a dialog (ThreadFunDlg) 
    on the foreground. Dialog's purpose is to display messages and/or progress bar according to the information
    sent by external thread function (worker_fun) by calling callback functions passed to it.
    """

    def __init__(self, dialog, worker_fun, worker_fun_args, display_msg_signal, set_progress_value_signal,
                 dlg_config_signal, show_dialog_signal):
        """
        Constructor.
        :param worker_fun: external function which will be executed from inside a thread 
        :param worker_fun_args: dictionary passed to worker_fun as it's argument's
        :param display_msg_signal: signal from owner's dialog to display text
        :param set_progress_value_signal: signal from owner's dialog to set a progressbar's value
        :param dlg_config_signal: signal from owner's dialog to configure dialog
        """
        super(WorkerDlgThread, self).__init__()
        self.dialog = dialog
        self.worker_fun = worker_fun
        self.worker_fun_args = worker_fun_args
        self.display_msg_signal = display_msg_signal
        self.set_progress_value_signal = set_progress_value_signal
        self.show_dialog_signal = show_dialog_signal
        self.dlg_config_signal = dlg_config_signal

        # prepare control object passed to a thread function
        self.ctrl_obj = CtrlObject()
        self.ctrl_obj.display_msg_fun = self.display_msg
        self.ctrl_obj.msg_link_activated_callback = None
        self.ctrl_obj.set_progress_value_fun = self.set_progress_value
        self.ctrl_obj.dlg_config_fun = self.dlg_config
        self.ctrl_obj.show_dialog_fun = self.show_dialog
        self.ctrl_obj.finish = False

    def display_msg(self, msg):
        """
        Called from a thread: displays a new message text.
        :param msg: text
        """
        self.display_msg_signal.emit(msg)

    def set_progress_value(self, value):
        """
        Called from a thread: sets progressbar's value
        :param value: new value
        """
        self.set_progress_value_signal.emit(value)

    def dlg_config(self, show_message=None, show_progress_bar=None, dlg_title=None, max_width=None):
        """
        Called from a thread function: configures dialog by sending a dediacted signal to a dialog class.
        :param show_message: True if dialog's text area is to be shown.
        :param show_progress_bar: True if dialog's progress bar is to be shown.
        :param dlg_title: New text to show on dialogs title bar.
        """
        self.dlg_config_signal.emit(show_message, show_progress_bar, dlg_title, max_width)

    def show_dialog(self, show: bool):
        self.show_dialog_signal.emit(show)

    def stop(self):
        """
        Sets information in control object that thread should finish its work as soon as possible.
        Finish attribute should be checked by a thread periodically.
        """
        self.ctrl_obj.finish = True

    def run(self):
        try:
            worker_result = self.worker_fun(self.ctrl_obj, *self.worker_fun_args)
            self.dialog.setWorkerResults(worker_result, None)
        except Exception as e:
            self.dialog.setWorkerResults(None, e)


class WorkerThread(QThread):
    """
    Helper class for running function inside a thread.
    """

    def __init__(self, parent, worker_fun, worker_fun_args):
        """
        """
        QThread.__init__(self, parent=parent)
        self.worker_fun = worker_fun
        self.worker_fun_args = worker_fun_args

        # prepare control object passed to external thread function
        self.ctrl_obj = CtrlObject()
        self.ctrl_obj.finish = False
        self.worker_result = None
        self.worker_exception = None

    def stop(self):
        """
        Sets information in control object that thread should finish its work as soon as possible.
        Finish attribute should be checked by a thread periodically.
        """
        self.ctrl_obj.finish = True

    def run(self):
        try:
            self.worker_result = self.worker_fun(self.ctrl_obj, *self.worker_fun_args)
        except Exception as e:
            self.worker_exception = e