"""
workspace --- Open documents and manage it
==========================================

Terminology:

Workspace - main working area, where documents are placed.

Document - opened file, widget on workspace. :class:`enki.core.document.Document` instance

:class:`enki.core.workspace.Workspace`
"""

import sys
import os
import os.path
import stat

from PyQt5.QtWidgets import QAction, QApplication, QDialog, QDialogButtonBox, \
    QListWidgetItem, QMessageBox, QStackedWidget, QShortcut, QAbstractButton
from PyQt5.QtGui import QKeySequence

from PyQt5.QtCore import pyqtSignal, pyqtSlot, QEvent, Qt  # pylint: disable=E0611
from PyQt5 import uic

from enki.core.core import core, DATA_FILES_PATH
import enki.core.openedfilemodel
from enki.core.document import Document


_MAX_SUPPORTED_FILE_SIZE = 50 * 1000 * 1000  # Enki may freeze or crash if file is too big


class _UISaveFiles(QDialog):
    """Save files dialog.
    Shows checkable list of not saved files.
    """

    def __init__(self, workspace, documents):
        super(_UISaveFiles, self).__init__(workspace)
        self.cancelled = False
        uic.loadUi(os.path.join(DATA_FILES_PATH, 'ui/SaveFiles.ui'), self)
        self.buttonBox.clicked.connect(self._onButtonClicked)

        self._documents = documents

        for index, document in enumerate(documents):
            name = document.fileName()
            if name is None:
                name = 'untitled'
            item = QListWidgetItem(name, self.listWidget)
            if document.filePath() is not None:
                item.setToolTip(document.filePath())
            item.setCheckState(Qt.Checked)

        # Retitle buttons, add first letter shortcuts for them.
        bb = self.buttonBox
        self._shortcut = (
            self._firstLetterShortcut(bb.Discard, 'close &Without saving'),
            self._firstLetterShortcut(bb.Cancel, '&Cancel close'),
            self._firstLetterShortcut(bb.Save, '&Save checked'))

        self.buttonBox.button(QDialogButtonBox.Cancel).setFocus()

    def _firstLetterShortcut(self, button, text):
        """ Provide first-letter only shortcuts (not Alt+first letter which is
            already present because of the & before letters) for a button.

            button - One of self.buttonBox.[Discard/Cancel/Save]
            text - Text for this button's label. The letter following the &
                   will have a shortcut created for it.

            Return value: the shortcut for the given button.
        """
        # Set the text of the button, which automatically provides an Alt+letter
        # shortcut.
        self.buttonBox.button(button).setText(self.tr(text))

        # Get the letter after the ampersand (there should be exactly one
        # ampersand in the string).
        assert text.count('&') == 1
        letter = text[text.index('&') + 1]

        # Create a first-letter only shortcut, which clicks the button when
        # the letter is typed.
        shortcut = QShortcut(QKeySequence(letter), self)
        shortcut.activated.connect(self.buttonBox.button(button).click)

        return shortcut

    @pyqtSlot(QAbstractButton)
    def _onButtonClicked(self, button):
        """Button click handler.
        Saves files, if necessary, accepts or rejects dialog
        """
        stButtton = self.buttonBox.standardButton(button)
        if stButtton == QDialogButtonBox.Save:
            for i in range(self.listWidget.count()):
                if self.listWidget.item(i).checkState() != Qt.Unchecked:
                    saved = self._documents[i].saveFile()
                    if not saved:
                        self.reject()
                        break
            else:
                self.accept()
        elif stButtton == QDialogButtonBox.Cancel:
            self.reject()
        elif stButtton == QDialogButtonBox.Discard:
            self.accept()
        else:
            assert 0


class Workspace(QStackedWidget):
    """
    Class manages set of opened documents, allows to open new file

    instance is accessible as: ::

        from enki.core.core import core
        core.workspace()

    """

    documentOpened = pyqtSignal(Document)
    """
    documentOpened(:class:`enki.core.document.Document`)

    **Signal** emitted, when document has been created, i.e. textual file opened,
    or some other document added to workspace
    """  # pylint: disable=W0105

    documentClosed = pyqtSignal(Document)
    """
    documentClosed(:class:`enki.core.document.Document`)

    **Signal** emitted, when document was closed
    """  # pylint: disable=W0105

    currentDocumentChanged = pyqtSignal(Document,
                                        Document)
    """
    currentDocumentChanged(:class:`enki.core.document.Document` old,
    :class:`enki.core.document.Document` current)

    **Signal** emitted, when current document changed, i.e. user selected another document,
    new document opened, current closed
    """  # pylint: disable=W0105

    modificationChanged = pyqtSignal(Document, bool)
    """
    modificationChanged(document, modified)

    **Signal** emitted, when modified state of a document had been changed (file edited, or saved)
    Bool parameter contains new value

    Convenience signal, which retransmits original signal, sent by the document
    """  # pylint: disable=W0105

    cursorPositionChanged = pyqtSignal(Document)
    """
    cursorPositionChanged(document)

    **Signal** emitted, when cursor position has been changed

    Convenience signal, which retransmits original signal, sent by the document
    """  # pylint: disable=W0105

    textChanged = pyqtSignal(Document)
    """
    textChanged(document)

    **Signal** emitted, when text has been chagned

    Convenience signal, which retransmits original signal, sent by the document
    """  # pylint: disable=W0105

    languageChanged = pyqtSignal(Document, str)
    """
    languageChanged(document, language)

    **Signal** emitted, when highlighting (programming) language of a file has been changed

    Convenience signal, which retransmits original signal, sent by `Qutepart<qutepart.rtfd.org>`_
    """  # pylint: disable=W0105

    indentWidthChanged = pyqtSignal(Document, int)
    """
    indentWidthChanged(document, width)

    **Signal** emitted, when indentation with has been changed

    Convenience signal, which retransmits original signal, sent by the document
    """  # pylint: disable=W0105

    indentUseTabsChanged = pyqtSignal(Document, bool)
    """
    indentUseTabsChanged(document, use)

    **Signal** emitted, when indentation mode has been changed

    Convenience signal, which retransmits original signal, sent by the document
    """  # pylint: disable=W0105

    eolChanged = pyqtSignal(Document, str)
    """
    eolChanged(document, eol)

    **Signal** emitted, when EOL mode has been changed

    Convenience signal, which retransmits original signal, sent by the document
    """  # pylint: disable=W0105

    escPressed = pyqtSignal()
    """
    escPressed()

    **Signal** emitted, when Esc pressed in the editor.
    Search widget closes themselves on this signal
    """  # pylint: disable=W0105

    _QUTEPART_ACTIONS = (

        ('mEdit/mCopyPasteLines/aCopy', 'copyLineAction'),
        ('mEdit/mCopyPasteLines/aPaste', 'pasteLineAction'),
        ('mEdit/mCopyPasteLines/aCut', 'cutLineAction'),
        ('mEdit/mCopyPasteLines/aDuplicate', 'duplicateLineAction'),

        ('mEdit/mIndentation/aIncreaseIndent', 'increaseIndentAction'),
        ('mEdit/mIndentation/aDecreaseIndent', 'decreaseIndentAction'),
        ('mEdit/mIndentation/aAutoIndent', 'autoIndentLineAction'),
        ('mEdit/mIndentation/aIndentWithSpace', 'indentWithSpaceAction'),
        ('mEdit/mIndentation/aUnIndentWithSpace', 'unIndentWithSpaceAction'),

        ('mEdit/aSeparatorAfterSubmenus', None),

        ('mEdit/aUndo', 'undoAction'),
        ('mEdit/aRedo', 'redoAction'),

        ('mEdit/aMoveLineUp', 'moveLineUpAction'),
        ('mEdit/aMoveLineDown', 'moveLineDownAction'),
        ('mEdit/aDeleteLine', 'deleteLineAction'),

        ('mEdit/aSeparatorBeforeCompletion', None),

        ('mEdit/aInvokeCompletion', 'invokeCompletionAction'),

        ('mNavigation/mScroll/aUp', 'scrollUpAction'),
        ('mNavigation/mScroll/aDown', 'scrollDownAction'),
        ('mNavigation/mScroll/aSelectAndScrollUp', 'selectAndScrollUpAction'),
        ('mNavigation/mScroll/aSelectAndScrollDown', 'selectAndScrollDownAction'),

        ('mFile/aPrint', 'printAction'),

        ('mNavigation/mBookmarks/aToggle', 'toggleBookmarkAction'),
        ('mNavigation/mBookmarks/aPrevious', 'prevBookmarkAction'),
        ('mNavigation/mBookmarks/aNext', 'nextBookmarkAction'),
    )

    def __init__(self, mainWindow):
        """ list of opened documents as it is displayed in the Opened Files Explorer.
        List accessed and modified by enki.core.openedfilemodel.OpenedFileModel class
        """
        QStackedWidget.__init__(self, mainWindow)
        mainWindow.setFocusProxy(self)

        self.sortedDocuments = []  # not protected, because available for OpenedFileModel
        self._oldCurrentDocument = None

        # create opened files explorer
        # openedFileExplorer is not protected, because it is available for OpenedFileModel
        self.openedFileExplorer = enki.core.openedfilemodel.OpenedFileExplorer(self)
        mainWindow.addDockWidget(Qt.LeftDockWidgetArea, self.openedFileExplorer)

        self.currentChanged.connect(self._onStackedLayoutIndexChanged)

        self.currentDocumentChanged.connect(self._updateMainWindowTitle)
        core.project().changed.connect(self._updateMainWindowTitle)
        self.currentDocumentChanged.connect(self._onCurrentDocumentChanged)

    def terminate(self):
        """Terminate workspace. Called by the core to clear actions
        """
        self.forceCloseAllDocuments()
        self.openedFileExplorer.terminate()
        core.project().changed.disconnect(self._updateMainWindowTitle)

    def _mainWindow(self):
        """Get mainWindow instance
        """
        return self.parentWidget().parentWidget()

    @pyqtSlot()
    def _updateMainWindowTitle(self):
        """Update window title after current project, document or it's modified state has been changed
        """
        projPath = core.project().path()
        if projPath is not None:
            homePath = os.path.expanduser("~" + os.sep)

            if projPath.startswith(homePath):
                projPath = os.path.relpath(projPath, homePath)

        document = self.currentDocument()
        if document:
            filePath = document.filePath()
            if filePath is None:
                filePath = 'untitled'
            elif core.project().path() is not None and \
                    filePath.startswith(core.project().path()):
                filePath = os.path.relpath(filePath, core.project().path())

            if document.qutepart.document().isModified():
                filePath += '*'
        else:
            filePath = None

        if projPath is not None and filePath is not None:
            title = u'{} - {}'.format(projPath, filePath)
        elif projPath is not None:
            title = projPath
        elif filePath is not None:
            title = filePath
        else:
            title = 'Enki v.{}'.format(enki.core.defines.PACKAGE_VERSION)

        self._mainWindow().setWindowTitle(title)

    def eventFilter(self, obj, event):
        pass  # suppress docstring for non-public method
        """
        Handler for QObject events of children
        """
        if obj.isWidgetType():
            document = obj
            if event.type() == QEvent.Close:
                event.ignore()
                self.closeDocument(document)
                return True
            elif (event.type() == QEvent.Drop and
                  event.mimeData().hasUrls()):  # file dropt. Open it
                self._mainWindow().dropEvent(event)
                return True

        return super(Workspace, self).eventFilter(obj, event)

    def keyPressEvent(self, event):
        pass  # suppress docstring for non-public method
        """Esc press handler. Closes search widget.
        If QShortcut is used instead of keyPressEvent(), completion list is not closed by Esc
        """
        if event.key() == Qt.Key_Escape:
            self.escPressed.emit()

    @pyqtSlot(int)
    def _onStackedLayoutIndexChanged(self, index):
        """Handler of change of current document in the stacked layout.
        Only emits a signal, if document realy has changed
        """
        document = self.widget(index)

        if document is None and self.count():  # just lost focus, no real change
            return
        if document == self._oldCurrentDocument:  # just recieved focus, no real change
            return

        if document is not None:
            self.setFocusProxy(document)

        self.currentDocumentChanged.emit(self._oldCurrentDocument, document)
        self._oldCurrentDocument = document

    @pyqtSlot(Document, Document)
    def _onCurrentDocumentChanged(self, old, new):
        """Change current directory, if current file changed
        """
        if(new and
           new.filePath() is not None and
           os.path.exists(os.path.dirname(new.filePath()))):
            try:
                os.chdir(os.path.dirname(new.filePath()))
            except OSError as ex:  # directory might be deleted
                print('Failed to change directory:', str(ex), file=sys.stderr)

        if old is not None:
            for path, name in self._QUTEPART_ACTIONS:
                core.actionManager().removeAction(path)

        if new is not None:
            for path, name in self._QUTEPART_ACTIONS:
                if name is not None:
                    core.actionManager().addAction(path, getattr(new.qutepart, name))
                else:
                    act = QAction(self)
                    act.setSeparator(True)
                    core.actionManager().addAction(path, act)

    def currentDocument(self):
        """Returns currently active (focused) document. None, if no documents are opened
        """
        return self.currentWidget()

    def setCurrentDocument(self, document):
        """Select active (focused and visible) document form list of opened documents
        """
        self.setCurrentWidget(document)

    def _activeDocumentByIndex(self, index):
        """Activate document by it's index in the list of documents
        """
        document = self.documents()[index]
        self.setCurrentDocument(document)

    def activateNextDocument(self):
        """Activate next document in the list
        """
        documents = self.documents()

        curIndex = documents.index(self.currentDocument())
        nextIndex = (curIndex + 1) % len(documents)
        self._activeDocumentByIndex(nextIndex)

    def activatePreviousDocument(self):
        """Activate previous document in the list
        """
        documents = self.documents()

        curIndex = documents.index(self.currentDocument())
        prevIndex = (curIndex - 1 + len(documents)) % len(documents)

        self._activeDocumentByIndex(prevIndex)

    def focusCurrentDocument(self):
        """Set focus (cursor) to current document.

        Used if user has finished work with some dialog, and, probably, want's to edit text
        """
        document = self.currentDocument()
        if document:
            document.setFocus()
            document.activateWindow()

    def goTo(self, filePath, absPos=None, line=None, column=None, selectionLength=None):
        """Open file, activate it, and go to specified position. Select text after position, if necessary.

        selectionLength specifies, how much characters should be selected
        """
        document = self.openFile(filePath)  # search for already opened or open new
        if document is None:
            return

        if line is not None:
            assert absPos is None
            if line >= len(document.qutepart.lines):
                line = len(document.qutepart.lines) - 1
                column = None

            if column is None:
                lineToGo = document.qutepart.lines[line]
                column = len(lineToGo) - len(lineToGo.lstrip())  # count of whitespaces before text

            document.qutepart.cursorPosition = line, column
        elif absPos is not None:
            assert line is None and column is None
            document.qutepart.absCursorPosition = absPos

        if selectionLength is not None:
            document.absSelectedPosition = (document.qutepart.absCursorPosition,
                                            document.qutepart.absCursorPosition + selectionLength)

    def _handleDocument(self, document):
        """Add document to the workspace. Connect signals
        """
        # update file menu
        document.qutepart.document().modificationChanged.connect(self._updateMainWindowTitle)

        # Create lambda functions, which retransmit conveniense signals, and connect it to document signals
        document.qutepart.document().modificationChanged.connect(
            lambda modified: self.modificationChanged.emit(document, modified))
        document.qutepart.cursorPositionChanged.connect(lambda: self.cursorPositionChanged.emit(document))
        document.qutepart.textChanged.connect(lambda: self.textChanged.emit(document))
        document.qutepart.languageChanged.connect(lambda name: self.languageChanged.emit(document, name))
        document.qutepart.indentWidthChanged.connect(lambda width: self.indentWidthChanged.emit(document, width))
        document.qutepart.indentUseTabsChanged.connect(
            lambda useTabs: self.indentUseTabsChanged.emit(document, useTabs))
        document.qutepart.eolChanged.connect(lambda eol: self.eolChanged.emit(document, eol))

        # add to workspace
        document.installEventFilter(self)
        document.qutepart.viewport().installEventFilter(self)

        self.documentOpened.emit(document)

        self.addWidget(document)

    def _unhandleDocument(self, document):
        """Remove document from the workspace. Disconnect signals
        """
        # remove from workspace
        document.removeEventFilter(self)
        document.qutepart.viewport().removeEventFilter(self)
        self.removeWidget(document)

    @staticmethod
    def _isSameFile(pathA, pathB):
        """Check if we are trying to open same file, as already opened
        None is never equal
        """
        if pathA is None or pathB is None:
            return False

        if hasattr(os.path, "samefile"):
            return os.path.isfile(pathA) and \
                os.path.isfile(pathB) and \
                os.path.samefile(pathA, pathB)
        else:  # os.path.samefile not available
            return os.path.normpath(pathA) == os.path.normpath(pathB)

    def _openSingleFile(self, filePath):
        """Open 1 file.
        Helper method, used by openFile() and openFiles()
        """
        # Close 'untitled'
        if len(self.documents()) == 1 and \
           self.documents()[0].fileName() is None and \
           not self.documents()[0].filePath() and \
           not self.documents()[0].qutepart.text and \
           not self.documents()[0].qutepart.document().isModified():
            self.closeDocument(self.documents()[0])

        # check if file is already opened
        alreadyOpenedDocument = self.findDocumentForPath(filePath)
        if alreadyOpenedDocument is not None:
            self.setCurrentDocument(alreadyOpenedDocument)
            return alreadyOpenedDocument

        # Check if exists, get stat
        try:
            statInfo = os.stat(filePath)
        except (OSError, IOError) as ex:
            QMessageBox.critical(self._mainWindow(),
                                 "Failed to stat the file",
                                 str(ex))
            return None

        # Check if is a directory
        if stat.S_ISDIR(statInfo.st_mode):
            QMessageBox.critical(self._mainWindow(),
                                 "Can not open a directory",
                                 "{} is a directory".format(filePath))
            return None

        # Check if too big
        if statInfo.st_size > _MAX_SUPPORTED_FILE_SIZE:
            msg = ("<html>" +
                   "{} file size is {}.<br/>" +
                   "I am a text editor, but not a data dump editor. " +
                   " I'm sory but I don't know how to open such a big files" +
                   "</html>") .format(filePath, statInfo.st_size)
            QMessageBox.critical(self._mainWindow(), "Too big file", msg)
            return None

        # Check if have access to read
        if not os.access(filePath, os.R_OK):
            QMessageBox.critical(self._mainWindow(),
                                 "Don't have the access",
                                 "You don't have the read permission for {}".format(filePath))
            return None

        # open the file
        document = Document(self, filePath)
        self._handleDocument(document)

        if not os.access(filePath, os.W_OK):
            core.mainWindow().appendMessage(
                self.tr("File '%s' is not writable" % filePath), 4000)  # todo fix

        return document

    def openFile(self, filePath):
        """Open file.

        Return document, if opened, None otherwise

        Open modal message box, if failed to open the file
        """
        try:
            QApplication.setOverrideCursor(Qt.WaitCursor)

            document = self._openSingleFile(filePath)
        finally:
            QApplication.restoreOverrideCursor()

        if document is not None:
            self.setCurrentWidget(document)
            document.setFocus()

        return document

    def openFiles(self, filePaths):
        """Open files.

        Open modal message box and stop opening files, if failed to open any file
        """
        documents = []
        try:
            QApplication.setOverrideCursor(Qt.WaitCursor)
            for filePath in filePaths:
                document = self._openSingleFile(filePath)
                if document is None:
                    break

                documents.append(document)
        finally:
            QApplication.restoreOverrideCursor()

    def findDocumentForPath(self, filePath):
        """Try to find document for path.
        Fimilar to open(), but doesn't open file, if it is not opened
        On Unix may return file, for which path is not equal, if soft or hards links are used
        Return None, if not found
        """
        for document in self.sortedDocuments:
            if self._isSameFile(filePath, document.filePath()):
                return document

    def createEmptyNotSavedDocument(self, filePath=None):
        """Create empty not saved document.
        Used on startup, if no file was specified, and after File->New file has been triggered
        """
        if filePath is not None and \
           not os.path.isabs(filePath):
            try:
                current = os.path.abspath(os.path.curdir)
            except OSError:  # current directory might have been deleted
                pass  # leave as is
            else:
                filePath = os.path.join(current, filePath)

        if filePath is not None:
            self._mainWindow().appendMessage('New file "%s" is going to be created' % filePath, 5000)

        document = Document(self, filePath, createNew=True)
        self._handleDocument(document)
        self.setCurrentWidget(document)

        document.setFocus()

        return document

    def documents(self):
        """Get list of opened documents (:class:`enki.core.document.Document` instances)
        """
        return self.sortedDocuments

    def _doCloseDocument(self, document):
        """Closes document, even if it is modified
        """
        if len(self.sortedDocuments) > 1:  # not the last document
            if document == self.sortedDocuments[-1]:  # the last document
                self.activatePreviousDocument()
            else:  # not the last
                self.activateNextDocument()

        self.documentClosed.emit(document)
        # close document
        self._unhandleDocument(document)
        document.terminate()

    def closeDocument(self, document):
        """Close opened file, remove document from workspace and delete the widget.

        Ask for confirmation with dialog, if modified.
        """
        if document.qutepart.document().isModified():
            if _UISaveFiles(self, [document]).exec_() == QDialog.Rejected:
                return

        self._doCloseDocument(document)

    def askToCloseAll(self):
        """If have unsaved documents, ask user to save it and close all.
        Will save documents, checked by user.

        Returns True, if user hasn't pressed Cancel Close
        """
        modifiedDocuments = [d for d in self.documents() if d.qutepart.document().isModified()]
        if modifiedDocuments:
            if (_UISaveFiles(self, modifiedDocuments).exec_() == QDialog.Rejected):
                return False  # do not close

        return True

    def closeAllDocuments(self):
        """Close all documents

        If there are not saved documents, dialog will be shown.

        Handler of File->Close->All

        Returns True, if all files had been closed, and False, if save dialog rejected

        If hideMainWindow is True, main window will be hidden, if user hadn't pressed "Cancel Close"
        """
        if not self.askToCloseAll():
            return

        self.forceCloseAllDocuments()

    def forceCloseAllDocuments(self):
        """Close all documents without asking user to save
        """
        for document in self.documents()[::-1]:
            self.documentClosed.emit(document)
            # close document
            self._unhandleDocument(document)
            document.terminate()
