# License: GPLv3 Copyright: 2011, John Schember <john@nachtimwald.com>

import re
from functools import partial
from random import shuffle

from qt.core import QCheckBox, QDialog, QDialogButtonBox, QGridLayout, QIcon, QLabel, QMenu, QSize, QStyle, Qt, QTabWidget, QTimer, QVBoxLayout, QWidget

from calibre.gui2 import JSONConfig, error_dialog, info_dialog
from calibre.gui2.dialogs.choose_format import ChooseFormatDialog
from calibre.gui2.ebook_download import show_download_info
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.store.config.chooser.chooser_widget import StoreChooserWidget
from calibre.gui2.store.config.search.search_widget import StoreConfigWidget
from calibre.gui2.store.search.adv_search_builder import AdvSearchBuilderDialog
from calibre.gui2.store.search.download_thread import CacheUpdateThreadPool, SearchThreadPool
from calibre.gui2.store.search.models import Matches
from calibre.gui2.store.search.search_ui import Ui_Dialog
from calibre.gui2.widgets2 import HistoryLineEdit2
from calibre.utils.filenames import ascii_filename
from calibre.utils.localization import _


def add_items_to_context_menu(widget: HistoryLineEdit2, menu: QMenu) -> None:
    menu.addSeparator()
    ac = menu.addAction(_('Clear search &history'))
    assert ac is not None
    ac.triggered.connect(widget.clear_history)


class SearchDialog(QDialog, Ui_Dialog):
    SEARCH_TEXT = _('&Search')
    STOP_TEXT = _('&Stop')

    def __init__(self, gui, parent=None, query=''):
        QDialog.__init__(self, parent)
        self.setupUi(self)
        s = self.style()
        assert s is not None
        self.close_button.setIcon(s.standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton))

        self.config = JSONConfig('store/search')
        self.search_title.initialize('store_search_search_title')
        self.search_author.initialize('store_search_search_author')
        self.search_edit.initialize('store_search_search')
        self.search_title.add_items_to_context_menu_callback = partial(add_items_to_context_menu, self.search_title)
        self.search_author.add_items_to_context_menu_callback = partial(add_items_to_context_menu, self.search_author)
        self.search_edit.add_items_to_context_menu_callback = partial(add_items_to_context_menu, self.search_edit)

        # Loads variables that store various settings.
        # This needs to be called soon in __init__ because
        # the variables it sets up are used later.
        self.load_settings()

        self.gui = gui

        # Setup our worker threads.
        self.search_pool = SearchThreadPool(self.search_thread_count)
        self.cache_pool = CacheUpdateThreadPool(self.cache_thread_count)
        _rv_model = self.results_view.model()
        assert _rv_model is not None
        assert isinstance(_rv_model, Matches)
        _rv_model.cover_pool.set_thread_count(self.cover_thread_count)
        _rv_model.details_pool.set_thread_count(self.details_thread_count)
        self.results_view.setCursor(Qt.CursorShape.PointingHandCursor)
        # needed for live updates of amazon_live.py
        from calibre.live import start_worker

        start_worker()

        # Check for results and hung threads.
        self.checker = QTimer()
        self.progress_checker = QTimer()
        self.hang_check = 0

        # Update store caches silently.
        for p in self.gui.istores.values():
            self.cache_pool.add_task(p, self.timeout)

        self.store_checks = {}
        self.setup_store_checks()

        # Set the search query
        if isinstance(query, (bytes, str)):
            self.search_edit.setText(query)
        elif isinstance(query, dict):
            if 'author' in query:
                self.search_author.setText(query['author'])
            if 'title' in query:
                self.search_title.setText(query['title'])

        # Create and add the progress indicator
        self.pi = ProgressIndicator(self, 24)
        self.button_layout.takeAt(0)
        self.button_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.button_layout.insertWidget(0, self.pi, 0, Qt.AlignmentFlag.AlignCenter)

        self.adv_search_button.setIcon(QIcon.ic('gear.png'))
        self.adv_search_button.setToolTip(_('Advanced search'))
        self.configure.setIcon(QIcon.ic('config.png'))

        self.adv_search_button.clicked.connect(self.build_adv_search)
        self.search.clicked.connect(self.toggle_search)
        self.search_title.returnPressed.connect(self.toggle_search)
        self.search_author.returnPressed.connect(self.toggle_search)
        self.search_edit.returnPressed.connect(self.toggle_search)
        self.checker.timeout.connect(self.get_results)
        self.progress_checker.timeout.connect(self.check_progress)
        self.results_view.activated.connect(self.result_item_activated)
        self.results_view.download_requested.connect(self.download_book)
        self.results_view.open_requested.connect(self.open_store)
        _rv_model.total_changed.connect(self.update_book_total)
        self.select_all_stores.clicked.connect(self.stores_select_all)
        self.select_invert_stores.clicked.connect(self.stores_select_invert)
        self.select_none_stores.clicked.connect(self.stores_select_none)
        self.configure.clicked.connect(self.do_config)
        self.finished.connect(self.dialog_closed)
        self.searching = False

        self.progress_checker.start(100)

        self.restore_state()

    def setup_store_checks(self):
        first_run = self.config.get('first_run', True)

        # Add check boxes for each store so the user
        # can disable searching specific stores on a
        # per search basis.
        existing = {}
        for n in self.store_checks:
            existing[n] = self.store_checks[n].isChecked()

        self.store_checks = {}

        stores_check_widget = QWidget()
        store_list_layout = QGridLayout()
        stores_check_widget.setLayout(store_list_layout)

        icon = QIcon.ic('donate.png')
        for i, x in enumerate(sorted(self.gui.istores.keys(), key=lambda x: x.lower())):
            cbox = QCheckBox(x)
            cbox.setChecked(existing.get(x, first_run))
            store_list_layout.addWidget(cbox, i, 0, 1, 1)
            if self.gui.istores[x].base_plugin.affiliate:
                iw = QLabel(self)
                iw.setToolTip('<p>' + _('Buying from this store supports the calibre developer: %s</p>') % self.gui.istores[x].base_plugin.author + '</p>')
                iw.setPixmap(icon.pixmap(16, 16))
                store_list_layout.addWidget(iw, i, 1, 1, 1)
            self.store_checks[x] = cbox
        store_list_layout.setRowStretch(store_list_layout.rowCount(), 10)
        self.store_list.setWidget(stores_check_widget)

        self.config['first_run'] = False

    def build_adv_search(self):
        adv = AdvSearchBuilderDialog(self)
        if adv.exec() == QDialog.DialogCode.Accepted:
            self.search_edit.setText(adv.search_string())

    def resize_columns(self):
        total = 600
        # Cover
        self.results_view.setColumnWidth(0, 85)
        total = total - 85
        # Title / Author
        self.results_view.setColumnWidth(1, int(total * 0.40))
        # Price
        self.results_view.setColumnWidth(2, int(total * 0.12))
        # DRM
        self.results_view.setColumnWidth(3, int(total * 0.15))
        # Store / Formats
        self.results_view.setColumnWidth(4, int(total * 0.25))
        # Download
        self.results_view.setColumnWidth(5, 20)
        # Affiliate
        self.results_view.setColumnWidth(6, 20)

    def toggle_search(self):
        if self.searching:
            self.search_pool.abort()
            m = self.results_view.model()
            assert m is not None
            assert isinstance(m, Matches)
            m.details_pool.abort()
            m.cover_pool.abort()
            self.search.setText(self.SEARCH_TEXT)
            self.checker.stop()
            self.searching = False
        else:
            self.do_search()
        # Prevent hitting the enter key twice in quick succession causing
        # the search to start and stop
        self.search.setEnabled(False)
        QTimer.singleShot(1000, lambda: self.search.setEnabled(True))

    def do_search(self):
        # Stop all running threads.
        self.checker.stop()
        self.search_pool.abort()
        # Clear the visible results.
        _m = self.results_view.model()
        assert _m is not None
        assert isinstance(_m, Matches)
        _m.clear_results()

        # Don't start a search if there is nothing to search for.
        query = []
        if self.search_title.text():
            query.append('title2:"~{}"'.format(str(self.search_title.text()).replace('"', ' ')))
        if self.search_author.text():
            query.append('author2:"{}"'.format(str(self.search_author.text()).replace('"', ' ')))
        if self.search_edit.text():
            query.append(str(self.search_edit.text()))
        query = ' '.join(query)
        if not query.strip():
            error_dialog(self, _('No query'), _('You must enter a title, author or keyword to search for.'), show=True)
            return
        self.searching = True
        self.search.setText(self.STOP_TEXT)
        # Give the query to the results model so it can do
        # further filtering.
        _m.set_query(query)

        # Plugins are in random order that does not change.
        # Randomize the order of the plugin names every time
        # there is a search. This way plugins closer
        # to a don't have an unfair advantage over
        # plugins further from a.
        store_names = list(self.store_checks)
        if not store_names:
            return
        # Remove all of our internal filtering logic from the query.
        query = self.clean_query(query)
        shuffle(store_names)
        # Add plugins that the user has checked to the search pool's work queue.
        self.gui.istores.join(4.0)  # Wait for updated plugins to load
        for n in store_names:
            if self.store_checks[n].isChecked():
                self.search_pool.add_task(query, n, self.gui.istores[n], self.max_results, self.timeout)
        self.hang_check = 0
        self.checker.start(100)
        self.pi.startAnimation()

    def clean_query(self, query):
        query = query.lower()
        # Remove control modifiers.
        query = query.replace('\\', '')
        query = query.replace('!', '')
        query = query.replace('=', '')
        query = query.replace('~', '')
        query = query.replace('>', '')
        query = query.replace('<', '')
        # Remove the prefix.
        for loc in ('all', 'author', 'author2', 'authors', 'title', 'title2'):
            query = re.sub(rf'{loc}:"(?P<a>[^\s"]+)"', r'\g<a>', query)
            query = query.replace(f'{loc}:', '')
        # Remove the prefix and search text.
        for loc in ('cover', 'download', 'downloads', 'drm', 'format', 'formats', 'price', 'store'):
            query = re.sub(rf'{loc}:"[^"]"', '', query)
            query = re.sub(rf'{loc}:[^\s]*', '', query)
        # Remove logic.
        query = re.sub(r'(^|\s|")(and|not|or|a|the|is|of)(\s|$|")', ' ', query)
        # Remove "
        query = query.replace('"', '')
        # Remove excess whitespace.
        query = re.sub(r'\s+', ' ', query)
        query = query.strip()
        return query.encode('utf-8')

    def save_state(self):
        self.save_geometry(self.config, 'geometry')
        self.config['store_splitter_state'] = bytearray(self.store_splitter.saveState())
        rv_model = self.results_view.model()
        assert rv_model is not None
        assert isinstance(rv_model, Matches)
        self.config['results_view_column_width'] = [self.results_view.columnWidth(i) for i in range(rv_model.columnCount())]
        self.config['sort_col'] = rv_model.sort_col
        self.config['sort_order'] = rv_model.sort_order.value
        self.config['open_external'] = self.open_external.isChecked()

        store_check = {}
        for k, v in self.store_checks.items():
            store_check[k] = v.isChecked()
        self.config['store_checked'] = store_check

    def restore_state(self):
        self.restore_geometry(self.config, 'geometry')
        splitter_state = self.config.get('store_splitter_state', None)
        if splitter_state:
            self.store_splitter.restoreState(splitter_state)

        results_cwidth = self.config.get('results_view_column_width', None)
        if results_cwidth:
            for i, x in enumerate(results_cwidth):
                rv_model2 = self.results_view.model()
                assert rv_model2 is not None
                if i >= rv_model2.columnCount():
                    break
                self.results_view.setColumnWidth(i, x)
        else:
            self.resize_columns()

        self.open_external.setChecked(self.should_open_external)

        store_check = self.config.get('store_checked', None)
        if store_check:
            for n in store_check:
                if n in self.store_checks:
                    self.store_checks[n].setChecked(store_check[n])

        _rv_m = self.results_view.model()
        assert _rv_m is not None
        assert isinstance(_rv_m, Matches)
        _rv_m.sort_col = self.config.get('sort_col', 2)
        so = self.config.get('sort_order', Qt.SortOrder.AscendingOrder)
        if isinstance(so, int):
            so = Qt.SortOrder(so)
        _rv_m.sort_order = so
        rv_header = self.results_view.header()
        assert rv_header is not None
        rv_header.setSortIndicator(_rv_m.sort_col, so)

    def load_settings(self):
        # Seconds
        self.timeout = self.config.get('timeout', 75)
        # Milliseconds
        self.hang_time = self.config.get('hang_time', 75) * 1000

        self.max_results = self.config.get('max_results', 15)
        self.should_open_external = self.config.get('open_external', True)

        # Number of threads to run for each type of operation
        self.search_thread_count = self.config.get('search_thread_count', 4)
        self.cache_thread_count = self.config.get('cache_thread_count', 2)
        self.cover_thread_count = self.config.get('cover_thread_count', 2)
        self.details_thread_count = self.config.get('details_thread_count', 4)

    def do_config(self):
        # Save values that need to be synced between the dialog and the
        # search widget.
        self.config['open_external'] = self.open_external.isChecked()

        # Create the config dialog. It's going to put two config widgets
        # into a QTabWidget for displaying all of the settings.
        d = QDialog(self)
        button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
        v = QVBoxLayout(d)
        button_box.accepted.connect(d.accept)
        button_box.rejected.connect(d.reject)
        d.setWindowTitle(_('Customize Get books search'))

        tab_widget = QTabWidget(d)
        v.addWidget(tab_widget)
        v.addWidget(button_box)

        chooser_config_widget = StoreChooserWidget()
        search_config_widget = StoreConfigWidget(self.config)

        tab_widget.addTab(chooser_config_widget, _('Choose s&tores'))
        tab_widget.addTab(search_config_widget, _('Configure s&earch'))

        # Restore dialog state.
        d.restore_geometry(self.config, 'config_dialog_geometry')
        tab_index = self.config.get('config_dialog_tab_index', 0)
        tab_index = min(tab_index, tab_widget.count() - 1)
        tab_widget.setCurrentIndex(tab_index)

        d.exec()

        # Save dialog state.
        d.save_geometry(self.config, 'config_dialog_geometry')
        self.config['config_dialog_tab_index'] = tab_widget.currentIndex()

        search_config_widget.save_settings()
        self.config_changed()
        self.gui.load_store_plugins()
        self.setup_store_checks()

    def sizeHint(self):
        return QSize(800, 600)

    def config_changed(self):
        self.load_settings()

        self.open_external.setChecked(self.should_open_external)
        self.search_pool.set_thread_count(self.search_thread_count)
        self.cache_pool.set_thread_count(self.cache_thread_count)
        _cc_m = self.results_view.model()
        assert _cc_m is not None
        assert isinstance(_cc_m, Matches)
        _cc_m.cover_pool.set_thread_count(self.cover_thread_count)
        _cc_m.details_pool.set_thread_count(self.details_thread_count)

    def get_results(self):
        # We only want the search plugins to run
        # a maximum set amount of time before giving up.
        self.hang_check += 1
        if self.hang_check >= self.hang_time:
            self.search_pool.abort()
            self.checker.stop()
        # Stop the checker if not threads are running.
        elif not self.search_pool.threads_running() and not self.search_pool.has_tasks():
            self.checker.stop()

        _gr_m = self.results_view.model()
        assert _gr_m is not None
        assert isinstance(_gr_m, Matches)
        while self.search_pool.has_results():
            res, store_plugin = self.search_pool.get_result()
            if res:
                _gr_m.add_result(res, store_plugin)

        if not self.search_pool.threads_running() and not _gr_m.has_results():
            info_dialog(
                self,
                _('No matches'),
                _("Couldn't find any books matching your query."),
                show=True,
                show_copy_button=False,
            )

    def update_book_total(self, total):
        self.total.setText(f'{total}')

    def result_item_activated(self, index):
        _ria_m = self.results_view.model()
        assert _ria_m is not None
        assert isinstance(_ria_m, Matches)
        result = _ria_m.get_result(index)

        if result.downloads:
            self.download_book(result)
        else:
            self.open_store(result)

    def download_book(self, result):
        d = ChooseFormatDialog(self, _('Choose format to download to your library.'), list(result.downloads.keys()))
        if d.exec() == QDialog.DialogCode.Accepted:
            ext = d.format()
            fname = result.title[:60] + '.' + ext.lower()
            fname = ascii_filename(fname)
            show_download_info(result.title, parent=self)
            self.gui.download_ebook(result.downloads[ext], filename=fname, create_browser=result.create_browser)

    def open_store(self, result):
        self.gui.istores[result.store_name].open(self, result.detail_item, self.open_external.isChecked())

    def check_progress(self):
        m = self.results_view.model()
        assert m is not None
        assert isinstance(m, Matches)
        if not self.search_pool.threads_running() and not m.cover_pool.threads_running() and not m.details_pool.threads_running():
            self.pi.stopAnimation()
            self.search.setText(self.SEARCH_TEXT)
            self.searching = False
        else:
            self.searching = True
            if str(self.search.text()) != self.STOP_TEXT:
                self.search.setText(self.STOP_TEXT)
            if not self.pi.isAnimated():
                self.pi.startAnimation()

    def stores_select_all(self):
        for check in self.store_checks.values():
            check.setChecked(True)

    def stores_select_invert(self):
        for check in self.store_checks.values():
            check.setChecked(not check.isChecked())

    def stores_select_none(self):
        for check in self.store_checks.values():
            check.setChecked(False)

    def dialog_closed(self, result):
        _dc_m = self.results_view.model()
        assert _dc_m is not None
        assert isinstance(_dc_m, Matches)
        _dc_m.closing()
        self.search_pool.abort()
        self.cache_pool.abort()
        self.save_state()

    def exec(self):
        if str(self.search_edit.text()).strip() or str(self.search_title.text()).strip() or str(self.search_author.text()).strip():
            self.do_search()
        return QDialog.exec(self)

    exec_ = exec


if __name__ == '__main__':
    import sys

    from calibre.gui2 import Application
    from calibre.gui2.preferences.main import init_gui

    app = Application([])
    app
    gui = init_gui()

    s = SearchDialog(gui, query=' '.join(sys.argv[1:]))
    s.exec()
