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

import re
import time
from functools import partial
from typing import TYPE_CHECKING

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

from calibre.gui2 import QT_HIDDEN_CLEAR_ACTION, config, gprefs, question_dialog
from calibre.gui2.dialogs.saved_search_editor import SavedSearchEditor
from calibre.gui2.dialogs.search import SearchDialog
from calibre.gui2.widgets import stylesheet_for_lineedit
from calibre.utils.icu import primary_sort_key
from calibre.utils.localization import _, pgettext

if TYPE_CHECKING:
    from calibre.gui2.ui import Main


class AsYouType(str):
    as_you_type: bool

    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, a0):
        self.key_pressed.emit(a0)
        QLineEdit.keyPressEvent(self, a0)

    def dropEvent(self, a0):
        p = self.parent()
        assert p is not None
        assert isinstance(p, SearchBox2)
        p.normalize_state()
        return QLineEdit.dropEvent(self, a0)

    def contextMenuEvent(self, a0):
        p = self.parent()
        assert p is not None
        assert isinstance(p, SearchBox2)
        p.normalize_state()
        menu = self.createStandardContextMenu()
        assert menu is not None
        menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
        ac = menu.addAction(_('Paste and &search'))
        assert ac is not None
        clipboard = QApplication.clipboard()
        assert clipboard is not None
        ac.setEnabled(bool(clipboard.text()))
        ac.setIcon(QIcon.ic('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()
        ac = menu.addAction(_('Invert current search'))
        assert ac is not None
        ac.setEnabled(bool(self.text().strip()))
        ac.setIcon(QIcon.ic('search.png'))
        ac.triggered.connect(self.invert_search)
        menu.addAction(ac)
        menu.addSeparator()
        if self.as_url is not None:
            url = self.as_url(self.text())
            if url:

                def copy_as_url():
                    cb = QApplication.clipboard()
                    assert cb is not None
                    cb.setText(url)

                menu.addAction(_('Copy search as URL'), copy_as_url)
        clear_ac = menu.addAction(_('&Clear search history'))
        assert clear_ac is not None
        clear_ac.triggered.connect(self.clear_history)
        menu.exec(a0.globalPos())

    def invert_search(self):
        q = self.text().strip()
        if q:
            if q.startswith('NOT ( ') and q.endswith(' )'):
                q = q[6:-2]
            else:
                q = f'NOT ( {q} )'
            self.setText(q)
            ev = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Enter, Qt.KeyboardModifier.NoModifier)
            self.keyPressEvent(ev)

    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):
        p = self.parent()
        assert p is not None
        assert isinstance(p, SearchBox2)
        p.normalize_state()
        return QLineEdit.paste(self)

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

    def mousePressEvent(self, a0):
        QLineEdit.mousePressEvent(self, a0)
        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:
            le = self.lineEdit()
            assert le is not None
            le.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()
        assert c is not None
        c.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
        c.highlighted[str].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.textActivated.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()
        self.parse_error_action = None

    def add_action(self, icon, position=QLineEdit.ActionPosition.TrailingPosition):
        if not isinstance(icon, QIcon):
            icon = QIcon.ic(icon)
        le = self.lineEdit()
        assert le is not None
        return le.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.clear()

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

    clear_search_history = clear_history

    def hide_completer_popup(self):
        try:
            le = self.lineEdit()
            assert le is not None
            comp = le.completer()
            assert comp is not None
            pop = comp.popup()
            assert pop is not None
            pop.setVisible(False)
        except Exception:
            pass

    def normalize_state(self):
        self.setToolTip(self.tool_tip_text)
        self.setStyleSheet('')
        self.show_parse_error_action(False)

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

    def clear(self, emit_search=True):
        self.show_parse_error_action(False)
        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 show_parse_error_action(self, to_show, tooltip=''):
        if self.parse_error_action is not None:
            self.parse_error_action.setVisible(to_show)
            self.parse_error_action.setToolTip(tooltip)

    def search_done(self, ok):
        if isinstance(ok, (str, bytes)):
            self.setToolTip(ok)
            self.show_parse_error_action(True, tooltip=ok)
            ok = False
        if not str(self.currentText()).strip():
            self.setStyleSheet('')
            self.clear(emit_search=False)
            return
        self._in_a_search = ok
        if self.parse_error_action is not None and not ok:
            self.setStyleSheet(stylesheet_for_lineedit(bool(ok), 'QComboBox'))
        else:
            self.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, e):
        self.show_parse_error_action(False)
        k = e.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, e)
        self.blockSignals(True)
        self.normalize_state()
        le = self.lineEdit()
        assert le is not None
        if k == Qt.Key.Key_Down and self.currentIndex() == 0 and not le.text():
            self.setCurrentIndex(1), self.setCurrentIndex(0)
            e.accept()
        else:
            QComboBox.keyPressEvent(self, e)
        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.textActivated.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
                line_edit_completer = self.line_edit.completer()
                assert line_edit_completer is not None
                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)
                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.textActivated.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):
        le = self.lineEdit()
        assert le is not None
        return str(le.text())

    # }}}


class SearchBoxMixin:  # {{{
    def init_search_box_mixin(self: Main):
        self.search.initialize('main_search_history', 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 = [str(x.toString(QKeySequence.SequenceFormat.PortableText)) for x in 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)
        self.refresh_search_bar_widgets()

    def refresh_search_bar_widgets(self: Main):
        self.set_highlight_only_button_icon()
        if gprefs['search_tool_bar_shows_text']:
            self.search_bar.search_button.setText(_('Search'))
            self.search_bar.search_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
        else:
            self.search_bar.search_button.setText(None)
            self.search_bar.search_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)

    def highlight_only_clicked(self: Main, 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: Main):
        b = self.highlight_only_button
        if config['highlight_search_matches']:
            b.setIcon(QIcon.ic('highlight_only_on.png'))
            if gprefs['search_tool_bar_shows_text']:
                b.setText(_('Filter'))
                b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
            else:
                b.setText(None)
                b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
        else:
            b.setIcon(QIcon.ic('highlight_only_off.png'))
            if gprefs['search_tool_bar_shows_text']:
                b.setText(pgettext('mark books matching search result instead of filtering them', 'Highlight'))
                b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
            else:
                b.setText(None)
                b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
        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: Main, *args):
        self.search.setFocus(Qt.FocusReason.OtherFocusReason)
        le = self.search.lineEdit()
        if le:
            le.selectAll()

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

    def search_box_changed(self: Main):
        self.tags_view.conditional_clear(self.search.current_text)

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

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

    def focus_to_library(self: Main):
        self.focus_current_view()

    # }}}


class SavedSearchBoxMixin:  # {{{
    def init_saved_seach_box_mixin(self: Main):
        pass

    def add_saved_searches_to_menu(self: Main, menu, db, add_action_func=None):
        def add_action(current_menu, whole_name, last_component, func=None):
            if add_action_func is None:
                return current_menu.addAction(last_component, func)
            return add_action_func(current_menu, whole_name, last_component)

        folder_icon = QIcon.ic('folder_saved_search.png')
        search_icon = QIcon.ic('search.png')
        use_hierarchy = 'search' in db.new_api.pref('categories_using_hierarchy', [])
        submenus = {}
        for name in sorted(db.saved_search_names(), key=lambda x: primary_sort_key(x.strip())):
            display_name = name.replace('&', '&&')
            current_menu = menu
            if use_hierarchy:
                components = tuple(n.strip() for n in name.split('.'))
                hierarchy = components[:-1]
                last = components[-1]
                # Walk the hierarchy, creating submenus as needed
                for i, c in enumerate(hierarchy, start=1):
                    hierarchical_prefix = '.'.join(hierarchy[:i])
                    if hierarchical_prefix not in submenus:
                        current_menu = current_menu.addMenu(c.replace('&', '&&'))
                        current_menu.setIcon(folder_icon)
                        submenus[hierarchical_prefix] = current_menu
                    else:
                        current_menu = submenus[hierarchical_prefix]
                ac = add_action(
                    current_menu,
                    display_name,
                    last.replace('&', '&&'),
                    partial(self.search.set_search_string, 'search:"=' + name + '"'),
                )
            else:
                ac = add_action(
                    current_menu,
                    display_name,
                    display_name,
                    partial(self.search.set_search_string, 'search:"=' + name + '"'),
                )
            if ac.icon().isNull():
                ac.setIcon(search_icon)

    def populate_add_saved_search_menu(self: Main, to_menu):
        m = to_menu
        m.clear()
        m.addAction(QIcon.ic('search_add_saved.png'), _('Add Saved search'), self.add_saved_search)
        m.addAction(QIcon.ic('search_copy_saved.png'), _('Get Saved search expression'), self.get_saved_search_text)
        m.addAction(QIcon.ic('folder_saved_search.png'), _('Manage Saved searches'), partial(self.do_saved_search_edit, None))
        m.addSeparator()
        self.add_saved_searches_to_menu(m, self.current_db)

    def saved_searches_changed(self: Main, 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: Main, search):
        d = SavedSearchEditor(self, search)
        d.exec()
        if d.result() == QDialog.DialogCode.Accepted:
            self.do_rebuild_saved_searches()

    def do_rebuild_saved_searches(self: Main):
        self.saved_searches_changed()

    def add_saved_search(self: Main):
        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: Main, search_name=None):
        db = self.current_db
        try:
            current_search = search_name or 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 Exception:
            from calibre.gui2.ui import get_gui

            get_gui(fail_if_absent=True).status_bar.show_message(_('Current search is not a saved search'), 3000)

    # }}}
