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

import copy
import json
import time
from collections import Counter, OrderedDict
from functools import partial

import regex
from qt.core import (
    QAbstractItemView,
    QAbstractListModel,
    QAction,
    QApplication,
    QCheckBox,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QEvent,
    QFont,
    QFrame,
    QGridLayout,
    QHBoxLayout,
    QIcon,
    QItemSelection,
    QItemSelectionModel,
    QKeySequence,
    QLabel,
    QLineEdit,
    QListView,
    QMenu,
    QMimeData,
    QModelIndex,
    QPushButton,
    QScrollArea,
    QSize,
    QSizePolicy,
    QStackedLayout,
    QStyledItemDelegate,
    Qt,
    QTimer,
    QToolBar,
    QToolButton,
    QVBoxLayout,
    QWidget,
    pyqtSignal,
)

from calibre import prepare_string_for_xml
from calibre.constants import iswindows
from calibre.ebooks.conversion.search_replace import REGEX_FLAGS, compile_regular_expression
from calibre.gui2 import choose_files, choose_save_file, error_dialog, info_dialog
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.dialogs.message_box import MessageBox
from calibre.gui2.tweak_book import current_container, editors, tprefs
from calibre.gui2.tweak_book.editor.snippets import KEY, MODIFIER, SnippetTextEdit, find_matching_snip, parse_template, string_length
from calibre.gui2.tweak_book.function_replace import Function, FunctionBox, FunctionEditor, remove_function
from calibre.gui2.tweak_book.function_replace import functions as replace_functions
from calibre.gui2.widgets import BusyCursor
from calibre.gui2.widgets2 import FlowLayout, HistoryComboBox
from calibre.startup import connect_lambda
from calibre.utils.icu import primary_contains
from polyglot.builtins import error_message

# The search panel {{{


class AnimatablePushButton(QPushButton):
    'A push button that can be animated without actually emitting a clicked signal'

    def __init__(self, *args, **kwargs):
        QPushButton.__init__(self, *args, **kwargs)
        self.timer = t = QTimer(self)
        t.setSingleShot(True), t.timeout.connect(self.animate_done)

    def animate_click(self, msec=100):
        self.setDown(True)
        self.update()
        self.timer.start(msec)

    def animate_done(self):
        self.setDown(False)
        self.update()


class PushButton(AnimatablePushButton):

    def __init__(self, text, action, parent):
        AnimatablePushButton.__init__(self, text, parent)
        connect_lambda(self.clicked, parent, lambda parent: parent.search_triggered.emit(action))


def expand_template(line_edit):
    pos = line_edit.cursorPosition()
    text = line_edit.text()[:pos]
    if text:
        snip, trigger = find_matching_snip(text)
        if snip is None:
            error_dialog(line_edit, _('No snippet found'), _(
                'No matching snippet was found'), show=True)
            return False
        text, tab_stops = parse_template(snip['template'])
        ft = line_edit.text()
        l = string_length(trigger)
        line_edit.setText(ft[:pos - l] + text + ft[pos:])
        line_edit.setCursorPosition(pos - l + string_length(text))
        return True
    return False


class HistoryBox(HistoryComboBox):

    max_history_items = 100
    save_search = pyqtSignal()
    show_saved_searches = pyqtSignal()
    min_history_entry_length = 1

    def __init__(self, parent, clear_msg):
        HistoryComboBox.__init__(self, parent, strip_completion_entries=False)
        self.disable_popup = tprefs['disable_completion_popup_for_search']
        self.clear_msg = clear_msg
        self.ignore_snip_expansion = False
        self.lineEdit().setClearButtonEnabled(True)
        self.set_uniform_item_sizes(False)

    def event(self, ev):
        if ev.type() in (QEvent.Type.ShortcutOverride, QEvent.Type.KeyPress) and ev.key() == KEY and ev.modifiers() & MODIFIER:
            if not self.ignore_snip_expansion:
                self.ignore_snip_expansion = True
                expand_template(self.lineEdit())
                QTimer.singleShot(100, lambda: setattr(self, 'ignore_snip_expansion', False))
            ev.accept()
            return True
        return HistoryComboBox.event(self, ev)

    def contextMenuEvent(self, event):
        menu = self.lineEdit().createStandardContextMenu()
        menu.addSeparator()
        menu.addAction(self.clear_msg, self.clear_history)
        menu.addAction((_('Enable completion based on search history') if self.disable_popup else _(
            'Disable completion based on search history')), self.toggle_popups)
        menu.addSeparator()
        menu.addAction(_('Save current search'), self.save_search.emit)
        menu.addAction(_('Show saved searches'), self.show_saved_searches.emit)
        menu.exec(event.globalPos())

    def toggle_popups(self):
        self.disable_popup = not bool(self.disable_popup)
        tprefs['disable_completion_popup_for_search'] = self.disable_popup


class WhereBox(QComboBox):

    def __init__(self, parent, emphasize=False):
        QComboBox.__init__(self)
        self.addItems([_('Current file'), _('All text files'), _('All style files'), _('Selected files'), _('Open files'), _('Marked text')])
        self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _(
            '''
            Where to search/replace:
            <dl>
            <dt><b>Current file</b></dt>
            <dd>Search only inside the currently opened file</dd>
            <dt><b>All text files</b></dt>
            <dd>Search in all text (HTML) files</dd>
            <dt><b>All style files</b></dt>
            <dd>Search in all style (CSS) files</dd>
            <dt><b>Selected files</b></dt>
            <dd>Search in the files currently selected in the File browser</dd>
            <dt><b>Open files</b></dt>
            <dd>Search in the files currently open in the editor</dd>
            <dt><b>Marked text</b></dt>
            <dd>Search only within the marked text in the currently opened file. You can mark text using the Search menu.</dd>
            </dl>'''))
        self.emphasize = emphasize
        self.ofont = QFont(self.font())
        if emphasize:
            f = self.emph_font = QFont(self.ofont)
            f.setBold(True), f.setItalic(True)
            self.setFont(f)

    @property
    def where(self):
        wm = {0:'current', 1:'text', 2:'styles', 3:'selected', 4:'open', 5:'selected-text'}
        return wm[self.currentIndex()]

    @where.setter
    def where(self, val):
        wm = {0:'current', 1:'text', 2:'styles', 3:'selected', 4:'open', 5:'selected-text'}
        self.setCurrentIndex({v:k for k, v in wm.items()}[val])

    def showPopup(self):
        # We do it like this so that the popup uses a normal font
        if self.emphasize:
            self.setFont(self.ofont)
        QComboBox.showPopup(self)

    def hidePopup(self):
        if self.emphasize:
            self.setFont(self.emph_font)
        QComboBox.hidePopup(self)


class DirectionBox(QComboBox):

    def __init__(self, parent):
        QComboBox.__init__(self, parent)
        self.addItems([_('Down'), _('Up')])
        self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _(
            '''
            Direction to search:
            <dl>
            <dt><b>Down</b></dt>
            <dd>Search for the next match from your current position</dd>
            <dt><b>Up</b></dt>
            <dd>Search for the previous match from your current position</dd>
            </dl>'''))

    @property
    def direction(self):
        return 'down' if self.currentIndex() == 0 else 'up'

    @direction.setter
    def direction(self, val):
        self.setCurrentIndex(1 if val == 'up' else 0)


class ModeBox(QComboBox):

    def __init__(self, parent):
        QComboBox.__init__(self, parent)
        self.addItems([_('Normal'), _('Fuzzy'), _('Regex'), _('Regex-function')])
        self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _(
            '''Select how the search expression is interpreted
            <dl>
            <dt><b>Normal</b></dt>
            <dd>The search expression is treated as normal text, calibre will look for the exact text</dd>
            <dt><b>Fuzzy</b></dt>
            <dd>The search expression is treated as "fuzzy" which means spaces will match any space character,
            including tabs and line breaks. Plain quotes will match the typographical equivalents, etc.</dd>
            <dt><b>Regex</b></dt>
            <dd>The search expression is interpreted as a regular expression. See the User Manual for more help on using regular expressions.</dd>
            <dt><b>Regex-function</b></dt>
            <dd>The search expression is interpreted as a regular expression. The replace expression is an arbitrarily powerful Python function.</dd>
            </dl>'''))

    @property
    def mode(self):
        return ('normal', 'fuzzy', 'regex', 'function')[self.currentIndex()]

    @mode.setter
    def mode(self, val):
        self.setCurrentIndex({'fuzzy': 1, 'regex':2, 'function':3}.get(val, 0))


class SearchWidget(QWidget):

    DEFAULT_STATE = {
        'mode': 'normal',
        'where': 'current',
        'case_sensitive': False,
        'direction': 'down',
        'wrap': True,
        'dot_all': False,
    }

    search_triggered = pyqtSignal(object)
    save_search = pyqtSignal()
    show_saved_searches = pyqtSignal()

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.l = l = QGridLayout(self)
        left, top, right, bottom = l.getContentsMargins()
        l.setContentsMargins(0, 0, right, 0)

        self.fl = fl = QLabel(_('&Find:'))
        fl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignCenter)
        self.find_text = ft = HistoryBox(self, _('Clear search &history'))
        ft.save_search.connect(self.save_search)
        ft.show_saved_searches.connect(self.show_saved_searches)
        ft.initialize('tweak_book_find_edit')
        connect_lambda(ft.lineEdit().returnPressed, self, lambda self: self.search_triggered.emit('find'))
        fl.setBuddy(ft)
        l.addWidget(fl, 0, 0)
        l.addWidget(ft, 0, 1)
        l.setColumnStretch(1, 10)

        self.rl = rl = QLabel(_('&Replace:'))
        rl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignCenter)
        self.replace_text = rt = HistoryBox(self, _('Clear replace &history'))
        rt.save_search.connect(self.save_search)
        rt.show_saved_searches.connect(self.show_saved_searches)
        rt.initialize('tweak_book_replace_edit')
        rl.setBuddy(rt)
        self.replace_stack1 = rs1 = QVBoxLayout()
        self.replace_stack2 = rs2 = QVBoxLayout()
        rs1.addWidget(rl), rs2.addWidget(rt)
        l.addLayout(rs1, 1, 0)
        l.addLayout(rs2, 1, 1)

        self.rl2 = rl2 = QLabel(_('F&unction:'))
        rl2.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignCenter)
        self.functions = fb = FunctionBox(self, show_saved_search_actions=True)
        fb.show_saved_searches.connect(self.show_saved_searches)
        fb.save_search.connect(self.save_search)
        rl2.setBuddy(fb)
        rs1.addWidget(rl2)
        self.functions_container = w = QWidget(self)
        rs2.addWidget(w)
        self.fhl = fhl = QHBoxLayout(w)
        fhl.setContentsMargins(0, 0, 0, 0)
        fhl.addWidget(fb, stretch=10, alignment=Qt.AlignmentFlag.AlignVCenter)
        self.ae_func = b = QPushButton(_('Create/&edit'), self)
        b.clicked.connect(self.edit_function)
        b.setToolTip(_('Create a new function, or edit an existing function'))
        fhl.addWidget(b)
        self.rm_func = b = QPushButton(_('Remo&ve'), self)
        b.setToolTip(_('Remove this function'))
        b.clicked.connect(self.remove_function)
        fhl.addWidget(b)
        self.fsep = f = QFrame(self)
        f.setFrameShape(QFrame.Shape.VLine)
        fhl.addWidget(f)

        self.fb = fb = PushButton(_('Fin&d'), 'find', self)
        self.rfb = rfb = PushButton(_('Replace a&nd Find'), 'replace-find', self)
        self.rb = rb = PushButton(_('Re&place'), 'replace', self)
        self.rab = rab = PushButton(_('Replace &all'), 'replace-all', self)
        rab.setToolTip(_('Replace all occurrences, ignoring wrap and current position.'))

        l.addWidget(fb, 0, 2)
        l.addWidget(rfb, 0, 3)
        l.addWidget(rb, 1, 2)
        l.addWidget(rab, 1, 3)

        self.ml = ml = QLabel(_('&Mode:'))
        self.ol = ol = FlowLayout()
        ml.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
        l.addWidget(ml, 2, 0)
        l.addLayout(ol, 2, 1, 1, 3)
        self.mode_box = mb = ModeBox(self)
        ml.setBuddy(mb)
        ol.addWidget(mb)

        self.where_box = wb = WhereBox(self)
        ol.addWidget(wb)

        self.direction_box = db = DirectionBox(self)
        ol.addWidget(db)

        self.cs = cs = QCheckBox(_('&Case sensitive'))
        ol.addWidget(cs)

        self.wr = wr = QCheckBox(_('&Wrap'))
        wr.setToolTip('<p>'+_('When searching reaches the end, wrap around to the beginning and continue the search'))
        ol.addWidget(wr)

        self.da = da = QCheckBox(_('&Dot all'))
        da.setToolTip('<p>'+_("Make the '.' special character match any character at all, including a newline"))
        ol.addWidget(da)

        self.mode_box.currentIndexChanged.connect(self.mode_changed)
        self.mode_changed(self.mode_box.currentIndex())

    def edit_function(self):
        d = FunctionEditor(func_name=self.functions.text().strip(), parent=self)
        if d.exec() == QDialog.DialogCode.Accepted:
            self.functions.setText(d.func_name)

    def remove_function(self):
        fname = self.functions.text().strip()
        if fname:
            if remove_function(fname, self):
                self.functions.setText('')

    def mode_changed(self, idx):
        self.da.setVisible(idx > 1)
        function_mode = idx == 3
        self.rl.setVisible(not function_mode)
        self.rl2.setVisible(function_mode)
        self.replace_text.setVisible(not function_mode)
        self.functions_container.setVisible(function_mode)

    @property
    def mode(self):
        return self.mode_box.mode

    @mode.setter
    def mode(self, val):
        self.mode_box.mode = val
        self.da.setVisible(self.mode in ('regex', 'function'))

    @property
    def find(self):
        return str(self.find_text.text())

    @find.setter
    def find(self, val):
        self.find_text.setText(val)

    @property
    def replace(self):
        if self.mode == 'function':
            return self.functions.text()
        return str(self.replace_text.text())

    @replace.setter
    def replace(self, val):
        self.replace_text.setText(val)

    @property
    def where(self):
        return self.where_box.where

    @where.setter
    def where(self, val):
        self.where_box.where = val

    @property
    def case_sensitive(self):
        return self.cs.isChecked()

    @case_sensitive.setter
    def case_sensitive(self, val):
        self.cs.setChecked(bool(val))

    @property
    def direction(self):
        return self.direction_box.direction

    @direction.setter
    def direction(self, val):
        self.direction_box.direction = val

    @property
    def wrap(self):
        return self.wr.isChecked()

    @wrap.setter
    def wrap(self, val):
        self.wr.setChecked(bool(val))

    @property
    def dot_all(self):
        return self.da.isChecked()

    @dot_all.setter
    def dot_all(self, val):
        self.da.setChecked(bool(val))

    @property
    def state(self):
        return {x:getattr(self, x) for x in self.DEFAULT_STATE}

    @state.setter
    def state(self, val):
        for x in self.DEFAULT_STATE:
            if x in val:
                setattr(self, x, val[x])

    def restore_state(self):
        self.state = tprefs.get('find-widget-state', self.DEFAULT_STATE)
        if self.where == 'selected-text':
            self.where = self.DEFAULT_STATE['where']

    def save_state(self):
        tprefs.set('find-widget-state', self.state)

    def pre_fill(self, text):
        if self.mode in ('regex', 'function'):
            text = regex.escape(text, special_only=True, literal_spaces=True)
        self.find = text
        self.find_text.lineEdit().setSelection(0, len(text)+10)

    def paste_saved_search(self, s):
        self.case_sensitive = s.get('case_sensitive') or False
        self.dot_all = s.get('dot_all') or False
        if 'wrap' in s:
            self.wrap = s.get('wrap') or False
        self.mode = s.get('mode') or 'normal'
        self.find = s.get('find') or ''
        self.replace = s.get('replace') or ''
# }}}


class SearchPanel(QWidget):  # {{{

    search_triggered = pyqtSignal(object)
    save_search = pyqtSignal()
    show_saved_searches = pyqtSignal()

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.where_before_marked = None
        self.l = l = QHBoxLayout()
        self.setLayout(l)
        l.setContentsMargins(0, 0, 0, 0)
        self.t = t = QToolBar(self)
        l.addWidget(t)
        t.setOrientation(Qt.Orientation.Vertical)
        t.setIconSize(QSize(12, 12))
        t.setMovable(False)
        t.setFloatable(False)
        t.cl = ac = t.addAction(QIcon.ic('window-close.png'), _('Close search panel'))
        ac.triggered.connect(self.hide_panel)
        self.widget = SearchWidget(self)
        l.addWidget(self.widget)
        self.restore_state, self.save_state = self.widget.restore_state, self.widget.save_state
        self.widget.search_triggered.connect(self.search_triggered)
        self.widget.save_search.connect(self.save_search)
        self.widget.show_saved_searches.connect(self.show_saved_searches)
        self.pre_fill = self.widget.pre_fill

    def paste_saved_search(self, s):
        self.widget.paste_saved_search(s)

    def hide_panel(self):
        self.setVisible(False)

    def show_panel(self):
        self.setVisible(True)
        self.widget.find_text.setFocus(Qt.FocusReason.OtherFocusReason)
        le = self.widget.find_text.lineEdit()
        le.setSelection(0, le.maxLength())

    @property
    def state(self):
        ans = self.widget.state
        ans['find'] = self.widget.find
        ans['replace'] = self.widget.replace
        return ans

    def set_where(self, val):
        if val == 'selected-text' and self.widget.where != 'selected-text':
            self.where_before_marked = self.widget.where
        self.widget.where = val

    def unset_marked(self):
        if self.widget.where == 'selected-text':
            self.widget.where = self.where_before_marked or self.widget.DEFAULT_STATE['where']
            self.where_before_marked = None

    def keyPressEvent(self, ev):
        if ev.key() == Qt.Key.Key_Escape:
            self.hide_panel()
            ev.accept()
        else:
            return QWidget.keyPressEvent(self, ev)
# }}}


class SearchDescription(QScrollArea):

    def __init__(self, parent):
        QScrollArea.__init__(self, parent)
        self.label = QLabel(' \n \n ')
        self.setWidget(self.label)
        self.setWidgetResizable(True)
        self.label.setTextFormat(Qt.TextFormat.PlainText)
        self.label.setWordWrap(True)
        self.set_text = self.label.setText


class SearchesModel(QAbstractListModel):

    def __init__(self, parent):
        QAbstractListModel.__init__(self, parent)
        self.searches = tprefs['saved_searches']
        self.filtered_searches = list(range(len(self.searches)))

    def rowCount(self, parent=QModelIndex()):
        return len(self.filtered_searches)

    def supportedDropActions(self):
        return Qt.DropAction.MoveAction

    def flags(self, index):
        ans = QAbstractListModel.flags(self, index)
        if index.isValid():
            ans |= Qt.ItemFlag.ItemIsDragEnabled
        else:
            ans |= Qt.ItemFlag.ItemIsDropEnabled
        return ans

    def mimeTypes(self):
        return ['x-calibre/searches-rows', 'application/vnd.text.list']

    def mimeData(self, indices):
        ans = QMimeData()
        names, rows = [], []
        for i in indices:
            if i.isValid():
                names.append(i.data())
                rows.append(i.row())
        ans.setData('x-calibre/searches-rows', ','.join(map(str, rows)).encode('ascii'))
        ans.setData('application/vnd.text.list', '\n'.join(names).encode('utf-8'))
        return ans

    def dropMimeData(self, data, action, row, column, parent):
        if parent.isValid() or action != Qt.DropAction.MoveAction or not data.hasFormat('x-calibre/searches-rows') or not self.filtered_searches:
            return False
        rows = sorted(map(int, bytes(bytearray(data.data('x-calibre/searches-rows'))).decode('ascii').split(',')))
        moved_searches = [self.searches[self.filtered_searches[r]] for r in rows]
        moved_searches_q = {id(s) for s in moved_searches}
        insert_at = max(0, min(row, len(self.filtered_searches)))
        while insert_at < len(self.filtered_searches):
            s = self.searches[self.filtered_searches[insert_at]]
            if id(s) in moved_searches_q:
                insert_at += 1
            else:
                break
        insert_before = id(self.searches[self.filtered_searches[insert_at]]) if insert_at < len(self.filtered_searches) else None
        visible_searches = {id(self.searches[self.filtered_searches[r]]) for r in self.filtered_searches}
        unmoved_searches = list(filter(lambda s:id(s) not in moved_searches_q, self.searches))
        if insert_before is None:
            searches = unmoved_searches + moved_searches
        else:
            idx = {id(x):i for i, x in enumerate(unmoved_searches)}[insert_before]
            searches = unmoved_searches[:idx] + moved_searches + unmoved_searches[idx:]
        filtered_searches = []
        for i, s in enumerate(searches):
            if id(s) in visible_searches:
                filtered_searches.append(i)
        self.modelAboutToBeReset.emit()
        self.searches, self.filtered_searches = searches, filtered_searches
        self.modelReset.emit()
        tprefs['saved_searches'] = self.searches
        return True

    def data(self, index, role):
        try:
            if role == Qt.ItemDataRole.DisplayRole:
                search = self.searches[self.filtered_searches[index.row()]]
                return search['name']
            if role == Qt.ItemDataRole.ToolTipRole:
                search = self.searches[self.filtered_searches[index.row()]]
                tt = '\n'.join((search['find'], search['replace']))
                return tt
            if role == Qt.ItemDataRole.UserRole:
                search = self.searches[self.filtered_searches[index.row()]]
                return (self.filtered_searches[index.row()], search)
        except IndexError:
            pass
        return None

    def do_filter(self, text):
        text = str(text)
        self.beginResetModel()
        self.filtered_searches = []
        filter_keywords = text.split()
        for i, search in enumerate(self.searches):
            if not filter_keywords or all(
                any(primary_contains(fk, nk) for nk in search['name'].split())
                for fk in filter_keywords
            ):
                self.filtered_searches.append(i)
        self.endResetModel()

    def search_for_index(self, index):
        try:
            return self.searches[self.filtered_searches[index.row()]]
        except IndexError:
            pass

    def index_for_search(self, search):
        for row, si in enumerate(self.filtered_searches):
            if self.searches[si] is search:
                return self.index(row)
        return self.index(-1)

    def move_entry(self, row, delta):
        a, b = row, row + delta
        if 0 <= b < len(self.filtered_searches):
            ai, bi = self.filtered_searches[a], self.filtered_searches[b]
            self.searches[ai], self.searches[bi] = self.searches[bi], self.searches[ai]
            self.dataChanged.emit(self.index(a), self.index(a))
            self.dataChanged.emit(self.index(b), self.index(b))
            tprefs['saved_searches'] = self.searches

    def add_searches(self, count=1):
        self.beginResetModel()
        self.searches = tprefs['saved_searches']
        self.filtered_searches.extend(range(len(self.searches) - count, len(self.searches), 1))
        self.endResetModel()

    def remove_searches(self, rows):
        indices = {self.filtered_searches[row] for row in frozenset(rows)}
        for idx in sorted(indices, reverse=True):
            del self.searches[idx]
        tprefs['saved_searches'] = self.searches
        self.do_filter('')


class EditSearch(QFrame):  # {{{

    done = pyqtSignal(object)

    def __init__(self, parent=None):
        QFrame.__init__(self, parent)
        self.setFrameShape(QFrame.Shape.StyledPanel)
        self.search_index = -1
        self.search = {}
        self.original_name = None

        self.l = l = QVBoxLayout(self)
        self.title = la = QLabel('<h2>Edit...')
        self.ht = h = QHBoxLayout()
        l.addLayout(h)
        h.addWidget(la)
        self.cb = cb = QToolButton(self)
        cb.setIcon(QIcon.ic('window-close.png'))
        cb.setToolTip(_('Abort editing of search'))
        h.addWidget(cb)
        cb.clicked.connect(self.abort_editing)
        self.search_name = n = QLineEdit('', self)
        n.setPlaceholderText(_('The name with which to save this search'))
        self.la1 = la = QLabel(_('&Name:'))
        la.setBuddy(n)
        self.h3 = h = QHBoxLayout()
        h.addWidget(la), h.addWidget(n)
        l.addLayout(h)

        self.find = f = SnippetTextEdit('', self)
        self.la2 = la = QLabel(_('&Find:'))
        la.setBuddy(f)
        l.addWidget(la), l.addWidget(f)

        self.replace = r = SnippetTextEdit('', self)
        self.la3 = la = QLabel(_('&Replace:'))
        la.setBuddy(r)
        l.addWidget(la), l.addWidget(r)

        self.functions_container = w = QWidget()
        l.addWidget(w)
        w.g = g = QGridLayout(w)
        self.la7 = la = QLabel(_('F&unction:'))
        self.function = f = FunctionBox(self)
        g.addWidget(la), g.addWidget(f)
        g.setContentsMargins(0, 0, 0, 0)
        la.setBuddy(f)
        self.ae_func = b = QPushButton(_('Create/&edit'), self)
        b.setToolTip(_('Create a new function, or edit an existing function'))
        b.clicked.connect(self.edit_function)
        g.addWidget(b, 1, 1)
        g.setColumnStretch(0, 10)
        self.rm_func = b = QPushButton(_('Remo&ve'), self)
        b.setToolTip(_('Remove this function'))
        b.clicked.connect(self.remove_function)
        g.addWidget(b, 1, 2)

        self.case_sensitive = c = QCheckBox(_('Case sensitive'))
        self.h = h = QHBoxLayout()
        l.addLayout(h)
        h.addWidget(c)

        self.dot_all = d = QCheckBox(_('Dot matches all'))
        h.addWidget(d), h.addStretch(2)

        self.h2 = h = QHBoxLayout()
        l.addLayout(h)
        self.mode_box = m = ModeBox(self)
        m.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        self.la4 = la = QLabel(_('&Mode:'))
        la.setBuddy(m)
        h.addWidget(la), h.addWidget(m), h.addStretch(2)

        self.done_button = b = QPushButton(QIcon.ic('ok.png'), _('&Done'))
        b.setToolTip(_('Finish editing of search'))
        h.addWidget(b)
        b.clicked.connect(self.emit_done)

        self.mode_box.currentIndexChanged.connect(self.mode_changed)
        self.mode_changed(self.mode_box.currentIndex())

    def edit_function(self):
        d = FunctionEditor(func_name=self.function.text().strip(), parent=self)
        if d.exec() == QDialog.DialogCode.Accepted:
            self.function.setText(d.func_name)

    def remove_function(self):
        fname = self.function.text().strip()
        if fname:
            if remove_function(fname, self):
                self.function.setText('')

    def mode_changed(self, idx):
        mode = self.mode_box.mode
        self.dot_all.setVisible(mode in ('regex', 'function'))
        function_mode = mode == 'function'
        self.functions_container.setVisible(function_mode)
        self.la3.setVisible(not function_mode)
        self.replace.setVisible(not function_mode)

    def show_search(self, search=None, search_index=-1, state=None):
        self.title.setText('<h2>' + (_('Add search') if search_index == -1 else _('Edit search')))
        self.search = search or {}
        self.original_name = self.search.get('name', None)
        self.search_index = search_index

        self.mode_box.mode = self.search.get('mode', 'regex')
        self.search_name.setText(self.search.get('name', ''))
        self.find.setPlainText(self.search.get('find', ''))
        if self.mode_box.mode == 'function':
            self.function.setText(self.search.get('replace', ''))
        else:
            self.replace.setPlainText(self.search.get('replace', ''))
        self.case_sensitive.setChecked(self.search.get('case_sensitive', SearchWidget.DEFAULT_STATE['case_sensitive']))
        self.dot_all.setChecked(self.search.get('dot_all', SearchWidget.DEFAULT_STATE['dot_all']))

        if state is not None:
            self.find.setPlainText(state['find'])
            self.mode_box.mode = state.get('mode')
            if self.mode_box.mode == 'function':
                self.function.setText(state['replace'])
            else:
                self.replace.setPlainText(state['replace'])
            self.case_sensitive.setChecked(state['case_sensitive'])
            self.dot_all.setChecked(state['dot_all'])

    def emit_done(self):
        self.done.emit(True)

    def keyPressEvent(self, ev):
        if ev.key() == Qt.Key.Key_Escape:
            self.abort_editing()
            ev.accept()
            return
        return QFrame.keyPressEvent(self, ev)

    def abort_editing(self):
        self.done.emit(False)

    @property
    def current_search(self):
        search = self.search.copy()
        f = str(self.find.toPlainText())
        search['find'] = f
        search['dot_all'] = bool(self.dot_all.isChecked())
        search['case_sensitive'] = bool(self.case_sensitive.isChecked())
        search['mode'] = self.mode_box.mode
        if search['mode'] == 'function':
            r = self.function.text()
        else:
            r = str(self.replace.toPlainText())
        search['replace'] = r
        return search

    def save_changes(self):
        searches = tprefs['saved_searches']
        all_names = {x['name'] for x in searches} - {self.original_name}
        n = self.search_name.text().strip()
        if not n:
            error_dialog(self, _('Must specify name'), _(
                'You must specify a search name'), show=True)
            return False
        if n in all_names:
            error_dialog(self, _('Name exists'), _(
                'Another search with the name %s already exists') % n, show=True)
            return False
        search = self.search
        search['name'] = n

        f = str(self.find.toPlainText())
        if not f:
            error_dialog(self, _('Must specify find'), _(
                'You must specify a find expression'), show=True)
            return False
        search['find'] = f
        search['mode'] = self.mode_box.mode

        if search['mode'] == 'function':
            r = self.function.text()
            if not r:
                error_dialog(self, _('Must specify function'), _(
                    'You must specify a function name in Function-Regex mode'), show=True)
                return False
        else:
            r = str(self.replace.toPlainText())
        search['replace'] = r

        search['dot_all'] = bool(self.dot_all.isChecked())
        search['case_sensitive'] = bool(self.case_sensitive.isChecked())

        if self.search_index == -1:
            searches.append(search)
        else:
            searches[self.search_index] = search
        tprefs.set('saved_searches', searches)
        return True

# }}}


class SearchDelegate(QStyledItemDelegate):

    def sizeHint(self, *args):
        ans = QStyledItemDelegate.sizeHint(self, *args)
        ans.setHeight(ans.height() + 4)
        return ans


class SavedSearches(QWidget):

    run_saved_searches = pyqtSignal(object, object)
    copy_search_to_search_panel = pyqtSignal(object)

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.setup_ui()

    def setup_ui(self):
        self.l = l = QVBoxLayout(self)

        self.filter_text = ft = QLineEdit(self)
        ft.setClearButtonEnabled(True)
        ft.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
        ft.textChanged.connect(self.do_filter)
        ft.setPlaceholderText(_('Filter displayed searches'))
        l.addWidget(ft)

        self.h2 = h = QHBoxLayout()
        self.searches = searches = QListView(self)
        self.stack = stack = QStackedLayout()
        self.main_widget = mw = QWidget(self)
        stack.addWidget(mw)
        self.edit_search_widget = es = EditSearch(self)
        es.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
        stack.addWidget(es)
        es.done.connect(self.search_editing_done)
        mw.v = QVBoxLayout(mw)
        mw.v.setContentsMargins(0, 0, 0, 0)
        mw.v.addWidget(searches)
        searches.doubleClicked.connect(self.edit_search)
        self.model = SearchesModel(self.searches)
        self.model.dataChanged.connect(self.show_details)
        searches.setModel(self.model)
        searches.selectionModel().currentChanged.connect(self.show_details)
        searches.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self.delegate = SearchDelegate(searches)
        searches.setItemDelegate(self.delegate)
        searches.setAlternatingRowColors(True)
        searches.setDragEnabled(True), searches.setAcceptDrops(True), searches.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
        searches.setDropIndicatorShown(True)
        h.addLayout(stack, stretch=10)
        self.v = v = QVBoxLayout()
        h.addLayout(v)
        l.addLayout(h)
        stack.currentChanged.connect(self.stack_current_changed)

        def pb(text, tooltip=None, action=None):
            b = AnimatablePushButton(text, self)
            b.setToolTip(tooltip or '')
            if action:
                b.setObjectName(action)
            b.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
            return b

        mulmsg = '\n\n' + _('The entries are tried in order until the first one matches.')
        self.action_button_map = {}

        for text, action, tooltip in [
                (_('&Find'), 'find', _('Run the search using the selected entries.') + mulmsg),
                (_('&Replace'), 'replace', _('Run replace using the selected entries.') + mulmsg),
                (_('Replace a&nd Find'), 'replace-find', _('Run replace and then find using the selected entries.') + mulmsg),
                (_('Replace &all'), 'replace-all', _('Run Replace all for all selected entries in the order selected')),
                (_('&Count all'), 'count', _('Run Count all for all selected entries')),
        ]:
            self.action_button_map[action] = b = pb(text, tooltip, action)
            v.addWidget(b)
            connect_lambda(b.clicked, self, lambda self: self.run_search(self.sender().objectName()))

        self.d1 = d = QFrame(self)
        d.setFrameStyle(QFrame.Shape.HLine)
        v.addWidget(d)

        self.h3 = h = QHBoxLayout()
        self.upb = b = QToolButton(self)
        self.move_up_action = a = QAction(self)
        a.setShortcut(QKeySequence('Alt+Up'))
        b.setIcon(QIcon.ic('arrow-up.png'))
        b.setToolTip(_('Move selected entries up') + f' [{a.shortcut().toString(QKeySequence.SequenceFormat.NativeText)}]')
        connect_lambda(a.triggered, self, lambda self: self.move_entry(-1))
        self.searches.addAction(a)
        connect_lambda(b.clicked, self, lambda self: self.move_entry(-1))

        self.dnb = b = QToolButton(self)
        self.move_down_action = a = QAction(self)
        a.setShortcut(QKeySequence('Alt+Down'))
        b.setIcon(QIcon.ic('arrow-down.png'))
        b.setToolTip(_('Move selected entries down') + f' [{a.shortcut().toString(QKeySequence.SequenceFormat.NativeText)}]')
        connect_lambda(a.triggered, self, lambda self: self.move_entry(1))
        self.searches.addAction(a)
        connect_lambda(b.clicked, self, lambda self: self.move_entry(1))
        h.addWidget(self.upb), h.addWidget(self.dnb)
        v.addLayout(h)

        self.eb = b = pb(_('&Edit search'), _('Edit the currently selected search'))
        b.clicked.connect(self.edit_search)
        v.addWidget(b)

        self.rb = b = pb(_('Re&move search'), _('Remove the currently selected searches'))
        b.clicked.connect(self.remove_search)
        v.addWidget(b)

        self.ab = b = pb(_('&Add search'), _('Add a new saved search'))
        b.clicked.connect(self.add_search)
        v.addWidget(b)

        self.d2 = d = QFrame(self)
        d.setFrameStyle(QFrame.Shape.HLine)
        v.addWidget(d)

        self.where_box = wb = WhereBox(self, emphasize=True)
        self.where = SearchWidget.DEFAULT_STATE['where']
        v.addWidget(wb)
        self.direction_box = db = DirectionBox(self)
        self.direction = SearchWidget.DEFAULT_STATE['direction']
        v.addWidget(db)

        self.wr = wr = QCheckBox(_('&Wrap'))
        wr.setToolTip('<p>'+_('When searching reaches the end, wrap around to the beginning and continue the search'))
        self.wr.setChecked(SearchWidget.DEFAULT_STATE['wrap'])
        v.addWidget(wr)

        self.d3 = d = QFrame(self)
        d.setFrameStyle(QFrame.Shape.HLine)
        v.addWidget(d)

        self.description = d = SearchDescription(self)
        mw.v.addWidget(d)
        mw.v.setStretch(0, 10)

        self.ib = b = pb(_('&Import'), _('Import saved searches'))
        b.clicked.connect(self.import_searches)
        v.addWidget(b)

        self.eb2 = b = pb(_('E&xport'), _('Export saved searches'))
        v.addWidget(b)
        self.em = m = QMenu(_('Export'))
        m.addAction(_('Export all'), lambda: QTimer.singleShot(0, partial(self.export_searches, all=True)))
        m.addAction(_('Export selected'), lambda: QTimer.singleShot(0, partial(self.export_searches, all=False)))
        m.addAction(_('Copy to search panel'), lambda: QTimer.singleShot(0, self.copy_to_search_panel))
        b.setMenu(m)

        self.searches.setFocus(Qt.FocusReason.OtherFocusReason)

    @property
    def state(self):
        return {'wrap':self.wrap, 'direction':self.direction, 'where':self.where}

    @state.setter
    def state(self, val):
        self.wrap, self.where, self.direction = val['wrap'], val['where'], val['direction']

    def save_state(self):
        tprefs['saved_seaches_state'] = self.state

    def restore_state(self):
        self.state = tprefs.get('saved_seaches_state', SearchWidget.DEFAULT_STATE)

    def has_focus(self):
        if self.hasFocus():
            return True
        for child in self.findChildren(QWidget):
            if child.hasFocus():
                return True
        return False

    def trigger_action(self, action, overrides=None):
        b = self.action_button_map.get(action)
        if b is not None:
            b.animate_click(300)
        self._run_search(action, overrides)

    def stack_current_changed(self, index):
        visible = index == 0
        for x in ('eb', 'ab', 'rb', 'upb', 'dnb', 'd2', 'filter_text', 'd3', 'ib', 'eb2'):
            getattr(self, x).setVisible(visible)

    @property
    def where(self):
        return self.where_box.where

    @where.setter
    def where(self, val):
        self.where_box.where = val

    @property
    def direction(self):
        return self.direction_box.direction

    @direction.setter
    def direction(self, val):
        self.direction_box.direction = val

    @property
    def wrap(self):
        return self.wr.isChecked()

    @wrap.setter
    def wrap(self, val):
        self.wr.setChecked(bool(val))

    def do_filter(self, text):
        self.model.do_filter(text)
        self.searches.scrollTo(self.model.index(0))

    def run_search(self, action):
        return self._run_search(action)

    def _run_search(self, action, overrides=None):
        searches = []

        def fill_in_search(search):
            search['wrap'] = self.wrap
            search['direction'] = self.direction
            search['where'] = self.where
            search['mode'] = search.get('mode', 'regex')

        if self.editing_search:
            search = SearchWidget.DEFAULT_STATE.copy()
            del search['mode']
            search.update(self.edit_search_widget.current_search)
            fill_in_search(search)
            searches.append(search)
        else:
            seen = set()
            for index in self.searches.selectionModel().selectedIndexes():
                if index.row() in seen:
                    continue
                seen.add(index.row())
                search = SearchWidget.DEFAULT_STATE.copy()
                del search['mode']
                search_index, s = index.data(Qt.ItemDataRole.UserRole)
                search.update(s)
                fill_in_search(search)
                searches.append(search)
        if not searches:
            return error_dialog(self, _('Cannot search'), _(
                'No saved search is selected'), show=True)
        if overrides:
            [sc.update(overrides) for sc in searches]
        self.run_saved_searches.emit(searches, action)

    @property
    def editing_search(self):
        return self.stack.currentIndex() != 0

    def move_entry(self, delta):
        if self.editing_search:
            return
        sm = self.searches.selectionModel()
        rows = {index.row() for index in sm.selectedIndexes()} - {-1}
        if rows:
            searches = [self.model.search_for_index(index) for index in sm.selectedIndexes()]
            current_search = self.model.search_for_index(self.searches.currentIndex())
            with tprefs:
                for row in sorted(rows, reverse=delta > 0):
                    self.model.move_entry(row, delta)
            sm.clear()
            for s in searches:
                index = self.model.index_for_search(s)
                if index.isValid() and index.row() > -1:
                    if s is current_search:
                        sm.setCurrentIndex(index, QItemSelectionModel.SelectionFlag.Select)
                    else:
                        sm.select(index, QItemSelectionModel.SelectionFlag.Select)

    def search_editing_done(self, save_changes):
        if save_changes and not self.edit_search_widget.save_changes():
            return
        self.stack.setCurrentIndex(0)
        if save_changes:
            if self.edit_search_widget.search_index == -1:
                self._add_search()
            else:
                index = self.searches.currentIndex()
                if index.isValid():
                    self.model.dataChanged.emit(index, index)

    def edit_search(self):
        index = self.searches.currentIndex()
        if not index.isValid():
            return error_dialog(self, _('Cannot edit'), _(
                'Cannot edit search - no search selected.'), show=True)
        if not self.editing_search:
            search_index, search = index.data(Qt.ItemDataRole.UserRole)
            self.edit_search_widget.show_search(search=search, search_index=search_index)
            self.stack.setCurrentIndex(1)
            self.edit_search_widget.find.setFocus(Qt.FocusReason.OtherFocusReason)

    def remove_search(self):
        if self.editing_search:
            return
        if confirm(_('Are you sure you want to permanently delete the selected saved searches?'),
                   'confirm-remove-editor-saved-search', config_set=tprefs):
            rows = {index.row() for index in self.searches.selectionModel().selectedIndexes()} - {-1}
            self.model.remove_searches(rows)
            self.show_details()

    def add_search(self):
        if self.editing_search:
            return
        self.edit_search_widget.show_search()
        self.stack.setCurrentIndex(1)
        self.edit_search_widget.search_name.setFocus(Qt.FocusReason.OtherFocusReason)

    def _add_search(self):
        self.model.add_searches()
        index = self.model.index(self.model.rowCount() - 1)
        self.searches.scrollTo(index)
        sm = self.searches.selectionModel()
        sm.setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect)
        self.show_details()

    def add_predefined_search(self, state):
        if self.editing_search:
            return
        self.edit_search_widget.show_search(state=state)
        self.stack.setCurrentIndex(1)
        self.edit_search_widget.search_name.setFocus(Qt.FocusReason.OtherFocusReason)

    def show_details(self):
        self.description.set_text(' \n \n ')
        i = self.searches.currentIndex()
        if i.isValid():
            try:
                search_index, search = i.data(Qt.ItemDataRole.UserRole)
            except TypeError:
                return  # no saved searches
            cs = '✓' if search.get('case_sensitive', SearchWidget.DEFAULT_STATE['case_sensitive']) else '✗'
            da = '✓' if search.get('dot_all', SearchWidget.DEFAULT_STATE['dot_all']) else '✗'
            if search.get('mode', SearchWidget.DEFAULT_STATE['mode']) in ('regex', 'function'):
                ts = _('(Case sensitive: {0} Dot all: {1})').format(cs, da)
            else:
                ts = _('(Case sensitive: {0} [Normal search])').format(cs)
            self.description.set_text(_('{2} {3}\nFind: {0}\nReplace: {1}').format(
                search.get('find', ''), search.get('replace', ''), search.get('name', ''), ts))

    def import_searches(self):
        path = choose_files(self, 'import_saved_searches', _('Choose file'), filters=[
            (_('Saved searches'), ['json'])], all_files=False, select_only_single_file=True)
        if path:
            with open(path[0], 'rb') as f:
                obj = json.loads(f.read())
            needed_keys = {'name', 'find', 'replace', 'case_sensitive', 'dot_all', 'mode'}

            def err():
                error_dialog(self, _('Invalid data'), _(
                    'The file %s does not contain valid saved searches') % path, show=True)
            if not isinstance(obj, dict) or 'version' not in obj or 'searches' not in obj or obj['version'] != 1:
                return err()
            searches = []
            for item in obj['searches']:
                if not isinstance(item, dict) or not set(item).issuperset(needed_keys):
                    return err
                searches.append({k:item[k] for k in needed_keys})

            if searches:
                tprefs['saved_searches'] = tprefs['saved_searches'] + searches
                count = len(searches)
                self.model.add_searches(count=count)
                sm = self.searches.selectionModel()
                top, bottom = self.model.index(self.model.rowCount() - count), self.model.index(self.model.rowCount() - 1)
                sm.select(QItemSelection(top, bottom), QItemSelectionModel.SelectionFlag.ClearAndSelect)
                self.searches.scrollTo(bottom)

    def copy_to_search_panel(self):
        ci = self.searches.selectionModel().currentIndex()
        if ci and ci.isValid():
            search = ci.data(Qt.ItemDataRole.UserRole)[-1]
            self.copy_search_to_search_panel.emit(search)

    def export_searches(self, all=True):
        if all:
            searches = copy.deepcopy(tprefs['saved_searches'])
            if not searches:
                return error_dialog(self, _('No searches'), _(
                    'No searches available to be saved'), show=True)
        else:
            searches = []
            for index in self.searches.selectionModel().selectedIndexes():
                search = index.data(Qt.ItemDataRole.UserRole)[-1]
                searches.append(search.copy())
            if not searches:
                return error_dialog(self, _('No searches'), _(
                    'No searches selected'), show=True)
        [s.__setitem__('mode', s.get('mode', 'regex')) for s in searches]
        path = choose_save_file(self, 'export-saved-searches', _('Choose file'), filters=[
            (_('Saved searches'), ['json'])], all_files=False)
        if path:
            if not path.lower().endswith('.json'):
                path += '.json'
            raw = json.dumps({'version':1, 'searches':searches}, ensure_ascii=False, indent=2, sort_keys=True)
            with open(path, 'wb') as f:
                f.write(raw.encode('utf-8'))


def validate_search_request(name, searchable_names, has_marked_text, state, gui_parent):
    err = None
    where = state['where']
    if name is None and where in {'current', 'selected-text'}:
        err = _('No file is being edited.')
    elif where == 'selected' and not searchable_names['selected']:
        err = _('No files are selected in the File browser')
    elif where == 'selected-text' and not has_marked_text:
        err = _('No text is marked. First select some text, and then use'
                ' The "Mark selected text" action in the Search menu to mark it.')
    if not err and not state['find']:
        err = _('No search query specified')
    if err:
        error_dialog(gui_parent, _('Cannot search'), err, show=True)
        return False
    return True


class InvalidRegex(regex.error):

    def __init__(self, raw, e):
        regex.error.__init__(self, error_message(e))
        self.regex = raw


def get_search_regex(state):
    raw = state['find']
    is_regex = state['mode'] not in ('normal', 'fuzzy')
    if not is_regex:
        if state['mode'] == 'fuzzy':
            from calibre.gui2.viewer.search import text_to_regex
            raw = text_to_regex(raw)
        else:
            raw = regex.escape(raw, special_only=True)
    flags = REGEX_FLAGS
    if not state['case_sensitive']:
        flags |= regex.IGNORECASE
    if is_regex and state['dot_all']:
        flags |= regex.DOTALL
    if state['direction'] == 'up':
        flags |= regex.REVERSE
    try:
        ans = compile_regular_expression(raw, flags=flags)
    except regex.error as e:
        raise InvalidRegex(raw, e)

    return ans


def get_search_function(state):
    ans = state['replace']
    is_regex = state['mode'] not in ('normal', 'fuzzy')
    if not is_regex:
        # We don't want backslash escape sequences interpreted in normal mode
        return lambda m: ans
    if state['mode'] == 'function':
        try:
            return replace_functions()[ans]
        except KeyError:
            if not ans:
                return Function('empty-function', '')
            raise NoSuchFunction(ans)
    return ans


def get_search_name(state):
    return state.get('name', state['find'])


def initialize_search_request(state, action, current_editor, current_editor_name, searchable_names):
    editor = None
    where = state['where']
    files = OrderedDict()
    do_all = state.get('wrap') or action in {'replace-all', 'count'}
    marked = False
    if where == 'current':
        editor = current_editor
    elif where in {'styles', 'text', 'selected', 'open'}:
        files = searchable_names[where]
        if current_editor_name in files:
            # Start searching in the current editor
            editor = current_editor
            # Re-order the list of other files so that we search in the same
            # order every time. Depending on direction, search the files
            # that come after the current file, or before the current file,
            # first.
            lfiles = list(files)
            idx = lfiles.index(current_editor_name)
            before, after = lfiles[:idx], lfiles[idx+1:]
            if state['direction'] == 'up':
                lfiles = list(reversed(before))
                if do_all:
                    lfiles += list(reversed(after)) + [current_editor_name]
            else:
                lfiles = after
                if do_all:
                    lfiles += before + [current_editor_name]
            files = OrderedDict((m, files[m]) for m in lfiles)
    else:
        editor = current_editor
        marked = True

    return editor, where, files, do_all, marked


class NoSuchFunction(ValueError):
    pass


def show_function_debug_output(func):
    if isinstance(func, Function):
        val = func.debug_buf.getvalue().strip()
        func.debug_buf.truncate(0)
        if val:
            from calibre.gui2.tweak_book.boss import get_boss
            get_boss().gui.sr_debug_output.show_log(func.name, val)


def reorder_files(names, order):
    reverse = order in {'spine-reverse', 'reverse-spine'}
    spine_order = {name:i for i, (name, is_linear) in enumerate(current_container().spine_names)}
    return sorted(frozenset(names), key=spine_order.get, reverse=reverse)


def run_search(
    searches, action, current_editor, current_editor_name, searchable_names,
    gui_parent, show_editor, edit_file, show_current_diff, add_savepoint, rewind_savepoint, set_modified):

    if isinstance(searches, dict):
        searches = [searches]

    editor, where, files, do_all_, marked = initialize_search_request(searches[0], action, current_editor, current_editor_name, searchable_names)
    wrap = searches[0]['wrap']

    errfind = searches[0]['find']
    if len(searches) > 1:
        errfind = _('the selected searches')

    search_names = [get_search_name(search) for search in searches]

    try:
        searches = [(get_search_regex(search), get_search_function(search)) for search in searches]
    except InvalidRegex as e:
        return error_dialog(gui_parent, _('Invalid regex'), '<p>' + _(
            'The regular expression you entered is invalid: <pre>{0}</pre>With error: {1}').format(
                prepare_string_for_xml(e.regex), error_message(e)), show=True)
    except NoSuchFunction as e:
        return error_dialog(gui_parent, _('No such function'), '<p>' + _(
            'No replace function with the name: %s exists') % prepare_string_for_xml(error_message(e)), show=True)

    def no_match():
        QApplication.restoreOverrideCursor()
        msg = '<p>' + _('No matches were found for %s') % ('<pre style="font-style:italic">' + prepare_string_for_xml(errfind) + '</pre>')
        if not wrap:
            msg += '<p>' + _('You have turned off search wrapping, so all text might not have been searched.'
                ' Try the search again, with wrapping enabled. Wrapping is enabled via the'
                ' "Wrap" checkbox at the bottom of the search panel.')
        return error_dialog(
            gui_parent, _('Not found'), msg, show=True)

    def do_find():
        for p, __ in searches:
            if editor is not None:
                if editor.find(p, marked=marked, save_match='gui'):
                    return True
                if wrap and not files and editor.find(p, wrap=True, marked=marked, save_match='gui'):
                    return True
            for fname, syntax in files.items():
                ed = editors.get(fname, None)
                if ed is not None:
                    if not wrap and ed is editor:
                        continue
                    if ed.find(p, complete=True, save_match='gui'):
                        show_editor(fname)
                        return True
                else:
                    raw = current_container().raw_data(fname)
                    if p.search(raw) is not None:
                        edit_file(fname, syntax)
                        if editors[fname].find(p, complete=True, save_match='gui'):
                            return True
        return no_match()

    def no_replace(prefix=''):
        QApplication.restoreOverrideCursor()
        if prefix:
            prefix += ' '
        error_dialog(
            gui_parent, _('Cannot replace'), prefix + _(
            'You must first click "Find", before trying to replace'), show=True)
        return False

    def do_replace():
        if editor is None:
            return no_replace()
        for p, repl in searches:
            repl_is_func = isinstance(repl, Function)
            if repl_is_func:
                repl.init_env(current_editor_name)
            if editor.replace(p, repl, saved_match='gui'):
                if repl_is_func:
                    repl.end()
                    show_function_debug_output(repl)
                return True
        return no_replace(_(
                'Currently selected text does not match the search query.'))

    def count_message(replaced, count, show_diff=False, show_dialog=True, count_map=None):
        if show_dialog:
            if replaced:
                msg = _('Performed the replacement at {num} occurrences of {query}')
            else:
                msg = _('Found {num} occurrences of {query}')
            msg = msg.format(num=count, query=prepare_string_for_xml(errfind))
            det_msg = ''
            if count_map is not None and count > 0 and len(count_map) > 1:
                for k in sorted(count_map):
                    det_msg += _('{0}: {1} occurrences').format(k, count_map[k]) + '\n'
            if show_diff and count > 0:
                d = MessageBox(MessageBox.INFO, _('Searching done'), '<p>'+msg, parent=gui_parent, show_copy_button=False, det_msg=det_msg)
                d.diffb = b = d.bb.addButton(_('See what &changed'), QDialogButtonBox.ButtonRole.AcceptRole)
                d.show_changes = False
                b.setIcon(QIcon.ic('diff.png')), b.clicked.connect(d.accept)
                connect_lambda(b.clicked, d, lambda d: setattr(d, 'show_changes', True))
                d.exec()
                if d.show_changes:
                    show_current_diff(allow_revert=True)
            else:
                info_dialog(gui_parent, _('Searching done'), prepare_string_for_xml(msg), show=True, det_msg=det_msg)
                if hasattr(editor, 'editor'):
                    QTimer.singleShot(0, lambda: editor.editor.setFocus(Qt.FocusReason.OtherFocusReason))

    def do_all(replace=True):
        count = 0
        count_map = Counter()
        if not files and editor is None:
            return 0
        lfiles = files or {current_editor_name:editor.syntax}
        updates = set()
        raw_data = {}
        for n in lfiles:
            if n in editors:
                raw = editors[n].get_raw_data()
            else:
                raw = current_container().raw_data(n)
            raw_data[n] = raw

        for search_name, (p, repl) in zip(search_names, searches):
            repl_is_func = isinstance(repl, Function)
            file_iterator = lfiles
            if repl_is_func:
                repl.init_env()
                if repl.file_order is not None and len(lfiles) > 1:
                    file_iterator = reorder_files(file_iterator, repl.file_order)
            for n in file_iterator:
                raw = raw_data[n]
                if replace:
                    if repl_is_func:
                        repl.context_name = n
                    raw, num = p.subn(repl, raw)
                    if num > 0:
                        updates.add(n)
                        raw_data[n] = raw
                else:
                    num = len(p.findall(raw))
                count += num
                count_map[search_name] += num
            if repl_is_func:
                repl.end()
                show_function_debug_output(repl)

        for n in updates:
            raw = raw_data[n]
            if n in editors:
                editors[n].replace_data(raw)
            else:
                try:
                    with current_container().open(n, 'wb') as f:
                        f.write(raw.encode('utf-8'))
                except PermissionError:
                    if not iswindows:
                        raise
                    time.sleep(2)
                    with current_container().open(n, 'wb') as f:
                        f.write(raw.encode('utf-8'))

        QApplication.restoreOverrideCursor()
        count_message(replace, count, show_diff=replace, count_map=count_map)
        return count

    post_search_action = None
    try:
        with BusyCursor():
            if action == 'find':
                return do_find()
            if action == 'replace':
                return do_replace()
            if action == 'replace-find' and do_replace():
                return do_find()
            if action == 'replace-all':
                if marked:
                    show_result_dialog = True
                    for p, repl in searches:
                        if getattr(getattr(repl, 'func', None), 'suppress_result_dialog', False):
                            show_result_dialog = False
                            break
                    res_count = sum(editor.all_in_marked(p, repl) for p, repl in searches)
                    post_search_action = partial(count_message, True, res_count, show_dialog=show_result_dialog)
                    return
                add_savepoint(_('Before: Replace all'))
                count = do_all()
                if count == 0:
                    rewind_savepoint()
                else:
                    set_modified()
                return
            if action == 'count':
                if marked:
                    res_count = sum(editor.all_in_marked(p) for p, __ in searches)
                    post_search_action = partial(count_message, False, res_count)
                    return
                return do_all(replace=False)
    finally:
        if post_search_action is not None:
            post_search_action()


if __name__ == '__main__':
    app = QApplication([])
    d = SavedSearches()
    d.show()
    app.exec()
