#!/usr/bin/env python
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
from __future__ import (unicode_literals, division, absolute_import, print_function)

import datetime
import functools
import re
import time

from PyQt5.Qt import (
    Qt, QAbstractItemView, QAbstractTableModel, QColor, QComboBox, QDialog, QDialogButtonBox, QEvent,
    QGridLayout, QGroupBox, QHBoxLayout, QIcon, QLabel, QMenu, QObject, QPalette, QProgressDialog, QPushButton,
    QSizePolicy, QStatusBar, QTableView, QToolButton, QVariant, QVBoxLayout, pyqtSignal)

from calibre.constants import (DEBUG, numeric_version)
from calibre.ebooks.metadata import (authors_to_string, author_to_author_sort, title_sort)
from calibre.utils.config_base import tweaks
from calibre.gui2.search_box import SearchBox2
from calibre.utils.search_query_parser import SearchQueryParser, ParseException

from calibre_plugins.overdrive_link import ActionOverdriveLink
from calibre_plugins.overdrive_link.config import (DiscoveredBooksDialogState, plugin_config)
from calibre_plugins.overdrive_link.match import (match_book_lists, match_single_book_to_list, primary_author)
from calibre_plugins.overdrive_link.link import (
    LINK_AVAILABLE, LINK_RECOMMENDABLE, LINK_PURCHASABLE, ODLinkSet, IDENT_AVAILABLE_LINK,
    IDENT_RECOMMENDABLE_LINK, IDENT_PURCHASABLE_LINK, IDENT_NEEDED_LINK, IDENT_ISBN,
    browse_to_url, linkset_menu)
from calibre_plugins.overdrive_link.book import (CalibreBook, DiscoveredBook)
from calibre_plugins.overdrive_link.numbers import value_unit
from calibre_plugins.overdrive_link.tweak import TWEAK_DISPLAY_IF_DISCOVERED_AT

from .python_transition import (IS_PYTHON2)
if IS_PYTHON2:
    from .python_transition import (str)


__license__ = 'GPL v3'
__copyright__ = '2012-2025, John Howell <jhowell@acm.org>'


CALIBRE_3_PLUS = numeric_version >= (2, 99, 0)
CALIBRE_6_PLUS = numeric_version >= (5, 99, 0)

if CALIBRE_6_PLUS:   # changes required for calibre 6 (QT6)
    QT_ForegroundRole = Qt.ForegroundRole
    QSizePolicy_Policy_Expanding = QSizePolicy.Policy.Expanding
    QSizePolicy_Policy_Minimum = QSizePolicy.Policy.Minimum
    Qt_SortOrder_AscendingOrder = Qt.SortOrder.AscendingOrder
    Qt_SortOrder_DescendingOrder = Qt.SortOrder.DescendingOrder
else:
    QT_ForegroundRole = Qt.TextColorRole
    QSizePolicy_Policy_Expanding = QSizePolicy.Expanding
    QSizePolicy_Policy_Minimum = QSizePolicy.Minimum
    Qt_SortOrder_AscendingOrder = Qt.AscendingOrder
    Qt_SortOrder_DescendingOrder = Qt.DescendingOrder


# Namespace for all configuration data stored by this plugin in the calibre per-library database
CalibreLibConfigNamespace = ActionOverdriveLink.name

# Key for discovered book list in the calibre per-library database
AvailableBooks = 'AvailableBooks'

ROW_BORDER = 2  # Pixels to add to row height as a border between rows

# search query types
CONTAINS_MATCH = 0
EQUALS_MATCH = 1
REGEXP_MATCH = 2
ANY_MATCH = 3
EXISTS_MATCH = 4
MISSING_MATCH = 5

# default value for sorting by found date
OLD_DATE = datetime.datetime(2000, 1, 1)


class BookAction(object):
    def __init__(self, index, name, icon, table_tooltip='', button_tooltip='', color=QColor(Qt.black), next=None):
        self.index = index
        self.name = name
        self.icon = QIcon(I(icon))
        self.table_tooltip = table_tooltip
        self.button_tooltip = button_tooltip
        self.color = color
        self.next = next
        action_map[index] = self

    def __repr__(self):
        return self.name

    def __str__(self):
        return self.name


action_map = {}

#define actions that can be performed on a discovered book
NO_ACTION = BookAction(
        0, 'No Action', 'dialog_question.png', 'Take no action for this book',
        'Take no action for selected books', QColor(Qt.blue))
ADD_BOOK = BookAction(
        1, 'Add Book', 'add_book.png', 'Add this book to the calibre library',
        'Add selected books to the calibre library', QColor(Qt.red))
UPDATE_BOOK = BookAction(
        2, 'Update', 'dot_red.png', 'Update this book in the calibre library and discard from search results',
        'Update selected books in the calibre library', QColor(Qt.red))
IGNORE_BOOK = BookAction(
        3, 'Ignore', 'minus.png', 'Ignore and hide this book',
        'Ignore and hide selected books', QColor(Qt.red))
DISCARD_BOOK = BookAction(
        4, 'Discard', 'list_remove.png', 'Discard this book from search results',
        'Discard selected books from search results', QColor(Qt.red))

# link actions round-robin
NO_ACTION.next = ADD_BOOK
ADD_BOOK.next = IGNORE_BOOK
IGNORE_BOOK.next = DISCARD_BOOK
DISCARD_BOOK.next = NO_ACTION
UPDATE_BOOK.next = NO_ACTION

KEEP_BOOK_ACTIONS = [NO_ACTION, IGNORE_BOOK]


# Custom roles
DoubleCLickRole = Qt.UserRole + 0

# Defaults for book attributes
default_book = DiscoveredBook()


def have_discovered_books(db):
    with db.new_api.read_lock:
        have_disc = len(db.new_api.backend.prefs.get_namespaced(CalibreLibConfigNamespace, AvailableBooks, default=[])) > 0

    return have_disc


def get_discovered_books(db):
    books = []
    defaults = default_book.__dict__
    num_failed = num_fallback = 0

    # warning: cannot change stored_book_dicts since get_namespaced can return the same object multiple times
    with db.new_api.read_lock:
        stored_book_dicts = db.new_api.backend.prefs.get_namespaced(CalibreLibConfigNamespace, AvailableBooks, default=[])

    for stored_book_dict in stored_book_dicts:
        try:
            book = DiscoveredBook(**stored_book_dict)
        except Exception:
            try:
                # failed to load, try using only known fields
                book_dict = {}
                for attrib, value in stored_book_dict.items():
                    if attrib in defaults:
                        book_dict[attrib] = value

                book = DiscoveredBook(**book_dict)
                num_fallback += 1
            except Exception:
                num_failed += 1
                continue

        book.clear_orig_links()
        books.append(book)

    if DEBUG and num_fallback:
        print("Fall back used to load %d discovered books from database" % num_fallback)

    if DEBUG and num_failed:
        print("Failed to load %d discovered books from database" % num_failed)

    version = stored_book_dicts[0].get('saved_data_version', 1) if stored_book_dicts else 0

    return (books, version)


def save_discovered_books(db, books):
    stored_books = []
    defaults = default_book.__dict__
    for book in books:
        stored_book_dict = {}
        for attrib, value in book.__dict__.items():
            if attrib in defaults and value != defaults[attrib]:
                stored_book_dict[attrib] = value                # Save only the attributes that are required

        stored_books.append(stored_book_dict)

    if stored_books:
        stored_books[0]['saved_data_version'] = time.time()     # use time of save as version number

    with db.new_api.write_lock:
        db.new_api.backend.prefs.set_namespaced(CalibreLibConfigNamespace, AvailableBooks, stored_books)


def new_books_discovered(db, new_discovered_books, orig_version, parent, config):
    '''
    Merge newly discovered lending library books with those found previously
    '''

    for new_book in new_discovered_books:
        new_book.matched = False

    libs_to_match = set(tweaks.get(TWEAK_DISPLAY_IF_DISCOVERED_AT, []))

    discovered_books, saved_version = get_discovered_books(db)

    if orig_version is None:
        # force fixing of links, no adding of missing books
        exact_match = True
        replace_links_only = True
        desc = 'exact'
    elif orig_version == saved_version:
        # saved discovered books are unchanged so do (fast) strict matching
        exact_match = True
        replace_links_only = False
        desc = 'new'
    else:
        # discovered books have already been updated, use loose matching to prevent duplicates
        exact_match = False
        replace_links_only = False
        desc = 'changed'

    #print('Merging and saving %s discovered book data (old=%s new=%s)' % (desc, str(orig_version), str(saved_version)))

    progress = QProgressDialog('Merging and saving %s discovered book data' % desc, 'Cancel', 0, 10000, parent)
    progress.setModal(True)
    progress.setWindowTitle(ActionOverdriveLink.name)
    progress.setWindowFlags(progress.windowFlags() & (~Qt.WindowContextHelpButtonHint))
    progress.setMinimumWidth(400)
    progress.setMinimumDuration(30000 if config.disable_job_completion_dlgs else 100)   # Show progress quickly
    progress.setValue(0)

    for book, new_book in match_book_lists(
            discovered_books, new_discovered_books, config, progress=progress, do_exact_match=exact_match):
        # merge the results (update original book using new links and any missing metadata)

        if not new_book.matched:
            if book.ignored and libs_to_match:
                previously_matched = not libs_to_match.isdisjoint(set(library_name_list(book, config)))
            else:
                previously_matched = True

            book.merge_from(new_book, replace_links=replace_links_only)

            if not previously_matched:
                if not libs_to_match.isdisjoint(set(library_name_list(book, config))):
                    book.ignored = False    # display books that have changed to match library

            new_book.matched = True     # prevent duplication

    if not replace_links_only:
        for new_book in new_discovered_books:
            if not new_book.matched:
                discovered_books.append(new_book)   # newly discovered book

    if not progress.wasCanceled():
        save_discovered_books(db, discovered_books)

    progress.reset()


def library_name_list(book, config):
    try:
        names = ODLinkSet(book.odid, config=config).name_list(LINK_AVAILABLE)
        names.extend(ODLinkSet(book.odrid, config=config).name_list(LINK_RECOMMENDABLE))
        names.extend(ODLinkSet(book.odpid, config=config).name_list(LINK_PURCHASABLE))
    except Exception:
        # Handle corrupted links caused by previous plugin bug
        names = ['(None)']

    return names


class DiscoveredBookSearchBox(SearchBox2):
    # redefine methods to allow plugin configuration to be used instead of global configuration

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

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

        if store_in_history:
            idx = self.findText(text, Qt.MatchFixedString | Qt.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())]
            self.config[self.opt_name] = history    # changed config to self.config


class AdaptSQP(SearchQueryParser):

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


class DiscoveredBookTableModel(QAbstractTableModel, AdaptSQP):
    '''
    Model for the list of additional discovered books found via search by author
    Handles sorting and filtering due to slowness of using a proxy model.
    Performs sorting of non-visible books so that they can later be shown in the correct order.
    '''
    attr_names = ['action', 'author', 'title', 'series_and_index', 'library_names', 'isbn', 'publisher_and_pubdate', 'found_date']
    header_names = ['Action', 'Author', 'Title', 'Series', 'Library', 'ISBN', 'Publisher', 'Found']
    sort_keys = [lambda book: book.action.index, lambda book: book.author_sort, lambda book: book.title_sort,
                 lambda book: book.series_and_index, lambda book: book.library_names, lambda book: book.isbn,
                 lambda book: book.publisher_and_pubdate,
                 lambda book: book.found_date.isoformat()[0:10] if book.found_date is not None else '']

    search_fields = ['author', 'title', 'series', 'library', 'isbn', 'publisher', 'language', 'action', 'ignored', 'all', 'search']

    search_done = pyqtSignal(object)

    def __init__(self, books, config, parent=None, *args):
        QAbstractTableModel.__init__(self, parent, *args)
        SearchQueryParser.__init__(self, self.search_fields)

        self.all_books = books
        self.config = config

        self.compare_book_list = None
        self.sort_order = []
        self.visible_books = []

        for book in self.all_books:
            # generate derived attributes
            book.title_sort = title_sort(book.title).lower()
            book.author_sort = author_to_author_sort(primary_author(book.authors)).lower()
            book.author = authors_to_string(book.authors)

            if book.series_index:
                i = ('%02d' % int(book.series_index)) if float(int(book.series_index)) == book.series_index else ('%05.2f' % book.series_index)
                book.series_and_index = '%s [%s]' % (book.series, i)
            else:
                book.series_and_index = book.series

            book.publisher_and_pubdate = (
                '%s %s' % (book.publisher, '(%s)' % book.pubdate.isoformat()[0:10] if book.pubdate else '')).strip()
            book.action = IGNORE_BOOK if book.ignored else NO_ACTION
            book.id = None

            try:
                book.remove_outdated_links()  # may be some left over from a prior bug
            except Exception:
                pass

            book.libraries = library_name_list(book, config)
            book.library_names = ', '.join(book.libraries)

        for n, attr in enumerate(DiscoveredBookTableModel.attr_names):
            setattr(self, attr + '_col', n)

        self.filter_query = ''
        self.hide_ignored = True
        self.filter_books()

    def columnCount(self, parent):
        return len(DiscoveredBookTableModel.header_names)

    def rowCount(self, parent):
        return len(self.visible_books)

    def headerData(self, sec, orientation, role):
        if role == Qt.DisplayRole:
            if orientation == Qt.Horizontal:
                return DiscoveredBookTableModel.header_names[sec]   # Named columns
            return "%d" % (sec + 1)   # Numbered rows

        if role == Qt.ToolTipRole:
            if orientation == Qt.Horizontal:
                return 'Click column header to sort'
            return 'Double-click Action to cycle through choices'

        return None

    def book(self, index):
        if not index.isValid():
            None

        return self.visible_books[index.row()]

    def raw_data(self, index):
        return getattr(self.book(index), DiscoveredBookTableModel.attr_names[index.column()])

    def data(self, index, role):
        if not index.isValid():
            None

        if role == Qt.DisplayRole or role == Qt.EditRole:
            raw = self.raw_data(index)
            if isinstance(raw, datetime.datetime):
                return raw.isoformat()[0:10]

            return str(raw)

        if role == Qt.ToolTipRole:
            if index.column() == self.action_col:
                return self.visible_books[index.row()].action.table_tooltip + '\n(Double-click to cycle through choices)'

            # Only provide tool tips for long strings that may be cut off in table display
            value = str(self.raw_data(index))
            if len(value) >= 20:
                return value

        if role == Qt.DecorationRole:
            if index.column() == self.action_col:
                return self.visible_books[index.row()].action.icon

        if role == QT_ForegroundRole:
            if index.column() == self.action_col:
                return self.visible_books[index.row()].action.color
            if self.visible_books[index.row()].ignored:
                return QColor(Qt.red)

        return None

    def setData(self, index, value, role):
        if index.isValid():
            if isinstance(value, QVariant):
                value = value.value()   # don't normally get QVariant from PyQt5, but handle properly just in case

            book = self.visible_books[index.row()]
            col = index.column()

            if col == self.action_col:
                if role == Qt.EditRole:
                    # set by action index
                    book.action = action_map[value]
                    self.dataChanged.emit(index, index)     # self.dataChanged.emit(index, index, [role])
                    return True

                if role == DoubleCLickRole:
                    # Double click of action (cycle to next action)
                    book.action = book.action.next
                    self.dataChanged.emit(index, index)     # self.dataChanged.emit(index, index, [role])
                    return True

            if role == Qt.EditRole:
                setattr(book, DiscoveredBookTableModel.attr_names[index.column()], value)
                self.dataChanged.emit(index, index)     # self.dataChanged.emit(index, index, [role])
                return True

        return False

    def ignored_count(self):
        ignored_count = 0
        for row, book in enumerate(self.all_books):
            if book.ignored:
                ignored_count += 1

        return ignored_count

    def action_counts(self):
        add_count = 0
        update_count = 0
        discard_count = 0
        ignore_count = 0
        for book in self.all_books:
            if book.action is ADD_BOOK:
                add_count += 1
            elif book.action is UPDATE_BOOK:
                update_count += 1
            elif book.action is DISCARD_BOOK:
                discard_count += 1
            elif book.action is IGNORE_BOOK and not book.ignored:
                ignore_count += 1

        return add_count, update_count, discard_count, ignore_count

    def sort(self, col, order):
        key = DiscoveredBookTableModel.sort_keys[col]
        reverse = (order == Qt_SortOrder_DescendingOrder)
        self.all_books = sorted(self.all_books, key=key, reverse=reverse)

        self.filter_books()

        if col == self.title_col:
            self.sort_order = []    # Title column is assumed to be (mostly) unique so any sorts done before a title sort are discarded

        self.sort_order = [(c, r) for c, r in self.sort_order if c != col]    # remove any prior state for this column

        # convert order to int since QT.SortOrder enum does not serialize in calibre 6
        self.sort_order.append((col, 1 if order == Qt_SortOrder_DescendingOrder else 0))

    def set_filter(self, filter_query, hide_ignored):
        self.filter_query = filter_query
        self.hide_ignored = hide_ignored
        self.filter_books()

    def filter_books(self):
        if self.filter_query:
            try:
                result_set = self.parse(self.filter_query)
                ok = True

                # convert set back to a list, maintaining sort order
                new_visible_books = [book for book in self.all_books if book in result_set]

            except ParseException:
                new_visible_books = []
                ok = False

            self.search_done.emit(ok)
        else:
            new_visible_books = self.books_to_search()

        # fast way to remove all rows and re-add as new, but any selection is lost!
        self.beginResetModel()
        self.visible_books = new_visible_books
        self.endResetModel()

    def books_to_search(self):
        if self.hide_ignored:
            return [book for book in self.all_books if book.action is not IGNORE_BOOK or not book.ignored]

        return self.all_books

    def universal_set(self):
        # SearchQueryParser.universal_set
        return set(self.books_to_search())

    def location_values(self, book, location):
        val = getattr(book, location, None)

        if val is False:
            return ['']

        if val is not None:
            return [str(val)]    # single attribute value

        if location == 'author':
            return book.authors

        if location == 'library':
            return book.libraries

        if location == 'all':
            vals = []
            for location in self.search_fields:
                if location not in ['all', 'search']:
                    vals.extend(self.location_values(book, location))

            return vals

        return []   # unknown attribute

    def get_matches(self, location, query, candidates=None):
        # SearchQueryParser.get_matches

        if candidates is None:
            candidates = self.universal_set()

        ans = set()

        location = location.lower()

        orig_query = query
        query = query.lower()

        # print('location="%s" query="%s" candidates=%d' % (location, query, len(candidates)))

        if len(query) == 0:
            query_type = ANY_MATCH
        elif query[0] == '=':
            query_type = EQUALS_MATCH
            query = query[1:]
        elif query[0] == '~':
            query_type = REGEXP_MATCH

            try:
                query = re.compile(orig_query[1:], flags=re.IGNORECASE)  # keep case of re for /W etc.
            except re.error:
                raise ParseException('bad regular expression')
        elif query[0] == '\\':
            query_type = CONTAINS_MATCH
            query = query[1:]
        elif query == 'true':
            query_type = EXISTS_MATCH
        elif query == 'false':
            query_type = MISSING_MATCH
        else:
            query_type = CONTAINS_MATCH

        for book in candidates:
            match = False

            for val in self.location_values(book, location):
                if query_type == ANY_MATCH:
                    match = True
                elif query_type == EQUALS_MATCH:
                    if query == val.lower():
                        match = True
                elif query_type == REGEXP_MATCH:
                    match = query.search(val)
                elif query_type == EXISTS_MATCH:
                    match = len(val) > 0
                elif query_type == MISSING_MATCH:
                    match = len(val) == 0
                else:
                    if query in val.lower():
                        match = True

                if match:
                    ans.add(book)
                    break

        return ans

    def books_to_add(self):
        return [b for b in self.all_books if b.action is ADD_BOOK]

    def books_to_update(self):
        return [b for b in self.all_books if b.action is UPDATE_BOOK]

    def books_to_keep(self):
        for book in self.all_books:
            book.ignored = (book.action is IGNORE_BOOK)

        return [b for b in self.all_books if b.action in [NO_ACTION, IGNORE_BOOK]]

    def get_sort_order(self):
        return self.sort_order


class DiscoveredBookTableView(QTableView):
    def __init__(self, model):
        QTableView.__init__(self)

        self.setModel(model)
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.setMinimumSize(850, 500)
        self.setShowGrid(False)
        self.setAlternatingRowColors(True)
        self.setWordWrap(False)
        self.verticalHeader().setVisible(True)   # Show row numbers
        self.horizontalHeader().setStretchLastSection(False)
        self.setSortingEnabled(True)    # Allow click on header to sort
        self.setContextMenuPolicy(Qt.CustomContextMenu)

    def resizeRowsToContents(self):
        # Fast re-implementation

        num_rows = self.model().rowCount(self.model().index(0, 0))
        if num_rows > 0:
            self.resizeRowToContents(0)
            height = self.rowHeight(0) + ROW_BORDER

            #print('row 0 height is %d, number of rows is %d' % (height, num_rows))

            for row in range(num_rows):
                self.setRowHeight(row, height)

    def reset(self):
        QTableView.reset(self)
        self.resizeRowsToContents()      # Fix problem where newly displayed rows are too large


class StatusTipMenu(QMenu):
    '''
    Variation on QMenu that allows a status bar to be set to receive status tip messages
    '''
    def __init__(self, parent=None, status_bar=None):
        QMenu.__init__(self, parent)
        self.status_bar = status_bar

    def event(self, e):
        # Direct status tip messages to the status bar of the parent dialog
        if e.type() == QEvent.StatusTip and self.status_bar:
            self.status_bar.showMessage(e.tip())
            return True

        return QMenu.event(self, e)


class DiscoveredBooksDialog(QDialog):

    def __init__(self, plugin_action, db, parent, config):
        QDialog.__init__(self, parent)

        self.setWindowTitle('Manage discovered books')
        self.setWindowIcon(QIcon(I('merge_books.png')))
        self.setWindowFlags(self.windowFlags() & (~Qt.WindowContextHelpButtonHint))

        self.plugin_action = plugin_action
        self.db = db
        self.config = config

        self.add_books = []     # list to books for the caller of this dialog to add later

        self.get_book_data()

        layout = QVBoxLayout(self)

        self.heading_label = QLabel('<b>Discovered lending library books missing from calibre library (Set actions and then press OK to perform)</b>', self)
        layout.addWidget(self.heading_label)
        layout.addWidget(self.book_table_widget())
        layout.addLayout(self.middle_layout())
        layout.addLayout(self.bottom_layout())

        self.setLayout(layout)

        self.context_menu = StatusTipMenu(self, self.status_bar)

        self.restore_dialog_state()

        self.filter_query = ''
        self.hide_ignored = True

        self.search.initialize('search_history', colorize=True, config=self.search_box_config)
        self.search.search_as_you_type(False)

        self.update_table_filtering()

        self.setModal(True)
        self.exec_()  # Wait for dialog to complete

    def get_book_data(self):
        books = get_discovered_books(self.db)[0]
        self.table_model = DiscoveredBookTableModel(books, self.config, self)

    def book_table_widget(self):
        self.table_view = DiscoveredBookTableView(self.table_model)
        self.table_view.customContextMenuRequested.connect(self.show_context_menu)
        self.table_view.doubleClicked.connect(self.double_clicked)
        self.table_view.selectionModel().selectionChanged.connect(self.selection_changed)
        return self.table_view

    def middle_layout(self):
        layout = QHBoxLayout()

        actions_group_box = QGroupBox('Select books above then press to apply actions:', self)
        actions_group_box.setLayout(self.actions_layout())
        layout.addWidget(actions_group_box)

        filter_group_box = QGroupBox('Filter displayed books:', self)
        filter_layout_v = QVBoxLayout()
        filter_layout = QHBoxLayout()

        self.search = DiscoveredBookSearchBox(self)
        self.search.setObjectName("search")
        self.search.setToolTip('Search string. Eg: library:=@BPL')
        self.table_model.search_done.connect(self.search.search_done)
        self.search.search.connect(self.apply_filter)   # emits null search when cleared
        self.search.setSizePolicy(QSizePolicy_Policy_Expanding, QSizePolicy_Policy_Minimum)
        filter_layout.addWidget(self.search)

        apply_filter_button = QToolButton()
        if CALIBRE_3_PLUS:
            apply_filter_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
            apply_filter_button.setIcon(QIcon(I('search.png')))
            apply_filter_button.setText('Search')
            apply_filter_button.setToolTip('Do Quick Search (you can also press the Enter key)')
        else:
            apply_filter_button.setToolButtonStyle(Qt.ToolButtonTextOnly)
            apply_filter_button.setText('Go!')
            apply_filter_button.setToolTip('Perform the search (you can also press the Enter key)')
            apply_filter_button.setSizePolicy(QSizePolicy_Policy_Minimum, QSizePolicy_Policy_Minimum)
        apply_filter_button.clicked.connect(self.search.do_search)
        filter_layout.addWidget(apply_filter_button)

        if not CALIBRE_3_PLUS:
            clear_filter_button = QPushButton('', self)
            clear_filter_button.setIcon(QIcon(I('clear_left.png')))
            clear_filter_button.setToolTip('Reset search')
            clear_filter_button.clicked.connect(self.search.clear_clicked)
            filter_layout.addWidget(clear_filter_button)

        filter_layout_v.addLayout(filter_layout)
        filter_group_box.setLayout(filter_layout_v)
        layout.addWidget(filter_group_box)

        misc_group_box = QGroupBox('', self)
        misc_layout = QVBoxLayout()

        self.show_hide_button = QPushButton('', self)  # text changed dynamically
        self.show_hide_button.setCheckable(True)
        self.show_hide_button.clicked.connect(self.toggle_show_hide_ignored)
        misc_layout.addWidget(self.show_hide_button)

        misc_bot_layout = QHBoxLayout()
        update_existing_button = QPushButton('Update Match', self)
        update_existing_button.setIcon(QIcon(I('dot_red.png')))
        update_existing_button.setToolTip('Check discovered books against calibre library and mark matches for update')
        update_existing_button.clicked.connect(self.update_existing)
        misc_bot_layout.addWidget(update_existing_button)

        discard_duplicates_button = QPushButton('Discard Dupes', self)
        discard_duplicates_button.setIcon(QIcon(I('list_remove.png')))
        discard_duplicates_button.setToolTip('Check discovered books against each other and mark ALL duplicates for discard')
        discard_duplicates_button.clicked.connect(self.discard_duplicates)
        misc_bot_layout.addWidget(discard_duplicates_button)
        misc_layout.addLayout(misc_bot_layout)

        misc_group_box.setLayout(misc_layout)
        layout.addWidget(misc_group_box)

        return layout

    def actions_layout(self):
        layout = QGridLayout()
        BUTTONS_PER_ROW = 3

        self.action_buttons = []
        for i in range(len(action_map)):
            action = action_map[i]
            button = QPushButton(action.name, self)
            button.setToolTip(action.button_tooltip)
            button.setIcon(action.icon)
            button.setEnabled(False)

            pal = QPalette(button.palette())
            pal.setColor(QPalette.ButtonText, action.color)
            button.setPalette(pal)
            button.clicked.connect(functools.partial(self.set_action_for_selection, action.index))

            if i == UPDATE_BOOK.index:
                self.update_action_button = button
            else:
                self.action_buttons.append(button)

            layout.addWidget(button, i // BUTTONS_PER_ROW, i % BUTTONS_PER_ROW)

        return layout

    def bottom_layout(self):
        layout = QHBoxLayout()

        self.status_bar = QStatusBar(self)
        self.status_bar.setSizeGripEnabled(False)
        self.status_label = QLabel('', self)  # text changed dynamically
        self.status_bar.addWidget(self.status_label)
        layout.addWidget(self.status_bar, 10)   # stretch

        self.button_box = QDialogButtonBox(QDialogButtonBox.Help | QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        self.button_box.accepted.connect(self.accept)
        self.button_box.rejected.connect(self.reject)
        self.button_box.helpRequested.connect(self.show_help)

        layout.addWidget(self.button_box)

        null_button = QPushButton()
        null_button.setDefault(True)    # dummy button to discard Enter key press events
        null_button.hide()
        layout.addWidget(null_button)

        return layout

    def update_existing(self):
        # Check the list of additional discovered books against the calibre library while displaying a progress dialog
        progress = QProgressDialog('Checking books against calibre library', 'Cancel', 0, 10000, self)
        progress.setModal(True)
        progress.setWindowTitle(ActionOverdriveLink.name)
        progress.setWindowFlags(progress.windowFlags() & (~Qt.WindowContextHelpButtonHint))
        progress.setMinimumWidth(400)
        progress.setMinimumDuration(0)   # Always show progress to let the user know something was done
        progress.setValue(0)

        # get the metadata we need for book matching from the calibre database
        all_calibre_books = []
        with self.db.new_api.read_lock:
            for book_id in self.db.new_api.all_book_ids():
                mi = self.db.new_api.get_proxy_metadata(book_id)
                all_calibre_books.append(CalibreBook(
                    id=book_id, authors=mi.authors, title=mi.title, series=mi.series, series_index=mi.series_index,
                    orig_odid=mi.identifiers.get(IDENT_AVAILABLE_LINK, ''),
                    orig_odrid=mi.identifiers.get(IDENT_RECOMMENDABLE_LINK, ''),
                    orig_odpid=mi.identifiers.get(IDENT_PURCHASABLE_LINK, ''),
                    orig_odnid=mi.identifiers.get(IDENT_NEEDED_LINK, ''),
                    isbn=mi.identifiers.get(IDENT_ISBN, '')))

        for cbook, book in match_book_lists(
                all_calibre_books, self.table_model.all_books, self.config, progress=progress, first_is_calibre_books=True):
            book.action = UPDATE_BOOK
            book.id = cbook.id

        progress.reset()

        self.update_action_counts()
        self.update_table_filtering()

    def discard_duplicates(self):
        # Check the list of additional discovered books against each other while displaying a progress dialog
        progress = QProgressDialog('Checking books against each other', 'Cancel', 0, 10000, self)
        progress.setModal(True)
        progress.setWindowTitle(ActionOverdriveLink.name)
        progress.setWindowFlags(progress.windowFlags() & (~Qt.WindowContextHelpButtonHint))
        progress.setMinimumWidth(400)
        progress.setMinimumDuration(0)   # Always show progress to let the user know something was done
        progress.setValue(0)

        for book1, book2 in match_book_lists(self.table_model.all_books, self.table_model.all_books, self.config, progress=progress):
            if book1 is not book2:
                book1.action = book2.action = DISCARD_BOOK
                book1.ignored = book2.ignored = False

        progress.reset()

        self.update_action_counts()
        self.update_table_filtering()

    def toggle_show_hide_ignored(self):
        self.hide_ignored = not self.hide_ignored
        self.update_table_filtering()

    def double_clicked(self, index):
        self.table_model.setData(index, 0, DoubleCLickRole)      # Cycle through actions on double click
        self.update_action_counts()

    def show_context_menu(self, pos):
        if not self.table_view.selectionModel().hasSelection():
            return

        m = self.context_menu
        m.clear()

        for i in range(len(action_map)):
            action = action_map[i]
            ac = m.addAction(action.icon, action.name)
            ac.setStatusTip(action.button_tooltip)
            ac.triggered.connect(functools.partial(self.set_action_for_selection, action.index))

        rows = self.table_view.selectionModel().selectedRows()
        if len(rows) == 1:
            # Show links to book data only if a single book is selected
            book = self.table_model.book(rows[0])

            linkset_menu(book.get_link_identifiers(self.config), m, self.config)

            if book.isbn:
                m.addSeparator()
                ac = m.addAction(QIcon(I('store.png')), 'Browse isbn %s' % book.isbn)
                url = 'http://www.worldcat.org/isbn/%s' % book.isbn
                ac.setStatusTip(url)
                ac.triggered.connect(functools.partial(browse_to_url, url))

        m.popup(self.mapToGlobal(pos))

    def selection_changed(self, selected, deselected):
        number_selected = len(self.table_view.selectionModel().selectedRows(self.table_model.action_col))

        for button in self.action_buttons:
            button.setEnabled(number_selected > 0)

        self.update_action_button.setEnabled(number_selected == 1)

    def set_action_for_selection(self, action_index):
        for index in self.table_view.selectionModel().selectedRows(self.table_model.action_col):
            if action_index == UPDATE_BOOK.index:
                book = self.table_model.book(index)
                UpdateMatchDialog(self, self.db, book, self.config)
                if book.id is None:
                    if book.action is UPDATE_BOOK:
                        self.table_model.setData(index, NO_ACTION.index, Qt.EditRole)

                    continue

            self.table_model.setData(index, action_index, Qt.EditRole)

        self.update_action_counts()

    def update_action_counts(self):
        add_count, update_count, discard_count, ignore_count = self.table_model.action_counts()

        labels = []
        if add_count > 0:
            labels.append('%s to be added to calibre library' % value_unit(add_count, 'book'))
        if update_count > 0:
            labels.append('%s to be updated in calibre library' % value_unit(update_count, 'book'))
        if ignore_count > 0:
            labels.append('%s to be ignored' % value_unit(ignore_count, 'book'))
        if discard_count > 0:
            labels.append('%s to be discarded from discovered list' % value_unit(discard_count, 'book'))

        self.status_label.setText('<b>' + ', '.join(labels) + '</b>')

    def apply_filter(self, text):
        if (not self.filter_query) and (not text):
            return  # ignore clear when already cleared

        self.filter_query = text
        self.update_table_filtering()

    def update_table_filtering(self):
        self.table_model.set_filter(self.filter_query, self.hide_ignored)

        if self.hide_ignored:
            self.show_hide_button.setChecked(False)
            ignored_count = self.table_model.ignored_count()
            self.show_hide_button.setText('Show Ignored (%d)' % ignored_count)
            self.show_hide_button.setIcon(QIcon(I('plus.png')))
            if ignored_count > 0:
                self.show_hide_button.setEnabled(True)
                self.show_hide_button.setToolTip('Reveal ignored books and allow changes')
            else:
                self.show_hide_button.setEnabled(False)
                self.show_hide_button.setToolTip('There are no books currently being ignored')
        else:
            self.show_hide_button.setChecked(True)
            self.show_hide_button.setText('Hide Ignored')
            self.show_hide_button.setToolTip('Hide ignored books from future display')
            self.show_hide_button.setIcon(QIcon(I('minus.png')))
            self.show_hide_button.setEnabled(True)

    def show_help(self):
        self.plugin_action.show_help()

    def save_dialog_state(self):
        state = {}
        state['search_history'] = self.search_box_config['search_history']
        state['sort_order'] = self.table_model.get_sort_order()
        state['column_widths'] = [self.table_view.columnWidth(col) for col in range(
            self.table_model.columnCount(self.table_model.index(0, 0)))]
        state['geometry'] = bytearray(self.saveGeometry())

        plugin_config[DiscoveredBooksDialogState] = state

    def restore_dialog_state(self):
        num_cols = self.table_model.columnCount(self.table_model.index(0, 0))

        plugin_config.defaults[DiscoveredBooksDialogState] = {}
        state = plugin_config[DiscoveredBooksDialogState]

        if 'geometry' in state:
            self.restoreGeometry(state['geometry'])

        self.table_view.resizeRowsToContents()

        if 'column_widths' in state:
            for col, width in enumerate(state['column_widths']):
                if col < num_cols:
                    self.table_view.setColumnWidth(col, width)
        else:
            self.table_view.resizeColumnsToContents()

            MAX_COL_WIDTH = 150     # Limit initial column width due to long titles/authors
            for col in range(num_cols):
                if self.table_view.columnWidth(col) > MAX_COL_WIDTH:
                    self.table_view.setColumnWidth(col, MAX_COL_WIDTH)

        if 'sort_order' in state:
            for col, order in state['sort_order']:
                if col < num_cols:
                    self.table_view.sortByColumn(
                        col, Qt_SortOrder_DescendingOrder if order == 1 else Qt_SortOrder_AscendingOrder)
        else:
            self.table_view.sortByColumn(self.table_model.title_col, Qt_SortOrder_AscendingOrder)  # Sort by Title
            self.table_view.sortByColumn(self.table_model.author_col, Qt_SortOrder_AscendingOrder)  # and then by Author

        self.search_box_config = {}
        self.search_box_config['search_as_you_type'] = False
        self.search_box_config['search_history'] = state.get('search_history', [])

    def event(self, e):
        # Direct status tip messages to the status bar of this dialog
        if e.type() == QEvent.StatusTip:
            self.status_bar.showMessage(e.tip())
            return True

        return QDialog.event(self, e)

    def accept(self):
        # save those not discarded, updated or added
        save_discovered_books(self.db, self.table_model.books_to_keep())

        # books-to-add and books-to-update are dealt with by the action menu that called this dialog
        self.add_books = self.table_model.books_to_add()
        self.update_books = self.table_model.books_to_update()

        self.save_dialog_state()

        QDialog.accept(self)    # exit dialog

    def reject(self):
        self.save_dialog_state()
        QDialog.reject(self)    # exit dialog

    def get_results(self):
        # for use by caller to get the resulting books list
        return (self.add_books, self.update_books)


class WheelFilter(QObject):

    def eventFilter(self, obj, event):
        '''
        Event filter to keep wheelEvents away from combo boxes.
        This prevents accidental value changes when scrolling by wheel.
        '''
        if event.type() == QEvent.Wheel and isinstance(obj, QComboBox):
            event.ignore()
            return True

        return False


class BookComboBox(QComboBox):

    def __init__(self, parent, books, id):
        QComboBox.__init__(self, parent)
        self.books = books
        self.clear()
        current_index = 0

        for i, book in enumerate(self.books):
            self.addItem(str(book))
            if book.id == id:
                current_index = i

        self.setCurrentIndex(current_index)

        self.filter = WheelFilter()
        self.installEventFilter(self.filter)

    def selected_value(self):
        return self.books[self.currentIndex()]


class UpdateMatchDialog(QDialog):

    def __init__(self, parent, db, book, config):
        QDialog.__init__(self, parent)

        self.db = db
        self.book = book
        self.config = config

        self.setWindowTitle('Match calibre book for update')
        self.setWindowIcon(QIcon(I('merge_books.png')))
        self.setWindowFlags(self.windowFlags() & (~Qt.WindowContextHelpButtonHint))

        layout = QVBoxLayout(self)

        self.heading_label = QLabel('Select match for: %s' % str(self.book), self)
        layout.addWidget(self.heading_label)

        self.pick_book_box = BookComboBox(self, self.get_matching_calibre_books(), book.id)
        self.pick_book_box.setToolTip('Select the matching book (if any)')
        layout.addWidget(self.pick_book_box)

        self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        self.button_box.accepted.connect(self.accept)
        self.button_box.rejected.connect(self.reject)

        layout.addWidget(self.button_box)
        self.setModal(True)
        self.exec_()  # Wait for dialog to complete

    def get_matching_calibre_books(self):
        progress = QProgressDialog('Checking book against calibre library', 'Cancel', 0, 10000, self)
        progress.setModal(True)
        progress.setWindowTitle(ActionOverdriveLink.name)
        progress.setWindowFlags(progress.windowFlags() & (~Qt.WindowContextHelpButtonHint))
        progress.setMinimumWidth(400)
        progress.setMinimumDuration(100)
        progress.setValue(0)

        # get the metadata we need for book matching from the calibre database
        all_calibre_books = []
        with self.db.new_api.read_lock:
            for book_id in self.db.new_api.all_book_ids():
                mi = self.db.new_api.get_proxy_metadata(book_id)
                all_calibre_books.append(CalibreBook(
                    id=book_id, authors=mi.authors, title=mi.title, series=mi.series, series_index=mi.series_index,
                    orig_odid=mi.identifiers.get(IDENT_AVAILABLE_LINK, ''),
                    orig_odrid=mi.identifiers.get(IDENT_RECOMMENDABLE_LINK, ''),
                    orig_odpid=mi.identifiers.get(IDENT_PURCHASABLE_LINK, ''),
                    orig_odnid=mi.identifiers.get(IDENT_NEEDED_LINK, ''),
                    isbn=mi.identifiers.get(IDENT_ISBN, '')))

        books = match_single_book_to_list(self.book, all_calibre_books, self.config, progress=progress)
        progress.reset()

        books.insert(0, CalibreBook(title='', id=None))

        return books

    def reject(self):
        QDialog.reject(self)    # exit dialog

    def accept(self):
        cbook = self.pick_book_box.selected_value()
        self.book.id = cbook.id
        QDialog.accept(self)    # exit dialog
