# docklog.py - Log dock widget for the TortoiseHg Workbench
#
# Copyright 2010 Steve Borho <steve@borho.org>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.

from __future__ import annotations

import os
from typing import (
    Dict,
    List,
)

from .qsci import (
    QsciScintilla,
)
from .qtcore import (
    QIODevice,
    QProcess,
    QTimer,
    Qt,
    pyqtSignal,
    pyqtSlot,
)
from .qtgui import (
    QColor,
    QDockWidget,
    QStackedWidget,
    QVBoxLayout,
    QWidget,
)

from mercurial import (
    commands,
    pycompat,
    util,
)

from ..util import hglib
from ..util.i18n import _
from . import (
    cmdui,
    qtlib,
)

class _LogWidgetForConsole(cmdui.LogWidget):
    """Wrapped LogWidget for ConsoleWidget"""

    returnPressed = pyqtSignal(str)
    """Return key pressed when cursor is on prompt line"""
    historyRequested = pyqtSignal(str, int)  # keyword, direction
    completeRequested = pyqtSignal(str)

    _prompt = '% '

    def __init__(self, parent=None):
        super().__init__(parent)
        self._prompt_marker = self.markerDefine(QsciScintilla.MarkerSymbol.Background)
        self.setMarkerBackgroundColor(QColor('#e8f3fe'), self._prompt_marker)
        self.cursorPositionChanged.connect(self._updatePrompt)
        # ensure not moving prompt line even if completion list get shorter,
        # by allowing to scroll one page below the last line
        self.SendScintilla(QsciScintilla.SCI_SETENDATLASTLINE, False)
        # don't reserve "slop" area at top/bottom edge on ensureFooVisible()
        self.SendScintilla(QsciScintilla.SCI_SETVISIBLEPOLICY, 0, 0)

        self._savedcommands = []  # temporarily-invisible command
        self._origcolor = None
        self._flashtimer = QTimer(self, interval=100, singleShot=True)
        self._flashtimer.timeout.connect(self._restoreColor)

    def keyPressEvent(self, event):
        cursoronprompt = not self.isReadOnly()
        if cursoronprompt:
            if event.key() == Qt.Key.Key_Up:
                return self.historyRequested.emit(self.commandText(), -1)
            elif event.key() == Qt.Key.Key_Down:
                return self.historyRequested.emit(self.commandText(), +1)
            del self._savedcommands[:]  # settle candidate by user input
            if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
                return self.returnPressed.emit(self.commandText())
            if event.key() == Qt.Key.Key_Tab:
                return self.completeRequested.emit(self.commandText())
        if event.key() == Qt.Key.Key_Escape:
            # When ESC is pressed, if the cursor is on the prompt,
            # this clears it, if not, this moves the cursor to the prompt
            self.setCommandText('')

        super().keyPressEvent(event)

    def setPrompt(self, text):
        if text == self._prompt:
            return
        if self._findPromptLine() < 0:
            self._prompt = text
            return
        self.clearPrompt()
        self._prompt = text
        self.openPrompt()

    @pyqtSlot()
    def openPrompt(self):
        """Show prompt line and enable user input"""
        self.closePrompt()
        line = self.lines() - 1
        self.markerAdd(line, self._prompt_marker)
        self.append(self._prompt)
        if self._savedcommands:
            self.append(self._savedcommands.pop())
        self.setCursorPosition(line, len(self.text(line)))
        self.setReadOnly(False)

        # make sure the prompt line is visible. Because QsciScintilla may
        # delay line wrapping, setCursorPosition() doesn't always scrolls
        # to the correct position.
        # http://www.scintilla.org/ScintillaDoc.html#LineWrapping
        self.SCN_PAINTED.connect(self._scrollCaretOnPainted)

    @pyqtSlot()
    def _scrollCaretOnPainted(self):
        self.SCN_PAINTED.disconnect(self._scrollCaretOnPainted)
        self.SendScintilla(self.SCI_SCROLLCARET)

    def _removeTrailingText(self, line, index):
        visline = self.firstVisibleLine()
        lastline = self.lines() - 1
        self.setSelection(line, index, lastline, len(self.text(lastline)))
        self.removeSelectedText()
        # restore scroll position changed by setSelection()
        self.verticalScrollBar().setValue(visline)

    def _findPromptLine(self):
        return self.markerFindPrevious(self.lines() - 1,
                                       1 << self._prompt_marker)

    @pyqtSlot()
    def clearLog(self):
        wasopen = self._findPromptLine() >= 0
        self.clear()
        if wasopen:
            self.openPrompt()

    @pyqtSlot()
    def closePrompt(self):
        """Disable user input"""
        line = self._findPromptLine()
        if line >= 0:
            if self.commandText():
                self._setmarker((line,), 'control')
            self.markerDelete(line, self._prompt_marker)
            self._removeTrailingText(line + 1, 0)  # clear completion
        self._newline()
        self.setCursorPosition(self.lines() - 1, 0)
        self.setReadOnly(True)

    @pyqtSlot()
    def clearPrompt(self):
        """Clear prompt line and subsequent text"""
        line = self._findPromptLine()
        if line < 0:
            return
        self._savedcommands = [self.commandText()]
        self.markerDelete(line)
        self._removeTrailingText(line, 0)

    @pyqtSlot(int, int)
    def _updatePrompt(self, line, pos):
        """Update availability of user input"""
        if self.markersAtLine(line) & (1 << self._prompt_marker):
            self.setReadOnly(pos < len(self._prompt))
            self._ensurePrompt(line)
            if pos < len(self._prompt):
                # avoid inconsistency caused by changing pos inside
                # cursorPositionChanged
                QTimer.singleShot(0, self._moveCursorToPromptHome)
        else:
            self.setReadOnly(True)

    @pyqtSlot()
    def _moveCursorToPromptHome(self):
        line = self._findPromptLine()
        if line >= 0:
            self.setCursorPosition(line, len(self._prompt))

    def _ensurePrompt(self, line):
        """Insert prompt string if not available"""
        s = self.text(line)
        if s.startswith(self._prompt):
            return
        for i, c in enumerate(self._prompt):
            if s[i:i + 1] != c:
                self.insertAt(self._prompt[i:], line, i)
                break

    def commandText(self):
        """Return the current command text"""
        if self._savedcommands:
            return self._savedcommands[-1]
        l = self._findPromptLine()
        if l >= 0:
            return self.text(l)[len(self._prompt):].rstrip('\n')
        else:
            return ''

    def setCommandText(self, text, candidate=False):
        """Replace the current command text; subsequent text is also removed.

        If candidate, the specified text is displayed but does not replace
        commandText() until the user takes some action.
        """
        line = self._findPromptLine()
        if line < 0:
            return
        if candidate:
            self._savedcommands = [self.commandText()]
        else:
            del self._savedcommands[:]
        self._ensurePrompt(line)
        self._removeTrailingText(line, len(self._prompt))
        self.insert(text)
        self.setCursorPosition(line, len(self.text(line)))

    def _newline(self):
        if self.text(self.lines() - 1):
            self.append('\n')

    def flash(self, color='brown'):
        """Briefly change the text color to catch the user attention"""
        if self._flashtimer.isActive():
            return
        self._origcolor = self.color()
        self.setColor(QColor(color))
        self._flashtimer.start()

    @pyqtSlot()
    def _restoreColor(self):
        assert self._origcolor
        self.setColor(self._origcolor)

def _searchhistory(items, text, direction, idx):
    """Search history items and return (item, index_of_item)

    Valid index is zero or negative integer. Zero is reserved for non-history
    item.

    >>> def searchall(items, text, direction, idx=0):
    ...     matched = []
    ...     while True:
    ...         it, idx = _searchhistory(items, text, direction, idx)
    ...         if not it:
    ...             return matched, idx
    ...         matched.append(it)

    >>> searchall('foo bar baz'.split(), '', direction=-1)
    (['baz', 'bar', 'foo'], -4)
    >>> searchall('foo bar baz'.split(), '', direction=+1, idx=-3)
    (['bar', 'baz'], 0)

    search by keyword:

    >>> searchall('foo bar baz'.split(), 'b', direction=-1)
    (['baz', 'bar'], -4)
    >>> searchall('foo bar baz'.split(), 'inexistent', direction=-1)
    ([], -4)

    empty history:

    >>> searchall([], '', direction=-1)
    ([], -1)

    initial index out of range:

    >>> searchall('foo bar baz'.split(), '', direction=-1, idx=-3)
    ([], -4)
    >>> searchall('foo bar baz'.split(), '', direction=+1, idx=0)
    ([], 1)
    """
    assert direction != 0
    idx += direction
    while -len(items) <= idx < 0:
        curcmdline = items[idx]
        if curcmdline.startswith(text):
            return curcmdline, idx
        idx += direction
    return None, idx

class ConsoleWidget(QWidget, qtlib.TaskWidget):
    """Console to run hg/thg command and show output"""
    closeRequested = pyqtSignal()

    def __init__(self, agent, parent=None):
        QWidget.__init__(self, parent)
        self.setLayout(QVBoxLayout())
        self.layout().setContentsMargins(0, 0, 0, 0)
        self._initlogwidget()
        self.setFocusProxy(self._logwidget)
        self._agent = agent
        agent.busyChanged.connect(self._suppressPromptOnBusy)
        agent.outputReceived.connect(self.appendLog)
        if util.safehasattr(agent, 'displayName'):
            self._logwidget.setPrompt('%s%% ' % agent.displayName())
        self.openPrompt()
        self._commandHistory = []
        self._commandIdx = 0

    def _initlogwidget(self):
        self._logwidget = _LogWidgetForConsole(self)
        self._logwidget.returnPressed.connect(self._runcommand)
        self._logwidget.historyRequested.connect(self.historySearch)
        self._logwidget.completeRequested.connect(self.completeCommandText)
        self.layout().addWidget(self._logwidget)

        # compatibility methods with LogWidget
        for name in ('openPrompt', 'closePrompt', 'clear'):
            setattr(self, name, getattr(self._logwidget, name))

    @pyqtSlot(str, int)
    def historySearch(self, text: str, direction: int):
        cmdline, idx = _searchhistory(self._commandHistory,
                                      text,
                                      direction, self._commandIdx)
        if cmdline:
            self._commandIdx = idx
            self._logwidget.setCommandText(cmdline, candidate=True)
        else:
            self._logwidget.flash()

    def _commandComplete(self, cmdtype: str, cmdline: str) -> List[str]:
        from tortoisehg.hgqt import run
        matches = []
        cmd = cmdline.split()
        if cmdtype == 'hg':
            cmdtable = commands.table
        else:
            cmdtable = run.table
        subcmd = ''
        if len(cmd) >= 2:
            subcmd = cmd[1].lower()
        def findhgcmd(cmdstart: str) -> Dict[str, str]:
            matchinfo = {}
            for cmdspec in cmdtable:
                for cmdname in cmdspec.split(b'|'):
                    if cmdname.startswith(hglib.fromunicode(cmdstart)):
                        matchinfo[hglib.tounicode(cmdname)] = hglib.tounicode(cmdspec)
            return matchinfo
        matchingcmds = findhgcmd(subcmd)
        if not matchingcmds:
            return matches
        if len(matchingcmds) > 1:
            basecmdline = '%s %%s' % cmdtype
            matches = [basecmdline % c for c in matchingcmds]
        else:
            scmdtype = list(matchingcmds)[0]
            cmdspec = hglib.fromunicode(matchingcmds[scmdtype])
            opts = cmdtable[cmdspec][1]
            def findcmdopt(cmdopt: str) -> List[str]:
                cmdopt = cmdopt.lower()
                while cmdopt.startswith('-'):
                    cmdopt = cmdopt[1:]
                matchingopts = []
                for opt in opts:
                    uopt = hglib.tounicode(opt[1])
                    if uopt.startswith(cmdopt):
                        matchingopts.append(uopt)
                return matchingopts
            basecmdline = '%s %s --%%s' % (cmdtype, scmdtype)
            if len(cmd) == 2:
                matches = ['%s %s ' % (cmdtype, scmdtype)]
                matches += [basecmdline % hglib.tounicode(opt[1]) for opt in opts]
            else:
                cmdopt = cmd[-1]
                if cmdopt.startswith('-'):
                    # find the matching options
                    basecmdline = ' '.join(cmd[:-1]) + ' --%s'
                    cmdopts = findcmdopt(cmdopt)
                    matches = [basecmdline % opt for opt in cmdopts]
        return sorted(matches)

    @pyqtSlot(str)
    def completeCommandText(self, text: str):
        """Show the list of history or known commands matching the search text

        Also complete the prompt with the common prefix to the matching items
        """
        text = text.strip()
        if not text:
            self._logwidget.flash()
            return
        history = set(self._commandHistory)
        commonprefix = ''
        matches = []
        for cmdline in history:
            if cmdline.startswith(text):
                matches.append(cmdline)
        if matches:
            matches.sort()
            commonprefix = os.path.commonprefix(matches)
        cmd = text.split()
        cmdtype = cmd[0].lower()
        if cmdtype in ('hg', 'thg'):
            hgcommandmatches = self._commandComplete(cmdtype, text)
            if hgcommandmatches:
                if not commonprefix:
                    commonprefix = os.path.commonprefix(hgcommandmatches)
                if matches:
                    matches.append('------ %s commands ------' % cmdtype)
                matches += hgcommandmatches
        if not matches:
            self._logwidget.flash()
            return
        self._logwidget.setCommandText(commonprefix)
        if len(matches) > 1:
            self._logwidget.append('\n' + '\n'.join(matches) + '\n')
            self._logwidget.ensureLineVisible(self._logwidget.lines() - 1)
            self._logwidget.ensureCursorVisible()

    @util.propertycache
    def _extproc(self):
        extproc = QProcess(self)
        extproc.started.connect(self.closePrompt)
        extproc.finished.connect(self.openPrompt)
        extproc.errorOccurred.connect(self._handleExtprocError)
        extproc.readyReadStandardOutput.connect(self._appendExtprocStdout)
        extproc.readyReadStandardError.connect(self._appendExtprocStderr)
        return extproc

    @pyqtSlot()
    def _handleExtprocError(self):
        if self._extproc.state() == QProcess.ProcessState.NotRunning:
            self._logwidget.closePrompt()
        msg = self._extproc.errorString()
        self._logwidget.appendLog(msg + '\n', 'ui.error')
        if self._extproc.state() == QProcess.ProcessState.NotRunning:
            self._logwidget.openPrompt()

    @pyqtSlot()
    def _appendExtprocStdout(self):
        text = hglib.tounicode(self._extproc.readAllStandardOutput().data())
        self._logwidget.appendLog(text, '')

    @pyqtSlot()
    def _appendExtprocStderr(self):
        text = hglib.tounicode(self._extproc.readAllStandardError().data())
        self._logwidget.appendLog(text, 'ui.warning')

    @pyqtSlot(str, str)
    def appendLog(self, msg, label):
        """Append log text to the last line while keeping the prompt line"""
        self._logwidget.clearPrompt()
        try:
            self._logwidget.appendLog(msg, label)
        finally:
            if not self._agent.isBusy():
                self.openPrompt()

    def repoRootPath(self):
        if util.safehasattr(self._agent, 'rootPath'):
            return self._agent.rootPath()

    @property
    def _repo(self):
        if util.safehasattr(self._agent, 'rawRepo'):
            return self._agent.rawRepo()

    def _workingDirectory(self):
        return self.repoRootPath() or hglib.getcwdu()

    @pyqtSlot(bool)
    def _suppressPromptOnBusy(self, busy):
        if busy:
            self._logwidget.clearPrompt()
        else:
            self.openPrompt()

    @pyqtSlot(str)
    def _runcommand(self, cmdline: str):
        self._commandIdx = 0
        try:
            args = hglib.parsecmdline(cmdline, self._workingDirectory())
        except ValueError as e:
            self.closePrompt()
            self._logwidget.appendLog(hglib.exception_str(e) + '\n', 'ui.error')
            self.openPrompt()
            return
        if not args:
            self.openPrompt()
            return
        # add command to command history
        if not self._commandHistory or self._commandHistory[-1] != cmdline:
            self._commandHistory.append(cmdline)
        # execute the command
        cmd = args.pop(0)
        try:
            self._cmdtable[cmd](self, args)
        except KeyError:
            return self._runextcommand(cmdline)

    def _runextcommand(self, cmdline):
        self._extproc.setWorkingDirectory(self._workingDirectory())
        self._extproc.start(cmdline, QIODevice.OpenModeFlag.ReadOnly)

    def _cmd_hg(self, args):
        self.closePrompt()
        self._agent.runCommand(args, self)

    def _cmd_thg(self, args):
        from tortoisehg.hgqt import run
        self.closePrompt()
        try:
            if self.repoRootPath():
                args = ['-R', self.repoRootPath()] + args
            # TODO: show errors
            run.dispatch(pycompat.maplist(hglib.fromunicode, args))
        finally:
            self.openPrompt()

    def _cmd_clear(self, args):
        self._logwidget.clearLog()

    def _cmd_exit(self, args):
        self._logwidget.clearLog()
        self.closeRequested.emit()

    _cmdtable = {
        'hg':    _cmd_hg,
        'thg':   _cmd_thg,
        'clear': _cmd_clear,
        'cls':   _cmd_clear,
        'exit':  _cmd_exit,
        }


class LogDockWidget(QDockWidget):

    def __init__(self, repomanager, cmdagent, parent=None):
        super().__init__(parent)

        self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable |
                         QDockWidget.DockWidgetFeature.DockWidgetMovable  |
                         QDockWidget.DockWidgetFeature.DockWidgetFloatable)
        self.setWindowTitle(_('Console'))
        # Not enabled until we have a way to make it configurable
        #self.setWindowFlags(Qt.WindowType.Drawer)
        self.dockLocationChanged.connect(self._updateTitleBarStyle)

        self._repomanager = repomanager
        self._repomanager.repositoryOpened.connect(self._createConsoleFor)
        self._repomanager.repositoryClosed.connect(self._destroyConsoleFor)

        self._consoles = QStackedWidget(self)
        self.setWidget(self._consoles)
        self._createConsole(cmdagent)
        for root in self._repomanager.repoRootPaths():
            self._createConsoleFor(root)

    def setCurrentRepoRoot(self, root):
        w = self._findConsoleFor(root)
        self._consoles.setCurrentWidget(w)
        self.setFocusProxy(w)

    def _findConsoleFor(self, root):
        for i in pycompat.xrange(self._consoles.count()):
            w = self._consoles.widget(i)
            if w.repoRootPath() == root:
                return w
        raise ValueError('no console found for %r' % root)

    def _createConsole(self, agent):
        w = ConsoleWidget(agent, self)
        w.closeRequested.connect(self.close)
        self._consoles.addWidget(w)
        return w

    @pyqtSlot(str)
    def _createConsoleFor(self, root: str):
        repoagent = self._repomanager.repoAgent(root)
        assert repoagent
        self._createConsole(repoagent)

    @pyqtSlot(str)
    def _destroyConsoleFor(self, root: str):
        w = self._findConsoleFor(root)
        self._consoles.removeWidget(w)
        w.setParent(None)

    def setVisible(self, visible):
        super().setVisible(visible)
        if visible:
            self.raise_()

    @pyqtSlot(Qt.DockWidgetArea)
    def _updateTitleBarStyle(self, area):
        f = self.features()
        if area & (Qt.DockWidgetArea.TopDockWidgetArea | Qt.DockWidgetArea.BottomDockWidgetArea):
            f |= QDockWidget.DockWidgetFeature.DockWidgetVerticalTitleBar  # saves vertical space
        else:
            f &= ~QDockWidget.DockWidgetFeature.DockWidgetVerticalTitleBar
        self.setFeatures(f)
