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

"""QPlainTextEdit base class"""

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

# Standard library imports
import os
import sys

# Third party imports
from qtpy.compat import to_qvariant
from qtpy.QtCore import QEvent, QPoint, Qt, Signal, Slot
from qtpy.QtGui import (QClipboard, QColor, QMouseEvent, QTextFormat,
                        QTextOption, QTextCursor)
from qtpy.QtWidgets import QApplication, QMainWindow, QPlainTextEdit, QToolTip

# Local imports
from spyder.api.fonts import SpyderFontsMixin, SpyderFontType
from spyder.api.widgets.mixins import SpyderWidgetMixin
from spyder.plugins.editor.api.decoration import TextDecoration, DRAW_ORDERS
from spyder.plugins.editor.utils.decoration import TextDecorationsManager
from spyder.plugins.editor.widgets.completion import CompletionWidget
from spyder.plugins.completion.api import CompletionItemKind
from spyder.plugins.outlineexplorer.api import is_cell_header, document_cells
from spyder.utils.palette import SpyderPalette
from spyder.widgets.calltip import CallTipWidget, ToolTipWidget
from spyder.widgets.mixins import BaseEditMixin


class TextEditBaseWidget(
    QPlainTextEdit,
    BaseEditMixin,
    SpyderFontsMixin,
    SpyderWidgetMixin
):
    """Text edit base widget"""
    BRACE_MATCHING_SCOPE = ('sof', 'eof')
    focus_in = Signal()
    zoom_in = Signal()
    zoom_out = Signal()
    zoom_reset = Signal()
    focus_changed = Signal()
    sig_insert_completion = Signal(str)
    sig_eol_chars_changed = Signal(str)
    sig_prev_cursor = Signal()
    sig_next_cursor = Signal()

    def __init__(self, parent=None, class_parent=None):
        QPlainTextEdit.__init__(self, parent)
        BaseEditMixin.__init__(self)
        SpyderWidgetMixin.__init__(self, class_parent=class_parent)

        self.has_cell_separators = False
        self.setAttribute(Qt.WA_DeleteOnClose)

        self._restore_selection_pos = None

        # Trailing newlines/spaces trimming
        self.remove_trailing_spaces = False
        self.remove_trailing_newlines = False

        # Add a new line when saving
        self.add_newline = False

        # Code snippets
        self.code_snippets = True

        self.cursorPositionChanged.connect(self.cursor_position_changed)

        self.indent_chars = " "*4
        self.tab_stop_width_spaces = 4

        # Code completion / calltips
        if parent is not None:
            mainwin = parent
            while not isinstance(mainwin, QMainWindow):
                mainwin = mainwin.parent()
                if mainwin is None:
                    break
            if mainwin is not None:
                parent = mainwin

        self.completion_widget = CompletionWidget(self, parent)
        self.codecompletion_auto = False
        self.setup_completion()

        self.calltip_widget = CallTipWidget(self, hide_timer_on=False)
        self.tooltip_widget = ToolTipWidget(self)

        self.highlight_current_cell_enabled = False

        # The color values may be overridden by the syntax highlighter
        # Highlight current line color
        self.currentline_color = QColor(
            SpyderPalette.COLOR_ERROR_2).lighter(190)
        self.currentcell_color = QColor(
            SpyderPalette.COLOR_ERROR_2).lighter(194)

        # Brace matching
        self.bracepos = None
        self.matched_p_color = QColor(SpyderPalette.COLOR_SUCCESS_1)
        self.unmatched_p_color = QColor(SpyderPalette.COLOR_ERROR_2)

        self.decorations = TextDecorationsManager(self)

        # Save current cell. This is invalidated as soon as the text changes.
        # Useful to avoid recomputing while scrolling.
        self.current_cell = None

        def reset_current_cell():
            self.current_cell = None
            self.highlight_current_cell()

        self.textChanged.connect(reset_current_cell)

        # Cache
        self._current_cell_cursor = None
        self._current_line_block = None

    def setup_completion(self):
        size = self.get_conf('completion/size', section='main')
        font = self.get_font(SpyderFontType.Monospace)
        self.completion_widget.setup_appearance(size, font)

    def set_indent_chars(self, indent_chars):
        self.indent_chars = indent_chars

    def set_tab_stop_width_spaces(self, tab_stop_width_spaces):
        self.tab_stop_width_spaces = tab_stop_width_spaces
        self.update_tab_stop_width_spaces()

    def set_remove_trailing_spaces(self, flag):
        self.remove_trailing_spaces = flag

    def set_add_newline(self, add_newline):
        self.add_newline = add_newline

    def set_remove_trailing_newlines(self, flag):
        self.remove_trailing_newlines = flag

    def update_tab_stop_width_spaces(self):
        self.setTabStopWidth(self.fontMetrics().width(
                             ' ' * self.tab_stop_width_spaces))

    def set_palette(self, background, foreground):
        """
        Set text editor palette colors:
        background color and caret (text cursor) color
        """
        # Because QtStylsheet overrides QPalette and because some style do not
        # use the palette for all drawing (e.g. macOS styles), the background
        # and foreground color of each TextEditBaseWidget instance must be set
        # with a stylesheet extended with an ID Selector.
        # Fixes spyder-ide/spyder#2028, spyder-ide/spyder#8069 and
        # spyder-ide/spyder#9248.
        if not self.objectName():
            self.setObjectName(self.__class__.__name__ + str(id(self)))
        style = "QPlainTextEdit#%s {background: %s; color: %s;}" % \
                (self.objectName(), background.name(), foreground.name())
        self.setStyleSheet(style)

    # ---- Extra selections
    def get_extra_selections(self, key):
        """Return editor extra selections.

        Args:
            key (str) name of the extra selections group

        Returns:
            list of sourcecode.api.TextDecoration.
        """
        return self.decorations.get(key, [])

    def set_extra_selections(self, key, extra_selections):
        """Set extra selections for a key.

        Also assign draw orders to leave current_cell and current_line
        in the background (and avoid them to cover other decorations)

        NOTE: This will remove previous decorations added to the same key.

        Args:
            key (str) name of the extra selections group.
            extra_selections (list of sourcecode.api.TextDecoration).
        """
        # use draw orders to highlight current_cell and current_line first
        draw_order = DRAW_ORDERS.get(key)
        if draw_order is None:
            draw_order = DRAW_ORDERS.get('on_top')

        for selection in extra_selections:
            selection.draw_order = draw_order
            selection.kind = key

        self.decorations.add_key(key, extra_selections)
        self.update()

    def clear_extra_selections(self, key):
        """Remove decorations added through set_extra_selections.

        Args:
            key (str) name of the extra selections group.
        """
        self.decorations.remove_key(key)
        self.update()

    def get_visible_block_numbers(self):
        """Get the first and last visible block numbers."""
        first = self.firstVisibleBlock().blockNumber()
        bottom_right = QPoint(self.viewport().width() - 1,
                              self.viewport().height() - 1)
        last = self.cursorForPosition(bottom_right).blockNumber()
        return (first, last)

    def get_buffer_block_numbers(self):
        """
        Get the first and last block numbers of a region that covers
        the visible one plus a buffer of half that region above and
        below to make more fluid certain operations.
        """
        first_visible, last_visible = self.get_visible_block_numbers()
        buffer_height = round((last_visible - first_visible) / 2)

        first = first_visible - buffer_height
        first = 0 if first < 0 else first

        last = last_visible + buffer_height
        last = self.blockCount() if last > self.blockCount() else last

        return (first, last)

    # ------Highlight current line
    def highlight_current_line(self):
        """Highlight current line"""
        cursor = self.textCursor()
        block = cursor.block()
        if self._current_line_block == block:
            return
        self._current_line_block = block
        selection = TextDecoration(cursor)
        selection.format.setProperty(QTextFormat.FullWidthSelection,
                                     to_qvariant(True))
        selection.format.setBackground(self.currentline_color)
        selection.cursor.clearSelection()
        self.set_extra_selections('current_line', [selection])

    def unhighlight_current_line(self):
        """Unhighlight current line"""
        self._current_line_block = None
        self.clear_extra_selections('current_line')

    # ------Highlight current cell
    def highlight_current_cell(self):
        """Highlight current cell"""
        if (not self.has_cell_separators or
                not self.highlight_current_cell_enabled):
            self._current_cell_cursor = None
            return
        cursor, whole_file_selected = self.select_current_cell()

        def same_selection(c1, c2):
            if c1 is None or c2 is None:
                return False
            return (
                c1.selectionStart() == c2.selectionStart() and
                c1.selectionEnd() == c2.selectionEnd()
            )

        if same_selection(self._current_cell_cursor, cursor):
            # Already correct
            return
        self._current_cell_cursor = cursor
        selection = TextDecoration(cursor)
        selection.format.setProperty(QTextFormat.FullWidthSelection,
                                     to_qvariant(True))
        selection.format.setBackground(self.currentcell_color)

        if whole_file_selected:
            self.clear_extra_selections('current_cell')
        else:
            self.set_extra_selections('current_cell', [selection])

    def unhighlight_current_cell(self):
        """Unhighlight current cell"""
        self._current_cell_cursor = None
        self.clear_extra_selections('current_cell')

    def in_comment(self, cursor=None, position=None):
        """Returns True if the given position is inside a comment.

        Trivial default implementation. To be overridden by subclass.
        This function is used to define the default behaviour of
        self.find_brace_match.
        """
        return False

    def in_string(self, cursor=None, position=None):
        """Returns True if the given position is inside a string.

        Trivial default implementation. To be overridden by subclass.
        This function is used to define the default behaviour of
        self.find_brace_match.
        """
        return False

    def find_brace_match(self, position, brace, forward,
                         ignore_brace=None, stop=None):
        """Returns position of matching brace.

        Parameters
        ----------
        position : int
            The position of the brace to be matched.
        brace : {'[', ']', '(', ')', '{', '}'}
            The brace character to be matched.
            [ <-> ], ( <-> ), { <-> }
        forward : boolean
            Whether to search forwards or backwards for a match.
        ignore_brace : callable taking int returning boolean, optional
            Whether to ignore a brace (as function of position).
        stop : callable taking int returning boolean, optional
            Whether to stop the search early (as function of position).

        If both *ignore_brace* and *stop* are None, then brace matching
        is handled differently depending on whether *position* is
        inside a string, comment or regular code. If in regular code,
        then any braces inside strings and comments are ignored. If in a
        string/comment, then only braces in the same string/comment are
        considered potential matches. The functions self.in_comment and
        self.in_string are used to determine string/comment/code status
        of characters in this case.

        If exactly one of *ignore_brace* and *stop* is None, then it is
        replaced by a function returning False for every position. I.e.:
            lambda pos: False

        Returns
        -------
        The position of the matching brace. If no matching brace
        exists, then None is returned.
        """

        if ignore_brace is None and stop is None:
            if self.in_string(position=position):
                # Only search inside the current string
                def stop(pos):
                    return not self.in_string(position=pos)
            elif self.in_comment(position=position):
                # Only search inside the current comment
                def stop(pos):
                    return not self.in_comment(position=pos)
            else:
                # Ignore braces inside strings and comments
                def ignore_brace(pos):
                    return (self.in_string(position=pos) or
                            self.in_comment(position=pos))

        # Deal with search range and direction
        start_pos, end_pos = self.BRACE_MATCHING_SCOPE
        if forward:
            closing_brace = {'(': ')', '[': ']', '{': '}'}[brace]
            text = self.get_text(position, end_pos, remove_newlines=False)
        else:
            # Handle backwards search with the same code as forwards
            # by reversing the string to be searched.
            closing_brace = {')': '(', ']': '[', '}': '{'}[brace]
            text = self.get_text(start_pos, position+1, remove_newlines=False)
            text = text[-1::-1]  # reverse

        def ind2pos(index):
            """Computes editor position from search index."""
            return (position + index) if forward else (position - index)

        # Search starts at the first position after the given one
        # (which is assumed to contain a brace).
        i_start_close = 1
        i_start_open = 1
        while True:
            i_close = text.find(closing_brace, i_start_close)
            i_start_close = i_close+1  # next potential start
            if i_close == -1:
                return  # no matching brace exists
            elif ignore_brace is None or not ignore_brace(ind2pos(i_close)):
                while True:
                    i_open = text.find(brace, i_start_open, i_close)
                    i_start_open = i_open+1  # next potential start
                    if i_open == -1:
                        # found matching brace, but should we have
                        # stopped before this point?
                        if stop is not None:
                            # There's room for optimization here...
                            for i in range(1, i_close+1):
                                if stop(ind2pos(i)):
                                    return
                        return ind2pos(i_close)
                    elif (ignore_brace is None or
                          not ignore_brace(ind2pos(i_open))):
                        break  # must find new closing brace

    def __highlight(self, positions, color=None, cancel=False):
        if cancel:
            self.clear_extra_selections('brace_matching')
            return
        extra_selections = []
        for position in positions:
            if position > self.get_position('eof'):
                return
            selection = TextDecoration(self.textCursor())
            selection.format.setBackground(color)
            selection.cursor.clearSelection()
            selection.cursor.setPosition(position)
            selection.cursor.movePosition(QTextCursor.NextCharacter,
                                          QTextCursor.KeepAnchor)
            extra_selections.append(selection)
        self.set_extra_selections('brace_matching', extra_selections)

    def cursor_position_changed(self):
        """Handle brace matching."""
        # Clear last brace highlight (if any)
        if self.bracepos is not None:
            self.__highlight(self.bracepos, cancel=True)
            self.bracepos = None

        # Get the current cursor position, check if it is at a brace,
        # and, if so, determine the direction in which to search for able
        # matching brace.
        cursor = self.textCursor()
        if cursor.position() == 0:
            return
        cursor.movePosition(QTextCursor.PreviousCharacter,
                            QTextCursor.KeepAnchor)
        text = str(cursor.selectedText())
        if text in (')', ']', '}'):
            forward = False
        elif text in ('(', '[', '{'):
            forward = True
        else:
            return

        pos1 = cursor.position()
        pos2 = self.find_brace_match(pos1, text, forward=forward)

        # Set a new brace highlight
        if pos2 is not None:
            self.bracepos = (pos1, pos2)
            self.__highlight(self.bracepos, color=self.matched_p_color)
        else:
            self.bracepos = (pos1,)
            self.__highlight(self.bracepos, color=self.unmatched_p_color)

    # -----Widget setup and options
    def set_wrap_mode(self, mode=None):
        """
        Set wrap mode
        Valid *mode* values: None, 'word', 'character'
        """
        if mode == 'word':
            wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere
        elif mode == 'character':
            wrap_mode = QTextOption.WrapAnywhere
        else:
            wrap_mode = QTextOption.NoWrap
        self.setWordWrapMode(wrap_mode)

    # ------Reimplementing Qt methods
    @Slot()
    def copy(self):
        """
        Reimplement Qt method
        Copy text to clipboard with correct EOL chars
        """
        if self.get_selected_text():
            QApplication.clipboard().setText(self.get_selected_text())
        else:
            cursor = self.select_current_line_and_sep(set_cursor=False)
            QApplication.clipboard().setText(self.get_selected_text(cursor))

    def toPlainText(self):
        """
        Reimplement Qt method
        Fix PyQt4 bug on Windows and Python 3
        """
        # Fix what appears to be a PyQt4 bug when getting file
        # contents under Windows and PY3. This bug leads to
        # corruptions when saving files with certain combinations
        # of unicode chars on them (like the one attached on
        # spyder-ide/spyder#1546).
        if os.name == 'nt':
            text = self.get_text('sof', 'eof')
            return text.replace('\u2028', '\n').replace('\u2029', '\n')\
                       .replace('\u0085', '\n')
        return super().toPlainText()

    def keyPressEvent(self, event):
        key = event.key()
        ctrl = event.modifiers() & Qt.ControlModifier
        meta = event.modifiers() & Qt.MetaModifier
        # Use our own copy method for {Ctrl,Cmd}+C to avoid Qt
        # copying text in HTML. See spyder-ide/spyder#2285.
        if (ctrl or meta) and key == Qt.Key_C:
            self.copy()
        else:
            super().keyPressEvent(event)

    # ------Text: get, set, ...
    def get_cell_list(self):
        """Get all cells."""
        # Reimplemented in childrens
        return []

    def get_selection_as_executable_code(self, cursor=None):
        """
        Get selected text in a way that allows other plugins to execute it.
        """
        ls = self.get_line_separator()

        _indent = lambda line: len(line)-len(line.lstrip())

        line_from, line_to = self.get_selection_bounds(cursor)
        line_col_from, line_col_to = self.get_selection_start_end(cursor)
        line_from_off, line_to_off = self.get_selection_offsets(cursor)

        text = self.get_selected_text(cursor)
        if not text:
            return

        lines = text.split(ls)
        if len(lines) > 1:
            # Multiline selection -> eventually fixing indentation
            original_indent = _indent(self.get_text_line(line_from))
            text = (" " * (original_indent - _indent(lines[0]))) + text

        # If there is a common indent to all lines, find it.
        # Moving from bottom line to top line ensures that blank
        # lines inherit the indent of the line *below* it,
        # which is the desired behavior.
        min_indent = 999
        current_indent = 0
        lines = text.split(ls)
        for i in range(len(lines) - 1, -1, -1):
            line = lines[i]
            if line.strip():
                current_indent = _indent(line)
                min_indent = min(current_indent, min_indent)
            else:
                lines[i] = ' ' * current_indent
        if min_indent:
            lines = [line[min_indent:] for line in lines]

        # Remove any leading whitespace or comment lines
        # since they confuse the reserved word detector that follows below
        lines_removed = 0
        while lines:
            first_line = lines[0].lstrip()
            if first_line == '' or first_line[0] == '#':
                lines_removed += 1
                lines.pop(0)
            else:
                break

        # Add removed lines back to have correct traceback line numbers
        leading_lines_str = ls * lines_removed

        return (
            leading_lines_str + ls.join(lines),
            (line_from_off, line_to_off),
            (line_col_from, line_col_to)
        )

    def get_cell_as_executable_code(self, cursor=None):
        """Return cell contents as executable code."""
        if cursor is None:
            cursor = self.textCursor()
        ls = self.get_line_separator()
        cursor, __ = self.select_current_cell(cursor)
        line_from, __ = self.get_selection_bounds(cursor)
        # Get the block for the first cell line
        start = cursor.selectionStart()
        block = self.document().findBlock(start)
        if not is_cell_header(block) and start > 0:
            block = self.document().findBlock(start - 1)
        # Get text
        text, off_pos, col_pos = self.get_selection_as_executable_code(cursor)
        if text is not None:
            text = ls * line_from + text
        return text, block, off_pos, col_pos

    def select_current_cell(self, cursor=None):
        """
        Select cell under cursor in the visible portion of the file
        cell = group of lines separated by CELL_SEPARATORS
        returns
         -the textCursor
         -a boolean indicating if the entire file is selected
        """
        if cursor is None:
            cursor = self.textCursor()

        if self.current_cell:
            current_cell, cell_full_file = self.current_cell
            cell_start_pos = current_cell.selectionStart()
            cell_end_position = current_cell.selectionEnd()
            # Check if the saved current cell is still valid
            if cell_start_pos <= cursor.position() < cell_end_position:
                return current_cell, cell_full_file
            else:
                self.current_cell = None

        block = cursor.block()
        try:
            if is_cell_header(block):
                header = block.userData().oedata
            else:
                header = next(document_cells(
                    block, forward=False,
                    cell_list=self.get_cell_list()))
            cell_start_pos = header.block.position()
            cell_at_file_start = False
            cursor.setPosition(cell_start_pos)
        except StopIteration:
            # This cell has no header, so it is the first cell.
            cell_at_file_start = True
            cursor.movePosition(QTextCursor.Start)

        try:
            footer = next(document_cells(
                block, forward=True,
                cell_list=self.get_cell_list()))
            cell_end_position = footer.block.position()
            cell_at_file_end = False
            cursor.setPosition(cell_end_position, QTextCursor.KeepAnchor)
        except StopIteration:
            # This cell has no next header, so it is the last cell.
            cell_at_file_end = True
            cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)

        cell_full_file = cell_at_file_start and cell_at_file_end
        self.current_cell = (cursor, cell_full_file)

        return cursor, cell_full_file

    def select_current_line_and_sep(self, cursor=None, set_cursor=True):
        """
        Selects the current line, including the correct line separator to
        delete or copy the whole current line.

        This means:
        - If there is a next block, select the current block's newline char.
        - Else if there is a previous block, select the previous newline char.
        - Else select no newline char (1-line file)

        Does a similar thing to `cursor.select(QTextCursor.BlockUnderCursor)`,
        which always selects the previous newline char.
        """
        if cursor is None:
            cursor = self.textCursor()

        cursor.movePosition(QTextCursor.StartOfBlock, QTextCursor.MoveAnchor)

        if not cursor.movePosition(QTextCursor.NextBlock,
                                   QTextCursor.KeepAnchor):
            # if there is no next Block, we select the previous newline
            if cursor.movePosition(QTextCursor.PreviousBlock,
                                   QTextCursor.MoveAnchor):
                cursor.movePosition(QTextCursor.EndOfBlock,
                                    QTextCursor.MoveAnchor)
                cursor.movePosition(QTextCursor.NextBlock,
                                    QTextCursor.KeepAnchor)
                cursor.movePosition(QTextCursor.EndOfBlock,
                                    QTextCursor.KeepAnchor)
            else:
                # if there is no previous block, we can select the current line
                # this is the 1-line file case
                cursor.select(QTextCursor.BlockUnderCursor)

        if set_cursor:
            self.setTextCursor(cursor)
        return cursor

    def go_to_next_cell(self):
        """Go to the next cell of lines"""
        cursor = self.textCursor()
        block = cursor.block()
        try:
            footer = next(document_cells(
                block, forward=True,
                cell_list=self.get_cell_list()))
            cursor.setPosition(footer.block.position())
        except StopIteration:
            return
        self.setTextCursor(cursor)

    def go_to_previous_cell(self):
        """Go to the previous cell of lines"""
        cursor = self.textCursor()
        block = cursor.block()
        if is_cell_header(block):
            block = block.previous()
        try:
            header = next(document_cells(
                block, forward=False,
                cell_list=self.get_cell_list()))
            cursor.setPosition(header.block.position())
        except StopIteration:
            return
        self.setTextCursor(cursor)

    def get_line_count(self):
        """Return document total line number"""
        return self.blockCount()

    def paintEvent(self, e):
        """
        Override Qt method to restore text selection after text gets inserted
        at the current position of the cursor.

        See spyder-ide/spyder#11089 for more info.
        """
        if self._restore_selection_pos is not None:
            self.__restore_selection(*self._restore_selection_pos)
            self._restore_selection_pos = None
        super().paintEvent(e)

    def __save_selection(self):
        """Save current cursor selection and return position bounds"""
        cursor = self.textCursor()
        return cursor.selectionStart(), cursor.selectionEnd()

    def __restore_selection(self, start_pos, end_pos):
        """Restore cursor selection from position bounds"""
        cursor = self.textCursor()
        cursor.setPosition(start_pos)
        cursor.setPosition(end_pos, QTextCursor.KeepAnchor)
        self.setTextCursor(cursor)

    def __duplicate_line_or_selection(self, after_current_line=True):
        """Duplicate current line or selected text"""
        cursor = self.textCursor()
        cursor.beginEditBlock()
        cur_pos = cursor.position()
        start_pos, end_pos = self.__save_selection()
        end_pos_orig = end_pos
        if str(cursor.selectedText()):
            cursor.setPosition(end_pos)
            # Check if end_pos is at the start of a block: if so, starting
            # changes from the previous block
            cursor.movePosition(QTextCursor.StartOfBlock,
                                QTextCursor.KeepAnchor)
            if not str(cursor.selectedText()):
                cursor.movePosition(QTextCursor.PreviousBlock)
                end_pos = cursor.position()

        cursor.setPosition(start_pos)
        cursor.movePosition(QTextCursor.StartOfBlock)
        while cursor.position() <= end_pos:
            cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor)
            if cursor.atEnd():
                cursor_temp = QTextCursor(cursor)
                cursor_temp.clearSelection()
                cursor_temp.insertText(self.get_line_separator())
                break
            cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor)
        text = cursor.selectedText()
        cursor.clearSelection()

        if not after_current_line:
            # Moving cursor before current line/selected text
            cursor.setPosition(start_pos)
            cursor.movePosition(QTextCursor.StartOfBlock)
            start_pos += len(text)
            end_pos_orig += len(text)
            cur_pos += len(text)

        # We save the end and start position of the selection, so that it
        # can be restored within the paint event that is triggered by the
        # text insertion. This is done to prevent a graphical glitch that
        # occurs when text gets inserted at the current position of the cursor.
        # See spyder-ide/spyder#11089 for more info.
        if cur_pos == start_pos:
            self._restore_selection_pos = (end_pos_orig, start_pos)
        else:
            self._restore_selection_pos = (start_pos, end_pos_orig)
        cursor.insertText(text)
        cursor.endEditBlock()

    def duplicate_line_down(self):
        """
        Copy current line or selected text and paste the duplicated text
        *after* the current line or selected text.
        """
        self.__duplicate_line_or_selection(after_current_line=False)

    def duplicate_line_up(self):
        """
        Copy current line or selected text and paste the duplicated text
        *before* the current line or selected text.
        """
        self.__duplicate_line_or_selection(after_current_line=True)

    def move_line_or_selection(self, after_current_line=True):
        """Move current line or selected text"""
        cursor = self.textCursor()
        cursor.beginEditBlock()
        start_pos, end_pos = self.__save_selection()
        last_line = False

        # ------ Select text
        # Get selection start location
        cursor.setPosition(start_pos)
        cursor.movePosition(QTextCursor.StartOfBlock)
        start_pos = cursor.position()

        # Get selection end location
        cursor.setPosition(end_pos)
        if not cursor.atBlockStart() or end_pos == start_pos:
            cursor.movePosition(QTextCursor.EndOfBlock)
            cursor.movePosition(QTextCursor.NextBlock)
        end_pos = cursor.position()

        # Check if selection ends on the last line of the document
        if cursor.atEnd():
            if not cursor.atBlockStart() or end_pos == start_pos:
                last_line = True

        # ------ Stop if at document boundary
        cursor.setPosition(start_pos)
        if cursor.atStart() and not after_current_line:
            # Stop if selection is already at top of the file while moving up
            cursor.endEditBlock()
            self.setTextCursor(cursor)
            self.__restore_selection(start_pos, end_pos)
            return

        cursor.setPosition(end_pos, QTextCursor.KeepAnchor)
        if last_line and after_current_line:
            # Stop if selection is already at end of the file while moving down
            cursor.endEditBlock()
            self.setTextCursor(cursor)
            self.__restore_selection(start_pos, end_pos)
            return

        # ------ Move text
        sel_text = str(cursor.selectedText())
        cursor.removeSelectedText()

        if after_current_line:
            # Shift selection down
            text = str(cursor.block().text())
            sel_text = os.linesep + sel_text[0:-1]  # Move linesep at the start
            cursor.movePosition(QTextCursor.EndOfBlock)
            start_pos += len(text)+1
            end_pos += len(text)
            if not cursor.atEnd():
                end_pos += 1
        else:
            # Shift selection up
            if last_line:
                # Remove the last linesep and add it to the selected text
                cursor.deletePreviousChar()
                sel_text = sel_text + os.linesep
                cursor.movePosition(QTextCursor.StartOfBlock)
                end_pos += 1
            else:
                cursor.movePosition(QTextCursor.PreviousBlock)
            text = str(cursor.block().text())
            start_pos -= len(text)+1
            end_pos -= len(text)+1

        cursor.insertText(sel_text)

        cursor.endEditBlock()
        self.setTextCursor(cursor)
        self.__restore_selection(start_pos, end_pos)

    def go_to_new_line(self):
        """Go to the end of the current line and create a new line"""
        self.stdkey_end(False, False)
        self.insert_text(self.get_line_separator())

    def extend_selection_to_complete_lines(self):
        """Extend current selection to complete lines"""
        cursor = self.textCursor()
        start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd()
        cursor.setPosition(start_pos)
        cursor.setPosition(end_pos, QTextCursor.KeepAnchor)
        if cursor.atBlockStart():
            cursor.movePosition(QTextCursor.PreviousBlock,
                                QTextCursor.KeepAnchor)
            cursor.movePosition(QTextCursor.EndOfBlock,
                                QTextCursor.KeepAnchor)
        self.setTextCursor(cursor)

    def set_selection(self, start, end):
        cursor = self.textCursor()
        cursor.setPosition(start)
        cursor.setPosition(end, QTextCursor.KeepAnchor)
        self.setTextCursor(cursor)

    def truncate_selection(self, position_from):
        """Unselect read-only parts in shell, like prompt"""
        position_from = self.get_position(position_from)
        cursor = self.textCursor()
        start, end = cursor.selectionStart(), cursor.selectionEnd()
        if start < end:
            start = max([position_from, start])
        else:
            end = max([position_from, end])
        self.set_selection(start, end)

    def restrict_cursor_position(self, position_from, position_to):
        """In shell, avoid editing text except between prompt and EOF"""
        position_from = self.get_position(position_from)
        position_to = self.get_position(position_to)
        cursor = self.textCursor()
        cursor_position = cursor.position()
        if cursor_position < position_from or cursor_position > position_to:
            self.set_cursor_position(position_to)

    # ------Code completion / Calltips
    def select_completion_list(self):
        """Completion list is active, Enter was just pressed"""
        self.completion_widget.item_selected()

    def insert_completion(self, completion, completion_position):
        """
        Insert a completion into the editor.

        completion_position is where the completion was generated.

        The replacement range is computed using the (LSP) completion's
        textEdit field if it exists. Otherwise, we replace from the
        start of the word under the cursor.
        """
        if not completion:
            return

        cursor = self.textCursor()

        has_selected_text = self.has_selected_text()
        selection_start, selection_end = self.get_selection_start_end()

        if isinstance(completion, dict) and 'textEdit' in completion:
            completion_range = completion['textEdit']['range']
            start = completion_range['start']
            end = completion_range['end']
            if isinstance(completion_range['start'], dict):
                start_line, start_col = start['line'], start['character']
                start = self.get_position_line_number(start_line, start_col)
            if isinstance(completion_range['start'], dict):
                end_line, end_col = end['line'], end['character']
                end = self.get_position_line_number(end_line, end_col)
            cursor.setPosition(start)
            cursor.setPosition(end, QTextCursor.KeepAnchor)
            text = str(completion['textEdit']['newText'])
        else:
            text = completion
            kind = None
            if isinstance(completion, dict):
                text = completion['insertText']
                kind = completion['kind']
            text = str(text)

            # Get word to the left of the cursor.
            result = self.get_current_word_and_position(
                completion=True, valid_python_variable=False)
            if result is not None:
                current_text, start_position = result
                end_position = start_position + len(current_text)

                # Remove text under cursor only if it's not an autocompletion
                # character
                is_auto_completion_character = False
                if self.objectName() == 'console':
                    if current_text == '.':
                        is_auto_completion_character = True
                else:
                    if (
                        kind != CompletionItemKind.FILE and
                        current_text in self.auto_completion_characters
                    ):
                        is_auto_completion_character = True

                # Adjustments for file completions
                if kind == CompletionItemKind.FILE:
                    special_chars = ['"', "'", '/', '\\']

                    if any(
                        [current_text.endswith(c) for c in special_chars]
                    ):
                        # This is necessary when completions are requested next
                        # to special characters.
                        start_position = end_position
                    elif current_text.endswith('.') and len(current_text) > 1:
                        # This inserts completions for files or directories
                        # that start with a dot
                        start_position = end_position - 1
                    elif current_text == '.':
                        # This is needed if users are asking for file
                        # completions to the right of a dot when some of its
                        # name is part of the completed text
                        cursor_1 = self.textCursor()
                        found_start = False

                        # Select text backwards until we find where the file
                        # name starts
                        while not found_start:
                            cursor_1.movePosition(
                                QTextCursor.PreviousCharacter,
                                QTextCursor.KeepAnchor,
                            )

                            selection = str(cursor_1.selectedText())
                            if text.startswith(selection):
                                found_start = True

                        current_text = str(cursor_1.selectedText())
                        start_position = cursor_1.selectionStart()
                        end_position = cursor_1.selectionEnd()

                if not is_auto_completion_character:
                    # Check if the completion position is in the expected range
                    if not (
                        start_position <= completion_position <= end_position
                    ):
                        return
                    cursor.setPosition(start_position)

                    # Remove the word under the cursor
                    cursor.setPosition(end_position, QTextCursor.KeepAnchor)
                else:
                    # Check if we are in the correct position
                    if cursor.position() != completion_position:
                        return
            else:
                # Check if we are in the correct position
                if cursor.position() != completion_position:
                    return

        if has_selected_text:
            self.sig_will_remove_selection.emit(selection_start, selection_end)

        cursor.removeSelectedText()
        self.setTextCursor(cursor)

        # Add text
        if self.objectName() == 'console':
            # Handle completions for the internal console
            self.insert_text(text)
        else:
            self.sig_insert_completion.emit(text)

    def is_completion_widget_visible(self):
        """Return True is completion list widget is visible"""
        try:
            return self.completion_widget.isVisible()
        except RuntimeError:
            # This is to avoid a RuntimeError exception when the widget is
            # already been deleted. See spyder-ide/spyder#13248.
            return False

    def hide_completion_widget(self, focus_to_parent=True):
        """Hide completion widget and tooltip."""
        # This is necessary to catch an error when creating new editor windows.
        # Fixes spyder-ide/spyder#19109
        try:
            self.completion_widget.hide(focus_to_parent=focus_to_parent)
        except RuntimeError:
            pass
        QToolTip.hideText()

    # ------Standard keys
    def stdkey_clear(self):
        if not self.has_selected_text():
            self.moveCursor(QTextCursor.NextCharacter, QTextCursor.KeepAnchor)
        self.remove_selected_text()

    def stdkey_backspace(self):
        if not self.has_selected_text():
            self.moveCursor(QTextCursor.PreviousCharacter,
                            QTextCursor.KeepAnchor)
        self.remove_selected_text()

    def __get_move_mode(self, shift):
        return QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor

    def stdkey_up(self, shift):
        self.moveCursor(QTextCursor.Up, self.__get_move_mode(shift))

    def stdkey_down(self, shift):
        self.moveCursor(QTextCursor.Down, self.__get_move_mode(shift))

    def stdkey_tab(self):
        self.insert_text(self.indent_chars)

    def stdkey_home(self, shift, ctrl, prompt_pos=None):
        """Smart HOME feature: cursor is first moved at
        indentation position, then at the start of the line"""
        move_mode = self.__get_move_mode(shift)
        if ctrl:
            self.moveCursor(QTextCursor.Start, move_mode)
        else:
            cursor = self.textCursor()
            if prompt_pos is None:
                start_position = self.get_position('sol')
            else:
                start_position = self.get_position(prompt_pos)
            text = self.get_text(start_position, 'eol')
            indent_pos = start_position+len(text)-len(text.lstrip())
            if cursor.position() != indent_pos:
                cursor.setPosition(indent_pos, move_mode)
            else:
                cursor.setPosition(start_position, move_mode)
            self.setTextCursor(cursor)

    def stdkey_end(self, shift, ctrl):
        move_mode = self.__get_move_mode(shift)
        if ctrl:
            self.moveCursor(QTextCursor.End, move_mode)
        else:
            self.moveCursor(QTextCursor.EndOfBlock, move_mode)

    # ----Qt Events
    def mousePressEvent(self, event):
        """Reimplement Qt method"""

        # mouse buttons for forward and backward navigation
        if event.button() == Qt.XButton1:
            self.sig_prev_cursor.emit()
        elif event.button() == Qt.XButton2:
            self.sig_next_cursor.emit()

        if sys.platform.startswith('linux') and event.button() == Qt.MidButton:
            self.calltip_widget.hide()
            self.setFocus()
            event = QMouseEvent(QEvent.MouseButtonPress, event.position(),
                                Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
            QPlainTextEdit.mousePressEvent(self, event)
            QPlainTextEdit.mouseReleaseEvent(self, event)
            # Send selection text to clipboard to be able to use
            # the paste method and avoid the strange spyder-ide/spyder#1445.
            # NOTE: This issue seems a focusing problem but it
            # seems really hard to track
            mode_clip = QClipboard.Clipboard
            mode_sel = QClipboard.Selection
            text_clip = QApplication.clipboard().text(mode=mode_clip)
            text_sel = QApplication.clipboard().text(mode=mode_sel)
            QApplication.clipboard().setText(text_sel, mode=mode_clip)
            self.paste()
            QApplication.clipboard().setText(text_clip, mode=mode_clip)
        else:
            self.calltip_widget.hide()
            QPlainTextEdit.mousePressEvent(self, event)

    def focusInEvent(self, event):
        """Reimplemented to handle focus"""
        self.focus_changed.emit()
        self.focus_in.emit()
        QPlainTextEdit.focusInEvent(self, event)

    def focusOutEvent(self, event):
        """Reimplemented to handle focus"""
        self.focus_changed.emit()
        QPlainTextEdit.focusOutEvent(self, event)

    def wheelEvent(self, event):
        """Reimplemented to emit zoom in/out signals when Ctrl is pressed"""
        # This feature is disabled on MacOS, see spyder-ide/spyder#1510.
        if (
            sys.platform != 'darwin'
            and not self.get_conf('disable_zoom_mouse', section='main')
        ):
            if event.modifiers() & Qt.ControlModifier:
                if hasattr(event, 'angleDelta'):
                    if event.angleDelta().y() < 0:
                        self.zoom_out.emit()
                    elif event.angleDelta().y() > 0:
                        self.zoom_in.emit()
                elif hasattr(event, 'delta'):
                    if event.delta() < 0:
                        self.zoom_out.emit()
                    elif event.delta() > 0:
                        self.zoom_in.emit()
                return

        QPlainTextEdit.wheelEvent(self, event)

        # Needed to prevent stealing focus when scrolling.
        # If the current widget with focus is the CompletionWidget, it means
        # it's being displayed in the editor, so we need to hide it and give
        # focus back to the editor. If not, we need to leave the focus in
        # the widget that currently has it.
        # See spyder-ide/spyder#11502
        current_widget = QApplication.focusWidget()
        if isinstance(current_widget, CompletionWidget):
            self.hide_completion_widget(focus_to_parent=True)
        else:
            self.hide_completion_widget(focus_to_parent=False)

    def position_widget_at_cursor(self, widget):
        # Retrieve current screen height
        srect = self.screen().availableGeometry()

        left, top, right, bottom = (srect.left(), srect.top(),
                                    srect.right(), srect.bottom())
        ancestor = widget.parent()
        if ancestor:
            left = max(left, ancestor.x())
            top = max(top, ancestor.y())
            right = min(right, ancestor.x() + ancestor.width())
            bottom = min(bottom, ancestor.y() + ancestor.height())

        point = self.cursorRect().bottomRight()
        point = self.calculate_real_position(point)
        point = self.mapToGlobal(point)

        # Move to left of cursor if not enough space on right
        widget_right = point.x() + widget.width()
        if widget_right > right:
            point.setX(point.x() - widget.width())

        # Push to right if not enough space on left
        if point.x() < left:
            point.setX(left)

        # Moving widget above if there is not enough space below
        widget_bottom = point.y() + widget.height()
        x_position = point.x()
        if widget_bottom > bottom:
            point = self.cursorRect().topRight()
            point = self.mapToGlobal(point)
            point.setX(x_position)
            point.setY(point.y() - widget.height())
            delta_y = -2
        else:
            delta_y = 5 if sys.platform == "darwin" else 6

        # Add small delta to the vertical position so that the widget is not
        # shown too close to the text
        point.setY(point.y() + delta_y)

        if ancestor is not None:
            # Useful only if we set parent to 'ancestor' in __init__
            point = ancestor.mapFromGlobal(point)

        widget.move(point)

    def calculate_real_position(self, point):
        return point
