#!/usr/pkg/bin/python3.13
# License: GPLv3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>


'''
Device driver for the Paladin devices
'''

import os, time, sys
from contextlib import closing

from calibre.devices.mime import mime_type_ext
from calibre.devices.errors import DeviceError
from calibre.devices.usbms.driver import USBMS, debug_print
from calibre.devices.usbms.books import CollectionsBookList, BookList

DBPATH = 'paladin/database/books.db'


class ImageWrapper:

    def __init__(self, image_path):
        self.image_path = image_path


class PALADIN(USBMS):
    name           = 'Paladin Device Interface'
    gui_name       = 'Paladin eLibrary'
    description    = _('Communicate with the Paladin readers')
    author         = 'David Hobley'
    supported_platforms = ['windows', 'osx', 'linux', 'netbsd']
    path_sep = '/'
    booklist_class = CollectionsBookList

    FORMATS      = ['epub', 'pdf']
    CAN_SET_METADATA = ['collections']
    CAN_DO_DEVICE_DB_PLUGBOARD = True

    VENDOR_ID    = [0x2207]   #: Onyx Vendor Id (currently)
    PRODUCT_ID   = [0x0010]
    BCD          = None

    SUPPORTS_SUB_DIRS = True
    SUPPORTS_USE_AUTHOR_SORT = True
    MUST_READ_METADATA = True
    EBOOK_DIR_MAIN   = 'paladin/books'

    EXTRA_CUSTOMIZATION_MESSAGE = [
        _(
            'Comma separated list of metadata fields '
            'to turn into collections on the device. Possibilities include: '
        ) + 'series, tags, authors',
    ]
    EXTRA_CUSTOMIZATION_DEFAULT = [
        ', '.join(['series', 'tags']),
    ]
    OPT_COLLECTIONS    = 0

    plugboards = None
    plugboard_func = None

    device_offset = None

    def books(self, oncard=None, end_session=True):
        import apsw
        dummy_bl = BookList(None, None, None)

        if (
                (oncard == 'carda' and not self._card_a_prefix) or
                (oncard and oncard != 'carda')
            ):
            self.report_progress(1.0, _('Getting list of books on device...'))
            return dummy_bl

        prefix = self._card_a_prefix if oncard == 'carda' else self._main_prefix

        # Let parent driver get the books
        self.booklist_class.rebuild_collections = self.rebuild_collections
        bl = USBMS.books(self, oncard=oncard, end_session=end_session)

        dbpath = self.normalize_path(prefix + DBPATH)
        debug_print("SQLite DB Path: " + dbpath)

        with closing(apsw.Connection(dbpath)) as connection:
            cursor = connection.cursor()
            # Query collections
            query = '''
                SELECT books._id, tags.tagname
                    FROM booktags
                    LEFT OUTER JOIN books
                    LEFT OUTER JOIN tags
                    WHERE booktags.book_id = books._id AND
                    booktags.tag_id = tags._id
                '''
            cursor.execute(query)

            bl_collections = {}
            for i, row in enumerate(cursor):
                bl_collections.setdefault(row[0], [])
                bl_collections[row[0]].append(row[1])

            # collect information on offsets, but assume any
            # offset we already calculated is correct
            if self.device_offset is None:
                query = 'SELECT filename, addeddate FROM books'
                cursor.execute(query)

                time_offsets = {}
                for i, row in enumerate(cursor):
                    try:
                        comp_date = int(os.path.getmtime(self.normalize_path(prefix + row[0])) * 1000)
                    except (OSError, TypeError):
                        # In case the db has incorrect path info
                        continue
                    device_date = int(row[1])
                    offset = device_date - comp_date
                    time_offsets.setdefault(offset, 0)
                    time_offsets[offset] = time_offsets[offset] + 1

                try:
                    device_offset = max(time_offsets, key=lambda a: time_offsets.get(a))
                    debug_print("Device Offset: %d ms"%device_offset)
                    self.device_offset = device_offset
                except ValueError:
                    debug_print("No Books To Detect Device Offset.")

            for idx, book in enumerate(bl):
                query = 'SELECT _id, thumbnail FROM books WHERE filename = ?'
                t = (book.lpath,)
                cursor.execute(query, t)

                for i, row in enumerate(cursor):
                    book.device_collections = bl_collections.get(row[0], None)
                    thumbnail = row[1]
                    if thumbnail is not None:
                        thumbnail = self.normalize_path(prefix + thumbnail)
                        book.thumbnail = ImageWrapper(thumbnail)

            cursor.close()

        return bl

    def set_plugboards(self, plugboards, pb_func):
        self.plugboards = plugboards
        self.plugboard_func = pb_func

    def sync_booklists(self, booklists, end_session=True):
        debug_print('PALADIN: starting sync_booklists')

        opts = self.settings()
        if opts.extra_customization:
            collections = [x.strip() for x in
                    opts.extra_customization[self.OPT_COLLECTIONS].split(',')]
        else:
            collections = []
        debug_print('PALADIN: collection fields:', collections)

        if booklists[0] is not None:
            self.update_device_database(booklists[0], collections, None)
        if len(booklists) > 1 and booklists[1] is not None:
            self.update_device_database(booklists[1], collections, 'carda')

        USBMS.sync_booklists(self, booklists, end_session=end_session)
        debug_print('PALADIN: finished sync_booklists')

    def update_device_database(self, booklist, collections_attributes, oncard):
        import apsw
        debug_print('PALADIN: starting update_device_database')

        plugboard = None
        if self.plugboard_func:
            plugboard = self.plugboard_func(self.__class__.__name__,
                    'device_db', self.plugboards)
            debug_print("PALADIN: Using Plugboard", plugboard)

        prefix = self._card_a_prefix if oncard == 'carda' else self._main_prefix
        if prefix is None:
            # Reader has no sd card inserted
            return
        source_id = 1 if oncard == 'carda' else 0

        dbpath = self.normalize_path(prefix + DBPATH)
        debug_print("SQLite DB Path: " + dbpath)

        collections = booklist.get_collections(collections_attributes)

        with closing(apsw.Connection(dbpath)) as connection:
            self.remove_orphaned_records(connection, dbpath)
            self.update_device_books(connection, booklist, source_id,
                    plugboard, dbpath)
            self.update_device_collections(connection, booklist, collections, source_id, dbpath)

        debug_print('PALADIN: finished update_device_database')

    def remove_orphaned_records(self, connection, dbpath):
        try:
            cursor = connection.cursor()

            debug_print("Removing Orphaned Collection Records")

            # Purge any collections references that point into the abyss
            query = 'DELETE FROM booktags WHERE book_id NOT IN (SELECT _id FROM books)'
            cursor.execute(query)
            query = 'DELETE FROM booktags WHERE tag_id NOT IN (SELECT _id FROM tags)'
            cursor.execute(query)

            debug_print("Removing Orphaned Book Records")

            cursor.close()
        except Exception:
            import traceback
            tb = traceback.format_exc()
            raise DeviceError((('The Paladin database is corrupted. '
                    ' Delete the file %s on your reader and then disconnect '
                    ' reconnect it. If you are using an SD card, you '
                    ' should delete the file on the card as well. Note that '
                    ' deleting this file will cause your reader to forget '
                    ' any notes/highlights, etc.')%dbpath)+' Underlying error:'
                    '\n'+tb)

    def get_database_min_id(self, source_id):
        sequence_min = 0
        if source_id == 1:
            sequence_min = 4294967296

        return sequence_min

    def set_database_sequence_id(self, connection, table, sequence_id):
        cursor = connection.cursor()

        # Update the sequence Id if it exists
        query = 'UPDATE sqlite_sequence SET seq = ? WHERE name = ?'
        t = (sequence_id, table,)
        cursor.execute(query, t)

        # Insert the sequence Id if it doesn't
        query = ('INSERT INTO sqlite_sequence (name, seq) '
                'SELECT ?, ? '
                'WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = ?)')
        cursor.execute(query, (table, sequence_id, table,))

        cursor.close()

    def read_device_books(self, connection, source_id, dbpath):
        sequence_min = self.get_database_min_id(source_id)
        sequence_max = sequence_min
        sequence_dirty = 0

        debug_print("Book Sequence Min: %d, Source Id: %d"%(sequence_min,source_id))

        try:
            cursor = connection.cursor()

            # Get existing books
            query = 'SELECT filename, _id FROM books'
            cursor.execute(query)
        except Exception:
            import traceback
            tb = traceback.format_exc()
            raise DeviceError((('The Paladin database is corrupted. '
                    ' Delete the file %s on your reader and then disconnect '
                    ' reconnect it. If you are using an SD card, you '
                    ' should delete the file on the card as well. Note that '
                    ' deleting this file will cause your reader to forget '
                    ' any notes/highlights, etc.')%dbpath)+' Underlying error:'
                    '\n'+tb)

        # Get the books themselves, but keep track of any that are less than the minimum.
        # Record what the max id being used is as well.
        db_books = {}
        for i, row in enumerate(cursor):
            if not hasattr(row[0], 'replace'):
                continue
            lpath = row[0].replace('\\', '/')
            db_books[lpath] = row[1]
            if row[1] < sequence_min:
                sequence_dirty = 1
            else:
                sequence_max = max(sequence_max, row[1])

        # If the database is 'dirty', then we should fix up the Ids and the sequence number
        if sequence_dirty == 1:
            debug_print("Book Sequence Dirty for Source Id: %d"%source_id)
            sequence_max = sequence_max + 1
            for book, bookId in db_books.items():
                if bookId < sequence_min:
                    # Record the new Id and write it to the DB
                    db_books[book] = sequence_max
                    sequence_max = sequence_max + 1

                    # Fix the Books DB
                    query = 'UPDATE books SET _id = ? WHERE filename = ?'
                    t = (db_books[book], book,)
                    cursor.execute(query, t)

                    # Fix any references so that they point back to the right book
                    t = (db_books[book], bookId,)
                    query = 'UPDATE booktags SET tag_id = ? WHERE tag_id = ?'
                    cursor.execute(query, t)

            self.set_database_sequence_id(connection, 'books', sequence_max)
            debug_print("Book Sequence Max: %d, Source Id: %d"%(sequence_max,source_id))

        cursor.close()
        return db_books

    def update_device_books(self, connection, booklist, source_id, plugboard,
            dbpath):
        from calibre.ebooks.metadata.meta import path_to_ext
        from calibre.ebooks.metadata import authors_to_sort_string, authors_to_string
        opts = self.settings()

        db_books = self.read_device_books(connection, source_id, dbpath)
        cursor = connection.cursor()

        for book in booklist:
            # Run through plugboard if needed
            if plugboard is not None:
                newmi = book.deepcopy_metadata()
                newmi.template_to_attribute(book, plugboard)
            else:
                newmi = book

            # Get Metadata We Want
            lpath = book.lpath
            try:
                if opts.use_author_sort:
                    if newmi.author_sort:
                        author = newmi.author_sort
                    else:
                        author = authors_to_sort_string(newmi.authors)
                else:
                    author = authors_to_string(newmi.authors)
            except Exception:
                author = _('Unknown')
            title = newmi.title or _('Unknown')

            # Get modified date
            # If there was a detected offset, use that. Otherwise use UTC (same as Sony software)
            modified_date = os.path.getmtime(book.path) * 1000
            if self.device_offset is not None:
                modified_date = modified_date + self.device_offset

            if lpath not in db_books:
                query = '''
                INSERT INTO books
                (bookname, authorname, description, addeddate, seriesname, seriesorder, filename, mimetype)
                values (?,?,?,?,?,?,?,?)
                '''
                t = (title, author, book.get('comments', None), int(time.time() * 1000),
                        book.get('series', None), book.get('series_index', sys.maxsize), lpath,
                        book.mime or mime_type_ext(path_to_ext(lpath)))
                cursor.execute(query, t)
                book.bookId = connection.last_insert_rowid()
                debug_print('Inserted New Book: (%u) '%book.bookId + book.title)
            else:
                query = '''
                UPDATE books
                SET bookname = ?, authorname = ?, addeddate = ?
                WHERE filename = ?
                '''
                t = (title, author, modified_date, lpath)
                cursor.execute(query, t)
                book.bookId = db_books[lpath]
                db_books[lpath] = None

        for book, bookId in db_books.items():
            if bookId is not None:
                # Remove From Collections
                query = 'DELETE FROM tags WHERE _id in (select tag_id from booktags where book_id = ?)'
                t = (bookId,)
                cursor.execute(query, t)
                # Remove from Books
                query = 'DELETE FROM books where _id = ?'
                t = (bookId,)
                cursor.execute(query, t)
                debug_print('Deleted Book:' + book)

        cursor.close()

    def read_device_collections(self, connection, source_id, dbpath):
        sequence_min = self.get_database_min_id(source_id)
        sequence_max = sequence_min
        sequence_dirty = 0

        debug_print("Collection Sequence Min: %d, Source Id: %d"%(sequence_min,source_id))

        try:
            cursor = connection.cursor()

            # Get existing collections
            query = 'SELECT _id, tagname FROM tags'
            cursor.execute(query)
        except Exception:
            import traceback
            tb = traceback.format_exc()
            raise DeviceError((('The Paladin database is corrupted. '
                    ' Delete the file %s on your reader and then disconnect '
                    ' reconnect it. If you are using an SD card, you '
                    ' should delete the file on the card as well. Note that '
                    ' deleting this file will cause your reader to forget '
                    ' any notes/highlights, etc.')%dbpath)+' Underlying error:'
                    '\n'+tb)

        db_collections = {}
        for i, row in enumerate(cursor):
            db_collections[row[1]] = row[0]
            if row[0] < sequence_min:
                sequence_dirty = 1
            else:
                sequence_max = max(sequence_max, row[0])

        # If the database is 'dirty', then we should fix up the Ids and the sequence number
        if sequence_dirty == 1:
            debug_print("Collection Sequence Dirty for Source Id: %d"%source_id)
            sequence_max = sequence_max + 1
            for collection, collectionId in db_collections.items():
                if collectionId < sequence_min:
                    # Record the new Id and write it to the DB
                    db_collections[collection] = sequence_max
                    sequence_max = sequence_max + 1

                    # Fix the collection DB
                    query = 'UPDATE tags SET _id = ? WHERE tagname = ?'
                    t = (db_collections[collection], collection, )
                    cursor.execute(query, t)

                    # Fix any references in existing collections
                    query = 'UPDATE booktags SET tag_id = ? WHERE tag_id = ?'
                    t = (db_collections[collection], collectionId,)
                    cursor.execute(query, t)

            self.set_database_sequence_id(connection, 'tags', sequence_max)
            debug_print("Collection Sequence Max: %d, Source Id: %d"%(sequence_max,source_id))

        # Fix up the collections table now...
        sequence_dirty = 0
        sequence_max = sequence_min

        debug_print("Collections Sequence Min: %d, Source Id: %d"%(sequence_min,source_id))

        query = 'SELECT _id FROM booktags'
        cursor.execute(query)

        db_collection_pairs = []
        for i, row in enumerate(cursor):
            db_collection_pairs.append(row[0])
            if row[0] < sequence_min:
                sequence_dirty = 1
            else:
                sequence_max = max(sequence_max, row[0])

        if sequence_dirty == 1:
            debug_print("Collections Sequence Dirty for Source Id: %d"%source_id)
            sequence_max = sequence_max + 1
            for pairId in db_collection_pairs:
                if pairId < sequence_min:
                    # Record the new Id and write it to the DB
                    query = 'UPDATE booktags SET _id = ? WHERE _id = ?'
                    t = (sequence_max, pairId,)
                    cursor.execute(query, t)
                    sequence_max = sequence_max + 1

            self.set_database_sequence_id(connection, 'booktags', sequence_max)
            debug_print("Collections Sequence Max: %d, Source Id: %d"%(sequence_max,source_id))

        cursor.close()
        return db_collections

    def update_device_collections(self, connection, booklist, collections,
            source_id, dbpath):

        if collections:
            db_collections = self.read_device_collections(connection, source_id, dbpath)
            cursor = connection.cursor()

            for collection, books in collections.items():
                if collection not in db_collections:
                    query = 'INSERT INTO tags (tagname) VALUES (?)'
                    t = (collection,)
                    cursor.execute(query, t)
                    db_collections[collection] = connection.last_insert_rowid()
                    debug_print('Inserted New Collection: (%u) '%db_collections[collection] + collection)

                # Get existing books in collection
                query = '''
                SELECT books.filename, book_id
                FROM booktags
                LEFT OUTER JOIN books
                WHERE tag_id = ? AND books._id = booktags.book_id
                '''
                t = (db_collections[collection],)
                cursor.execute(query, t)

                db_books = {}
                for i, row in enumerate(cursor):
                    db_books[row[0]] = row[1]

                for idx, book in enumerate(books):
                    if collection not in book.device_collections:
                        book.device_collections.append(collection)
                    if db_books.get(book.lpath, None) is None:
                        query = '''
                        INSERT INTO booktags (tag_id, book_id) values (?,?)
                        '''
                        t = (db_collections[collection], book.bookId)
                        cursor.execute(query, t)
                        debug_print('Inserted Book Into Collection: ' +
                                book.title + ' -> ' + collection)

                    db_books[book.lpath] = None

                for bookPath, bookId in db_books.items():
                    if bookId is not None:
                        query = ('DELETE FROM booktags '
                                'WHERE book_id = ? AND tag_id = ? ')
                        t = (bookId, db_collections[collection],)
                        cursor.execute(query, t)
                        debug_print('Deleted Book From Collection: ' + bookPath + ' -> ' + collection)

                db_collections[collection] = None

            for collection, collectionId in db_collections.items():
                if collectionId is not None:
                    # Remove Books from Collection
                    query = ('DELETE FROM booktags '
                            'WHERE tag_id = ?')
                    t = (collectionId,)
                    cursor.execute(query, t)
                    # Remove Collection
                    query = ('DELETE FROM tags '
                            'WHERE _id = ?')
                    t = (collectionId,)
                    cursor.execute(query, t)
                    debug_print('Deleted Collection: ' + repr(collection))

            cursor.close()

    def rebuild_collections(self, booklist, oncard):
        debug_print('PALADIN: starting rebuild_collections')

        opts = self.settings()
        if opts.extra_customization:
            collections = [x.strip() for x in
                    opts.extra_customization[self.OPT_COLLECTIONS].split(',')]
        else:
            collections = []
        debug_print('PALADIN: collection fields:', collections)

        self.update_device_database(booklist, collections, oncard)

        debug_print('PALADIN: finished rebuild_collections')
