#!/usr/pkg/bin/python3.11


__license__   = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'

import hashlib
from collections import OrderedDict, namedtuple
from functools import partial
from html5_parser import parse
from lxml import etree
from lxml.builder import ElementMaker

from calibre import force_unicode, guess_type, prepare_string_for_xml as xml
from calibre.constants import __appname__
from calibre.db.view import sanitize_sort_field_name
from calibre.ebooks.metadata import authors_to_string, fmt_sidx, rating_to_stars
from calibre.library.comments import comments_to_html
from calibre.srv.errors import HTTPInternalServerError, HTTPNotFound
from calibre.srv.http_request import parse_uri
from calibre.srv.routes import endpoint
from calibre.srv.utils import Offsets, get_library_data, http_date
from calibre.utils.config import prefs
from calibre.utils.date import as_utc, is_date_undefined, timestampfromdt
from calibre.utils.icu import sort_key
from calibre.utils.search_query_parser import ParseException
from calibre.utils.xml_parse import safe_xml_fromstring
from polyglot.binary import as_hex_unicode, from_hex_unicode
from polyglot.builtins import as_bytes, iteritems
from polyglot.urllib import unquote_plus, urlencode


def atom(ctx, rd, endpoint, output):
    rd.outheaders.set('Content-Type', 'application/atom+xml; charset=UTF-8', replace_all=True)
    rd.outheaders.set('Calibre-Instance-Id', force_unicode(prefs['installation_uuid'], 'utf-8'), replace_all=True)
    if isinstance(output, bytes):
        ans = output  # Assume output is already UTF-8 XML
    elif isinstance(output, str):
        ans = output.encode('utf-8')
    else:
        ans = etree.tostring(output, encoding='utf-8', xml_declaration=True, pretty_print=True)
    return ans


def format_tag_string(tags, sep, joinval=', '):
    if tags:
        tlist = tags if sep is None else [t.strip() for t in tags.split(sep)]
    else:
        tlist = []
    tlist.sort(key=sort_key)
    return joinval.join(tlist) if tlist else ''


# Vocabulary for building OPDS feeds {{{
DC_NS = 'http://purl.org/dc/terms/'
E = ElementMaker(namespace='http://www.w3.org/2005/Atom',
                 nsmap={
                     None   : 'http://www.w3.org/2005/Atom',
                     'dc'   : DC_NS,
                     'opds' : 'http://opds-spec.org/2010/catalog',
                     })


FEED    = E.feed
TITLE   = E.title
ID      = E.id
ICON    = E.icon


def UPDATED(dt, *args, **kwargs):
    return E.updated(as_utc(dt).strftime('%Y-%m-%dT%H:%M:%S+00:00'), *args, **kwargs)


LINK = partial(E.link, type='application/atom+xml')
NAVLINK = partial(E.link,
        type='application/atom+xml;type=feed;profile=opds-catalog')


def SEARCH_LINK(url_for, *args, **kwargs):
    kwargs['rel'] = 'search'
    kwargs['title'] = 'Search'
    kwargs['href'] = url_for('/opds/search', query='XXX').replace('XXX', '{searchTerms}')
    return LINK(*args, **kwargs)


def AUTHOR(name, uri=None):
    args = [E.name(name)]
    if uri is not None:
        args.append(E.uri(uri))
    return E.author(*args)


SUBTITLE = E.subtitle


def NAVCATALOG_ENTRY(url_for, updated, title, description, query):
    href = url_for('/opds/navcatalog', which=as_hex_unicode(query))
    id_ = 'calibre-navcatalog:' + hashlib.sha1(as_bytes(href)).hexdigest()
    return E.entry(
        TITLE(title),
        ID(id_),
        UPDATED(updated),
        E.content(description, type='text'),
        NAVLINK(href=href)
    )


START_LINK = partial(NAVLINK, rel='start')
UP_LINK = partial(NAVLINK, rel='up')
FIRST_LINK = partial(NAVLINK, rel='first')
LAST_LINK  = partial(NAVLINK, rel='last')
NEXT_LINK  = partial(NAVLINK, rel='next', title='Next')
PREVIOUS_LINK  = partial(NAVLINK, rel='previous')


def html_to_lxml(raw):
    raw = '<div>%s</div>'%raw
    root = parse(raw, keep_doctype=False, namespace_elements=False, maybe_xhtml=False, sanitize_names=True)
    root = next(root.iterdescendants('div'))
    root.set('xmlns', "http://www.w3.org/1999/xhtml")
    raw = etree.tostring(root, encoding='unicode')
    try:
        return safe_xml_fromstring(raw, recover=False)
    except:
        for x in root.iterdescendants():
            remove = []
            for attr in x.attrib:
                if ':' in attr:
                    remove.append(attr)
            for a in remove:
                del x.attrib[a]
        raw = etree.tostring(root, encoding='unicode')
        try:
            return safe_xml_fromstring(raw, recover=False)
        except:
            from calibre.ebooks.oeb.parse_utils import _html4_parse
            return _html4_parse(raw)


def CATALOG_ENTRY(item, item_kind, request_context, updated, catalog_name,
                  ignore_count=False, add_kind=False):
    id_ = 'calibre:category:'+item.name
    iid = 'N' + item.name
    if item.id is not None:
        iid = 'I' + str(item.id)
        iid += ':'+item_kind
    href = request_context.url_for('/opds/category', category=as_hex_unicode(catalog_name), which=as_hex_unicode(iid))
    link = NAVLINK(href=href)
    if ignore_count:
        count = ''
    else:
        count = ngettext('one book', '{} books', item.count).format(item.count)
    if item.use_sort_as_name:
        name = item.sort
    else:
        name = item.name
    return E.entry(
            TITLE(name + ('' if not add_kind else ' (%s)'%item_kind)),
            ID(id_),
            UPDATED(updated),
            E.content(count, type='text'),
            link
            )


def CATALOG_GROUP_ENTRY(item, category, request_context, updated):
    id_ = 'calibre:category-group:'+category+':'+item.text
    iid = item.text
    link = NAVLINK(href=request_context.url_for('/opds/categorygroup', category=as_hex_unicode(category), which=as_hex_unicode(iid)))
    return E.entry(
        TITLE(item.text),
        ID(id_),
        UPDATED(updated),
        E.content(ngettext('one item', '{} items', item.count).format(item.count), type='text'),
        link
    )


def ACQUISITION_ENTRY(book_id, updated, request_context):
    field_metadata = request_context.db.field_metadata
    mi = request_context.db.get_metadata(book_id)
    extra = []
    if (mi.rating or 0) > 0:
        rating = rating_to_stars(mi.rating)
        extra.append(_('RATING: %s<br />')%rating)
    if mi.tags:
        extra.append(_('TAGS: %s<br />')%xml(format_tag_string(mi.tags, None)))
    if mi.series:
        extra.append(_('SERIES: %(series)s [%(sidx)s]<br />')%
                dict(series=xml(mi.series),
                sidx=fmt_sidx(float(mi.series_index))))
    for key in filter(request_context.ctx.is_field_displayable, field_metadata.ignorable_field_keys()):
        name, val = mi.format_field(key)
        if val:
            fm = field_metadata[key]
            datatype = fm['datatype']
            if datatype == 'text' and fm['is_multiple']:
                extra.append('%s: %s<br />'%
                             (xml(name),
                              xml(format_tag_string(val,
                                    fm['is_multiple']['ui_to_list'],
                                    joinval=fm['is_multiple']['list_to_ui']))))
            elif datatype == 'comments' or (fm['datatype'] == 'composite' and fm['display'].get('contains_html', False)):
                extra.append('%s: %s<br />'%(xml(name), comments_to_html(str(val))))
            else:
                extra.append('%s: %s<br />'%(xml(name), xml(str(val))))
    if mi.comments:
        comments = comments_to_html(mi.comments)
        extra.append(comments)
    if extra:
        extra = html_to_lxml('\n'.join(extra))
    ans = E.entry(TITLE(mi.title), E.author(E.name(authors_to_string(mi.authors))), ID('urn:uuid:' + mi.uuid), UPDATED(mi.last_modified),
                  E.published(mi.timestamp.isoformat()))
    if mi.pubdate and not is_date_undefined(mi.pubdate):
        ans.append(ans.makeelement('{%s}date' % DC_NS))
        ans[-1].text = mi.pubdate.isoformat()
    if len(extra):
        ans.append(E.content(extra, type='xhtml'))
    get = partial(request_context.ctx.url_for, '/get', book_id=book_id, library_id=request_context.library_id)
    if mi.formats:
        fm = mi.format_metadata
        for fmt in mi.formats:
            fmt = fmt.lower()
            mt = guess_type('a.'+fmt)[0]
            if mt:
                link = E.link(type=mt, href=get(what=fmt), rel="http://opds-spec.org/acquisition")
                ffm = fm.get(fmt.upper())
                if ffm:
                    link.set('length', str(ffm['size']))
                    link.set('mtime', ffm['mtime'].isoformat())
                ans.append(link)
    ans.append(E.link(type='image/jpeg', href=get(what='cover'), rel="http://opds-spec.org/cover"))
    ans.append(E.link(type='image/jpeg', href=get(what='thumb'), rel="http://opds-spec.org/thumbnail"))
    ans.append(E.link(type='image/jpeg', href=get(what='cover'), rel="http://opds-spec.org/image"))
    ans.append(E.link(type='image/jpeg', href=get(what='thumb'), rel="http://opds-spec.org/image/thumbnail"))

    return ans


# }}}

default_feed_title = __appname__ + ' ' + _('Library')


class Feed:  # {{{

    def __init__(self, id_, updated, request_context, subtitle=None,
            title=None,
            up_link=None, first_link=None, last_link=None,
            next_link=None, previous_link=None):
        self.base_href = request_context.url_for('/opds')

        self.root = \
            FEED(
                    TITLE(title or default_feed_title),
                    AUTHOR(__appname__, uri='https://calibre-ebook.com'),
                    ID(id_),
                    ICON(request_context.ctx.url_for('/favicon.png')),
                    UPDATED(updated),
                    SEARCH_LINK(request_context.url_for),
                    START_LINK(href=request_context.url_for('/opds'))
                )
        if up_link:
            self.root.append(UP_LINK(href=up_link))
        if first_link:
            self.root.append(FIRST_LINK(href=first_link))
        if last_link:
            self.root.append(LAST_LINK(href=last_link))
        if next_link:
            self.root.append(NEXT_LINK(href=next_link))
        if previous_link:
            self.root.append(PREVIOUS_LINK(href=previous_link))
        if subtitle:
            self.root.insert(1, SUBTITLE(subtitle))

    # }}}


class TopLevel(Feed):  # {{{

    def __init__(self,
            updated,  # datetime object in UTC
            categories,
            request_context,
            id_='urn:calibre:main',
            subtitle=_('Books in your library')
            ):
        Feed.__init__(self, id_, updated, request_context, subtitle=subtitle)

        subc = partial(NAVCATALOG_ENTRY, request_context.url_for, updated)
        subcatalogs = [subc(_('By ')+title,
            _('Books sorted by ') + desc, q) for title, desc, q in
            categories]
        for x in subcatalogs:
            self.root.append(x)
        for library_id, library_name in sorted(iteritems(request_context.library_map), key=lambda item: sort_key(item[1])):
            id_ = 'calibre-library:' + library_id
            self.root.append(E.entry(
                TITLE(_('Library:') + ' ' + library_name),
                ID(id_),
                UPDATED(updated),
                E.content(_('Change calibre library to:') + ' ' + library_name, type='text'),
                NAVLINK(href=request_context.url_for('/opds', library_id=library_id))
            ))
# }}}


class NavFeed(Feed):

    def __init__(self, id_, updated, request_context, offsets, page_url, up_url, title=None):
        kwargs = {'up_link': up_url}
        kwargs['first_link'] = page_url
        kwargs['last_link']  = page_url+'&offset=%d'%offsets.last_offset
        if offsets.offset > 0:
            kwargs['previous_link'] = \
                page_url+'&offset=%d'%offsets.previous_offset
        if offsets.next_offset > -1:
            kwargs['next_link'] = \
                page_url+'&offset=%d'%offsets.next_offset
        if title:
            kwargs['title'] = title
        Feed.__init__(self, id_, updated, request_context, **kwargs)


class AcquisitionFeed(NavFeed):

    def __init__(self, id_, updated, request_context, items, offsets, page_url, up_url, title=None):
        NavFeed.__init__(self, id_, updated, request_context, offsets, page_url, up_url, title=title)
        for book_id in items:
            self.root.append(ACQUISITION_ENTRY(book_id, updated, request_context))


class CategoryFeed(NavFeed):

    def __init__(self, items, which, id_, updated, request_context, offsets, page_url, up_url, title=None):
        NavFeed.__init__(self, id_, updated, request_context, offsets, page_url, up_url, title=title)
        ignore_count = False
        if which == 'search':
            ignore_count = True
        for item in items:
            self.root.append(CATALOG_ENTRY(
                item, item.category, request_context, updated, which, ignore_count=ignore_count, add_kind=which != item.category))


class CategoryGroupFeed(NavFeed):

    def __init__(self, items, which, id_, updated, request_context, offsets, page_url, up_url, title=None):
        NavFeed.__init__(self, id_, updated, request_context, offsets, page_url, up_url, title=title)
        for item in items:
            self.root.append(CATALOG_GROUP_ENTRY(item, which, request_context, updated))


class RequestContext:

    def __init__(self, ctx, rd):
        self.db, self.library_id, self.library_map, self.default_library = get_library_data(ctx, rd)
        self.ctx, self.rd = ctx, rd

    def url_for(self, path, **kwargs):
        lid = kwargs.pop('library_id', self.library_id)
        ans = self.ctx.url_for(path, **kwargs)
        q = {'library_id':lid}
        ans += '?' + urlencode(q)
        return ans

    def allowed_book_ids(self):
        return self.ctx.allowed_book_ids(self.rd, self.db)

    @property
    def outheaders(self):
        return self.rd.outheaders

    @property
    def opts(self):
        return self.ctx.opts

    def last_modified(self):
        return self.db.last_modified()

    def get_categories(self, report_parse_errors=False):
        return self.ctx.get_categories(self.rd, self.db,
                                       report_parse_errors=report_parse_errors)

    def search(self, query):
        return self.ctx.search(self.rd, self.db, query)


def get_acquisition_feed(rc, ids, offset, page_url, up_url, id_,
        sort_by='title', ascending=True, feed_title=None):
    if not ids:
        raise HTTPNotFound('No books found')
    with rc.db.safe_read_lock:
        sort_by = sanitize_sort_field_name(rc.db.field_metadata, sort_by)
        items = rc.db.multisort([(sort_by, ascending)], ids)
        max_items = rc.opts.max_opds_items
        offsets = Offsets(offset, max_items, len(items))
        items = items[offsets.offset:offsets.offset+max_items]
        lm = rc.last_modified()
        rc.outheaders['Last-Modified'] = http_date(timestampfromdt(lm))
        return AcquisitionFeed(id_, lm, rc, items, offsets, page_url, up_url, title=feed_title).root


def get_all_books(rc, which, page_url, up_url, offset=0):
    try:
        offset = int(offset)
    except Exception:
        raise HTTPNotFound('Not found')
    if which not in ('title', 'newest'):
        raise HTTPNotFound('Not found')
    sort = 'timestamp' if which == 'newest' else 'title'
    ascending = which == 'title'
    feed_title = {'newest':_('Newest'), 'title': _('Title')}.get(which, which)
    feed_title = default_feed_title + ' :: ' + _('By %s') % feed_title
    ids = rc.allowed_book_ids()
    return get_acquisition_feed(rc, ids, offset, page_url, up_url,
            id_='calibre-all:'+sort, sort_by=sort, ascending=ascending,
            feed_title=feed_title)


def get_navcatalog(request_context, which, page_url, up_url, offset=0):
    categories = request_context.get_categories()
    if which not in categories:
        raise HTTPNotFound('Category %r not found'%which)

    items = categories[which]
    updated = request_context.last_modified()
    category_meta = request_context.db.field_metadata
    meta = category_meta.get(which, {})
    category_name = meta.get('name', which)
    feed_title = default_feed_title + ' :: ' + _('By %s') % category_name

    id_ = 'calibre-category-feed:'+which

    MAX_ITEMS = request_context.opts.max_opds_ungrouped_items

    if MAX_ITEMS > 0 and len(items) <= MAX_ITEMS:
        max_items = request_context.opts.max_opds_items
        offsets = Offsets(offset, max_items, len(items))
        items = list(items)[offsets.offset:offsets.offset+max_items]
        ans = CategoryFeed(items, which, id_, updated, request_context, offsets,
            page_url, up_url, title=feed_title)
    else:
        Group = namedtuple('Group', 'text count')
        starts = set()
        for x in items:
            val = getattr(x, 'sort', x.name)
            if not val:
                val = 'A'
            starts.add(val[0].upper())
        category_groups = OrderedDict()
        for x in sorted(starts, key=sort_key):
            category_groups[x] = len([y for y in items if
                getattr(y, 'sort', y.name).upper().startswith(x)])
        items = [Group(x, y) for x, y in category_groups.items()]
        max_items = request_context.opts.max_opds_items
        offsets = Offsets(offset, max_items, len(items))
        items = items[offsets.offset:offsets.offset+max_items]
        ans = CategoryGroupFeed(items, which, id_, updated, request_context, offsets,
            page_url, up_url, title=feed_title)

    request_context.outheaders['Last-Modified'] = http_date(timestampfromdt(updated))

    return ans.root


@endpoint('/opds', postprocess=atom)
def opds(ctx, rd):
    rc = RequestContext(ctx, rd)
    db = rc.db
    try:
        categories = rc.get_categories(report_parse_errors=True)
    except ParseException as p:
        raise HTTPInternalServerError(p.msg)

    category_meta = db.field_metadata
    cats = [
        (_('Newest'), _('Date'), 'Onewest'),
        (_('Title'), _('Title'), 'Otitle'),
    ]

    def getter(x):
        try:
            return category_meta[x]['name'].lower()
        except KeyError:
            return x

    fm = rc.db.field_metadata
    for category in sorted(categories, key=lambda x: sort_key(getter(x))):
        if fm.is_ignorable_field(category) and not rc.ctx.is_field_displayable(category):
            continue
        if len(categories[category]) == 0:
            continue
        if category in ('formats', 'identifiers'):
            continue
        meta = category_meta.get(category, None)
        if meta is None:
            continue
        cats.append((meta['name'], meta['name'], 'N'+category))
    last_modified = db.last_modified()
    rd.outheaders['Last-Modified'] = http_date(timestampfromdt(last_modified))
    return TopLevel(last_modified, cats, rc).root


@endpoint('/opds/navcatalog/{which}', postprocess=atom)
def opds_navcatalog(ctx, rd, which):
    try:
        offset = int(rd.query.get('offset', 0))
    except Exception:
        raise HTTPNotFound('Not found')
    rc = RequestContext(ctx, rd)

    page_url = rc.url_for('/opds/navcatalog', which=which)
    up_url = rc.url_for('/opds')
    which = from_hex_unicode(which)
    type_ = which[0]
    which = which[1:]
    if type_ == 'O':
        return get_all_books(rc, which, page_url, up_url, offset=offset)
    elif type_ == 'N':
        return get_navcatalog(rc, which, page_url, up_url, offset=offset)
    raise HTTPNotFound('Not found')


@endpoint('/opds/category/{category}/{which}', postprocess=atom)
def opds_category(ctx, rd, category, which):
    try:
        offset = int(rd.query.get('offset', 0))
    except Exception:
        raise HTTPNotFound('Not found')

    if not which or not category:
        raise HTTPNotFound('Not found')
    rc = RequestContext(ctx, rd)
    page_url = rc.url_for('/opds/category', which=which, category=category)
    up_url = rc.url_for('/opds/navcatalog', which=category)

    which, category = from_hex_unicode(which), from_hex_unicode(category)
    type_ = which[0]
    which = which[1:]
    if type_ == 'I':
        try:
            p = which.rindex(':')
            category = which[p+1:]
            which = which[:p]
            # This line will toss an exception for composite columns
            which = int(which[:p])
        except Exception:
            # Might be a composite column, where we have the lookup key
            if not (category in rc.db.field_metadata and rc.db.field_metadata[category]['datatype'] == 'composite'):
                raise HTTPNotFound('Tag %r not found'%which)

    categories = rc.get_categories()
    if category not in categories:
        raise HTTPNotFound('Category %r not found'%which)

    if category == 'search':
        try:
            ids = rc.search('search:"%s"'%which)
        except Exception:
            raise HTTPNotFound('Search: %r not understood'%which)
        return get_acquisition_feed(rc, ids, offset, page_url, up_url, 'calibre-search:'+which)

    if type_ != 'I':
        raise HTTPNotFound('Non id categories not supported')

    q = category
    if q == 'news':
        q = 'tags'
    ids = rc.db.get_books_for_category(q, which) & rc.allowed_book_ids()
    sort_by = 'series' if category == 'series' else 'title'

    return get_acquisition_feed(rc, ids, offset, page_url, up_url, 'calibre-category:'+category+':'+str(which), sort_by=sort_by)


@endpoint('/opds/categorygroup/{category}/{which}', postprocess=atom)
def opds_categorygroup(ctx, rd, category, which):
    try:
        offset = int(rd.query.get('offset', 0))
    except Exception:
        raise HTTPNotFound('Not found')

    if not which or not category:
        raise HTTPNotFound('Not found')

    rc = RequestContext(ctx, rd)
    categories = rc.get_categories()
    page_url = rc.url_for('/opds/categorygroup', category=category, which=which)

    category = from_hex_unicode(category)
    if category not in categories:
        raise HTTPNotFound('Category %r not found'%which)
    category_meta = rc.db.field_metadata
    meta = category_meta.get(category, {})
    category_name = meta.get('name', which)
    which = from_hex_unicode(which)
    feed_title = default_feed_title + ' :: ' + (_('By {0} :: {1}').format(category_name, which))
    owhich = as_hex_unicode('N'+category)
    up_url = rc.url_for('/opds/navcatalog', which=owhich)
    items = categories[category]

    def belongs(x, which):
        return getattr(x, 'sort', x.name).lower().startswith(which.lower())
    items = [x for x in items if belongs(x, which)]
    if not items:
        raise HTTPNotFound('No items in group %r:%r'%(category, which))
    updated = rc.last_modified()

    id_ = 'calibre-category-group-feed:'+category+':'+which

    max_items = rc.opts.max_opds_items
    offsets = Offsets(offset, max_items, len(items))
    items = list(items)[offsets.offset:offsets.offset+max_items]

    rc.outheaders['Last-Modified'] = http_date(timestampfromdt(updated))

    return CategoryFeed(items, category, id_, updated, rc, offsets, page_url, up_url, title=feed_title).root


@endpoint('/opds/search/{query=""}', postprocess=atom)
def opds_search(ctx, rd, query):
    try:
        offset = int(rd.query.get('offset', 0))
    except Exception:
        raise HTTPNotFound('Not found')

    rc = RequestContext(ctx, rd)
    if query:
        path = parse_uri(rd.request_original_uri, parse_query=False, unquote_func=unquote_plus)[1]
        query = path[-1]
        if isinstance(query, bytes):
            query = query.decode('utf-8')
    try:
        ids = rc.search(query)
    except Exception:
        raise HTTPNotFound('Search: %r not understood'%query)
    page_url = rc.url_for('/opds/search', query=query)
    return get_acquisition_feed(rc, ids, offset, page_url, rc.url_for('/opds'), 'calibre-search:'+query)
