#!/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 json
import re

from calibre.utils.date import parse_only_date

from calibre_plugins.overdrive_link.book import (LibraryBook, InfoBook)
from calibre_plugins.overdrive_link.formats import (
    FORMAT_HOOPLA_AUDIOBOOK, FORMAT_HOOPLA_BOOK_READER, FORMAT_HOOPLA_COMIC_READER)
from calibre_plugins.overdrive_link.library import SearchableLibrary
from calibre_plugins.overdrive_link.net import open_url
from calibre_plugins.overdrive_link.author_prep import normalize_author
from calibre_plugins.overdrive_link.title_prep import normalize_title
from calibre_plugins.overdrive_link.parseweb import LibraryError
from calibre_plugins.overdrive_link.language import LANGUAGES

try:
    from calibre_plugins.overdrive_link_debug.config import DEBUG_MODE
except ImportError:
    DEBUG_MODE = False

from .python_transition import (IS_PYTHON2)
if IS_PYTHON2:
    from .python_transition import (http, repr, str, urllib)
else:
    import http.cookiejar
    import urllib.parse


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


HOOPLA_HOST = 'www.hoopladigital.com'
API_HOST2 = 'patron-api-gateway.hoopladigital.com'

DEFAULT_WWW_VERSION = '4.50.0'       # as of 1/25/22

FORMAT_OF_KIND = {
    'EBOOK': FORMAT_HOOPLA_BOOK_READER,
    'COMIC': FORMAT_HOOPLA_COMIC_READER,
    'AUDIOBOOK': FORMAT_HOOPLA_AUDIOBOOK,
    }

LICENSE_TYPES = {
    "",                 # unknown (not signed in)
    "EST",              # flex borrow, limited availability with holds
    "NONE",             # unknown (not signed in)
    "PPU",              # instant borrow, always available
    }

STATUS_TYPES = {
    "",                 # unknown (not signed in)
    "BORROW",           # available to borrow
    "BORROWED",         # on loan to user
    "HELD",             # on hold by user
    "HOLD",             # checked out to someone else
    "NONE",             # seen only for TELEVISION format (seems to indicate available)
    "NOT_AVAILABLE"     # unavailable
}

QUERY = """query FilterSearch($criteria: SearchCriteria!, $from: Int = 0, $size: Int = 50, $sort: Sort) {
  search(criteria: $criteria, from: $from, size: $size, sort: $sort) {
    found
    hits {
      ...TitleListItemFragment
      __typename
    }
    __typename
  }
}

fragment TitleListItemFragment on Title {
  id
  artKey
  issueNumberDescription
  kind {
    name
    __typename
  }
  parentalAdvisory
  primaryArtist {
    name
    __typename
  }
  releaseDate
  title
  titleId
  status
  licenseType
  __typename
}
"""


class Hoopla(SearchableLibrary):
    id = 'ho'
    name = 'Hoopla'
    formats_supported = {FORMAT_HOOPLA_AUDIOBOOK, FORMAT_HOOPLA_BOOK_READER, FORMAT_HOOPLA_COMIC_READER}
    sign_in_affects_get_current_availability = True     # sign in needed to detect holds and flex titles

    @staticmethod
    def validate_library_id(library_id, migrate=True, config=None):
        # allow null library id for full site without sign in
        if not (re.match(r'^([0-9]+)$', library_id) or library_id == ''):
            raise ValueError('Hoopla library id must be empty or numeric: "%s"' % library_id)

        return library_id

    @staticmethod
    def validate_book_id(book_id, library_id):
        if not re.match(r'^([0-9]+)$', book_id):
            raise ValueError('Hoopla book id must be numeric: "%s"' % book_id)

        return book_id

    @staticmethod
    def book_url(library_id, book_id):
        return 'https://%s/title/%s' % (HOOPLA_HOST, book_id)

    def __init__(self):
        self.cookiejar = http.cookiejar.CookieJar()
        self.www_version = DEFAULT_WWW_VERSION
        self.api_addheaders = [('Accept', 'application/json, text/plain, */*')]
        self.holds_checked = False
        self.holds = {}
        self.loans = {}

    def sign_in(self, use_credentials):
        # load enough of main page and scripts to determine API version in use
        found_hoopla_version = False
        response = open_url(self.log, 'https://%s' % HOOPLA_HOST, cookiejar=self.cookiejar)
        m = re.search('<script[^>]* src="(/static/js/main[0-9a-z.]+)">', response.data_string)
        if m:
            response = open_url(self.log, 'https://%s%s' % (HOOPLA_HOST, m.group(1)), cookiejar=self.cookiejar)
            m = re.search('hooplaVersion:"([0-9.]+)",', response.data_string)
            if m:
                self.www_version = m.group(1)
                found_hoopla_version = True

        if not found_hoopla_version:
            self.log.warn("Hoopla API version was not found")

        if self.card_number and use_credentials:
            # sign in to library to produce selective results
            self.cookiejar.clear()
            self.signin_required = True

            self.log.info('Signing in to %s' % self.name)

            data = {}
            data['username'] = self.card_number
            data['password'] = self.card_pin

            core_tokens_headers = self.api_addheaders.copy()
            core_tokens_headers.append(("hoopla-version", self.www_version))

            response = open_url(self.log, 'https://%s/core/tokens' % API_HOST2, urllib.parse.urlencode(data), cookiejar=self.cookiejar,
                                addheaders=core_tokens_headers)

            results = json.loads(response.data_string)
            status = results.get("tokenStatus", "")

            if status != "SUCCESS":
                raise LibraryError('Sign in failed with status "%s". Check email address (card number) and password (PIN).' % status)

            self.token = results["token"]
            self.api_addheaders.append(('authorization', 'Bearer %s' % self.token))

            response = open_url(self.log, 'https://%s/core/users?wwwVersion=%s' % (API_HOST2, self.www_version),
                                cookiejar=self.cookiejar, addheaders=self.api_addheaders)
            users = json.loads(response.data_string)
            library_ids = {}

            for patron in users["patrons"]:
                library_id = str(patron["libraryId"])
                library_ids[library_id] = patron["libraryName"]

                if self.library_id and self.library_id == library_id:
                    self.patron_id = patron["id"]
                    self.user_uri = patron["hooplaUserId"]
                    break
            else:
                libraries = ", ".join(["%s for %s" % x for x in library_ids.items()])
                if not self.library_id:
                    raise Exception('Library ID is required - Configure %s with library ID: %s' % (self.name, libraries))

                if self.library_id != library_id:
                    raise Exception('Library ID %s is incorrect - Configure %s with library ID: %s' % (self.library_id, self.name, libraries))

            self.log.info('Sign in to %s successful' % self.name)
            self.signed_in = True

        elif self.library_id and not self.card_number:
            self.signin_required = True
            raise Exception('email address (card number) must be configured for a specific Hoopla library.')

    def find_books(self, books, search_author, search_title, keyword_search):
        '''
        Search hoopla for audiobooks that match an author/title (or subsets thereof).
        '''

        '''
        q = search_author or search_title   # including title with author causes missed books!

        # non-ascii characters in search causing 500 server errors as of Nov 2017
        try:
            q.encode("ascii")
        except UnicodeEncodeError:
            self.log.info("Skipping search due to non-ascii character in query")
            return False
        '''

        results_processed = 0
        offset = 0

        PAGE_SIZE = 50
        MAX_RESULTS_ALLOWED = 500

        while True:
            data = {
                "operationName": "FilterSearch",
                "variables": {
                    "from": offset,
                    "size": PAGE_SIZE,
                    "criteria": {
                        # "kindId": 8,  # for "AUDIOBOOK"
                        # "q": q,       # general search query - author or title
                        "availability": "ALL_TITLES"    # "AVAILABLE_NOW"
                        }
                    },
                "query": QUERY,
                }

            criteria = data["variables"]["criteria"]

            if search_author:
                criteria["artistName"] = search_author

            if search_author:
                criteria["title"] = search_title

            # kind: 'AUDIOBOOK'=8, 'MOVIE'=7, 'MUSIC'=6, 'EBOOK'=5, 'COMIC'=10, 'TELEVISION'=9
            if (FORMAT_HOOPLA_BOOK_READER in self.config.search_formats or FORMAT_HOOPLA_COMIC_READER in self.config.search_formats):
                # leave out 'kind' in order to default to all since need both 'EBOOK' and 'COMIC'
                pass
            elif FORMAT_HOOPLA_AUDIOBOOK in self.config.search_formats:
                criteria["kindId"] = 8     # 'AUDIOBOOK'
            else:
                return False    # no formats needed

            json_data = json.dumps(data, indent=None, separators=(",", ":"))    # compact

            graphql_headers = self.api_addheaders.copy()
            graphql_headers.append(("apollographql-client-name", "hoopla-www"))
            graphql_headers.append(("apollographql-client-version", self.www_version))

            response = open_url(
                    self.log, 'https://%s/graphql' % API_HOST2, json_data, content_type="application/json",
                    cookiejar=self.cookiejar, addheaders=graphql_headers)

            results = json.loads(response.data_string)

            if "errors" in results:
                for error in results["errors"]:
                    message = error.get("message")
                    if message:
                        self.log_warn_debug('Hoopla search error: %s' % message)

            results_data = results.get("data") or {}    # data can be null in case of search error
            search = results_data.get("search", {})

            if offset == 0:
                self.log.info('%d search results' % search.get("found", -1))

            hits = search.get("hits", [])

            for book in hits:
                book_id = book["id"]

                title = book.get("title", "")
                subtitle = book.get("subtitle", "")     # not sure if ever present
                if subtitle:
                    title = '%s: %s' % (title, subtitle)
                title = normalize_title(title)

                author = normalize_author(name_field(book, "primaryArtist"), unreverse=False)
                authors = [author] if author and not author.startswith("Various") else []

                fmt_name = name_field(book, "kind")
                frmt = FORMAT_OF_KIND.get(fmt_name, "Unknown")
                formats = {frmt} if frmt else set()

                pubdate = parse_only_date(book["releaseDate"], assume_utc=True) if "releaseDate" in book else None

                license_type = book.get("licenseType") or ""
                status = book.get("status") or ""
                self.check_license_type_and_status(license_type, status)

                lbook = LibraryBook(title=title, authors=authors, formats=formats, pubdate=pubdate,
                                    available=True, lib=self, book_id=book_id, search_author=search_author)

                desc = '(%s %s %s): %s' % (fmt_name, license_type, status, repr(lbook))

                if self.config.search_formats.isdisjoint(formats):
                    self.log.info('Ignoring wrong format %s' % desc)
                else:
                    self.log.info('Found %s' % desc)
                    books.add(lbook)

                results_processed += 1

            if len(hits) < PAGE_SIZE:
                break               # reached end of results

            offset += len(hits)
            if offset > MAX_RESULTS_ALLOWED:
                return False        # don't indicate limit reached since retry not possible

        return False

    def get_book_info(self, book_id, cache):
        response = open_url(
                self.log, 'https://%s/core/v3/titles/%s?wwwVersion=%s' % (API_HOST2, book_id, self.www_version),
                cookiejar=self.cookiejar, addheaders=self.api_addheaders)
        book = json.loads(response.data_string)

        if "message" in book:
            self.log.error("Message: %s" % book["message"])
            return None     # assume error

        license_type = book.get("licenseType", "")
        status = book.get("status", "")
        self.check_license_type_and_status(license_type, status)

        if status == "NOT_AVAILABLE":
            self.log.info("Book is unavailable")
            return None

        if not book.get("contents", []):
            self.log.info("No contents - assuming unavailable")
            return None

        authors = []

        def add_author(a):
            a = normalize_author(a, unreverse=False)
            if a and (not a.startswith("Various")) and a not in authors:
                authors.append(a)

        title = book.get("title", "")
        subtitle = book.get("subtitle", "")

        for content in book.get("contents", []):
            if "title" in content:
                title = content.get("title", "")
                subtitle = content.get("subtitle", "")

        if subtitle:
            title = '%s: %s' % (title, subtitle)
        title = normalize_title(title)

        add_author(name_field(book, "artist"))

        for artist in book.get("artists", []):
            add_author(artist.get("name", ""))

        format = FORMAT_OF_KIND.get(name_field(book, "kind"), "Unknown")
        formats = {format} if format else set()

        series = name_field(book, "series")
        series_index = book.get("seriesNumber", 0.0)
        language = book.get("language", {}).get("label", "")
        if language not in LANGUAGES:
            language = ""

        artkey = book.get("artKey", "")[4:]
        isbn = artkey if len(artkey) == 13 and artkey.startswith("978") else ""

        publisher = name_field(book, "publisher")

        if "releaseDate" in book:
            timestamp = book["releaseDate"] // 1000
            if timestamp < 0:
                pubdate = datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds=timestamp)
            else:
                pubdate = datetime.datetime.utcfromtimestamp(timestamp)  # only handles positive values
        elif "year" in book:
            pubdate = parse_only_date(str(book["year"]), assume_utc=True)
        else:
            pubdate = None

        return InfoBook(authors=authors, title=title, series=series, series_index=series_index, publisher=publisher, pubdate=pubdate,
                        isbn=isbn, formats=formats, language=language, lib=self, book_id=book_id)

    def get_current_book_availability(self, book_id):
        response = open_url(
                self.log, 'https://%s/core/v3/titles/%s?wwwVersion=%s' % (API_HOST2, book_id, self.www_version),
                cookiejar=self.cookiejar, addheaders=self.api_addheaders, expect_errors=[404])

        if response.is_httperror_exception:
            self.log.info('Book does not exist')
            return False

        book = json.loads(response.data_string)

        if "message" in book:
            self.log.info("Message: %s" % book["message"])
            return False        # assume error

        license_type = book.get("licenseType", "")
        status = book.get("status", "")
        self.log.info('licenseType=%s, status=%s' % (license_type, status))
        self.check_license_type_and_status(license_type, status)

        if status == "NOT_AVAILABLE":
            self.log.info("Status: %s" % status)
            return False

        if not book.get("contents", []):
            self.log.info("No contents - assuming unavailable")
            return False

        if license_type == "EST" and status in {"HELD", "HOLD"}:
            zombie_hold_count = book.get("zombieHoldCount", 0)      # unknown purpose
            if zombie_hold_count != 0:
                self.log_warn_debug("zombieHoldCount=%d" % zombie_hold_count)

            self.check_current_holds()
            have_checked_out = book_id in self.loans        # should match status=="BORROWED"
            is_on_hold = book_id in self.holds              # should match status=="HELD"
            holds_per_copy = book.get("holdsPerCopy", 0)
            hold_position_per_copy = self.holds[book_id]["positionPerCopy"] if is_on_hold else None

            wait_weeks = self.calculate_wait_weeks(
                    library_copies=1, available_copies=0, number_waiting_per_copy=holds_per_copy,
                    have_checked_out=have_checked_out, hold_position_per_copy=hold_position_per_copy)

            return (wait_weeks, is_on_hold, book_id)

        #wait_weeks = self.calculate_wait_weeks(
        #    library_copies=1, available_copies=0, release_date=release_date)

        return 0    # always available, assuming no pre-release titles

    def check_current_holds(self):
        #GET /holds/users/018b12b2-91fb-489b-8168-1c394f6c196f/holds
        #Host	patron-api-gateway.hoopladigital.com

        if self.holds_checked or not self.signed_in:
            return

        self.holds_checked = True

        try:
            # get hold info
            response = open_url(
                    self.log, 'https://%s/holds/users/%s/holds' % (API_HOST2, self.user_uri),
                    cookiejar=self.cookiejar, addheaders=self.api_addheaders)

            for hold in json.loads(response.data_string):
                #self.log.info("hold: %s" % repr(hold))
                self.holds[str(hold["contentId"])] = hold

        except Exception as e:
            self.log.exception('', e)

        if self.holds:
            self.log.info('Holds: %s' % ', '.join(self.holds.keys()))

        try:
            # get loan info
            response = open_url(
                    self.log, 'https://%s/core/users/%s/borrowed-titles?wwwVersion=%s' % (API_HOST2, self.user_uri, self.www_version),
                    cookiejar=self.cookiejar, addheaders=self.api_addheaders)

            for book in json.loads(response.data_string):
                #self.log.info("book: %s" % repr(book))
                self.loans[str(book["id"])] = book

        except Exception as e:
            self.log.exception('', e)

        if self.loans:
            self.log.info('Loans: %s' % ', '.join(self.loans.keys()))

    def check_license_type_and_status(self, license_type, status):
        if license_type not in LICENSE_TYPES:
            self.log_warn_debug("Unknown license_type: %s" % license_type)

        if status not in STATUS_TYPES:
            self.log_warn_debug("Unknown status: %s" % status)

    def log_warn_debug(self, msg):
        if DEBUG_MODE:
            self.log.warn(msg)
        else:
            self.log.info(msg)


def name_field(data, field_type):
    field = data.get(field_type)     # value may be missing or None
    return field.get("name", "") if field is not None else ""
