#!/usr/bin/env python
# License: GPLv3 Copyright: 2008, Kovid Goyal <kovid at kovidgoyal.net>

import os
import re
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Protocol

from qt.core import (
    QAction,
    QApplication,
    QClipboard,
    QColor,
    QComboBox,
    QCompleter,
    QContextMenuEvent,
    QCursor,
    QEvent,
    QFont,
    QGraphicsPixmapItem,
    QGraphicsScene,
    QGraphicsView,
    QIcon,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMenu,
    QPageSize,
    QPainter,
    QPalette,
    QPen,
    QPixmap,
    QPrinter,
    QRect,
    QSize,
    QSplitterHandle,
    QStringListModel,
    QSyntaxHighlighter,
    Qt,
    QTextCharFormat,
    QWidget,
    pyqtSignal,
)

from calibre import fit_image, force_unicode, strftime
from calibre.constants import ismacos, iswindows
from calibre.ebooks import BOOK_EXTENSIONS
from calibre.gui2 import clip_border_radius, error_dialog, gprefs, pixmap_to_data, qapplication_or_fail, warning_dialog
from calibre.gui2.dnd import DownloadDialog, dnd_get_files, dnd_get_image, dnd_get_local_image_and_pixmap, dnd_has_extension, dnd_has_image, image_extensions
from calibre.gui2.filename_pattern_ui import Ui_Form
from calibre.gui2.progress_indicator import ProgressIndicator as _ProgressIndicator
from calibre.startup import connect_lambda
from calibre.utils.config import XMLConfig, prefs
from calibre.utils.localization import _, localize_user_manual_link

history = XMLConfig('history')


class ProgressIndicator(QWidget):  # {{{
    def __init__(self, *args):
        QWidget.__init__(self, *args)
        self.setGeometry(0, 0, 300, 350)
        self.pi = _ProgressIndicator(self)
        self.status = QLabel(self)
        self.status.setWordWrap(True)
        self.status.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
        self.setVisible(False)
        self.current_pos: tuple[int, int] | None = None

    def start(self, msg=''):
        view = self.parent()
        assert isinstance(view, QWidget)
        pwidth, pheight = view.size().width(), view.size().height()
        self.resize(pwidth, min(pheight, 250))
        if self.current_pos is None:
            self.move(0, int((pheight - self.size().height()) / 2))
        else:
            self.move(self.current_pos[0], self.current_pos[1])
        self.pi.resize(self.pi.sizeHint())
        self.pi.move(int((self.size().width() - self.pi.size().width()) / 2), 0)
        self.status.resize(self.size().width(), self.size().height() - self.pi.size().height() - 10)
        self.status.move(0, self.pi.size().height() + 10)
        self.status.setText('<h1>' + msg + '</h1>')
        self.setVisible(True)
        self.pi.startAnimation()

    def stop(self):
        self.pi.stopAnimation()
        self.setVisible(False)


# }}}


class FilenamePattern(QWidget, Ui_Form):  # {{{
    changed_signal = pyqtSignal()

    def __init__(self, parent):
        QWidget.__init__(self, parent)
        self.setupUi(self)
        try:
            self.help_label.setText(self.help_label.text() % localize_user_manual_link('https://manual.calibre-ebook.com/regexp.html'))
        except TypeError:
            pass  # link already localized

        self.test_button.clicked.connect(self.do_test)
        re_line_edit = self.re.lineEdit()
        assert re_line_edit is not None
        re_line_edit.returnPressed[()].connect(self.do_test)
        self.filename.returnPressed[()].connect(self.do_test)
        connect_lambda(re_line_edit.textChanged, self, lambda self, x: self.changed_signal.emit())

    def initialize(self, defaults=False):
        # Get all items in the combobox. If we are resetting
        # to defaults we don't want to lose what the user
        # has added.
        re_line_edit = self.re.lineEdit()
        assert re_line_edit is not None
        val_hist = [str(re_line_edit.text())] + [str(self.re.itemText(i)) for i in range(self.re.count())]
        self.re.clear()

        if defaults:
            val = prefs.defaults['filename_pattern']
        else:
            val = prefs['filename_pattern']
        re_line_edit.setText(val)

        val_hist += gprefs.get(
            'filename_pattern_history',
            [
                '(?P<title>.+)',
                r'(?P<author>[^_-]+) -?\s*(?P<series>[^_0-9-]*)(?P<series_index>[0-9]*)\s*-\s*(?P<title>[^_].+) ?',
            ],
        )
        if val in val_hist:
            del val_hist[val_hist.index(val)]
        val_hist.insert(0, val)
        for v in val_hist:
            # Ensure we don't have duplicate items.
            if v and self.re.findText(v) == -1:
                self.re.addItem(v)
        self.re.setCurrentIndex(0)

    def do_test(self):
        from calibre.ebooks.metadata import authors_to_string
        from calibre.ebooks.metadata.meta import metadata_from_filename

        fname = str(self.filename.text())
        ext = os.path.splitext(fname)[1][1:].lower()
        if ext not in BOOK_EXTENSIONS:
            return warning_dialog(
                self,
                _('Test file name invalid'),
                _('The file name <b>%s</b> does not appear to end with a file extension. It must end with a file  extension like .epub or .mobi') % fname,
                show=True,
            )

        try:
            pat = self.pattern()
        except Exception as err:
            error_dialog(self, _('Invalid regular expression'), _('Invalid regular expression: %s') % err).exec()
            return
        mi = metadata_from_filename(fname, pat)
        if mi.title:
            self.title.setText(mi.title)
        else:
            self.title.setText(_('No match'))
        if mi.authors:
            self.authors.setText(authors_to_string(mi.authors))
        else:
            self.authors.setText(_('No match'))

        if mi.series:
            self.series.setText(mi.series)
        else:
            self.series.setText(_('No match'))

        if mi.series_index is not None:
            self.series_index.setText(str(mi.series_index))
        else:
            self.series_index.setText(_('No match'))

        if mi.publisher:
            self.publisher.setText(mi.publisher)
        else:
            self.publisher.setText(_('No match'))

        if mi.pubdate:
            self.pubdate.setText(strftime('%Y-%m-%d', mi.pubdate))
        else:
            self.pubdate.setText(_('No match'))

        self.isbn.setText(_('No match') if mi.isbn is None else str(mi.isbn))
        self.comments.setText(mi.comments or _('No match'))

    def pattern(self):
        re_line_edit = self.re.lineEdit()
        assert re_line_edit is not None
        pat = str(re_line_edit.text())
        return re.compile(pat)

    def commit(self):
        pat = self.pattern().pattern
        prefs['filename_pattern'] = pat

        history = []
        re_line_edit = self.re.lineEdit()
        assert re_line_edit is not None
        history_pats = [str(re_line_edit.text())] + [str(self.re.itemText(i)) for i in range(self.re.count())]
        for p in history_pats[:24]:
            # Ensure we don't have duplicate items.
            if p and p not in history:
                history.append(p)
        gprefs['filename_pattern_history'] = history

        return pat


# }}}


class FormatList(QListWidget):  # {{{
    DROPABBLE_EXTENSIONS = BOOK_EXTENSIONS
    formats_dropped = pyqtSignal(object, object)
    delete_format = pyqtSignal()

    def dragEnterEvent(self, e):
        md = e.mimeData()
        if dnd_has_extension(md, self.DROPABBLE_EXTENSIONS, allow_all_extensions=True):
            e.acceptProposedAction()

    def dropEvent(self, event):
        event.setDropAction(Qt.DropAction.CopyAction)
        md = event.mimeData()
        # Now look for ebook files
        urls, filenames = dnd_get_files(md, self.DROPABBLE_EXTENSIONS, allow_all_extensions=True)
        if not urls:
            # Nothing found
            return

        if not filenames:
            # Local files
            self.formats_dropped.emit(event, urls)
        else:
            # Remote files, use the first file
            d = DownloadDialog(urls[0], filenames[0], self)
            d.start_download()
            if d.err is None:
                self.formats_dropped.emit(event, [d.fpath])

    def dragMoveEvent(self, e):
        e.acceptProposedAction()

    def keyPressEvent(self, e):
        if e.key() == Qt.Key.Key_Delete:
            self.delete_format.emit()
        else:
            return QListWidget.keyPressEvent(self, e)


# }}}


class ImageDropWidgetProtocol(Protocol):  # {{{
    def setAcceptDrops(self, on: bool) -> None: ...
    def pixmap(self) -> QPixmap: ...
    def get_pixmap(self) -> QPixmap: ...
    def setPixmap(self, pixmap: QPixmap) -> None: ...
    def set_pixmap(self, pixmap: QPixmap) -> None: ...
    def handle_image_drop(self, pmap: QPixmap, data: bytes | None = None) -> None: ...

    cover_changed: Any


class ImageDropMixin:
    """
    Adds support for dropping images onto widgets and a context menu for
    copy/pasting images.
    """

    DROPABBLE_EXTENSIONS = None

    if TYPE_CHECKING:

        def pixmap(self) -> QPixmap: ...
        def setPixmap(self, pixmap: QPixmap) -> None: ...

    def __init__(self: ImageDropWidgetProtocol):
        self.setAcceptDrops(True)

    def dragEnterEvent(self, event):
        md = event.mimeData()
        exts = self.DROPABBLE_EXTENSIONS or image_extensions()
        if dnd_has_extension(md, exts) or dnd_has_image(md):
            event.acceptProposedAction()

    def dropEvent(self: ImageDropWidgetProtocol, event):
        event.setDropAction(Qt.DropAction.CopyAction)
        md = event.mimeData()
        pmap, data = dnd_get_local_image_and_pixmap(md)
        if pmap is not None:
            self.handle_image_drop(pmap, data)
            return

        x, y = dnd_get_image(md)
        if x is not None:
            # We have an image, set cover
            event.accept()
            if y is None:
                # Local image
                self.handle_image_drop(x)
            else:
                # Remote files, use the first file
                d = DownloadDialog(x, y, self)
                d.start_download()
                if d.err is None:
                    pmap = QPixmap()
                    with open(d.fpath, 'rb') as f:
                        data = f.read()
                    pmap.loadFromData(data)
                    if not pmap.isNull():
                        self.handle_image_drop(pmap, data=data)

    def handle_image_drop(self: ImageDropWidgetProtocol, pmap, data=None):
        self.set_pixmap(pmap)
        self.cover_changed.emit(data or pixmap_to_data(pmap, format='PNG'))

    def dragMoveEvent(self, event):
        event.acceptProposedAction()

    def get_pixmap(self) -> QPixmap:
        return self.pixmap()

    def set_pixmap(self, pixmap: QPixmap) -> None:
        self.setPixmap(pixmap)

    def build_context_menu(self):
        cm = QMenu(self)  # type: ignore
        paste = cm.addAction(QIcon.ic('edit-paste.png'), _('Paste cover'))
        assert paste is not None
        copy = cm.addAction(QIcon.ic('edit-copy.png'), _('Copy cover'))
        assert copy is not None
        _cb = qapplication_or_fail().clipboard()
        assert _cb is not None
        _mime = _cb.mimeData()
        assert _mime is not None
        if not _mime.hasImage():
            paste.setEnabled(False)
        copy.triggered.connect(self.copy_to_clipboard)
        paste.triggered.connect(self.paste_from_clipboard)
        return cm

    def contextMenuEvent(self, a0):
        self.build_context_menu().exec(a0.globalPos())

    def copy_to_clipboard(self) -> None:
        _cb = qapplication_or_fail().clipboard()
        assert _cb is not None
        _cb.setPixmap(self.get_pixmap())

    def paste_from_clipboard(self: ImageDropWidgetProtocol):
        cb = qapplication_or_fail().clipboard()
        assert cb is not None
        pmap = cb.pixmap()
        if pmap.isNull() and cb.supportsSelection():
            pmap = cb.pixmap(QClipboard.Mode.Selection)
        if ismacos:  # Without this there is a crash when Qt tries to save this pixmap as JPEG data
            pmap = pmap.copy()
        if not pmap.isNull():
            self.set_pixmap(pmap)
            self.cover_changed.emit(pixmap_to_data(pmap, format='PNG'))
            return True
        return False


# }}}

# ImageView {{{


def draw_size(p, rect, w, h):
    rect = rect.adjusted(0, 0, 0, -4)
    f = p.font()
    f.setBold(True)
    p.setFont(f)
    sz = f'\xa0{w} x {h}\xa0'
    flags = Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight | Qt.TextFlag.TextSingleLine
    szrect = p.boundingRect(rect, flags, sz)
    p.fillRect(szrect.adjusted(0, 0, 0, 4), QColor(0, 0, 0, 200))
    p.setPen(QPen(QColor(255, 255, 255)))
    p.drawText(rect, flags, sz)


class ImageView(QWidget, ImageDropMixin):
    BORDER_WIDTH = 1
    cover_changed = pyqtSignal(object)
    draw_empty_border = False

    def __init__(self, parent=None, show_size_pref_name=None, default_show_size=False):
        QWidget.__init__(self, parent)
        self.show_size_pref_name = ('show_size_on_cover_' + show_size_pref_name) if show_size_pref_name else None
        self._pixmap = QPixmap()
        self.setMinimumSize(QSize(150, 200))
        ImageDropMixin.__init__(self)
        self.draw_border = True
        self.show_size = False
        if self.show_size_pref_name:
            self.show_size = gprefs.get(self.show_size_pref_name, default_show_size)

    def dragEnterEvent(self, a0):
        ImageDropMixin.dragEnterEvent(self, a0)

    def dragMoveEvent(self, a0):
        ImageDropMixin.dragMoveEvent(self, a0)

    def dropEvent(self, a0):
        ImageDropMixin.dropEvent(self, a0)

    def setPixmap(self, pixmap):
        if not isinstance(pixmap, QPixmap):
            raise TypeError('Must use a QPixmap')
        self._pixmap = pixmap
        self.updateGeometry()
        self.update()

    def build_context_menu(self):
        m = ImageDropMixin.build_context_menu(self)
        if self.show_size_pref_name:
            text = _('Hide size in corner') if self.show_size else _('Show size in corner')
            m.addAction(text, self.toggle_show_size)
        return m

    def toggle_show_size(self):
        self.show_size ^= True
        if self.show_size_pref_name:
            gprefs[self.show_size_pref_name] = self.show_size
        self.update()

    def pixmap(self):
        return self._pixmap

    def sizeHint(self):
        if self._pixmap.isNull():
            return self.minimumSize()
        return self._pixmap.size()

    def paintEvent(self, a0):
        QWidget.paintEvent(self, a0)
        pmap = self._pixmap
        p = QPainter(self)
        p.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
        if pmap.isNull():
            if self.draw_empty_border:
                pen = QPen()
                pen.setWidth(self.BORDER_WIDTH)
                p.setPen(pen)
                p.drawRect(self.rect())
                p.end()
            return
        w, h = pmap.width(), pmap.height()
        ow, oh = w, h
        cw, ch = self.rect().width(), self.rect().height()
        scaled, nw, nh = fit_image(w, h, cw, ch)
        if scaled:
            pmap = pmap.scaled(
                int(nw * pmap.devicePixelRatio()),
                int(nh * pmap.devicePixelRatio()),
                Qt.AspectRatioMode.IgnoreAspectRatio,
                Qt.TransformationMode.SmoothTransformation,
            )
        w, h = int(pmap.width() / pmap.devicePixelRatio()), int(pmap.height() / pmap.devicePixelRatio())
        x = int(abs(cw - w) / 2)
        y = int(abs(ch - h) / 2)
        target = QRect(x, y, w, h)
        with clip_border_radius(p, target):
            p.drawPixmap(target, pmap)
        if self.draw_border:
            pen = QPen()
            pen.setWidth(self.BORDER_WIDTH)
            p.setPen(pen)
            p.drawRect(target)
        if self.show_size:
            draw_size(p, target, ow, oh)


# }}}

# CoverView {{{


class RoundedPixmap(QGraphicsPixmapItem):
    def paint(self, painter, option, widget):
        painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
        target = self.boundingRect().toAlignedRect()
        with clip_border_radius(painter, target):
            painter.drawPixmap(target, self.pixmap())


class CoverView(QGraphicsView, ImageDropMixin):
    cover_changed = pyqtSignal(object)

    def __init__(self, *args, **kwargs):
        self.show_size = kwargs.pop('show_size', False)
        QGraphicsView.__init__(self, *args, **kwargs)
        ImageDropMixin.__init__(self)
        self.pixmap_size = 0, 0
        self._pixmap_scene: QGraphicsScene | None = None
        if self.show_size:
            self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate)
        self.set_background()

    def contextMenuEvent(self, event):
        return ImageDropMixin.contextMenuEvent(self, event)

    def get_pixmap(self) -> QPixmap:
        if self._pixmap_scene is not None:
            for item in self._pixmap_scene.items():
                if isinstance(item, QGraphicsPixmapItem):
                    return item.pixmap()
        return QPixmap()

    def pixmap(self) -> QPixmap:
        return self.get_pixmap()

    def set_pixmap(self, pixmap: QPixmap) -> None:
        self._pixmap_scene = QGraphicsScene()
        self._pixmap_scene.addItem(RoundedPixmap(pixmap))
        self.setScene(self._pixmap_scene)

    def setPixmap(self, pixmap: QPixmap) -> None:
        self.set_pixmap(pixmap)

    def set_background(self, brush=None):
        self.setBackgroundBrush(brush or self.palette().color(QPalette.ColorRole.Window))

    def paintEvent(self, event):
        QGraphicsView.paintEvent(self, event)
        if self.show_size:
            v = self.viewport()
            assert v is not None
            p = QPainter(v)
            draw_size(p, v.rect(), *self.pixmap_size)


# }}}

# BasicList {{{


class BasicListItem(QListWidgetItem):
    def __init__(self, text, user_data=None):
        QListWidgetItem.__init__(self, text)
        self.user_data = user_data

    def __eq__(self, other):
        if hasattr(other, 'text'):
            return self.text() == other.text()
        return False


class BasicList(QListWidget):
    def add_item(self, text, user_data=None, replace=False):
        item = BasicListItem(text, user_data)

        for oitem in self.items():
            if oitem == item:
                if replace:
                    self.takeItem(self.row(oitem))
                else:
                    raise ValueError('Item already in list')

        self.addItem(item)

    def remove_selected_items(self, *args):
        for item in self.selectedItems():
            self.takeItem(self.row(item))

    def items(self, data=None):
        for i in range(self.count()):
            yield self.item(i)


# }}}


class LineEditECMProtocol(Protocol):
    def createStandardContextMenu(self) -> QMenu | None: ...
    def create_change_case_menu(self, menu: QMenu) -> QMenu: ...
    def add_items_to_context_menu(self, menu: QMenu) -> QMenu: ...
    def hasSelectedText(self) -> bool: ...
    def selectedText(self) -> str: ...
    def text(self) -> str: ...
    def insert(self, text: str) -> None: ...
    def setText(self, text: str) -> None: ...
    def modify_case_operation(self, func: Callable[[str], str]) -> None: ...


class LineEditECM:  # {{{
    """
    Extend the context menu of a QLineEdit to include more actions.
    """

    add_items_to_context_menu_callback: Callable[[QMenu], None] | None = None

    if TYPE_CHECKING:

        def hasSelectedText(self) -> bool: ...
        def selectedText(self) -> str: ...
        def text(self) -> str: ...
        def insert(self, a0: str | None) -> None: ...
        def setText(self, a0: str | None) -> None: ...
        def createStandardContextMenu(self) -> QMenu | None: ...

    def create_change_case_menu(self, menu: QMenu) -> QMenu:
        case_menu = QMenu(_('Change case'), menu)
        action_upper_case = case_menu.addAction(_('Upper case'))
        action_lower_case = case_menu.addAction(_('Lower case'))
        action_swap_case = case_menu.addAction(_('Swap case'))
        action_title_case = case_menu.addAction(_('Title case'))
        action_capitalize = case_menu.addAction(_('Capitalize'))
        assert action_upper_case is not None
        assert action_lower_case is not None
        assert action_swap_case is not None
        assert action_title_case is not None
        assert action_capitalize is not None
        action_upper_case.triggered.connect(self.upper_case)
        action_lower_case.triggered.connect(self.lower_case)
        action_swap_case.triggered.connect(self.swap_case)
        action_title_case.triggered.connect(self.title_case)
        action_capitalize.triggered.connect(self.capitalize)
        menu.addMenu(case_menu)
        return case_menu

    def add_items_to_context_menu(self, menu: QMenu) -> QMenu:
        if self.add_items_to_context_menu_callback is not None:
            self.add_items_to_context_menu_callback(menu)
        return menu

    def contextMenuEvent(self, a0: QContextMenuEvent | None) -> None:
        menu = self.createStandardContextMenu()
        assert isinstance(menu, QMenu)
        menu.addSeparator()
        self.create_change_case_menu(menu)
        menu = self.add_items_to_context_menu(menu)
        menu.exec(a0.globalPos())  # type: ignore

    def modify_case_operation(self, func: Callable[[str], str]) -> None:
        has_selection = self.hasSelectedText()
        text = self.selectedText() if has_selection else self.text()
        ntext = func(text)
        if ntext != text:
            self.insert(ntext) if has_selection else self.setText(ntext)

    def upper_case(self: LineEditECMProtocol):
        from calibre.utils.icu import upper

        self.modify_case_operation(upper)

    def lower_case(self: LineEditECMProtocol):
        from calibre.utils.icu import lower

        self.modify_case_operation(lower)

    def swap_case(self: LineEditECMProtocol):
        from calibre.utils.icu import swapcase

        self.modify_case_operation(swapcase)

    def title_case(self: LineEditECMProtocol):
        from calibre.utils.titlecase import titlecase

        self.modify_case_operation(titlecase)

    def capitalize(self: LineEditECMProtocol):
        from calibre.utils.icu import capitalize

        self.modify_case_operation(capitalize)


# }}}


class EnLineEdit(LineEditECM, QLineEdit):  # {{{
    """
    Enhanced QLineEdit.

    Includes an extended content menu.
    """

    def event(self, a0):
        # See https://bugreports.qt.io/browse/QTBUG-46911
        if a0.type() == QEvent.Type.ShortcutOverride and (
            hasattr(a0, 'key')
            and a0.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right)
            and (a0.modifiers() & ~Qt.KeyboardModifier.KeypadModifier) == Qt.KeyboardModifier.ControlModifier
        ):
            a0.accept()
        return QLineEdit.event(self, a0)


# }}}

# LineEditIndicators {{{


class LineEditIndicatorsProtocol(Protocol):
    status_actions: tuple[QAction, QAction]

    def addAction(self, icon: QIcon, position: QLineEdit.ActionPosition) -> QAction | None: ...
    def setStyleSheet(self, styleSheet: str) -> None: ...


def setup_status_actions(self: LineEditIndicatorsProtocol):
    ok = self.addAction(QIcon.ic('ok.png'), QLineEdit.ActionPosition.TrailingPosition)
    err = self.addAction(QIcon.ic('dialog_error.png'), QLineEdit.ActionPosition.TrailingPosition)
    assert ok is not None and err is not None
    self.status_actions = ok, err
    self.status_actions[0].setVisible(False)
    self.status_actions[1].setVisible(False)


def stylesheet_for_lineedit(ok, selector='QLineEdit') -> str:
    if ok is None:
        return ''
    col = '#50c878' if ok else '#FF2400'
    return f'{selector} {{ border: 2px solid {col}; border-radius: 3px }}'


def update_status_actions(self: LineEditIndicatorsProtocol, ok, tooltip: str = ''):
    self.status_actions[0].setVisible(bool(ok))
    self.status_actions[1].setVisible(not ok)
    if ok:
        self.status_actions[0].setToolTip(tooltip)
    elif ok is None:
        self.status_actions[1].setVisible(False)
    else:
        self.status_actions[1].setToolTip(tooltip)
    self.setStyleSheet(stylesheet_for_lineedit(ok))


class LineEditIndicators:
    status_actions: tuple[QAction, QAction]

    def setup_status_actions(self: LineEditIndicatorsProtocol):
        setup_status_actions(self)

    def update_status_actions(self: LineEditIndicatorsProtocol, ok, tooltip=''):
        update_status_actions(self, ok, tooltip)


# }}}


class ItemsCompleter(QCompleter):  # {{{
    """
    A completer object that completes a list of tags. It is used in conjunction
    with a CompleterLineEdit.
    """

    def __init__(self, parent, all_items):
        QCompleter.__init__(self, all_items, parent)
        self.all_items = set(all_items)

    def update(self, text_items, completion_prefix):
        items = list(self.all_items.difference(text_items))
        model = QStringListModel(items, self)
        self.setModel(model)

        self.setCompletionPrefix(completion_prefix)
        if completion_prefix.strip():
            self.complete()

    def update_items_cache(self, items):
        self.all_items = set(items)
        model = QStringListModel(items, self)
        self.setModel(model)


# }}}


class CompleteLineEdit(EnLineEdit):  # {{{
    """
    A QLineEdit that can complete parts of text separated by separator.
    """

    def __init__(self, parent=None, complete_items=[], sep=',', space_before_sep=False):
        EnLineEdit.__init__(self, parent)

        self.separator = sep
        self.space_before_sep = space_before_sep

        self.textChanged.connect(self.text_changed)

        self.items_completer = ItemsCompleter(self, complete_items)
        self.items_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)

        self.items_completer.activated[str].connect(self.complete_text)

        self.items_completer.setWidget(self)

    def update_items_cache(self, complete_items):
        self.items_completer.update_items_cache(complete_items)

    def set_separator(self, sep):
        self.separator = sep

    def set_space_before_sep(self, space_before):
        self.space_before_sep = space_before

    def text_changed(self, text):
        all_text = str(text)
        text = all_text[: self.cursorPosition()]
        prefix = text.split(self.separator)[-1].strip()

        text_items = []
        for t in all_text.split(self.separator):
            t1 = str(t).strip()
            if t1:
                text_items.append(t)
        text_items = list(set(text_items))
        self.items_completer.update(text_items, prefix)

    def complete_text(self, text):
        cursor_pos = self.cursorPosition()
        before_text = str(self.text())[:cursor_pos]
        after_text = str(self.text())[cursor_pos:]
        prefix_len = len(before_text.split(self.separator)[-1].lstrip())
        if self.space_before_sep:
            complete_text_pat = '%s%s %s %s'
            len_extra = 3
        else:
            complete_text_pat = '%s%s%s %s'
            len_extra = 2
        self.setText(complete_text_pat % (before_text[: cursor_pos - prefix_len], text, self.separator, after_text))
        self.setCursorPosition(cursor_pos - prefix_len + len(text) + len_extra)


# }}}


class EnComboBox(QComboBox):  # {{{
    """
    Enhanced QComboBox.

    Includes an extended context menu.
    """

    def __init__(self, *args):
        QComboBox.__init__(self, *args)
        self.setLineEdit(EnLineEdit(self))
        _completer = self.completer()
        assert _completer is not None
        _completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
        self.setMinimumContentsLength(20)

    def text(self):
        return str(self.currentText())

    def setText(self, text):
        idx = self.findText(text, Qt.MatchFlag.MatchFixedString | Qt.MatchFlag.MatchCaseSensitive)
        if idx == -1:
            self.insertItem(0, text)
            idx = 0
        self.setCurrentIndex(idx)


# }}}


class CompleteComboBox(EnComboBox):  # {{{
    def __init__(self, *args):
        EnComboBox.__init__(self, *args)
        self.setLineEdit(CompleteLineEdit(self))

    def update_items_cache(self, complete_items):
        le = self.lineEdit()
        assert le is not None
        assert isinstance(le, CompleteLineEdit)
        le.update_items_cache(complete_items)

    def set_separator(self, sep):
        le = self.lineEdit()
        assert le is not None
        assert isinstance(le, CompleteLineEdit)
        le.set_separator(sep)

    def set_space_before_sep(self, space_before):
        le = self.lineEdit()
        assert le is not None
        assert isinstance(le, CompleteLineEdit)
        le.set_space_before_sep(space_before)


# }}}


class HistoryLineEdit(QComboBox):  # {{{
    lost_focus = pyqtSignal()

    def __init__(self, parent=None):
        QComboBox.__init__(self, parent)
        self.setEditable(True)
        self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
        self.setMaxCount(10)
        _line_edit = self.lineEdit()
        assert _line_edit is not None
        self.setClearButtonEnabled = _line_edit.setClearButtonEnabled
        self.textChanged = self.editTextChanged

    def setPlaceholderText(self, placeholderText):
        line_edit = self.lineEdit()
        assert line_edit is not None
        return line_edit.setPlaceholderText(placeholderText)

    def contextMenuEvent(self, e):
        _line_edit = self.lineEdit()
        assert _line_edit is not None
        menu = _line_edit.createStandardContextMenu()
        assert menu is not None
        menu.addSeparator()
        menu.addAction(_('Clear history'), self.clear_history_default_impl)
        menu.exec(e.globalPos())

    def clear_history_default_impl(self):
        self.clear()
        history.set(self.store_name, [])

    @property
    def store_name(self):
        return 'lineedit_history_' + self._name

    def initialize(self, name):
        self._name = name
        self.addItems(history.get(self.store_name, []))
        self.setEditText('')
        _line_edit = self.lineEdit()
        assert _line_edit is not None
        _line_edit.editingFinished.connect(self.save_history)

    def save_history(self):
        items = []
        ct = str(self.currentText())
        if ct:
            items.append(ct)
        for i in range(self.count()):
            item = str(self.itemText(i))
            if item not in items:
                items.append(item)
        self.blockSignals(True)
        self.clear()
        self.addItems(items)
        self.setEditText(ct)
        self.blockSignals(False)
        try:
            history.set(self.store_name, items)
        except ValueError:
            from calibre.utils.cleantext import clean_ascii_chars

            items = [clean_ascii_chars(force_unicode(x)) for x in items]
            try:
                history.set(self.store_name, items)
            except ValueError:
                pass

    def setText(self, t):
        self.setEditText(t)
        line_edit = self.lineEdit()
        assert line_edit is not None
        line_edit.setCursorPosition(0)

    def text(self):
        return self.currentText()

    def focusOutEvent(self, e):
        QComboBox.focusOutEvent(self, e)
        view = self.view()
        assert view is not None
        if not (self.hasFocus() or view.hasFocus()):
            self.lost_focus.emit()


# }}}


class ComboBoxWithHelp(QComboBox):  # {{{
    """
    A combobox where item 0 is help text. CurrentText will return '' for item 0.
    Be sure to always fetch the text with currentText. Don't use the signals
    that pass a string, because they will not correct the text.
    """

    def __init__(self, parent=None):
        QComboBox.__init__(self, parent)
        self.currentIndexChanged.connect(self.index_changed)
        self.help_text = ''
        self.state_set = False

    def initialize(self, help_text=_('Search')):
        self.help_text = help_text
        self.set_state()

    def set_state(self):
        if not self.state_set:
            if self.currentIndex() == 0:
                self.setItemText(0, self.help_text)
                self.setStyleSheet('QComboBox { color: gray }')
            else:
                self.setItemText(0, '')
                self.setStyleSheet('QComboBox { color: black }')

    def index_changed(self, index):
        self.state_set = False
        self.set_state()

    def currentText(self):
        if self.currentIndex() == 0:
            return ''
        return QComboBox.currentText(self)

    def itemText(self, index):
        if index == 0:
            return ''
        return QComboBox.itemText(self, index)

    def showPopup(self):
        self.setItemText(0, '')
        QComboBox.showPopup(self)

    def hidePopup(self):
        QComboBox.hidePopup(self)
        self.set_state()


# }}}


class EncodingComboBox(QComboBox):  # {{{
    """
    A combobox that holds text encodings support
    by Python. This is only populated with the most
    common and standard encodings. There is no good
    way to programmatically list all supported encodings
    using encodings.aliases.aliases.keys(). It
    will not work.
    """

    ENCODINGS = [
        '',
        'cp1252',
        'latin1',
        'utf-8',
        '',
        'ascii',
        'big5',
        'cp1250',
        'cp1251',
        'cp1253',
        'cp1254',
        'cp1255',
        'cp1256',
        'euc_jp',
        'euc_kr',
        'gb2312',
        'gb18030',
        'hz',
        'iso2022_jp',
        'iso2022_kr',
        'iso8859_5',
        'shift_jis',
    ]

    def __init__(self, parent=None):
        QComboBox.__init__(self, parent)
        self.setEditable(True)
        self.setLineEdit(EnLineEdit(self))

        for item in self.ENCODINGS:
            self.addItem(item)


# }}}


class PythonHighlighter(QSyntaxHighlighter):  # {{{
    Rules = ()
    Formats = {}

    KEYWORDS = [
        'and',
        'as',
        'assert',
        'break',
        'class',
        'continue',
        'def',
        'del',
        'elif',
        'else',
        'except',
        'exec',
        'finally',
        'for',
        'from',
        'global',
        'if',
        'import',
        'in',
        'is',
        'lambda',
        'not',
        'or',
        'pass',
        'print',
        'raise',
        'return',
        'try',
        'while',
        'with',
        'yield',
    ]

    BUILTINS = [
        'abs',
        'all',
        'any',
        'basestring',
        'bool',
        'callable',
        'chr',
        'classmethod',
        'cmp',
        'compile',
        'complex',
        'delattr',
        'dict',
        'dir',
        'divmod',
        'enumerate',
        'eval',
        'execfile',
        'exit',
        'file',
        'filter',
        'float',
        'frozenset',
        'getattr',
        'globals',
        'hasattr',
        'hex',
        'id',
        'int',
        'isinstance',
        'issubclass',
        'iter',
        'len',
        'list',
        'locals',
        'long',
        'map',
        'max',
        'min',
        'object',
        'oct',
        'open',
        'ord',
        'pow',
        'property',
        'range',
        'reduce',
        'repr',
        'reversed',
        'round',
        'set',
        'setattr',
        'slice',
        'sorted',
        'staticmethod',
        'str',
        'sum',
        'super',
        'tuple',
        'type',
        'unichr',
        'unicode',
        'vars',
        'xrange',
        'zip',
    ]

    CONSTANTS = ['False', 'True', 'None', 'NotImplemented', 'Ellipsis']

    def __init__(self, parent=None):
        super().__init__(parent)
        if not self.Rules:
            self.initialize_class_members()

    @classmethod
    def initialize_class_members(cls):
        cls.initializeFormats()
        r = []

        def a(a, b):
            r.append((a, b))

        a(re.compile('|'.join([rf'\b{keyword}\b' for keyword in cls.KEYWORDS])), 'keyword')
        a(re.compile('|'.join([rf'\b{builtin}\b' for builtin in cls.BUILTINS])), 'builtin')
        a(re.compile('|'.join([rf'\b{constant}\b' for constant in cls.CONSTANTS])), 'constant')
        a(
            re.compile(
                r'\b[+-]?[0-9]+[lL]?\b'
                r'|\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b'
                r'|\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b'
            ),
            'number',
        )
        a(re.compile(r'\bPyQt6\b|\bQt?[A-Z][a-z]\w+\b'), 'pyqt')
        a(re.compile(r'\b@\w+\b'), 'decorator')
        stringRe = re.compile(r'''(?:'[^']*?'|"[^"]*?")''')
        a(stringRe, 'string')
        cls.stringRe = re.compile(r"""(:?"["]".*?"["]"|'''.*?''')""")
        a(cls.stringRe, 'string')
        cls.tripleSingleRe = re.compile(r"""'''(?!")""")
        cls.tripleDoubleRe = re.compile(r'''"""(?!')''')
        cls.Rules = tuple(r)

    @classmethod
    def initializeFormats(cls):
        baseFormat = QTextCharFormat()
        baseFormat.setFontFamilies(['monospace'])
        p = qapplication_or_fail().palette()
        is_dark = qapplication_or_fail().is_dark_theme
        for name, color, bold, italic in (
            ('normal', None, False, False),
            ('keyword', p.color(QPalette.ColorRole.Link).name(), True, False),
            ('builtin', p.color(QPalette.ColorRole.Link).name(), False, False),
            ('constant', p.color(QPalette.ColorRole.Link).name(), False, False),
            ('decorator', '#0000E0', False, False),
            ('comment', '#00c700' if is_dark else '#007F00', False, True),
            ('string', '#b6b600' if is_dark else '#808000', False, False),
            ('number', '#d96d00' if is_dark else '#924900', False, False),
            ('error', '#FF0000', False, False),
            ('pyqt', '#50621A', False, False),
        ):
            fmt = QTextCharFormat(baseFormat)
            if color is not None:
                fmt.setForeground(QColor(color))
            if bold:
                fmt.setFontWeight(QFont.Weight.Bold)
            if italic:
                fmt.setFontItalic(italic)
            cls.Formats[name] = fmt

    def highlightBlock(self, text):
        NORMAL, TRIPLESINGLE, TRIPLEDOUBLE, ERROR = range(4)

        textLength = len(text)
        prevState = self.previousBlockState()

        self.setFormat(0, textLength, self.Formats['normal'])

        if text.startswith(('Traceback', 'Error: ')):
            self.setCurrentBlockState(ERROR)
            self.setFormat(0, textLength, self.Formats['error'])
            return
        if prevState == ERROR and not text.startswith(('>>>', '#')):
            self.setCurrentBlockState(ERROR)
            self.setFormat(0, textLength, self.Formats['error'])
            return

        for regex, fmt in PythonHighlighter.Rules:
            for m in regex.finditer(text):
                self.setFormat(m.start(), m.end() - m.start(), self.Formats[fmt])

        # Slow but good quality highlighting for comments. For more
        # speed, comment this out and add the following to __init__:
        # PythonHighlighter.Rules.append((re.compile(r"#.*"), "comment"))
        if not text:
            pass
        elif text[0] == '#':
            self.setFormat(0, len(text), self.Formats['comment'])
        else:
            stack = []
            for i, c in enumerate(text):
                if c in ('"', "'"):
                    if stack and stack[-1] == c:
                        stack.pop()
                    else:
                        stack.append(c)
                elif c == '#' and len(stack) == 0:
                    self.setFormat(i, len(text), self.Formats['comment'])
                    break

        self.setCurrentBlockState(NORMAL)

        if self.stringRe.search(text) is not None:
            return
        # This is fooled by triple quotes inside single quoted strings
        for m, state in (
            (self.tripleSingleRe.search(text), TRIPLESINGLE),
            (self.tripleDoubleRe.search(text), TRIPLEDOUBLE),
        ):
            i = -1 if m is None else m.start()
            if self.previousBlockState() == state:
                if i == -1:
                    i = len(text)
                    self.setCurrentBlockState(state)
                self.setFormat(0, i + 3, self.Formats['string'])
            elif i > -1:
                self.setCurrentBlockState(state)
                self.setFormat(i, len(text), self.Formats['string'])

    def rehighlight(self):
        QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
        super().rehighlight()
        QApplication.restoreOverrideCursor()


# }}}

# Splitter {{{


class SplitterHandle(QSplitterHandle):
    double_clicked = pyqtSignal(object)

    def __init__(self, orientation, splitter):
        super().__init__(orientation, splitter)
        splitter.splitterMoved.connect(self.splitter_moved, type=Qt.ConnectionType.QueuedConnection)
        self.double_clicked.connect(splitter.double_clicked, type=Qt.ConnectionType.QueuedConnection)
        self.highlight = False
        self.setToolTip(_('Drag to resize') + ' ' + splitter.label)

    def splitter_moved(self, *args):
        oh = self.highlight
        splitter = self.splitter()
        assert splitter is not None
        self.highlight = 0 in splitter.sizes()
        if oh != self.highlight:
            self.update()

    def mouseDoubleClickEvent(self, a0):
        self.double_clicked.emit(self)


class PaperSizes(QComboBox):  # {{{
    system_default_paper_size = None

    def initialize(self, choices=None):
        from calibre.utils.icu import numeric_sort_key

        if self.system_default_paper_size is None:
            PaperSizes.system_default_paper_size = 'a4'
            if iswindows or ismacos:
                # On Linux, this can cause Qt to load the system cups plugin
                # which can crash: https://bugs.launchpad.net/calibre/+bug/1861741
                ps_id = QPrinter().pageLayout().pageSize().id()  # type: ignore
                PaperSizes.system_default_paper_size = 'letter' if ps_id == QPageSize.PageSizeId.Letter else 'a4'
        if not choices:
            from calibre.ebooks.conversion.plugins.pdf_output import PAPER_SIZES

            choices = PAPER_SIZES
        for a in sorted(choices, key=numeric_sort_key):
            s = getattr(QPageSize.PageSizeId, a.capitalize())
            sz = QPageSize.definitionSize(s)
            unit = {QPageSize.Unit.Millimeter: 'mm', QPageSize.Unit.Inch: 'inch'}[QPageSize.definitionUnits(s)]
            name = f'{QPageSize.name(s)} ({sz.width():g} x {sz.height():g} {unit})'
            self.addItem(name, a)

    @property
    def get_value_for_config(self):
        return self.currentData()

    @get_value_for_config.setter
    def set_value_for_config(self, val):
        idx = self.findData(val or PaperSizes.system_default_paper_size)
        if idx == -1:
            idx = self.findData('a4')
        self.setCurrentIndex(idx)


# }}}


class BusyCursor:  # {{{
    def __enter__(self):
        QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))

    def __exit__(self, *args):
        QApplication.restoreOverrideCursor()


# }}}

if __name__ == '__main__':
    from qt.core import QTextEdit

    app = QApplication([])
    w = QTextEdit()
    s = PythonHighlighter(w)
    # w.setSyntaxHighlighter(s)
    w.setText(open(__file__, 'rb').read().decode('utf-8'))
    w.show()
    app.exec()
