#!/usr/local/bin/python3.12


__license__   = 'GPL v3'
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'

import re, time
from functools import partial


from qt.core import (
    QComboBox, Qt, QLineEdit, pyqtSlot, QDialog, QEvent,
    pyqtSignal, QCompleter, QAction, QKeySequence, QTimer,
    QIcon, QMenu, QApplication, QKeyEvent)

from calibre.gui2 import config, error_dialog, question_dialog, gprefs, QT_HIDDEN_CLEAR_ACTION
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.dialogs.saved_search_editor import SavedSearchEditor
from calibre.gui2.dialogs.search import SearchDialog
from calibre.utils.icu import primary_sort_key
from polyglot.builtins import native_string_type, string_or_bytes


class AsYouType(str):

    def __new__(cls, text):
        self = str.__new__(cls, text)
        self.as_you_type = True
        return self


class SearchLineEdit(QLineEdit):  # {{{
    key_pressed = pyqtSignal(object)
    clear_history = pyqtSignal()
    select_on_mouse_press = None
    as_url = None

    def keyPressEvent(self, event):
        self.key_pressed.emit(event)
        QLineEdit.keyPressEvent(self, event)

    def dropEvent(self, ev):
        self.parent().normalize_state()
        return QLineEdit.dropEvent(self, ev)

    def contextMenuEvent(self, ev):
        self.parent().normalize_state()
        menu = self.createStandardContextMenu()
        menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
        ac = menu.addAction(_('Paste and &search'))
        ac.setEnabled(bool(QApplication.clipboard().text()))
        ac.setIcon(QIcon(I('search.png')))
        ac.triggered.connect(self.paste_and_search)
        for action in menu.actions():
            if action.text().startswith(_('&Paste') + '\t'):
                menu.insertAction(action, ac)
                break
        else:
            menu.addAction(ac)
        menu.addSeparator()
        if self.as_url is not None:
            url = self.as_url(self.text())
            if url:
                menu.addAction(_('Copy search as URL'), lambda : QApplication.clipboard().setText(url))
        menu.addAction(_('&Clear search history')).triggered.connect(self.clear_history)
        menu.exec(ev.globalPos())

    def paste_and_search(self):
        self.paste()
        ev = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Enter, Qt.KeyboardModifier.NoModifier)
        self.keyPressEvent(ev)

    @pyqtSlot()
    def paste(self, *args):
        self.parent().normalize_state()
        return QLineEdit.paste(self)

    def focusInEvent(self, ev):
        self.select_on_mouse_press = time.time()
        return QLineEdit.focusInEvent(self, ev)

    def mousePressEvent(self, ev):
        QLineEdit.mousePressEvent(self, ev)
        if self.select_on_mouse_press is not None and abs(time.time() - self.select_on_mouse_press) < 0.2:
            self.selectAll()
        self.select_on_mouse_press = None
# }}}


class SearchBox2(QComboBox):  # {{{

    '''
    To use this class:

        * Call initialize()
        * Connect to the search() and cleared() signals from this widget.
        * Connect to the changed() signal to know when the box content changes
        * Connect to focus_to_library() signal to be told to manually change focus
        * Call search_done() after every search is complete
        * Call set_search_string() to perform a search programmatically
        * You can use the current_text property to get the current search text
          Be aware that if you are using it in a slot connected to the
          changed() signal, if the connection is not queued it will not be
          accurate.
    '''

    INTERVAL = 1500  #: Time to wait before emitting search signal
    MAX_COUNT = 25

    search  = pyqtSignal(object)
    cleared = pyqtSignal()
    changed = pyqtSignal()
    focus_to_library = pyqtSignal()

    def __init__(self, parent=None, add_clear_action=True, as_url=None):
        QComboBox.__init__(self, parent)
        self.line_edit = SearchLineEdit(self)
        self.line_edit.as_url = as_url
        self.setLineEdit(self.line_edit)
        self.line_edit.clear_history.connect(self.clear_history)
        if add_clear_action:
            self.lineEdit().setClearButtonEnabled(True)
            ac = self.findChild(QAction, QT_HIDDEN_CLEAR_ACTION)
            if ac is not None:
                ac.triggered.connect(self.clear_clicked)

        c = self.line_edit.completer()
        c.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
        c.highlighted[native_string_type].connect(self.completer_used)

        self.line_edit.key_pressed.connect(self.key_pressed, type=Qt.ConnectionType.DirectConnection)
        # QueuedConnection as workaround for https://bugreports.qt-project.org/browse/QTBUG-40807
        self.activated[native_string_type].connect(self.history_selected, type=Qt.ConnectionType.QueuedConnection)
        self.setEditable(True)
        self.as_you_type = True
        self.timer = QTimer()
        self.timer.setSingleShot(True)
        self.timer.timeout.connect(self.timer_event, type=Qt.ConnectionType.QueuedConnection)
        self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
        self.setMaxCount(self.MAX_COUNT)
        self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
        self.setMinimumContentsLength(25)
        self._in_a_search = False
        self.tool_tip_text = self.toolTip()

    def add_action(self, icon, position=QLineEdit.ActionPosition.TrailingPosition):
        if not isinstance(icon, QIcon):
            icon = QIcon(I(icon))
        return self.lineEdit().addAction(icon, position)

    def initialize(self, opt_name, colorize=False, help_text=_('Search'), as_you_type=None):
        self.as_you_type = config['search_as_you_type'] if as_you_type is None else as_you_type
        self.opt_name = opt_name
        items = []
        for item in config[opt_name]:
            if item not in items:
                items.append(item)
        self.addItems(items)
        self.line_edit.setPlaceholderText(help_text)
        self.colorize = colorize
        self.clear()

    def clear_history(self):
        config[self.opt_name] = []
        super().clear()
        self.clear()
    clear_search_history = clear_history

    def hide_completer_popup(self):
        try:
            self.lineEdit().completer().popup().setVisible(False)
        except:
            pass

    def normalize_state(self):
        self.setToolTip(self.tool_tip_text)
        self.line_edit.setStyleSheet('')

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

    def clear(self, emit_search=True):
        self.normalize_state()
        self.setEditText('')
        if emit_search:
            self.search.emit('')
        self._in_a_search = False
        self.cleared.emit()

    def clear_clicked(self, *args):
        self.clear()
        self.setFocus(Qt.FocusReason.OtherFocusReason)

    def search_done(self, ok):
        if isinstance(ok, string_or_bytes):
            self.setToolTip(ok)
            ok = False
        if not str(self.currentText()).strip():
            self.clear(emit_search=False)
            return
        self._in_a_search = ok
        if self.colorize:
            self.line_edit.setStyleSheet(QApplication.instance().stylesheet_for_line_edit(not ok))
        else:
            self.line_edit.setStyleSheet('')

    # Comes from the lineEdit control
    def key_pressed(self, event):
        k = event.key()
        if k in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down,
                Qt.Key.Key_Home, Qt.Key.Key_End, Qt.Key.Key_PageUp, Qt.Key.Key_PageDown,
                Qt.Key.Key_unknown):
            return
        self.normalize_state()
        if self._in_a_search:
            self.changed.emit()
            self._in_a_search = False
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            self.do_search()
            self.focus_to_library.emit()
        elif self.as_you_type and str(event.text()):
            self.timer.start(1500)

    # Comes from the combobox itself
    def keyPressEvent(self, event):
        k = event.key()
        if k in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
            return self.do_search()
        if k not in (Qt.Key.Key_Up, Qt.Key.Key_Down):
            return QComboBox.keyPressEvent(self, event)
        self.blockSignals(True)
        self.normalize_state()
        if k == Qt.Key.Key_Down and self.currentIndex() == 0 and not self.lineEdit().text():
            self.setCurrentIndex(1), self.setCurrentIndex(0)
            event.accept()
        else:
            QComboBox.keyPressEvent(self, event)
        self.blockSignals(False)

    def completer_used(self, text):
        self.timer.stop()
        self.normalize_state()

    def timer_event(self):
        self._do_search(as_you_type=True)
        # since this is an automatic search keep focus
        self.setFocus(Qt.FocusReason.OtherFocusReason)

    def history_selected(self, text):
        self.changed.emit()
        self.do_search()

    def _do_search(self, store_in_history=True, as_you_type=False):
        self.hide_completer_popup()
        text = str(self.currentText()).strip()
        if not text:
            return self.clear()
        if as_you_type:
            text = AsYouType(text)
        self.search.emit(text)

        if store_in_history:
            idx = self.findText(text, Qt.MatchFlag.MatchFixedString|Qt.MatchFlag.MatchCaseSensitive)
            self.block_signals(True)
            if idx < 0:
                self.insertItem(0, text)
            else:
                t = self.itemText(idx)
                self.removeItem(idx)
                self.insertItem(0, t)
            self.setCurrentIndex(0)
            self.block_signals(False)
            history = [str(self.itemText(i)) for i in
                    range(self.count())]
            config[self.opt_name] = history

    def do_search(self, *args):
        self._do_search()
        self.timer.stop()

    def block_signals(self, yes):
        self.blockSignals(yes)
        self.line_edit.blockSignals(yes)

    def set_search_string(self, txt, store_in_history=False, emit_changed=True):
        if not store_in_history:
            self.activated[native_string_type].disconnect()
        try:
            self.setFocus(Qt.FocusReason.OtherFocusReason)
            if not txt:
                self.clear()
            else:
                self.normalize_state()
                # must turn on case sensitivity here so that tag browser strings
                # are not case-insensitively replaced from history
                self.line_edit.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive)
                self.setEditText(txt)
                self.line_edit.end(False)
                if emit_changed:
                    self.changed.emit()
                self._do_search(store_in_history=store_in_history)
                self.line_edit.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
            self.focus_to_library.emit()
        finally:
            if not store_in_history:
                # QueuedConnection as workaround for https://bugreports.qt-project.org/browse/QTBUG-40807
                self.activated[native_string_type].connect(self.history_selected, type=Qt.ConnectionType.QueuedConnection)

    def search_as_you_type(self, enabled):
        self.as_you_type = enabled

    def in_a_search(self):
        return self._in_a_search

    @property
    def current_text(self):
        return str(self.lineEdit().text())

    # }}}


class SavedSearchBox(QComboBox):  # {{{

    '''
    To use this class:
        * Call initialize()
        * Connect to the changed() signal from this widget
          if you care about changes to the list of saved searches.
    '''

    changed = pyqtSignal()

    def __init__(self, parent=None):
        QComboBox.__init__(self, parent)

        self.line_edit = SearchLineEdit(self)
        self.setLineEdit(self.line_edit)
        self.line_edit.key_pressed.connect(self.key_pressed, type=Qt.ConnectionType.DirectConnection)
        self.activated[native_string_type].connect(self.saved_search_selected)

        # Turn off auto-completion so that it doesn't interfere with typing
        # names of new searches.
        completer = QCompleter(self)
        self.setCompleter(completer)

        self.setEditable(True)
        self.setMaxVisibleItems(25)
        self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
        self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
        self.setMinimumContentsLength(25)
        self.tool_tip_text = self.toolTip()

    def initialize(self, _search_box, colorize=False, help_text=_('Search')):
        self.search_box = _search_box
        try:
            self.line_edit.setPlaceholderText(help_text)
        except:
            # Using Qt < 4.7
            pass
        self.colorize = colorize
        self.clear()

    def normalize_state(self):
        # need this because line_edit will call it in some cases such as paste
        pass

    def clear(self):
        QComboBox.clear(self)
        self.initialize_saved_search_names()
        self.setEditText('')
        self.setToolTip(self.tool_tip_text)
        self.line_edit.home(False)

    def key_pressed(self, event):
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            self.saved_search_selected(self.currentText())

    def saved_search_selected(self, qname):
        from calibre.gui2.ui import get_gui
        db = get_gui().current_db
        qname = str(qname)
        if qname is None or not qname.strip():
            self.search_box.clear()
            return
        if not db.saved_search_lookup(qname):
            self.search_box.clear()
            self.setEditText(qname)
            return
        self.search_box.set_search_string('search:"%s"' % qname, emit_changed=False)
        self.setEditText(qname)
        self.setToolTip(db.saved_search_lookup(qname))

    def initialize_saved_search_names(self):
        from calibre.gui2.ui import get_gui
        gui = get_gui()
        try:
            names = gui.current_db.saved_search_names()
        except AttributeError:
            # Happens during gui initialization
            names = []
        self.addItems(names)
        self.setCurrentIndex(-1)

    # SIGNALed from the main UI
    def save_search_button_clicked(self):
        from calibre.gui2.ui import get_gui
        db = get_gui().current_db
        name = str(self.currentText())
        if not name.strip():
            name = str(self.search_box.text()).replace('"', '')
        name = name.replace('\\', '')
        if not name:
            error_dialog(self, _('Create saved search'),
                         _('Invalid saved search name. '
                           'It must contain at least one letter or number'), show=True)
            return
        if not self.search_box.text():
            error_dialog(self, _('Create saved search'),
                         _('There is no search to save'), show=True)
            return
        db.saved_search_delete(name)
        db.saved_search_add(name, str(self.search_box.text()))
        # now go through an initialization cycle to ensure that the combobox has
        # the new search in it, that it is selected, and that the search box
        # references the new search instead of the text in the search.
        self.clear()
        self.setCurrentIndex(self.findText(name))
        self.saved_search_selected(name)
        self.changed.emit()

    def delete_current_search(self):
        from calibre.gui2.ui import get_gui
        db = get_gui().current_db
        idx = self.currentIndex()
        if idx <= 0:
            error_dialog(self, _('Delete current search'),
                         _('No search is selected'), show=True)
            return
        if not confirm('<p>'+_('The selected search will be '
                       '<b>permanently deleted</b>. Are you sure?') +
                       '</p>', 'saved_search_delete', self):
            return
        ss = db.saved_search_lookup(str(self.currentText()))
        if ss is None:
            return
        db.saved_search_delete(str(self.currentText()))
        self.clear()
        self.search_box.clear()
        self.changed.emit()

    # SIGNALed from the main UI
    def copy_search_button_clicked(self):
        from calibre.gui2.ui import get_gui
        db = get_gui().current_db
        idx = self.currentIndex()
        if idx < 0:
            return
        self.search_box.set_search_string(db.saved_search_lookup(str(self.currentText())))

    # }}}


class SearchBoxMixin:  # {{{

    def __init__(self, *args, **kwargs):
        pass

    def init_search_box_mixin(self):
        self.search.initialize('main_search_history', colorize=True,
                help_text=_('Search (For advanced search click the gear icon to the left)'))
        self.search.cleared.connect(self.search_box_cleared)
        # Queued so that search.current_text will be correct
        self.search.changed.connect(self.search_box_changed,
                type=Qt.ConnectionType.QueuedConnection)
        self.search.focus_to_library.connect(self.focus_to_library)
        self.advanced_search_toggle_action.triggered.connect(self.do_advanced_search)

        self.search.clear()
        self.search.setMaximumWidth(self.width()-150)
        self.action_focus_search = QAction(self)
        shortcuts = list(
                map(lambda x:str(x.toString(QKeySequence.SequenceFormat.PortableText)),
                QKeySequence.keyBindings(QKeySequence.StandardKey.Find)))
        shortcuts += ['/', 'Alt+S']
        self.keyboard.register_shortcut('start search', _('Start search'),
                default_keys=shortcuts, action=self.action_focus_search)
        self.action_focus_search.triggered.connect(self.focus_search_box)
        self.addAction(self.action_focus_search)
        self.search.setStatusTip(re.sub(r'<\w+>', ' ',
            str(self.search.toolTip())))
        self.set_highlight_only_button_icon()
        self.highlight_only_button.clicked.connect(self.highlight_only_clicked)
        tt = _('Enable or disable search highlighting.') + '<br><br>'
        tt += config.help('highlight_search_matches')
        self.highlight_only_button.setToolTip(tt)
        self.highlight_only_action = ac = QAction(self)
        self.addAction(ac), ac.triggered.connect(self.highlight_only_clicked)
        self.keyboard.register_shortcut('highlight search results', _('Highlight search results'), action=self.highlight_only_action)

    def highlight_only_clicked(self, state):
        if not config['highlight_search_matches'] and not question_dialog(self, _('Are you sure?'),
            _('This will change how searching works. When you search, instead of showing only the '
                'matching books, all books will be shown with the matching books highlighted. '
                'Are you sure this is what you want?'), skip_dialog_name='confirm_search_highlight_toggle'):
            return
        config['highlight_search_matches'] = not config['highlight_search_matches']
        self.set_highlight_only_button_icon()
        self.search.do_search()
        self.focus_to_library()

    def set_highlight_only_button_icon(self):
        b = self.highlight_only_button
        if config['highlight_search_matches']:
            b.setIcon(QIcon(I('highlight_only_on.png')))
            b.setText(_('Filter'))
        else:
            b.setIcon(QIcon(I('highlight_only_off.png')))
            b.setText(_('Highlight'))
        self.highlight_only_button.setVisible(gprefs['show_highlight_toggle_button'])
        self.library_view.model().set_highlight_only(config['highlight_search_matches'])

    def focus_search_box(self, *args):
        self.search.setFocus(Qt.FocusReason.OtherFocusReason)
        self.search.lineEdit().selectAll()

    def search_box_cleared(self):
        self.tags_view.clear()
        self.saved_search.clear()
        self.set_number_of_books_shown()

    def search_box_changed(self):
        self.saved_search.clear()
        self.tags_view.conditional_clear(self.search.current_text)

    def do_advanced_search(self, *args):
        d = SearchDialog(self, self.library_view.model().db)
        if d.exec() == QDialog.DialogCode.Accepted:
            self.search.set_search_string(d.search_string(), store_in_history=True)

    def do_search_button(self):
        self.search.do_search()
        self.focus_to_library()

    def focus_to_library(self):
        self.current_view().setFocus(Qt.FocusReason.OtherFocusReason)

    # }}}


class SavedSearchBoxMixin:  # {{{

    def __init__(self, *args, **kwargs):
        pass

    def init_saved_seach_box_mixin(self):
        self.saved_search.changed.connect(self.saved_searches_changed)
        ac = self.search.findChild(QAction, QT_HIDDEN_CLEAR_ACTION)
        if ac is not None:
            ac.triggered.connect(self.saved_search.clear)
        self.save_search_button.clicked.connect(
                                self.saved_search.save_search_button_clicked)
        self.copy_search_button.clicked.connect(
                                self.saved_search.copy_search_button_clicked)
        # self.saved_searches_changed()
        self.saved_search.initialize(self.search, colorize=True,
                help_text=_('Saved searches'))
        self.saved_search.tool_tip_text=_('Choose saved search or enter name for new saved search')
        self.saved_search.setToolTip(self.saved_search.tool_tip_text)
        self.saved_search.setStatusTip(self.saved_search.tool_tip_text)
        for x in ('copy', 'save'):
            b = getattr(self, x+'_search_button')
            b.setStatusTip(b.toolTip())
        self.save_search_button.setToolTip('<p>' +
         _("Save current search under the name shown in the box. "
           "Press and hold for a pop-up options menu.") + '</p>')
        self.save_search_button.setMenu(QMenu(self.save_search_button))
        self.save_search_button.menu().addAction(
                            QIcon(I('plus.png')),
                            _('Create Saved search'),
                            self.saved_search.save_search_button_clicked)
        self.save_search_button.menu().addAction(
            QIcon(I('trash.png')), _('Delete Saved search'), self.saved_search.delete_current_search)
        self.save_search_button.menu().addAction(
            QIcon(I('search.png')), _('Manage Saved searches'), partial(self.do_saved_search_edit, None))
        self.add_saved_search_button.setMenu(QMenu(self.add_saved_search_button))
        self.add_saved_search_button.menu().aboutToShow.connect(self.populate_add_saved_search_menu)

    def populate_add_saved_search_menu(self):
        m = self.add_saved_search_button.menu()
        m.clear()
        m.addAction(QIcon(I('plus.png')), _('Add Saved search'), self.add_saved_search)
        m.addAction(QIcon(I("search_copy_saved.png")), _('Get Saved search expression'),
                    self.get_saved_search_text)
        m.addActions(list(self.save_search_button.menu().actions())[-1:])
        m.addSeparator()
        db = self.current_db
        for name in sorted(db.saved_search_names(), key=lambda x: primary_sort_key(x.strip())):
            m.addAction(name.strip(), partial(self.saved_search.saved_search_selected, name))

    def saved_searches_changed(self, set_restriction=None, recount=True):
        self.build_search_restriction_list()
        if recount:
            self.tags_view.recount()
        if set_restriction:  # redo the search restriction if there was one
            self.apply_named_search_restriction(set_restriction)

    def do_saved_search_edit(self, search):
        d = SavedSearchEditor(self, search)
        d.exec()
        if d.result() == QDialog.DialogCode.Accepted:
            self.do_rebuild_saved_searches()

    def do_rebuild_saved_searches(self):
        self.saved_searches_changed()
        self.saved_search.clear()

    def add_saved_search(self):
        from calibre.gui2.dialogs.saved_search_editor import AddSavedSearch
        d = AddSavedSearch(parent=self, search=self.search.current_text)
        if d.exec() == QDialog.DialogCode.Accepted:
            self.current_db.new_api.ensure_has_search_category(fail_on_existing=False)
            self.do_rebuild_saved_searches()

    def get_saved_search_text(self, search_name=None):
        db = self.current_db
        try:
            current_search = search_name if search_name else self.search.currentText()
            if not current_search.startswith('search:'):
                raise ValueError()
            # This strange expression accounts for the four ways a search can be written:
            # search:fff, search:"fff", search:"=fff". and search:="fff"
            current_search = current_search[7:].lstrip('=').strip('"').lstrip('=')
            current_search = db.saved_search_lookup(current_search)
            if not current_search:
                raise ValueError()
            self.search.set_search_string(current_search)
        except:
            from calibre.gui2.ui import get_gui
            get_gui().status_bar.show_message(_('Current search is not a saved search'), 3000)
    # }}}
