#!/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 traceback
import functools
from PyQt5.Qt import (QIcon, QMenu, QUrl)

from calibre.gui2 import (open_url)
from calibre.utils.config_base import tweaks

from calibre_plugins.overdrive_link.library import (full_library_id, SearchableLibrary)
from calibre_plugins.overdrive_link.tweak import TWEAK_AMAZON_IDENT
from calibre_plugins.overdrive_link.formats import (ALL_READABLE_FORMATS, ALL_LISTENABLE_FORMATS)

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


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


# Identifiers
IDENT_AVAILABLE_LINK = 'odid'
IDENT_RECOMMENDABLE_LINK = 'odrid'
IDENT_PURCHASABLE_LINK = 'odpid'
IDENT_NEEDED_LINK = 'odnid'
IDENT_AMAZON = 'amazon'
IDENT_ISBN = 'isbn'

FIELD_WAIT_WEEKS = '#ol_wait_weeks'                 # lookup name for custom column holding when-available results
FIELD_WAIT_WEEKS_DATE = '#ol_wait_weeks_date'       # lookup name for custom column holding date of last change for above

# yes/no custom column. valid values are: None, True, False
FIELD_ET_STATUS = '#et_status'                      # lookup name for custom column holding et-status results

# date column
FIELD_ET_DATE = '#et_date'                          # lookup name for custom column holding date et-status became True


LIDLTYPES = {IDENT_AVAILABLE_LINK, IDENT_RECOMMENDABLE_LINK, IDENT_PURCHASABLE_LINK}  # library & discovered books
CIDLTYPES = {IDENT_AVAILABLE_LINK, IDENT_RECOMMENDABLE_LINK, IDENT_PURCHASABLE_LINK, IDENT_NEEDED_LINK}  # calibre books

MAX_LINKS_BEFORE_SUBMENU = 10   # excessive links for a book cause the creation of a submenu to avoid a long main menu


# Link display key
LINK_AVAILABLE = '@'
LINK_RECOMMENDABLE = '!'
LINK_PURCHASABLE = '$'
LINK_NEEDED = '>'

# link format prefixes
LINK_FORMAT_EBOOK = ''
LINK_FORMAT_AUDIOBOOK = '#'
BOOK_DISCOVERED = '*'

# separators for book-library links
SEP_EBOOK = '@'
SEP_AUDIOBOOK = '#'


@functools.total_ordering
class ODLink(object):
    '''
    A ODLink represents a link between a specific edition of a book and a particular lending library.

    These links are in the form of book-id@provider-id/library-id for ebooks or book-id#provider-id/library-id for audiobooks
    The book id must not contain an @ or # symbol!

    For OverDrive, the provider_id and trailing '/' are not present, the library is the host name of the library web site,
    and the book is the content reserve id (36 character UUID) used by OverDrive to identify an edition of a book.

    All fields are lower case.
    '''

    def __init__(self, string=None, provider_id=None, library_id=None, book_id=None, is_audiobook=None,
                 book_key=False, check=False, config=None, ltype=None):

        self.config = config    # only needed for some operations

        if string is not None:
            if SEP_EBOOK in string:
                self.book_id, full_library_id = string.split(SEP_EBOOK, 1)
                self.is_audiobook = False
            elif SEP_AUDIOBOOK in string:
                self.book_id, full_library_id = string.split(SEP_AUDIOBOOK, 1)
                self.is_audiobook = True
            elif check:
                raise ValueError('ODLink: missing separator: %s' % string)
            else:
                self.book_id = ''
                full_library_id = string
                self.is_audiobook = False

            if '/' in full_library_id:
                self.provider_id, self.library_id = full_library_id.split('/', 1)
            else:
                self.provider_id = ''
                self.library_id = full_library_id
                self.book_id = self.book_id.lower()     # fix case from older version of plugin

        else:
            self.provider_id = provider_id
            self.library_id = library_id
            self.book_id = book_id
            self.is_audiobook = is_audiobook

        if check or book_key:
            # Check link components for validity. Repair if possible, otherwise raise exception.
            SearchableLibrary.validate_provider_id(self.provider_id)
            provider = SearchableLibrary.provider(self.provider_id)

            if book_key:
                self.library_id = provider.book_key_library_id(self.library_id)
            else:
                self.library_id = provider.validate_library_id(self.library_id, config=config)
                self.book_id = provider.validate_book_id(self.book_id, self.library_id)

                # migrate an Axis 360 link to Boundless if the user has changed the provider type for this library
                if config is not None and self.provider_id == 'ax' and (not self.configured()) and config.configured('bl', self.library_id):
                    self.provider_id = 'bl'
                    provider = SearchableLibrary.provider(self.provider_id)

                # add 'a' to Everand audiobook book ids
                if self.provider_id == 'sc' and self.is_audiobook and not self.book_id.endswith('a'):
                    self.book_id += 'a'

        if check and ltype is not None:
            #if self.book_id == 'bec8343d-2419-4113-87d4-e8a3b150fc33':
            #    raise Exception('Test error link')

            if ltype == IDENT_RECOMMENDABLE_LINK and not provider.supports_recommendation:
                raise Exception('%s does not support recommendation' % provider.name)

            if ltype == IDENT_PURCHASABLE_LINK and not provider.supports_purchase(self.library_id):
                raise Exception('%s library does not support purchase' % provider.name)

            if self.is_audiobook and provider.formats_supported.isdisjoint(ALL_LISTENABLE_FORMATS):
                raise Exception('%s does not support audiobooks' % provider.name)

            if (not self.is_audiobook) and provider.formats_supported.isdisjoint(ALL_READABLE_FORMATS):
                raise Exception('%s does not support e-books' % provider.name)

    def __repr__(self):
        return ''.join((self.book_id, SEP_AUDIOBOOK if self.is_audiobook else SEP_EBOOK, full_library_id(self.provider_id, self.library_id)))

    def __str__(self):
        return self.__repr__()

    def url(self):
        # Generate the url to open the web page showing a book edition at a lending library
        return SearchableLibrary.provider(self.provider_id).book_url(self.library_id, self.book_id)

    def name_only(self):
        return self.config.library_name(self.provider_id, self.library_id)

    def format_and_name(self):
        return ''.join((LINK_FORMAT_AUDIOBOOK if self.is_audiobook else LINK_FORMAT_EBOOK, self.name_only()))

    def format_desc(self):
        return 'audiobook' if self.is_audiobook else 'book'

    def configured(self):
        return self.config.configured(self.provider_id, self.library_id)

    def enabled(self):
        return self.config.enabled(self.provider_id, self.library_id)

    def disabled(self):
        return self.config.disabled(self.provider_id, self.library_id)

    def __hash__(self):
        return hash((self.provider_id, self.library_id, self.book_id, self.is_audiobook))

    def key(self):
        if self.config is None:
            return (
                self.is_audiobook,
                self.provider_id,
                self.library_id,
                self.book_id)

        # Key for sorting in order of decreasing importance using configured information about libraries
        return (
            self.is_audiobook,
            self.config.priority(self.provider_id, self.library_id),
            self.config.library_name(self.provider_id, self.library_id),
            self.book_id)

    def __eq__(self, other):
        return str(self) == str(other)

    def __lt__(self, other):
        return str(self) < str(other)

    def amazon_ident(self):
        return tweaks.get(TWEAK_AMAZON_IDENT, SearchableLibrary.provider(self.provider_id).amazon_ident(self.library_id))


@functools.total_ordering
class ODLinkSet(object):
    '''
    A set of lending library links that all apply to a book in the calibre library, separated by '&'s.
    ('&' used for separator since ',' and '|' have special handling within calibre.)
    '''

    def __init__(self, string=None, config=None, odlink=None, odlinks=None, errors=None, desc=None, book_key=False, ltype=None):
        if string:
            if errors is not None:
                # check links for problems and only keep good ones
                self.odlinks = set()
                for s in string.split('&'):
                    if s:
                        try:
                            self.odlinks.add(ODLink(string=s, check=True, config=config, book_key=book_key, ltype=ltype))
                        except Exception as e:
                            errors.append('%s: %s' % (desc, repr(e)))
            else:
                self.odlinks = set([ODLink(string=s, config=config, book_key=book_key) for s in string.split('&') if s])
        elif odlinks:
            self.odlinks = odlinks
        elif odlink:
            self.odlinks = set([odlink])
        else:
            self.odlinks = set()

    def add(self, odlink):
        self.odlinks.add(odlink)
        return self

    def discard(self, odlink):
        self.odlinks.discard(odlink)
        return self

    def update(self, other):
        self.odlinks.update(other.odlinks)
        return self

    def __repr__(self):
        return '&'.join(sorted([repr(link) for link in self.odlinks]))

    def __str__(self):
        return self.__repr__()

    def __len__(self):
        # Also used to true/false value of an object
        return len(self.odlinks)

    def __eq__(self, other):
        return str(self) == str(other)

    def __lt__(self, other):
        return str(self) < str(other)

    def __or__(self, other):
        return ODLinkSet(odlinks=self.odlinks | other.odlinks)  # Union

    def __and__(self, other):
        return ODLinkSet(odlinks=self.odlinks & other.odlinks)  # Intersection

    def __sub__(self, other):
        return ODLinkSet(odlinks=self.odlinks - other.odlinks)  # Difference

    def __rxor__(self, other):
        return ODLinkSet(odlinks=self.odlinks ^ other.odlinks)  # Symmetric difference

    def __contains__(self, odlink):
        return odlink in self.odlinks

    def sorted_odlinks(self):
        # List of individual links sorted by decreasing priority
        return sorted(list(self.odlinks), key=lambda odlink: odlink.key())

    def primary_odlink(self):
        return self.sorted_odlinks()[0]

    def enabled(self):
        return ODLinkSet(odlinks=set([odlink for odlink in self.odlinks if odlink.enabled()]))

    def disabled(self):
        return ODLinkSet(odlinks=set([odlink for odlink in self.odlinks if odlink.disabled()]))

    def ebooks(self):
        return ODLinkSet(odlinks=set([odlink for odlink in self.odlinks if not odlink.is_audiobook]))

    def audiobooks(self):
        return ODLinkSet(odlinks=set([odlink for odlink in self.odlinks if odlink.is_audiobook]))

    def has_provider_id(self, provider_id):
        return ODLinkSet(odlinks=set([odlink for odlink in self.odlinks if odlink.provider_id == provider_id]))

    def configured(self):
        return ODLinkSet(odlinks=set([odlink for odlink in self.odlinks if odlink.configured()]))

    def unconfigured(self):
        return ODLinkSet(odlinks=set([odlink for odlink in self.odlinks if not odlink.configured()]))

    def amazon(self):
        return ODLinkSet(odlinks=set([odlink for odlink in self.odlinks if SearchableLibrary.provider(odlink.provider_id).is_amazon]))

    def name_list(self, prefix=''):
        def name_key(link_str):
            # sort audiobook links after e-book links
            if link_str.startswith('#'):
                return (True, link_str[1:])
            return (False, link_str)

        return ['%s%s' % (prefix, link) for link in
                sorted(list(set([odlink.format_and_name() for odlink in self.odlinks])), key=name_key)]

    def names(self, prefix=''):
        return ', '.join(self.name_list(prefix))


def linksets_str(idlinks):
    if not (idlinks[IDENT_AVAILABLE_LINK] or idlinks[IDENT_RECOMMENDABLE_LINK] or
            idlinks[IDENT_PURCHASABLE_LINK] or idlinks.get(IDENT_NEEDED_LINK, ODLinkSet())):
        return 'No library links'

    return ', '.join([s for s in [
            idlinks[IDENT_AVAILABLE_LINK].names(LINK_AVAILABLE),
            idlinks[IDENT_RECOMMENDABLE_LINK].names(LINK_RECOMMENDABLE),
            idlinks[IDENT_PURCHASABLE_LINK].names(LINK_PURCHASABLE),
            idlinks.get(IDENT_NEEDED_LINK, ODLinkSet()).names(LINK_NEEDED),
            ] if s])


def linkset_menu(idlinks, menu, config, unlink_action=None, save=None, icon=None):
    have_links = False

    if icon is None:
        icon = QIcon(I('store.png'))

    for ident, action, article in [
            (IDENT_AVAILABLE_LINK, 'Borrow', 'from'),
            (IDENT_RECOMMENDABLE_LINK, 'Recommend', 'to'),
            (IDENT_PURCHASABLE_LINK, 'Purchase', 'at'),
            (IDENT_NEEDED_LINK, 'Needed', 'from')]:
        try:
            if unlink_action is not None:
                action = 'Unlink ' + action.lower()

            odxid = idlinks.get(ident, '')

            if isinstance(odxid, str):
                odxid = ODLinkSet(odxid, config=config)  # convert link strings to linksets

            odlinks = odxid.sorted_odlinks()
            if odlinks:
                have_links = True

                menu.addSeparator()

                if len(odlinks) > MAX_LINKS_BEFORE_SUBMENU:
                    # create a submenu for excessive number of links of same type
                    ac = menu.addAction(icon, action)
                    submenu = QMenu(menu)
                    ac.setMenu(submenu)

                else:
                    submenu = menu  # put on main menu

                for odlink in odlinks:
                    ac = submenu.addAction(icon, '%s %s %s %s' % (action, odlink.format_desc(), article, odlink.name_only()))

                    if unlink_action is not None:
                        ac.setStatusTip('Unlink book from %s (%s)' % (odlink.name_only(), repr(odlink)))
                        ac.triggered.connect(functools.partial(unlink_action, ident, odlink))
                    else:
                        ac.setStatusTip(odlink.url())
                        ac.triggered.connect(functools.partial(browse_to_url, odlink.url()))

                    if save:
                        save.addAction(ac)  # save copy of created action to avoid problems with Qt

        except Exception:
            traceback.print_exc()

    return have_links


def browse_to_url(url):
    if url:
        open_url(QUrl(url))
