# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)

"""Find/Replace widget"""

# pylint: disable=C0103
# pylint: disable=R0903
# pylint: disable=R0911
# pylint: disable=R0201

# Standard library imports
import re

# Third party imports
from qtpy.QtCore import QEvent, QSize, Qt, QTimer, Signal, Slot
from qtpy.QtGui import QTextCursor
from qtpy.QtWidgets import (QAction, QGridLayout, QHBoxLayout, QLabel,
                            QLineEdit, QToolButton, QSizePolicy, QSpacerItem,
                            QWidget)

# Local imports
from spyder.config.base import _
from spyder.config.manager import CONF
from spyder.py3compat import to_text_string
from spyder.utils.icon_manager import ima
from spyder.utils.misc import regexp_error_msg
from spyder.plugins.editor.utils.editor import TextHelper
from spyder.utils.qthelpers import create_toolbutton
from spyder.utils.sourcecode import get_eol_chars
from spyder.widgets.comboboxes import PatternComboBox


def is_position_sup(pos1, pos2):
    """Return True is pos1 > pos2"""
    return pos1 > pos2

def is_position_inf(pos1, pos2):
    """Return True is pos1 < pos2"""
    return pos1 < pos2


class SearchText(PatternComboBox):

    def __init__(self, parent):
        self.recommended_width = 400
        super().__init__(parent, adjust_to_minimum=False)

    def sizeHint(self):
        """Recommended size."""
        return QSize(self.recommended_width, 10)


class FindReplace(QWidget):
    """Find widget"""
    TOOLTIP = {
        'regexp_error': _("Regular expression error"),
        'no_matches': _("No matches")
    }

    visibility_changed = Signal(bool)
    return_shift_pressed = Signal()
    return_pressed = Signal()

    def __init__(self, parent, enable_replace=False):
        QWidget.__init__(self, parent)
        self.enable_replace = enable_replace
        self.editor = None
        self.is_code_editor = None

        glayout = QGridLayout()
        glayout.setContentsMargins(6, 3, 6, 3)
        self.setLayout(glayout)

        self.close_button = create_toolbutton(
            self,
            triggered=self.hide,
            icon=ima.icon('DialogCloseButton')
        )
        glayout.addWidget(self.close_button, 0, 0)

        # Find layout
        self.search_text = SearchText(self)

        self.return_shift_pressed.connect(
            lambda:
            self.find(changed=False, forward=False, rehighlight=False,
                      multiline_replace_check = False)
        )

        self.return_pressed.connect(
            lambda:
            self.find(changed=False, forward=True, rehighlight=False,
                      multiline_replace_check = False)
        )

        self.search_text.lineEdit().textEdited.connect(
            self.text_has_been_edited)
        self.search_text.sig_resized.connect(self._resize_replace_text)

        self.number_matches_text = QLabel(self)
        self.search_text.clear_action.triggered.connect(
            self.number_matches_text.hide
        )

        self.no_matches_icon = ima.icon('no_matches')
        self.error_icon = ima.icon('error')
        self.messages_action = QAction(self)
        self.messages_action.setVisible(False)
        self.search_text.lineEdit().addAction(
            self.messages_action, QLineEdit.TrailingPosition)
        self.search_text.clear_action.triggered.connect(
            lambda: self.messages_action.setVisible(False)
        )

        # Button corresponding to the messages_action above
        self.messages_button = (
            self.search_text.lineEdit().findChildren(QToolButton)[1]
        )

        self.replace_on = False
        self.replace_text_button = create_toolbutton(
            self,
            toggled=self.change_replace_state,
            icon=ima.icon('replace'),
            tip=_("Replace text")
        )
        if not self.enable_replace:
            self.replace_text_button.hide()

        self.previous_button = create_toolbutton(
            self,
            triggered=self.find_previous,
            icon=ima.icon('findprevious'),
            tip=_("Find previous")
        )

        self.next_button = create_toolbutton(
            self,
            triggered=self.find_next,
            icon=ima.icon('findnext'),
            tip=_("Find next")
        )
        self.next_button.clicked.connect(self.update_search_combo)
        self.previous_button.clicked.connect(self.update_search_combo)

        self.re_button = create_toolbutton(
            self, icon=ima.icon('regex'),
            tip=_("Use regular expressions")
        )
        self.re_button.setCheckable(True)
        self.re_button.toggled.connect(lambda state: self.find())

        self.case_button = create_toolbutton(
            self,
            icon=ima.icon("format_letter_case"),
            tip=_("Enable case sensitive searches")
        )
        self.case_button.setCheckable(True)
        self.case_button.toggled.connect(lambda state: self.find())

        self.words_button = create_toolbutton(
            self,
            icon=ima.icon("format_letter_matches"),
            tip=_("Only search for whole words")
        )
        self.words_button.setCheckable(True)
        self.words_button.toggled.connect(lambda state: self.find())

        self.widgets = [
            self.close_button,
            self.search_text,
            self.previous_button,
            self.next_button,
            self.re_button,
            self.case_button,
            self.words_button,
            self.replace_text_button,
            self.number_matches_text,
        ]

        # Search layout
        search_layout = QHBoxLayout()
        for widget in self.widgets[1:-1]:
            search_layout.addWidget(widget)

        search_layout.addSpacerItem(QSpacerItem(10, 0))
        search_layout.addWidget(self.number_matches_text)
        search_layout.addSpacerItem(
            QSpacerItem(6, 0, hPolicy=QSizePolicy.Expanding)
        )

        glayout.addLayout(search_layout, 0, 1)

        # Replace layout
        self.replace_text = PatternComboBox(
            self,
            adjust_to_minimum=False
        )
        self.replace_text.valid.connect(
            lambda _: self.replace_find(focus_replace_text=True))
        self.replace_text.lineEdit().setPlaceholderText(_("Replace"))

        self.replace_button = create_toolbutton(
            self,
            tip=_('Replace next occurrence'),
            icon=ima.icon('replace_next'),
            triggered=self.replace_find,
        )

        self.replace_sel_button = create_toolbutton(
            self,
            tip=_('Replace occurrences in selection'),
            icon=ima.icon('replace_selection'),
            triggered=self.replace_find_selection,
        )
        self.replace_sel_button.clicked.connect(self.update_replace_combo)
        self.replace_sel_button.clicked.connect(self.update_search_combo)

        self.replace_all_button = create_toolbutton(
            self,
            tip=_('Replace all occurrences'),
            icon=ima.icon('replace_all'),
            triggered=self.replace_find_all,
        )
        self.replace_all_button.clicked.connect(self.update_replace_combo)
        self.replace_all_button.clicked.connect(self.update_search_combo)

        replace_layout = QHBoxLayout()
        widgets = [
            self.replace_text,
            self.replace_button,
            self.replace_sel_button,
            self.replace_all_button
        ]
        for widget in widgets:
            replace_layout.addWidget(widget)
        replace_layout.addStretch(1)
        glayout.addLayout(replace_layout, 1, 1)
        self.widgets.extend(widgets)
        self.replace_widgets = widgets
        self.hide_replace()

        # Additional adjustments
        self.search_text.setTabOrder(self.search_text, self.replace_text)
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        self.shortcuts = self.create_shortcuts(parent)

        self.highlight_timer = QTimer(self)
        self.highlight_timer.setSingleShot(True)
        self.highlight_timer.setInterval(300)
        self.highlight_timer.timeout.connect(self.highlight_matches)
        self.search_text.installEventFilter(self)

    def eventFilter(self, widget, event):
        """
        Event filter for search_text widget.

        Notes
        -----
        * Emit signals when Enter and Shift+Enter are pressed. These signals
          are used for search forward and backward.
        * Add crude hack to get tab working between the find/replace boxes.
        * Reduce space between the messages_button and the clear one.
        """

        # Type check: Prevent error in PySide where 'event' may be of type
        # QtGui.QPainter (for whatever reason).
        if not isinstance(event, QEvent):
            return True

        if event.type() == QEvent.KeyPress:
            key = event.key()
            shift = event.modifiers() & Qt.ShiftModifier

            if key == Qt.Key_Return:
                if shift:
                    self.return_shift_pressed.emit()
                else:
                    self.return_pressed.emit()

            if key == Qt.Key_Tab:
                if self.search_text.hasFocus():
                    self.replace_text.set_current_text(
                        self.search_text.currentText())
                self.focusNextChild()

        if event.type() == QEvent.Paint:
            self.messages_button.move(
                self.search_text.lineEdit().width() - 42,
                self.messages_button.y()
            )

        return super().eventFilter(widget, event)

    def create_shortcuts(self, parent):
        """Create shortcuts for this widget"""
        # Configurable
        findnext = CONF.config_shortcut(
            self.find_next,
            context='find_replace',
            name='Find next',
            parent=parent)

        findprev = CONF.config_shortcut(
            self.find_previous,
            context='find_replace',
            name='Find previous',
            parent=parent)

        togglefind = CONF.config_shortcut(
            self.show,
            context='find_replace',
            name='Find text',
            parent=parent)

        togglereplace = CONF.config_shortcut(
            self.show_replace,
            context='find_replace',
            name='Replace text',
            parent=parent)

        hide = CONF.config_shortcut(
            self.hide,
            context='find_replace',
            name='hide find and replace',
            parent=self)

        return [findnext, findprev, togglefind, togglereplace, hide]

    def get_shortcut_data(self):
        """
        Returns shortcut data, a list of tuples (shortcut, text, default)
        shortcut (QShortcut or QAction instance)
        text (string): action/shortcut description
        default (string): default key sequence
        """
        return [sc.data for sc in self.shortcuts]

    def update_search_combo(self):
        self.search_text.lineEdit().returnPressed.emit()

    def update_replace_combo(self):
        self.replace_text.lineEdit().returnPressed.emit()

    @Slot(bool)
    def toggle_highlighting(self, state):
        """Toggle the 'highlight all results' feature"""
        if self.editor is not None:
            if state:
                self.highlight_matches()
            else:
                self.clear_matches()

    def show(self, hide_replace=True):
        """Overrides Qt Method"""
        QWidget.show(self)

        self._resize_search_text()
        self.visibility_changed.emit(True)
        self.change_number_matches()

        if self.editor is not None:
            if hide_replace:
                if self.replace_widgets[0].isVisible():
                    self.hide_replace()
            else:
                self.replace_text_button.setChecked(True)

            # When selecting several lines, and replace box is activated the
            # text won't be replaced for the selection
            text = self.editor.get_selected_text()
            if hide_replace or len(text.splitlines()) <= 1:
                highlighted = True
                # If no text is highlighted for search, use whatever word is
                # under the cursor
                if not text:
                    highlighted = False
                    try:
                        cursor = self.editor.textCursor()
                        cursor.select(QTextCursor.WordUnderCursor)
                        text = to_text_string(cursor.selectedText())
                    except AttributeError:
                        # We can't do this for all widgets, e.g. WebView's
                        pass

                # Now that text value is sorted out, use it for the search
                if text and not self.search_text.currentText() or highlighted:
                    self.search_text.setEditText(text)
                    self.search_text.lineEdit().selectAll()
                    self.refresh()
                else:
                    self.search_text.lineEdit().selectAll()
            self.search_text.setFocus()

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self._resize_search_text()

    @Slot()
    def replace_widget(self, replace_on):
        """Show and hide replace widget"""
        if replace_on:
            self.show_replace()
        else:
            self.hide_replace()

    def change_replace_state(self):
        """Handle the change of the replace state widget."""
        self.replace_on = not self.replace_on
        self.replace_text_button.setChecked(self.replace_on)
        self.replace_widget(self.replace_on)

    def hide(self):
        """Overrides Qt Method"""
        for widget in self.replace_widgets:
            widget.hide()
        QWidget.hide(self)
        self.replace_text_button.setChecked(False)
        self.visibility_changed.emit(False)
        if self.editor is not None:
            self.editor.setFocus()
            self.clear_matches()

    def show_replace(self):
        """Show replace widgets"""
        if self.enable_replace:
            self.show(hide_replace=False)
            for widget in self.replace_widgets:
                widget.show()

    def hide_replace(self):
        """Hide replace widgets"""
        for widget in self.replace_widgets:
            widget.hide()
            self.replace_text_button.setChecked(False)

    def refresh(self):
        """Refresh widget"""
        if self.isHidden():
            if self.editor is not None:
                self.clear_matches()
            return
        state = self.editor is not None
        for widget in self.widgets:
            widget.setEnabled(state)
        if state:
            self.find()

    def set_editor(self, editor, refresh=True):
        """Set associated editor."""
        # Note: This is necessary to test widgets/editor.py in Qt builds that
        # don't have web widgets
        try:
            from qtpy.QtWebEngineWidgets import QWebEngineView
        except ImportError:
            QWebEngineView = type(None)
        from spyder.plugins.editor.widgets.codeeditor import CodeEditor

        self.words_button.setVisible(not isinstance(editor, QWebEngineView))
        self.re_button.setVisible(not isinstance(editor, QWebEngineView))
        self.is_code_editor = isinstance(editor, CodeEditor)

        # Disconnect previous connection to highlight matches
        if self.editor is not None and self.is_code_editor:
            self.editor.textChanged.disconnect(self.update_matches)

        # Set current editor
        self.editor = editor

        # Keep number of matches updated if editor text has changed
        if self.is_code_editor:
            self.editor.textChanged.connect(self.update_matches)

        if refresh:
            self.refresh()
        if self.isHidden() and editor is not None:
            self.clear_matches()

    @Slot()
    def find_next(self, set_focus=True):
        """Find next occurrence"""
        state = self.find(changed=False, forward=True, rehighlight=False,
                          multiline_replace_check=False)
        if set_focus:
            self.editor.setFocus()
        self.search_text.add_current_text()
        return state

    @Slot()
    def find_previous(self, set_focus=True):
        """Find previous occurrence"""
        state = self.find(changed=False, forward=False, rehighlight=False,
                          multiline_replace_check=False)
        if set_focus:
            self.editor.setFocus()
        return state

    def text_has_been_edited(self, text):
        """
        Find text has been edited (this slot won't be triggered when setting
        the search pattern combo box text programmatically).
        """
        self.find(changed=True, forward=True, start_highlight_timer=True)

    def highlight_matches(self):
        """Highlight found results"""
        if self.is_code_editor:
            text = self.search_text.currentText()
            case = self.case_button.isChecked()
            word = self.words_button.isChecked()
            regexp = self.re_button.isChecked()
            self.editor.highlight_found_results(text, word=word,
                                                regexp=regexp, case=case)

    def clear_matches(self):
        """Clear all highlighted matches"""
        if self.is_code_editor:
            self.editor.clear_found_results()

    def find(self, changed=True, forward=True, rehighlight=True,
             start_highlight_timer=False, multiline_replace_check=True):
        """Call the find function"""
        # When several lines are selected in the editor and replace box is
        # activated, dynamic search is deactivated to prevent changing the
        # selection. Otherwise we show matching items.
        if multiline_replace_check and self.replace_widgets[0].isVisible():
            sel_text = self.editor.get_selected_text()
            if len(to_text_string(sel_text).splitlines()) > 1:
                return None
        text = self.search_text.currentText()
        if len(text) == 0:
            if not self.is_code_editor:
                # Clears the selection for WebEngine
                self.editor.find_text('')
            self.change_number_matches()
            self.messages_action.setVisible(False)
            self.clear_matches()
            return None
        else:
            case = self.case_button.isChecked()
            word = self.words_button.isChecked()
            regexp = self.re_button.isChecked()
            found = self.editor.find_text(text, changed, forward, case=case,
                                          word=word, regexp=regexp)

            error_msg = False
            if not found and regexp:
                error_msg = regexp_error_msg(text)
                if error_msg:
                    self.show_error(error_msg)

            # No need to continue after this point if we detected an error in
            # the passed regexp.
            if error_msg:
                return

            if self.is_code_editor and found:
                cursor = QTextCursor(self.editor.textCursor())
                TextHelper(self.editor).unfold_if_colapsed(cursor)

                if rehighlight or not self.editor.found_results:
                    self.highlight_timer.stop()
                    if start_highlight_timer:
                        self.highlight_timer.start()
                    else:
                        self.highlight_matches()
            else:
                self.clear_matches()

            number_matches = self.editor.get_number_matches(text, case=case,
                                                            regexp=regexp,
                                                            word=word)
            if hasattr(self.editor, 'get_match_number'):
                match_number = self.editor.get_match_number(text, case=case,
                                                            regexp=regexp,
                                                            word=word)
            else:
                match_number = 0
            self.change_number_matches(current_match=match_number,
                                       total_matches=number_matches)
            return found

    @Slot()
    def replace_find(self, focus_replace_text=False):
        """Replace and find."""
        if self.editor is None:
            return
        replace_text = to_text_string(self.replace_text.currentText())
        search_text = to_text_string(self.search_text.currentText())
        re_pattern = None
        case = self.case_button.isChecked()
        re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE

        # Check regexp before proceeding
        if self.re_button.isChecked():
            try:
                re_pattern = re.compile(search_text, flags=re_flags)
                # Check if replace_text can be substituted in re_pattern
                # Fixes spyder-ide/spyder#7177.
                re_pattern.sub(replace_text, '')
            except re.error:
                # Do nothing with an invalid regexp
                return

        # First found
        seltxt = to_text_string(self.editor.get_selected_text())
        cmptxt1 = search_text if case else search_text.lower()
        cmptxt2 = seltxt if case else seltxt.lower()
        do_replace = True
        if re_pattern is None:
            has_selected = self.editor.has_selected_text()
            if not has_selected or cmptxt1 != cmptxt2:
                if not self.find(changed=False, forward=True,
                                 rehighlight=False):
                    do_replace = False
        else:
            if len(re_pattern.findall(cmptxt2)) <= 0:
                if not self.find(changed=False, forward=True,
                                 rehighlight=False):
                    do_replace = False
        cursor = None
        if do_replace:
            cursor = self.editor.textCursor()
            cursor.beginEditBlock()

            if re_pattern is None:
                cursor.removeSelectedText()
                cursor.insertText(replace_text)
            else:
                seltxt = to_text_string(cursor.selectedText())

                # Note: If the selection obtained from an editor spans a line
                # break, the text will contain a Unicode U+2029 paragraph
                # separator character instead of a newline \n character.
                # See: spyder-ide/spyder#2675
                eol_char = get_eol_chars(self.editor.toPlainText())
                seltxt = seltxt.replace(u'\u2029', eol_char)

                cursor.removeSelectedText()
                cursor.insertText(re_pattern.sub(replace_text, seltxt))

            if self.find_next(set_focus=False):
                found_cursor = self.editor.textCursor()
                cursor.setPosition(found_cursor.selectionStart(),
                                   QTextCursor.MoveAnchor)
                cursor.setPosition(found_cursor.selectionEnd(),
                                   QTextCursor.KeepAnchor)


        if cursor is not None:
            cursor.endEditBlock()

        if focus_replace_text:
            self.replace_text.setFocus()
        else:
            self.editor.setFocus()

        if getattr(self.editor, 'document_did_change', False):
            self.editor.document_did_change()

    @Slot()
    def replace_find_all(self):
        """Replace and find all matching occurrences"""
        if self.editor is None:
            return
        replace_text = to_text_string(self.replace_text.currentText())
        search_text = to_text_string(self.search_text.currentText())
        re_pattern = None
        case = self.case_button.isChecked()
        re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE
        re_enabled = self.re_button.isChecked()
        # Check regexp before proceeding
        if re_enabled:
            try:
                re_pattern = re.compile(search_text, flags=re_flags)
                # Check if replace_text can be substituted in re_pattern
                # Fixes spyder-ide/spyder#7177.
                re_pattern.sub(replace_text, '')
            except re.error:
                # Do nothing with an invalid regexp
                return
        else:
            re_pattern = re.compile(re.escape(search_text), flags=re_flags)

        cursor = self.editor._select_text("sof", "eof")
        text = self.editor.toPlainText()
        cursor.beginEditBlock()
        cursor.removeSelectedText()
        cursor.insertText(re_pattern.sub(replace_text, text))
        cursor.endEditBlock()

        self.editor.setFocus()

    @Slot()
    def replace_find_selection(self, focus_replace_text=False):
        """Replace and find in the current selection"""
        if self.editor is not None:
            replace_text = to_text_string(self.replace_text.currentText())
            search_text = to_text_string(self.search_text.currentText())
            case = self.case_button.isChecked()
            word = self.words_button.isChecked()
            re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE

            re_pattern = None
            if self.re_button.isChecked():
                pattern = search_text
            else:
                pattern = re.escape(search_text)
                replace_text = replace_text.replace('\\', r'\\')
            if word:  # match whole words only
                pattern = r'\b{pattern}\b'.format(pattern=pattern)

            # Check regexp before proceeding
            try:
                re_pattern = re.compile(pattern, flags=re_flags)
                # Check if replace_text can be substituted in re_pattern
                # Fixes spyder-ide/spyder#7177.
                re_pattern.sub(replace_text, '')
            except re.error:
                # Do nothing with an invalid regexp
                return

            selected_text = to_text_string(self.editor.get_selected_text())
            replacement = re_pattern.sub(replace_text, selected_text)
            if replacement != selected_text:
                cursor = self.editor.textCursor()
                start_pos = cursor.selectionStart()
                cursor.beginEditBlock()
                cursor.removeSelectedText()
                cursor.insertText(replacement)
                # Restore selection
                self.editor.set_cursor_position(start_pos)
                for c in range(len(replacement)):
                    self.editor.extend_selection_to_next('character', 'right')
                cursor.endEditBlock()

            if focus_replace_text:
                self.replace_text.setFocus()
            else:
                self.editor.setFocus()

            if getattr(self.editor, 'document_did_change', False):
                self.editor.document_did_change()

    def change_number_matches(self, current_match=0, total_matches=0):
        """Change number of match and total matches."""
        if current_match and total_matches:
            self.number_matches_text.show()
            self.messages_action.setVisible(False)
            matches_string = u"{} {} {}".format(current_match, _(u"of"),
                                                total_matches)
            self.number_matches_text.setText(matches_string)
        elif total_matches:
            self.number_matches_text.show()
            self.messages_action.setVisible(False)
            matches_string = u"{} {}".format(total_matches, _(u"matches"))
            self.number_matches_text.setText(matches_string)
        else:
            self.number_matches_text.hide()
            if self.search_text.currentText():
                self.show_no_matches()

    def update_matches(self):
        """Update total number of matches if text has changed in the editor."""
        if self.isVisible():
            number_matches = self.editor.get_number_matches(
                self.search_text.lineEdit().text(),
                case=self.case_button.isChecked(),
                regexp=self.re_button.isChecked(),
                word=self.words_button.isChecked()
            )
            self.change_number_matches(total_matches=number_matches)

    def show_no_matches(self):
        """Show a no matches message with an icon."""
        self._show_icon_message('no_matches')

    def show_error(self, error_msg):
        """Show a regexp error message with an icon."""
        self._show_icon_message('error', extra_info=error_msg)

    def _show_icon_message(self, kind, extra_info=None):
        """
        Show a message to users with an icon when no matches can be found or
        there's an error in the passed regexp.

        Parameters
        ----------
        kind: str
            The kind of message. It can be 'no_matches' or 'error'.
        extra_info:
            Extra info to add to the icon's tooltip.
        """
        if kind == 'no_matches':
            tooltip = self.TOOLTIP['no_matches']
            icon = self.no_matches_icon
        else:
            tooltip = self.TOOLTIP['regexp_error']
            icon = self.error_icon

        if extra_info:
            tooltip = tooltip + ': ' + extra_info

        self.messages_action.setIcon(icon)
        self.messages_action.setToolTip(tooltip)
        self.messages_action.setVisible(True)

    def _resize_search_text(self):
        """Adjust search_text combobox min width according to total one."""
        total_width = self.size().width()
        if total_width < (self.search_text.recommended_width + 200):
            self.search_text.setMinimumWidth(30)
        else:
            self.search_text.setMinimumWidth(int(total_width / 2))

    def _resize_replace_text(self, size, old_size):
        """
        Resize replace_text combobox to match the width of the search one.
        """
        self.replace_text.setMinimumWidth(size.width())
        self.replace_text.setMaximumWidth(size.width())
