#!/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 functools
import json
import dateutil.parser

from calibre.ebooks.metadata import (authors_to_string, author_to_author_sort, title_sort)
from calibre_plugins.overdrive_link.match import (primary_author)
from calibre_plugins.overdrive_link.link import (
    LIDLTYPES, CIDLTYPES, ODLink, ODLinkSet, linksets_str,
    LINK_AVAILABLE, LINK_RECOMMENDABLE, LINK_PURCHASABLE,
    IDENT_AVAILABLE_LINK, IDENT_RECOMMENDABLE_LINK)
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)

try:
    from calibre_plugins.overdrive_link_debug.formats import remove_conflicting_needed_links
except ImportError:
    remove_conflicting_needed_links = None


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


UNKNOWN = 'Unknown'     # used by calibre for empty author or title

ORIG = 'orig_'


def add_author(old_authors, new_author):
    if new_author and (new_author not in old_authors):
        old_authors.append(new_author)  # keep order of existing authors


def merge_authors(old_authors, new_authors):
    for new_author in new_authors:
        add_author(old_authors, new_author)


def unique_authors(authors):
    new_authors = []    # create a fresh list
    for author in authors:
        add_author(new_authors, author)

    return new_authors


# for books str() returns "title by author" and repr() returns full details
@functools.total_ordering
class BookObj(object):
    '''
    methods common to all types of books: calibre, library, discovered
    '''

    def get_link_identifier(self, ltype):
        return getattr(self, ltype, '')

    def get_link_identifiers(self, config, orig=False):
        idlinks = {}

        for ltype in self.idltypes:
            idlinks[ltype] = ODLinkSet(getattr(self, ORIG + ltype if orig else ltype, ''), config=config)

        return idlinks

    def links_have_changed(self):
        for ltype in self.idltypes:
            if getattr(self, ltype, '') != getattr(self, ORIG + ltype, ''):
                #print("*** links changed for %s:" % self.title)
                #print("   old %s: %s" % (ltype, getattr(self, ORIG + ltype, '')))
                #print("   new %s: %s" % (ltype, getattr(self, ltype, '')))
                return True

        return False

    def apply_changed_links_from(self, other):
        for ltype in self.idltypes:
            orig_links = ODLinkSet(getattr(other, ORIG + ltype, ''))
            new_links = ODLinkSet(getattr(other, ltype, ''))
            added_links = new_links - orig_links
            removed_links = orig_links - new_links
            setattr(self, ltype, str((ODLinkSet(getattr(self, ltype, '')) | added_links) - removed_links))

    def get_updated_link_identifier(self, ltype, orig):
        orig_links = ODLinkSet(getattr(self, ORIG + ltype, ''))
        new_links = ODLinkSet(getattr(self, ltype, ''))
        added_links = new_links - orig_links
        removed_links = orig_links - new_links
        return str((ODLinkSet(orig) | added_links) - removed_links)

    def add_links_from(self, other):
        for ltype in self.idltypes:
            setattr(self, ltype, str(ODLinkSet(getattr(self, ltype, '')) | ODLinkSet(getattr(other, ltype, ''))))

    def preserve_orig_links(self):
        for ltype in self.idltypes:
            setattr(self, ltype, str(ODLinkSet(getattr(self, ltype, '')) | ODLinkSet(getattr(self, ORIG + ltype, ''))))

        if hasattr(self, 'orig_wait_weeks'):
            self.wait_weeks = self.orig_wait_weeks

    def preserve_partial_orig_links(self, config):
        for ltype in self.idltypes:
            links = ODLinkSet(getattr(self, ltype, ''), config=config)
            orig_links = ODLinkSet(getattr(self, 'orig_' + ltype, ''), config=config)

            if ltype != IDENT_AVAILABLE_LINK:
                links |= orig_links
            else:
                # Treat disabled libraries as if they were found/unchanged
                links |= orig_links.disabled()

                if config.search_formats.isdisjoint(ALL_READABLE_FORMATS):
                    # Not searching e-books so keep all e-book links
                    links |= orig_links.ebooks()

                if config.search_formats.isdisjoint(ALL_LISTENABLE_FORMATS):
                    # Not searching audiobooks so keep all audiobook links
                    links |= orig_links.audiobooks()

            setattr(self, ltype, str(links))

        if hasattr(self, 'orig_wait_weeks'):
            self.wait_weeks = self.orig_wait_weeks

    def preserve_disabled_links(self, config):
        for ltype in self.idltypes:
            setattr(self, ltype,
                    str(ODLinkSet(getattr(self, ltype, ''), config=config) |
                        ODLinkSet(getattr(self, ORIG + ltype, ''), config=config).disabled()))

    def remove_single_link(self, ltype, odlink):
        setattr(self, ltype, str(ODLinkSet(getattr(self, ltype, '')).discard(odlink)))

    def migrate_orig_links_to_current(self, config):
        for ltype in self.idltypes:
            setattr(self, ltype, getattr(self, ORIG + ltype, ''))

        # For the most part links migrate themselves in library_id and book_id validation.
        # Any other migration goes here.

        # migrate Amazon KOLL purchase links to Amazon store purchase links
        if self.odpid:
            linkset = ODLinkSet(self.odpid, config=config)
            for odlink in linkset.odlinks:
                if odlink.provider_id == 'ak' and (odlink.library_id in ['', 'prime']):
                    odlink.library_id = 'store'

            self.odpid = str(linkset)

    def check_links(self, errors, config):
        book_name = str(self)

        for ltype in self.idltypes:
            setattr(
                self, ltype,
                str(ODLinkSet(getattr(self, ltype, ''), errors=errors, desc=book_name + ', ' + ltype, config=config, ltype=ltype).configured()))

    def links_str(self, config):
        return linksets_str(self.get_link_identifiers(config))

    def remove_outdated_links(self):
        # remove recommend links if this book has become available
        if IDENT_RECOMMENDABLE_LINK in self.idltypes:
            self.odrid = str((ODLinkSet(self.odrid)) - ODLinkSet(self.odid))

        if remove_conflicting_needed_links is not None:
            remove_conflicting_needed_links(self)

    def move_current_links_to_orig(self):
        for ltype in self.idltypes:
            setattr(self, ORIG + ltype, getattr(self, ltype, ''))
            setattr(self, ltype, '')

    def clear_orig_links(self):
        for ltype in self.idltypes:
            setattr(self, ORIG + ltype, '')

    def book_link_keys(self, config):
        keys = set()
        for lset in ['', ORIG]:
            for ltype in self.idltypes:
                links_str = getattr(self, lset + ltype, '')
                if links_str:
                    for link_str in links_str.split('&'):
                        if link_str:
                            try:
                                key = ODLink(link_str, config=config, book_key=True)
                            except ValueError:
                                pass    # prevent failure for bad links resulting from unsupported providers
                            else:
                                keys.add(str(key))
        return keys

    def sort_key(self):
        # Establish sort order: author sort then title sort. Use unique ID as tie breaker.
        # cache sort key to speed up "sorted"
        if not hasattr(self, 'cached_sort_key'):
            self.cached_sort_key = (author_sort_key(primary_author(self.authors)) if self.authors else '', title_sort(self.title), id(self))

        return self.cached_sort_key

    def del_sort_key(self):
        # invalidate cache if author or title changes
        if hasattr(self, 'cached_sort_key'):
            del self.cached_sort_key

    def __hash__(self):
        return hash(self.sort_key())

    def __eq__(self, other):
        if not isinstance(other, BookObj):
            raise Exception("BookObj __eq__: comparing with %s" % type(other).__name__)

        return self.sort_key() == other.sort_key()

    def __lt__(self, other):
        if not isinstance(other, BookObj):
            raise Exception("BookObj __lt__: comparing with %s" % type(other).__name__)

        return self.sort_key() < other.sort_key()

    def __str__(self):
        return '%s%s' % (self.title, (' by %s' % limited_authors_to_string(self.authors)) if self.authors else '')

    def __repr__(self):
        # will be overridden for specific book types to provice more detail
        return self.__str__()


class CalibreBook(BookObj):
    '''
    An object keeping track of all of the information related to a calibre book.

    id              calibre book id (None if this book not already in the calibre database)
    authors         list of author names (assume primary first)
    title           book title
    series          book series
    isbn            book isbn for matching instead of by title/author
    orig_odid       lending library links as of when the database was read
    orig_odrid      lending library recommendations as of when the database was read
    orig_odpid      purchasable links as of when the database was read
    orig_odnid      needed links as of when the database was read
    last_modified   last modified date/time as of when the book data was extracted from calibre database

    odid            new lending library links to be applied to the book
    odrid           new lending library recommendations to be applied to the book
    odpid           new purchasable links to be applied to the book
    odnid           new needed links to be applied to the book
    '''

    idltypes = CIDLTYPES    # calibre book link types

    def __init__(self, id=None, authors=[], title='', series='', series_index=0.0, isbn='',
                 orig_odid='', orig_odrid='', orig_odpid='', orig_odnid='',
                 orig_wait_weeks='', wait_weeks_field=None,
                 orig_et_asin='', orig_et_status=None, epub_file_name=None,
                 levels_have=[], is_fxl=False, last_modified=None, from_book=None):
        self.id = id
        self.authors = authors
        self.title = title
        self.series = series
        self.series_index = series_index
        self.isbn = isbn
        self.orig_odid = orig_odid
        self.orig_odrid = orig_odrid
        self.orig_odpid = orig_odpid
        self.orig_odnid = orig_odnid
        self.orig_wait_weeks = orig_wait_weeks
        self.wait_weeks_field = wait_weeks_field
        self.orig_et_asin = orig_et_asin
        self.orig_et_status = orig_et_status
        self.epub_file_name = epub_file_name
        self.levels_have = levels_have
        self.is_fxl = is_fxl
        self.last_modified = last_modified

        # Initialize fields set by search
        self.matched = False
        self.odid = ''
        self.odrid = ''
        self.odpid = ''
        self.odnid = ''
        self.wait_weeks = ''
        self.et_asin = ''
        self.et_status = None
        self.inventory = ''

        if from_book is not None:
            # copy attributes from an existing book object
            self.__dict__.update(from_book.__dict__)


class InfoBook(BookObj):
    '''
    An object keeping track of all of the information related to a book at one or more lending library
    without link or presence/absence information. (Caution when changing - values will be cached!)
    '''

    idltypes = None    # no link types

    def __init__(
            self, provider_id='', library_id='', book_id='',
            authors=[], title='', series='', series_index=0.0, isbn='', publisher='',
            pubdate=None, language='', formats=set(), lib=None, cache_allowed=True,
            from_json=None):

        if lib:
            self.provider_id = lib.provider.id
            self.library_id = lib.library_id
            self.book_id = lib.validate_book_id(book_id, lib.library_id)
        else:
            self.provider_id = provider_id
            self.library_id = library_id
            self.book_id = book_id

        self.authors = unique_authors(authors)  # drop any empty or non-unique author strings & force copy
        self.title = title
        self.series = series
        self.series_index = series_index
        self.isbn = isbn
        self.publisher = publisher
        self.pubdate = pubdate
        self.language = language
        self.formats = formats
        self.cache_allowed = cache_allowed

        if from_json is not None:
            dict_copy = json.loads(from_json)

            # fix types for some attributes
            dict_copy['formats'] = set(dict_copy.get('formats', []))

            if dict_copy['pubdate'] is not None:
                dict_copy['pubdate'] = dateutil.parser.parse(dict_copy['pubdate'])

            for attrib, value in dict_copy.items():
                self.__dict__[attrib] = value

        if len(self.book_id) == 0:
            raise AssertionError

        self.book_key = str(ODLink(provider_id=self.provider_id, library_id=self.library_id, book_id=self.book_id, book_key=True))

    def to_json(self):
        dict_copy = self.__dict__.copy()    # shallow copy

        # make json serializable
        dict_copy['formats'] = list(dict_copy['formats'])
        if dict_copy['pubdate'] is not None:
            dict_copy['pubdate'] = dict_copy['pubdate'].isoformat()

        del dict_copy['book_key']    # will be re-created on load

        #for attrib, value in dict_copy.items():
        #    print(' dict[%s] = "%s"' %(attrib, str(value)))

        result = json.dumps(dict_copy)
        return result.decode("ascii") if IS_PYTHON2 else result     # always return str

    def sort_key(self):
        return self.book_key

    def __repr__(self):
        # unambiguous string representation of a book
        return '%s%s [%s]' % (
                self.title, ' by %s' % authors_to_string(self.authors) if self.authors else '',
                ', '.join([s for s in [
                    self.series if self.series_index == 0.0 else '%s [%02d]' % (self.series, int(self.series_index)),
                    self.book_key,
                    '|'.join(sorted(list(self.formats))),
                    self.isbn,
                    self.publisher,
                    self.pubdate.isoformat() if self.pubdate else '',
                    self.language,
                    '' if self.cache_allowed else 'no-cache',
                    ] if s]))


class LibraryBook(BookObj):
    '''
    An object keeping track of all of the information related to a book at one or more lending library,
    with the same provider.
    '''

    idltypes = LIDLTYPES    # library book link types

    def __init__(self, lib=None, book_id=None, authors=[], title='', series='', series_index=0.0, isbn='', publisher='',
                 pubdate=None, language='', available=False, recommendable=False, purchasable=False,
                 formats=set(), search_author='', allow_get_from_cache=True):

        self.first_lib = lib
        self.provider_id = lib.provider_id
        self.book_id = lib.validate_book_id(book_id, lib.library_id)
        self.book_key = str(ODLink(provider_id=self.provider_id, library_id=self.first_lib.library_id, book_id=self.book_id, book_key=True))
        self.allow_get_from_cache = allow_get_from_cache

        self.authors = unique_authors(authors)  # drop any empty or non-unique author strings & force copy
        self.title = title
        self.series = series
        self.series_index = series_index
        self.isbn = isbn
        self.publisher = publisher
        self.pubdate = pubdate
        self.language = language

        self.formats = formats.copy()  # force copy

        self.available = set()
        self.recommendable = set()
        self.purchasable = set()

        if available:
            self.available.add(lib)
        if recommendable:
            self.recommendable.add(lib)
        if purchasable:
            self.purchasable.add(lib)

        self.search_authors = set()
        if search_author:
            self.search_authors.add(search_author)

    def sort_key(self):
        return (author_sort_key(primary_author(self.authors)) if self.authors else '', title_sort(self.title), id(self))

    def __repr__(self):
        # unambiguous string representation of a book
        return '%s%s [%s]' % (
                self.title, ' by %s' % authors_to_string(self.authors) if self.authors else '',
                ', '.join([s for s in [
                    self.series if self.series_index == 0.0 else '%s [%02d]' % (self.series, int(self.series_index)),
                    '%s@%s' % (self.book_id, self.provider_id),
                    ' '.join([LINK_AVAILABLE + repr(lib) for lib in self.available] +
                             [LINK_RECOMMENDABLE + repr(lib) for lib in self.recommendable] +
                             [LINK_PURCHASABLE + repr(lib) for lib in self.purchasable]),
                    '|'.join(sorted(list(self.formats))),
                    self.isbn,
                    self.publisher,
                    self.pubdate.isoformat() if self.pubdate else '',
                    self.language,
                    ] if s]))

    def merge_from(self, other, check_same_formats=True, check_add_formats=True):
        if other.book_key != self.book_key:
            raise ValueError('cannot merge library books with different book keys')

        if check_same_formats and len(self.formats ^ other.formats) != 0:
            raise ValueError('cannot merge library books with different formats')

        # if any formats are provided in find phase they cannot be added-to in get book details
        if check_add_formats and len(self.formats) > 0 and len(other.formats - self.formats) > 0:
            raise ValueError('cannot merge library books with additional formats')

        # combine fields that allow multiple values

        '''
        # base combined author set on the longer list of authors, giving preference to the 'other' set
        if len(other.authors) < len(self.authors):
            merge_authors(self.authors, other.authors)
        else:
            new_authors = []    # create a fresh list
            merge_authors(new_authors, other.authors)
            merge_authors(new_authors, self.authors)
            self.authors = new_authors
        '''

        self.formats |= other.formats

        if hasattr(other, 'available'):
            self.available |= other.available
            self.recommendable = (self.recommendable | other.recommendable) - self.available    # remove available from recommendable
            self.purchasable |= other.purchasable

        if hasattr(other, 'search_authors'):
            self.search_authors |= other.search_authors

        # although 'authors' allows multiple values use only one complete set
        if other.authors:
            self.authors = unique_authors(other.authors)

        # prefer new (other) to old (self) for single value fields
        if other.title:
            self.title = other.title
        if other.series:
            self.series = other.series
        if other.series_index:
            self.series_index = other.series_index
        if other.language:
            self.language = other.language
        if other.publisher:
            self.publisher = other.publisher
        if other.pubdate:
            self.pubdate = other.pubdate
        if other.isbn:
            self.isbn = other.isbn

        self.del_sort_key()

    def create_links(self, allowed_formats):
        # use existing fields to create library links for this book

        def create_odlinkset(book_id, libraries, is_ebook, is_audiobook):
            odlinks = set()
            for lib in libraries:
                if is_ebook:
                    odlinks.add(ODLink(provider_id=lib.provider_id, library_id=lib.library_id, book_id=book_id))

                if is_audiobook:
                    odlinks.add(ODLink(provider_id=lib.provider_id, library_id=lib.library_id, book_id=book_id, is_audiobook=True))

            return str(ODLinkSet(odlinks=odlinks))

        is_ebook = not self.formats.isdisjoint(ALL_READABLE_FORMATS & allowed_formats)
        is_audiobook = not self.formats.isdisjoint(ALL_LISTENABLE_FORMATS & allowed_formats)

        self.odid = create_odlinkset(self.book_id, self.available, is_ebook, is_audiobook)
        self.odrid = create_odlinkset(self.book_id, self.recommendable, is_ebook, is_audiobook)
        self.odpid = create_odlinkset(self.book_id, self.purchasable, is_ebook, is_audiobook)


class DiscoveredBook(BookObj):
    '''
    An object keeping track of all of the information related to a book discovered at a lending library.

    In order to allow storage of a discovered book list all attributes must be
    JSON convertible types: list, dict, string, int/long, float, True, False, None


    authors         list of author names (assume primary first)
    title           book title
    odid            links for libraries carrying this book
    odrid           links for libraries that can accept recommendations for this book
    odpid           links where this book can be purchased
    isbn            book isbn (string)
    publisher       book publisher
    pubdate         publication date as utc datetime object
    language        language of publication
    ignored         Hidden from UI (True/False)
    found_date      date this book was first discovered as utc datetime object
    '''

    idltypes = LIDLTYPES    # library book link types

    def __init__(self, authors=[], title='', odid='', odrid='', odpid='',
                 series='', series_index=0.0, isbn='', publisher='', pubdate=None, language='', ignored=False,
                 found_date=None, odid_str='', saved_data_version=None):

        # Required fields - will be stored for discovered books (even if empty)

        self.authors = unique_authors(authors)  # drop any empty or non-unique author strings & force copy
        self.title = title
        self.odid = odid or odid_str   # odid_str may be in older saved data
        self.odrid = odrid
        self.odpid = odpid
        self.series = series
        self.series_index = series_index
        self.isbn = isbn
        self.publisher = publisher
        self.pubdate = pubdate
        self.language = language
        self.ignored = ignored
        self.found_date = found_date

    def merge_from(self, other, replace_links=False):
        # update original book using new links and any missing data

        if replace_links:
            self.odid = other.odid
            self.odrid = other.odrid
            self.odpid = other.odpid
        else:
            self.apply_changed_links_from(other)

        if not self.series:
            self.series = other.series

        if not self.series_index:
            self.series_index = other.series_index

        if not self.isbn:
            self.isbn = other.isbn

        if not self.publisher:
            self.publisher = other.publisher

        if not self.pubdate:
            self.pubdate = other.pubdate

        if not self.language:
            self.language = other.language

        if not self.found_date:
            self.found_date = other.found_date

        self.del_sort_key()

    def __repr__(self):
        # unambiguous string representation of a book
        return '%s%s [%s]' % (
                self.title, ' by %s' % authors_to_string(self.authors) if self.authors else '',
                ', '.join([s for s in [
                    self.series if self.series_index == 0.0 else '%s [%02d]' % (self.series, int(self.series_index)),
                    self.odid,
                    LINK_RECOMMENDABLE + self.odrid if self.odrid else '',
                    LINK_PURCHASABLE + self.odpid if self.odpid else '',
                    self.isbn,
                    self.publisher,
                    self.pubdate.isoformat() if self.pubdate else '',
                    self.language,
                    'ignored' if self.ignored else '',
                    ] if s]))


'''
Assorted utility routines
'''

MAX_AUTHORS = 3


def limited_authors_to_string(authors):
    # List only a limited number of authors when a book has many listed

    if len(authors) <= MAX_AUTHORS:
        return authors_to_string(authors)

    return "%s..." % authors_to_string(authors[:MAX_AUTHORS])


def author_sort_key(author):
    return author_to_author_sort(author).lower()
