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

# Copyright (c) 2002 - 2014 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing the UI to the pyunit package.
"""

import unittest
import sys
import time
import re
import os

from PyQt4.QtCore import pyqtSignal, QEvent, Qt, pyqtSlot
from PyQt4.QtGui import QWidget, QColor, QDialog, QApplication, \
    QDialogButtonBox, QListWidgetItem

from E5Gui.E5Application import e5App
from E5Gui.E5Completers import E5FileCompleter
from E5Gui import E5MessageBox, E5FileDialog
from E5Gui.E5MainWindow import E5MainWindow

from .Ui_UnittestDialog import Ui_UnittestDialog

import UI.PixmapCache

import Utilities
import Preferences


class UnittestDialog(QWidget, Ui_UnittestDialog):
    """
    Class implementing the UI to the pyunit package.
    
    @signal unittestFile(str, int, int) emitted to show the source of a
        unittest file
    @signal unittestStopped() emitted after a unit test was run
    """
    unittestFile = pyqtSignal(str, int, int)
    unittestStopped = pyqtSignal()
    
    def __init__(self, prog=None, dbs=None, ui=None, fromEric=False,
                 parent=None, name=None):
        """
        Constructor
        
        @param prog filename of the program to open
        @param dbs reference to the debug server object. It is an indication
            whether we were called from within the eric5 IDE
        @param ui reference to the UI object
        @param fromEric flag indicating an instantiation from within the
            eric IDE (boolean)
        @param parent parent widget of this dialog (QWidget)
        @param name name of this dialog (string)
        """
        super().__init__(parent)
        if name:
            self.setObjectName(name)
        self.setupUi(self)
        
        self.startButton = self.buttonBox.addButton(
            self.trUtf8("Start"), QDialogButtonBox.ActionRole)
        self.startButton.setToolTip(self.trUtf8(
            "Start the selected testsuite"))
        self.startButton.setWhatsThis(self.trUtf8(
            """<b>Start Test</b>"""
            """<p>This button starts the selected testsuite.</p>"""))
        self.startFailedButton = self.buttonBox.addButton(
            self.trUtf8("Rerun Failed"), QDialogButtonBox.ActionRole)
        self.startFailedButton.setToolTip(
            self.trUtf8("Reruns failed tests of the selected testsuite"))
        self.startFailedButton.setWhatsThis(self.trUtf8(
            """<b>Rerun Failed</b>"""
            """<p>This button reruns all failed tests of the selected"""
            """ testsuite.</p>"""))
        self.stopButton = self.buttonBox.addButton(
            self.trUtf8("Stop"), QDialogButtonBox.ActionRole)
        self.stopButton.setToolTip(self.trUtf8("Stop the running unittest"))
        self.stopButton.setWhatsThis(self.trUtf8(
            """<b>Stop Test</b>"""
            """<p>This button stops a running unittest.</p>"""))
        self.stopButton.setEnabled(False)
        self.startButton.setDefault(True)
        self.startFailedButton.setEnabled(False)
        
        self.dbs = dbs
        self.__fromEric = fromEric
        
        self.setWindowFlags(
            self.windowFlags() | Qt.WindowFlags(
                Qt.WindowContextHelpButtonHint))
        self.setWindowIcon(UI.PixmapCache.getIcon("eric.png"))
        self.setWindowTitle(self.trUtf8("Unittest"))
        if dbs:
            self.ui = ui
        else:
            self.localCheckBox.hide()
        self.__setProgressColor("green")
        self.progressLed.setDarkFactor(150)
        self.progressLed.off()
        
        self.testSuiteCompleter = E5FileCompleter(self.testsuiteComboBox)
        
        self.fileHistory = []
        self.testNameHistory = []
        self.running = False
        self.savedModulelist = None
        self.savedSysPath = sys.path
        if prog:
            self.insertProg(prog)
        
        self.rxPatterns = [
            self.trUtf8("^Failure: "),
            self.trUtf8("^Error: "),
        ]
        
        self.__failedTests = []
        
        # now connect the debug server signals if called from the eric5 IDE
        if self.dbs:
            self.dbs.utPrepared.connect(self.__UTPrepared)
            self.dbs.utFinished.connect(self.__setStoppedMode)
            self.dbs.utStartTest.connect(self.testStarted)
            self.dbs.utStopTest.connect(self.testFinished)
            self.dbs.utTestFailed.connect(self.testFailed)
            self.dbs.utTestErrored.connect(self.testErrored)
            self.dbs.utTestSkipped.connect(self.testSkipped)
            self.dbs.utTestFailedExpected.connect(self.testFailedExpected)
            self.dbs.utTestSucceededUnexpected.connect(
                self.testSucceededUnexpected)
    
    def keyPressEvent(self, evt):
        """
        Protected slot to handle key press events.
        
        @param evt key press event to handle (QKeyEvent)
        """
        if evt.key() == Qt.Key_Escape and self.__fromEric:
            self.close()
    
    def __setProgressColor(self, color):
        """
        Private methode to set the color of the progress color label.
        
        @param color colour to be shown (string)
        """
        self.progressLed.setColor(QColor(color))
        
    def insertProg(self, prog):
        """
        Public slot to insert the filename prog into the testsuiteComboBox
        object.
        
        @param prog filename to be inserted (string)
        """
        # prepend the selected file to the testsuite combobox
        if prog is None:
            prog = ""
        if prog in self.fileHistory:
            self.fileHistory.remove(prog)
        self.fileHistory.insert(0, prog)
        self.testsuiteComboBox.clear()
        self.testsuiteComboBox.addItems(self.fileHistory)
        
    def insertTestName(self, testName):
        """
        Public slot to insert a test name into the testComboBox object.
        
        @param testName name of the test to be inserted (string)
        """
        # prepend the selected file to the testsuite combobox
        if testName is None:
            testName = ""
        if testName in self.testNameHistory:
            self.testNameHistory.remove(testName)
        self.testNameHistory.insert(0, testName)
        self.testComboBox.clear()
        self.testComboBox.addItems(self.testNameHistory)
        
    @pyqtSlot()
    def on_fileDialogButton_clicked(self):
        """
        Private slot to open a file dialog.
        """
        if self.dbs:
            py2Extensions = \
                ' '.join(["*{0}".format(ext)
                          for ext in self.dbs.getExtensions('Python2')])
            py3Extensions = \
                ' '.join(["*{0}".format(ext)
                          for ext in self.dbs.getExtensions('Python3')])
            filter = self.trUtf8(
                "Python3 Files ({1});;Python2 Files ({0});;All Files (*)")\
                .format(py2Extensions, py3Extensions)
        else:
            filter = self.trUtf8("Python Files (*.py);;All Files (*)")
        prog = E5FileDialog.getOpenFileName(
            self,
            "",
            self.testsuiteComboBox.currentText(),
            filter)
        
        if not prog:
            return
        
        self.insertProg(Utilities.toNativeSeparators(prog))
        
    @pyqtSlot(str)
    def on_testsuiteComboBox_editTextChanged(self, txt):
        """
        Private slot to handle changes of the test file name.
        
        @param txt name of the test file (string)
        """
        if self.dbs:
            exts = self.dbs.getExtensions("Python2")
            flags = Utilities.extractFlagsFromFile(txt)
            if txt.endswith(exts) or \
               ("FileType" in flags and
                    flags["FileType"] in ["Python", "Python2"]):
                self.coverageCheckBox.setChecked(False)
                self.coverageCheckBox.setEnabled(False)
                self.localCheckBox.setChecked(False)
                self.localCheckBox.setEnabled(False)
                return
        
        self.coverageCheckBox.setEnabled(True)
        self.localCheckBox.setEnabled(True)
        
    def on_buttonBox_clicked(self, button):
        """
        Private slot called by a button of the button box clicked.
        
        @param button button that was clicked (QAbstractButton)
        """
        if button == self.startButton:
            self.on_startButton_clicked()
        elif button == self.stopButton:
            self.on_stopButton_clicked()
        elif button == self.startFailedButton:
            self.on_startButton_clicked(failedOnly=True)
        
    @pyqtSlot()
    def on_startButton_clicked(self, failedOnly=False):
        """
        Public slot to start the test.
        
        @keyparam failedOnly flag indicating to run only failed tests (boolean)
        """
        if self.running:
            return
        
        prog = self.testsuiteComboBox.currentText()
        if not prog:
            E5MessageBox.critical(
                self,
                self.trUtf8("Unittest"),
                self.trUtf8("You must enter a test suite file."))
            return
        
        # prepend the selected file to the testsuite combobox
        self.insertProg(prog)
        self.sbLabel.setText(self.trUtf8("Preparing Testsuite"))
        QApplication.processEvents()
        
        testFunctionName = self.testComboBox.currentText()
        if testFunctionName:
            self.insertTestName(testFunctionName)
        else:
            testFunctionName = "suite"
        
        # build the module name from the filename without extension
        self.testName = os.path.splitext(os.path.basename(prog))[0]
        
        if self.dbs and not self.localCheckBox.isChecked():
            # we are cooperating with the eric5 IDE
            project = e5App().getObject("Project")
            if project.isOpen() and project.isProjectSource(prog):
                mainScript = project.getMainScript(True)
                clientType = project.getProjectLanguage()
            else:
                mainScript = os.path.abspath(prog)
                flags = Utilities.extractFlagsFromFile(mainScript)
                if mainScript.endswith(
                    tuple(Preferences.getPython("PythonExtensions"))) or \
                   ("FileType" in flags and
                        flags["FileType"] in ["Python", "Python2"]):
                    clientType = "Python2"
                else:
                    clientType = ""
            if failedOnly and self.__failedTests:
                failed = [t.split(".", 1)[1] for t in self.__failedTests]
            else:
                failed = []
            self.__failedTests = []
            self.dbs.remoteUTPrepare(
                prog, self.testName, testFunctionName, failed,
                self.coverageCheckBox.isChecked(), mainScript,
                self.coverageEraseCheckBox.isChecked(), clientType=clientType)
        else:
            # we are running as an application or in local mode
            sys.path = [os.path.dirname(os.path.abspath(prog))] + \
                self.savedSysPath
            
            # clean up list of imported modules to force a reimport upon
            # running the test
            if self.savedModulelist:
                for modname in list(sys.modules.keys()):
                    if modname not in self.savedModulelist:
                        # delete it
                        del(sys.modules[modname])
            self.savedModulelist = sys.modules.copy()
            
            # now try to generate the testsuite
            try:
                module = __import__(self.testName)
                try:
                    if failedOnly and self.__failedTests:
                        test = unittest.defaultTestLoader.loadTestsFromNames(
                            [t.split(".", 1)[1] for t in self.__failedTests],
                            module)
                    else:
                        test = unittest.defaultTestLoader.loadTestsFromName(
                            testFunctionName, module)
                except AttributeError:
                    test = unittest.defaultTestLoader.loadTestsFromModule(
                        module)
            except:
                exc_type, exc_value, exc_tb = sys.exc_info()
                E5MessageBox.critical(
                    self,
                    self.trUtf8("Unittest"),
                    self.trUtf8(
                        "<p>Unable to run test <b>{0}</b>.<br>"
                        "{1}<br>{2}</p>")
                    .format(self.testName, str(exc_type),
                            str(exc_value)))
                return
                
            # now set up the coverage stuff
            if self.coverageCheckBox.isChecked():
                if self.dbs:
                    # we are cooperating with the eric5 IDE
                    project = e5App().getObject("Project")
                    if project.isOpen() and project.isProjectSource(prog):
                        mainScript = project.getMainScript(True)
                        if not mainScript:
                            mainScript = os.path.abspath(prog)
                    else:
                        mainScript = os.path.abspath(prog)
                else:
                    mainScript = os.path.abspath(prog)
                
                from DebugClients.Python3.coverage import coverage
                cover = coverage(
                    data_file="{0}.coverage".format(
                        os.path.splitext(mainScript)[0]))
                cover.use_cache(True)
                if self.coverageEraseCheckBox.isChecked():
                    cover.erase()
            else:
                cover = None
            
            self.testResult = QtTestResult(self)
            self.totalTests = test.countTestCases()
            self.__failedTests = []
            self.__setRunningMode()
            if cover:
                cover.start()
            test.run(self.testResult)
            if cover:
                cover.stop()
                cover.save()
            self.__setStoppedMode()
            sys.path = self.savedSysPath
        
    def __UTPrepared(self, nrTests, exc_type, exc_value):
        """
        Private slot to handle the utPrepared signal.
        
        If the unittest suite was loaded successfully, we ask the
        client to run the test suite.
        
        @param nrTests number of tests contained in the test suite (integer)
        @param exc_type type of exception occured during preparation (string)
        @param exc_value value of exception occured during preparation (string)
        """
        if nrTests == 0:
            E5MessageBox.critical(
                self,
                self.trUtf8("Unittest"),
                self.trUtf8(
                    "<p>Unable to run test <b>{0}</b>.<br>{1}<br>{2}</p>")
                .format(self.testName, exc_type, exc_value))
            return
            
        self.totalTests = nrTests
        self.__setRunningMode()
        self.dbs.remoteUTRun()
        
    @pyqtSlot()
    def on_stopButton_clicked(self):
        """
        Private slot to stop the test.
        """
        if self.dbs and not self.localCheckBox.isChecked():
            self.dbs.remoteUTStop()
        elif self.testResult:
            self.testResult.stop()
            
    def on_errorsListWidget_currentTextChanged(self, text):
        """
        Private slot to handle the highlighted signal.
        
        @param text current text (string)
        """
        if text:
            for pattern in self.rxPatterns:
                text = re.sub(pattern, "", text)
            itm = self.testsListWidget.findItems(
                text, Qt.MatchFlags(Qt.MatchExactly))[0]
            self.testsListWidget.setCurrentItem(itm)
            self.testsListWidget.scrollToItem(itm)
        
    def __setRunningMode(self):
        """
        Private method to set the GUI in running mode.
        """
        self.running = True
        
        # reset counters and error infos
        self.runCount = 0
        self.failCount = 0
        self.errorCount = 0
        self.skippedCount = 0
        self.expectedFailureCount = 0
        self.unexpectedSuccessCount = 0
        self.remainingCount = self.totalTests

        # reset the GUI
        self.progressCounterRunCount.setText(str(self.runCount))
        self.progressCounterRemCount.setText(str(self.remainingCount))
        self.progressCounterFailureCount.setText(str(self.failCount))
        self.progressCounterErrorCount.setText(str(self.errorCount))
        self.progressCounterSkippedCount.setText(str(self.skippedCount))
        self.progressCounterExpectedFailureCount.setText(
            str(self.expectedFailureCount))
        self.progressCounterUnexpectedSuccessCount.setText(
            str(self.unexpectedSuccessCount))
        self.errorsListWidget.clear()
        self.testsListWidget.clear()
        self.progressProgressBar.setRange(0, self.totalTests)
        self.__setProgressColor("green")
        self.progressProgressBar.reset()
        self.stopButton.setEnabled(True)
        self.startButton.setEnabled(False)
        self.stopButton.setDefault(True)
        self.sbLabel.setText(self.trUtf8("Running"))
        self.progressLed.on()
        QApplication.processEvents()
        
        self.startTime = time.time()
        
    def __setStoppedMode(self):
        """
        Private method to set the GUI in stopped mode.
        """
        self.stopTime = time.time()
        self.timeTaken = float(self.stopTime - self.startTime)
        self.running = False
        
        self.startButton.setEnabled(True)
        self.startFailedButton.setEnabled(bool(self.__failedTests))
        self.stopButton.setEnabled(False)
        if self.__failedTests:
            self.startFailedButton.setDefault(True)
            self.startButton.setDefault(False)
        else:
            self.startFailedButton.setDefault(False)
            self.startButton.setDefault(True)
        if self.runCount == 1:
            self.sbLabel.setText(self.trUtf8("Ran {0} test in {1:.3f}s")
                .format(self.runCount, self.timeTaken))
        else:
            self.sbLabel.setText(self.trUtf8("Ran {0} tests in {1:.3f}s")
                .format(self.runCount, self.timeTaken))
        self.progressLed.off()
        
        self.unittestStopped.emit()

    def testFailed(self, test, exc, id):
        """
        Public method called if a test fails.
        
        @param test name of the test (string)
        @param exc string representation of the exception (string)
        @param id id of the test (string)
        """
        self.failCount += 1
        self.progressCounterFailureCount.setText(str(self.failCount))
        itm = QListWidgetItem(self.trUtf8("Failure: {0}").format(test))
        itm.setData(Qt.UserRole, (test, exc))
        self.errorsListWidget.insertItem(0, itm)
        self.__failedTests.append(id)
        
    def testErrored(self, test, exc, id):
        """
        Public method called if a test errors.
        
        @param test name of the test (string)
        @param exc string representation of the exception (string)
        @param id id of the test (string)
        """
        self.errorCount += 1
        self.progressCounterErrorCount.setText(str(self.errorCount))
        itm = QListWidgetItem(self.trUtf8("Error: {0}").format(test))
        itm.setData(Qt.UserRole, (test, exc))
        self.errorsListWidget.insertItem(0, itm)
        self.__failedTests.append(id)
        
    def testSkipped(self, test, reason, id):
        """
        Public method called if a test was skipped.
        
        @param test name of the test (string)
        @param reason reason for skipping the test (string)
        @param id id of the test (string)
        """
        self.skippedCount += 1
        self.progressCounterSkippedCount.setText(str(self.skippedCount))
        itm = QListWidgetItem(self.trUtf8("    Skipped: {0}").format(reason))
        itm.setForeground(Qt.blue)
        self.testsListWidget.insertItem(1, itm)
        
    def testFailedExpected(self, test, exc, id):
        """
        Public method called if a test fails expectedly.
        
        @param test name of the test (string)
        @param exc string representation of the exception (string)
        @param id id of the test (string)
        """
        self.expectedFailureCount += 1
        self.progressCounterExpectedFailureCount.setText(
            str(self.expectedFailureCount))
        itm = QListWidgetItem(self.trUtf8("    Expected Failure"))
        itm.setForeground(Qt.blue)
        self.testsListWidget.insertItem(1, itm)
        
    def testSucceededUnexpected(self, test, id):
        """
        Public method called if a test succeeds unexpectedly.
        
        @param test name of the test (string)
        @param id id of the test (string)
        """
        self.unexpectedSuccessCount += 1
        self.progressCounterUnexpectedSuccessCount.setText(
            str(self.unexpectedSuccessCount))
        itm = QListWidgetItem(self.trUtf8("    Unexpected Success"))
        itm.setForeground(Qt.red)
        self.testsListWidget.insertItem(1, itm)
        
    def testStarted(self, test, doc):
        """
        Public method called if a test is about to be run.
        
        @param test name of the started test (string)
        @param doc documentation of the started test (string)
        """
        if doc:
            self.testsListWidget.insertItem(0, "    {0}".format(doc))
        self.testsListWidget.insertItem(0, test)
        if self.dbs is None or self.localCheckBox.isChecked():
            QApplication.processEvents()
        
    def testFinished(self):
        """
        Public method called if a test has finished.
        
        <b>Note</b>: It is also called if it has already failed or errored.
        """
        # update the counters
        self.remainingCount -= 1
        self.runCount += 1
        self.progressCounterRunCount.setText(str(self.runCount))
        self.progressCounterRemCount.setText(str(self.remainingCount))
        
        # update the progressbar
        if self.errorCount:
            self.__setProgressColor("red")
        elif self.failCount:
            self.__setProgressColor("orange")
        self.progressProgressBar.setValue(self.runCount)
        
    def on_errorsListWidget_itemDoubleClicked(self, lbitem):
        """
        Private slot called by doubleclicking an errorlist entry.
        
        It will popup a dialog showing the stacktrace.
        If called from eric, an additional button is displayed
        to show the python source in an eric source viewer (in
        erics main window.
        
        @param lbitem the listbox item that was double clicked
        """
        self.errListIndex = self.errorsListWidget.row(lbitem)
        text = lbitem.text()
        self.on_errorsListWidget_currentTextChanged(text)

        # get the error info
        test, tracebackText = lbitem.data(Qt.UserRole)

        # now build the dialog
        from .Ui_UnittestStacktraceDialog import Ui_UnittestStacktraceDialog
        self.dlg = QDialog()
        ui = Ui_UnittestStacktraceDialog()
        ui.setupUi(self.dlg)
        self.dlg.traceback = ui.traceback
        
        ui.showButton = ui.buttonBox.addButton(
            self.trUtf8("Show Source"), QDialogButtonBox.ActionRole)
        ui.buttonBox.button(QDialogButtonBox.Close).setDefault(True)
        
        self.dlg.setWindowTitle(text)
        ui.testLabel.setText(test)
        ui.traceback.setPlainText(tracebackText)
        
        # one more button if called from eric
        if self.dbs:
            ui.showButton.clicked[()].connect(self.__showSource)
        else:
            ui.showButton.hide()

        # and now fire it up
        self.dlg.show()
        self.dlg.exec_()
        
    def __showSource(self):
        """
        Private slot to show the source of a traceback in an eric5 editor.
        """
        if not self.dbs:
            return
            
        # get the error info
        tracebackLines = self.dlg.traceback.toPlainText().splitlines()
        # find the last entry matching the pattern
        for index in range(len(tracebackLines) - 1, -1, -1):
            fmatch = re.search(r'File "(.*?)", line (\d*?),.*',
                               tracebackLines[index])
            if fmatch:
                break
        if fmatch:
            fn, ln = fmatch.group(1, 2)
            self.unittestFile.emit(fn, int(ln), 1)
    
    def hasFailedTests(self):
        """
        Public method to check, if there are failed tests from the last run.
        
        @return flag indicating the presence of failed tests (boolean)
        """
        return bool(self.__failedTests)


class QtTestResult(unittest.TestResult):
    """
    A TestResult derivative to work with a graphical GUI.
    
    For more details see pyunit.py of the standard python distribution.
    """
    def __init__(self, parent):
        """
        Constructor
        
        @param parent The parent widget.
        """
        super().__init__()
        self.parent = parent
        
    def addFailure(self, test, err):
        """
        Method called if a test failed.
        
        @param test reference to the test object
        @param err error traceback
        """
        super().addFailure(test, err)
        tracebackLines = self._exc_info_to_string(err, test)
        self.parent.testFailed(str(test), tracebackLines, test.id())
        
    def addError(self, test, err):
        """
        Method called if a test errored.
        
        @param test reference to the test object
        @param err error traceback
        """
        super().addError(test, err)
        tracebackLines = self._exc_info_to_string(err, test)
        self.parent.testErrored(str(test), tracebackLines, test.id())
        
    def addSkip(self, test, reason):
        """
        Method called if a test was skipped.
        
        @param test reference to the test object
        @param reason reason for skipping the test (string)
        """
        super().addSkip(test, reason)
        self.parent.testSkipped(str(test), reason, test.id())
        
    def addExpectedFailure(self, test, err):
        """
        Method called if a test failed expected.
        
        @param test reference to the test object
        @param err error traceback
        """
        super().addExpectedFailure(test, err)
        tracebackLines = self._exc_info_to_string(err, test)
        self.parent.testFailedExpected(str(test), tracebackLines, test.id())
        
    def addUnexpectedSuccess(self, test):
        """
        Method called if a test succeeded expectedly.
        
        @param test reference to the test object
        """
        super().addUnexpectedSuccess(test)
        self.parent.testSucceededUnexpected(str(test), test.id())
        
    def startTest(self, test):
        """
        Method called at the start of a test.
        
        @param test Reference to the test object
        """
        super().startTest(test)
        self.parent.testStarted(str(test), test.shortDescription())

    def stopTest(self, test):
        """
        Method called at the end of a test.
        
        @param test Reference to the test object
        """
        super().stopTest(test)
        self.parent.testFinished()


class UnittestWindow(E5MainWindow):
    """
    Main window class for the standalone dialog.
    """
    def __init__(self, prog=None, parent=None):
        """
        Constructor
        
        @param prog filename of the program to open
        @param parent reference to the parent widget (QWidget)
        """
        super().__init__(parent)
        self.cw = UnittestDialog(prog=prog, parent=self)
        self.cw.installEventFilter(self)
        size = self.cw.size()
        self.setCentralWidget(self.cw)
        self.resize(size)
        
        self.setStyle(Preferences.getUI("Style"),
                      Preferences.getUI("StyleSheet"))
    
    def eventFilter(self, obj, event):
        """
        Public method to filter events.
        
        @param obj reference to the object the event is meant for (QObject)
        @param event reference to the event object (QEvent)
        @return flag indicating, whether the event was handled (boolean)
        """
        if event.type() == QEvent.Close:
            QApplication.exit()
            return True
        
        return False
