#!/usr/bin/env python
#!/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 sqlite3
import time
import datetime
import os
import random
import json
import bz2

from calibre.constants import config_dir
from calibre.utils.config_base import tweaks

from calibre_plugins.overdrive_link import ActionOverdriveLink
from calibre_plugins.overdrive_link.book import InfoBook
from calibre_plugins.overdrive_link.numbers import value_unit
from calibre_plugins.overdrive_link.tweak import (TWEAK_DISABLE_CACHE, TWEAK_CACHE_MISS_PROBABILITY)

from .python_transition import (IS_PYTHON2)


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


FMT_JSON_INFOBOOK = 0
FMT_JSON_DATA = 1
MAX_DAYS_TO_KEEP = 999      # anything greater means keep forever

PLUGIN_DIR = 'plugins'


class LibraryBookCache(object):
    '''
    Cache of library book data from individual book pages. Should be thread/process safe and support
    sparse access from a very large >1M set of cached entries.

    Kept as an SQL database in config directory.
    SQLite natively supports only the types TEXT, INTEGER, REAL, BLOB and NULL.

    There are default adapters for the date and datetime types in the datetime module. They will be sent as
    ISO dates/ISO timestamps to SQLite. The default converters are registered under the name “date” for
    datetime.date and under the name “timestamp” for datetime.datetime.

    The default setting for auto-vacuum is "none". auto-vacuum is disabled. When data is deleted, the
    database file remains the same size. Unused database file pages are added to a "freelist" and reused
    for subsequent inserts. So no database file space is lost. However, the database file does not shrink.
    In this mode the VACUUM command can be used to rebuild the entire database file and thus reclaim unused
    disk space.

    __enter__ and __exit__ methods allow calling via python "with" statement.

    sqlite3 overdrive_link_cache.db
    .schema
    .dump
    .quit

    Can clean and compact database using:
    sqlite3 overdrive_link_cache.db "VACUUM;"
    '''

    def __init__(self, log, config):
        self.conn = None
        self.log = log
        self.days_to_keep = config.cache_days_to_keep

        self.db_filename = os.path.join(os.path.join(config_dir, PLUGIN_DIR), '%s Cache.db' % ActionOverdriveLink.name)
        self.db_timeout = 30
        self.num_added = 0

        random.seed()

    def __enter__(self):
        if tweaks.get(TWEAK_DISABLE_CACHE, False):
            self.log.info('Cache disabled by tweak')
            return self     # do nothing

        if os.path.exists(self.db_filename):
            self.log.info('Opening cache database %s' % self.db_filename)
        elif self.days_to_keep <= 0:
            self.log.info('Cache disabled by user')
            return self     # do nothing
        else:
            self.log.info('Creating cache database %s' % self.db_filename)

        self.log.context('Open book cache')

        try:
            start = time.time()
            self.conn = sqlite3.connect(self.db_filename, self.db_timeout)

            self.conn.execute('''
                CREATE TABLE IF NOT EXISTS books(
                    book_key TEXT UNIQUE ON CONFLICT REPLACE,
                    format INT,
                    data TEXT,
                    added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
                ''')    # implicit index on book_key created by unique constraint

            self.conn.execute('CREATE INDEX IF NOT EXISTS added_at_index ON books (added_at);')

            result = self.conn.execute('SELECT Count() FROM books;').fetchone()
            if result:
                self.log.info('Opened in %.1f sec. Cache is %s and contains %s.' % (
                    time.time() - start,
                    value_unit(os.stat(self.db_filename).st_size, 'byte'),
                    value_unit(int(result[0]), 'item')))

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

        self.log.context(None)

        self.remove_expired_entries()

        if self.days_to_keep <= 0:
            self.log.info('Cache disabled by user')
            self.close()

        return self

    def __exit__(self, type, value, traceback):
        self.close()

    def save_book(self, info_book):
        # save InfoBook to cache
        self.save_item(info_book.book_key, FMT_JSON_INFOBOOK, info_book.to_json())

    def get_book(self, book_key):
        # return InfoBook or None
        item_data = self.get_item(book_key, FMT_JSON_INFOBOOK)
        return None if item_data is None else InfoBook(from_json=item_data)

    def save_data(self, key, data):
        # save data object (must be json compatible) to cache
        self.save_item(key, FMT_JSON_DATA, json.dumps(data))

    def get_data(self, key):
        # return saved data object or None
        item_data = self.get_item(key, FMT_JSON_DATA)
        return None if item_data is None else json.loads(item_data)

    def save_item(self, item_key, item_format, item_data):
        if self.conn is None:
            return

        self.log.context('Caching %s' % item_key)

        try:
            self.conn.execute('INSERT INTO books VALUES (?,?,?,?);', (
                item_key, item_format, item_data,
                datetime.datetime.now().replace(microsecond=0)))

            self.conn.commit()  # commit changes
            self.num_added += 1

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

        self.log.context(None)

    def get_item(self, item_key, item_format):
        # return item data or None

        if self.conn is None:
            return None

        r = random.random()
        #self.log.info("random %f" % r)

        if r > 0.0 and r < tweaks.get(TWEAK_CACHE_MISS_PROBABILITY, 0.0):
            # simulated cache miss even if present. for testing
            self.delete_item(item_key)

        self.log.context('Checking cache for %s' % item_key)

        try:
            result = self.conn.execute('SELECT format, data FROM books WHERE book_key = ?;', (item_key,)).fetchone()
        except Exception as e:
            self.log.exception('', e)
            result = None

        self.log.context(None)

        if result is None:
            #self.log.info('cache miss for %s' %  item_key)
            return None

        if result[0] != item_format:
            self.log.warn('Unknown cache format %d for %s' % (result[0], item_key))
            return None

        self.log.info('Found %s in cache' % item_key)

        return result[1]

    def delete_item(self, item_key):
        if self.conn is None:
            return

        self.log.context('Deleting %s from cache' % item_key)

        try:
            changes = self.conn.execute('DELETE FROM books WHERE book_key = ?;', (item_key,)).rowcount
            self.conn.commit()  # commit changes

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

        self.log.context(None)

        if changes:
            self.log.info('Deleted %s from cache' % item_key)

    def remove_expired_entries(self):
        if self.conn is None or self.days_to_keep > MAX_DAYS_TO_KEEP:
            return

        self.log.context('Deleting expired entries from cache')
        start = time.time()

        try:
            when = datetime.datetime.now().replace(microsecond=0) - datetime.timedelta(days=self.days_to_keep)
            changes = self.conn.execute('DELETE FROM books WHERE added_at < ?;', (when,)).rowcount
            self.conn.commit()  # commit changes

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

        self.log.context(None)

        if changes:
            self.log.info('%s deleted from cache in %.1f sec' % (
                value_unit(changes, 'expired entry'), time.time() - start))

    def close(self):
        if self.conn is not None:
            if self.num_added:
                self.log.info('Closing cache database: %s added' % value_unit(self.num_added, 'item'))

            self.conn.close()   # close the database
            self.conn = None


class ProjectGutenbergIndex(object):
    def __init__(self, log, config):
        self.log = log
        self.config = config
        self.filename = os.path.join(config_dir, PLUGIN_DIR, '%s Project Gutenberg Index.json.bz2' % ActionOverdriveLink.name)

    def read(self):
        if not os.path.exists(self.filename):
            return None

        self.log.info('Loading %s' % self.filename)
        start = time.time()

        with bz2.BZ2File(self.filename, 'rb') as of:
            data = json.load(of)

        self.log.info('Load took %.1f sec' % (time.time() - start))
        return data

    def write(self, data):
        self.log.info('Saving %s' % self.filename)
        start = time.time()

        with bz2.BZ2File(self.filename, 'wb') as of:
            json_data = json.dumps(data, indent=2)
            of.write(json_data if IS_PYTHON2 else json_data.encode("ascii"))

        self.log.info('Save took %.1f sec' % (time.time() - start))


class AmazonUserAgent(object):
    def __init__(self, log):
        self.log = log
        self.filename = os.path.join(config_dir, PLUGIN_DIR, '%s Amazon User Agent.json' % ActionOverdriveLink.name)

    def read(self):
        if not os.path.exists(self.filename):
            return 0

        with open(self.filename, 'rb') as of:
            data = json.load(of)

        return data

    def write(self, data):
        with open(self.filename, 'wb') as of:
            json_data = json.dumps(data, indent=2)
            of.write(json_data if IS_PYTHON2 else json_data.encode("ascii"))


def main():
    return


if __name__ == '__main__':
    main()
