"""
Sqlitecachedb.

Author(s): Jie Yang
"""
import logging
import os

import apsw
from apsw import CantOpenError, SQLError
from base64 import encodestring, decodestring
from threading import currentThread, RLock

from twisted.internet.task import LoopingCall
from twisted.python.threadable import isInIOThread

from Tribler.Core.CacheDB.db_versions import LATEST_DB_VERSION
from Tribler.Core.Utilities.install_dir import get_lib_path
from Tribler.pyipv8.ipv8.taskmanager import TaskManager
from Tribler.pyipv8.ipv8.util import blocking_call_on_reactor_thread

DB_SCRIPT_NAME = "schema_sdb_v%s.sql" % str(LATEST_DB_VERSION)

DB_DIR_NAME = u"sqlite"
DB_FILE_NAME = u"tribler.sdb"
DB_FILE_RELATIVE_PATH = os.path.join(DB_DIR_NAME, DB_FILE_NAME)
DB_SCRIPT_ABSOLUTE_PATH = os.path.join(get_lib_path(), 'Core', 'CacheDB', DB_SCRIPT_NAME)

DEFAULT_BUSY_TIMEOUT = 10000

forceDBThread = blocking_call_on_reactor_thread
forceAndReturnDBThread = blocking_call_on_reactor_thread


class CorruptedDatabaseError(Exception):
    pass


def bin2str(bin_data):
    return encodestring(bin_data).replace("\n", "")


def str2bin(str_data):
    return decodestring(str_data)


class SQLiteCacheDB(TaskManager):

    def __init__(self, db_path, db_script_path=DB_SCRIPT_ABSOLUTE_PATH, busytimeout=DEFAULT_BUSY_TIMEOUT):
        super(SQLiteCacheDB, self).__init__()

        self._logger = logging.getLogger(self.__class__.__name__)

        self._cursor_lock = RLock()
        self._cursor_table = {}

        self._connection = None
        self.sqlite_db_path = db_path
        self.db_script_path = db_script_path
        self._busytimeout = busytimeout  # busytimeout is in milliseconds

        self._version = None

        self._should_commit = False
        self._show_execute = False
        self.initialize()
        self.initial_begin()

    @property
    def version(self):
        """The version of this database."""
        return self._version

    @property
    def connection(self):
        """
        Returns the connection of the database, which may be None if not initialized or closed.
        :return: The connection object of the database
        """
        return self._connection

    @blocking_call_on_reactor_thread
    def initialize(self):
        """ Initializes the database. If the database doesn't exist, we create a new one. Otherwise, we check the
            version and upgrade to the latest version.
        """

        # open a connection to the database
        self._open_connection()

    @blocking_call_on_reactor_thread
    def close(self):
        """
        Cancels all pending tasks and closes all cursors. Then, it closes the connection.
        """
        self.shutdown_task_manager()
        with self._cursor_lock:
            self.commit_now(exiting=True)
            for cursor in self._cursor_table.itervalues():
                cursor.close()
            self._cursor_table = {}
            self._connection.close()
            self._connection = None

    def _open_connection(self):
        """ Opens a connection to the database. If the database doesn't exist, we create a new one and run the
            initialization SQL scripts. If the database doesn't exist, we simply connect to it.
            And finally, we read the database version.
        """
        # check if it is in memory
        is_in_memory = self.sqlite_db_path == u":memory:"
        is_new_db = is_in_memory

        # check if database file exists
        if not is_in_memory:
            if not os.path.exists(self.sqlite_db_path):
                # create a new one
                is_new_db = True
            elif not os.path.isfile(self.sqlite_db_path):
                msg = u"Not a file: %s" % self.sqlite_db_path
                raise OSError(msg)

        # create connection
        try:
            self._connection = apsw.Connection(self.sqlite_db_path)
            self._connection.setbusytimeout(self._busytimeout)
        except CantOpenError as e:
            msg = u"Failed to open connection to %s: %s" % (self.sqlite_db_path, e)
            raise CantOpenError(msg)

        cursor = self.get_cursor()

        # Check integrity of the database only if there is Walk-Ahead Log (WAL) or Shared-Memory (shm) files present
        shm_file = "%s-shm" % self.sqlite_db_path
        wal_file = "%s-wal" % self.sqlite_db_path
        if os.path.exists(shm_file) or os.path.exists(wal_file):
            self.do_quick_integrity_check()

        # Enable memory map in sqlite (256MB)
        cursor.execute(u"PRAGMA mmap_size=268435456;")

        # apply pragma
        page_size, = next(cursor.execute(u"PRAGMA page_size"))
        if page_size < 8192:
            # journal_mode and page_size only need to be set once.  because of the VACUUM this
            # is very expensive
            self._logger.info(u"begin page_size upgrade...")
            cursor.execute(u"PRAGMA journal_mode = DELETE;")
            cursor.execute(u"PRAGMA page_size = 8192;")
            cursor.execute(u"VACUUM;")
            self._logger.info(u"...end page_size upgrade")

        # http://www.sqlite.org/pragma.html
        # When synchronous is NORMAL, the SQLite database engine will still
        # pause at the most critical moments, but less often than in FULL
        # mode. There is a very small (though non-zero) chance that a power
        # failure at just the wrong time could corrupt the database in
        # NORMAL mode. But in practice, you are more likely to suffer a
        # catastrophic disk failure or some other unrecoverable hardware
        # fault.
        #
        cursor.execute(u"PRAGMA synchronous = NORMAL;")
        cursor.execute(u"PRAGMA cache_size = 10000;")

        # Niels 19-09-2012: even though my database upgraded to increase the pagesize it did not keep wal mode?
        # Enabling WAL on every starup
        cursor.execute(u"PRAGMA journal_mode = WAL;")

        # create tables if this is a new database
        if is_new_db and self.db_script_path is not None:
            self._logger.info(u"Initializing new database...")
            # check if the SQL script exists
            if not os.path.exists(self.db_script_path):
                msg = u"SQL script doesn't exist: %s" % self.db_script_path
                raise OSError(msg)
            if not os.path.isfile(self.db_script_path):
                msg = u"SQL script is not a file: %s" % self.db_script_path
                raise OSError(msg)

            try:
                f = open(self.db_script_path, "r")
                sql_script = f.read()
                f.close()
            except IOError as e:
                msg = u"Failed to load SQL script %s: %s" % (self.db_script_path, e)
                raise IOError(msg)

            cursor.execute(sql_script)

        if self.db_script_path is not None:
            # read database version
            self._logger.info(u"Reading database version...")
            try:
                version_str, = cursor.execute(u"SELECT value FROM MyInfo WHERE entry == 'version'").next()
                self._version = int(version_str)
                self._logger.info(u"Current database version is %s", self._version)
            except (StopIteration, SQLError) as e:
                msg = u"Failed to load database version: %s" % e
                raise CorruptedDatabaseError(msg)
        else:
            self._version = 1

    def do_quick_integrity_check(self):
        check_response, = self.execute(u"PRAGMA quick_check").next()
        if check_response != 'ok':
            msg = u"Quick integrity check of database failed"
            self._logger.error(msg)
            raise CorruptedDatabaseError(msg)

    def get_cursor(self):
        thread_name = currentThread().getName()

        with self._cursor_lock:
            if thread_name not in self._cursor_table:
                self._cursor_table[thread_name] = self._connection.cursor()
            return self._cursor_table[thread_name]

    @blocking_call_on_reactor_thread
    def initial_begin(self):
        try:
            self._logger.info(u"Beginning the first transaction...")
            self.execute(u"BEGIN;")

        except:
            self._logger.exception(u"Failed to begin the first transaction")
            raise
        self._should_commit = False

    @blocking_call_on_reactor_thread
    def write_version(self, version):
        assert isinstance(version, int), u"Invalid version type: %s is not int" % type(version)
        assert version <= LATEST_DB_VERSION, u"Invalid version value: %s > the latest %s" % (version, LATEST_DB_VERSION)

        sql = u"UPDATE MyInfo SET value = ? WHERE entry == 'version'"
        self.execute_write(sql, (version,))
        self.commit_now()
        self._version = version

    @blocking_call_on_reactor_thread
    def commit_now(self, vacuum=False, exiting=False):
        if self._should_commit and isInIOThread():
            try:
                self._logger.info(u"Start committing...")
                self.execute(u"COMMIT;")
            except:
                self._logger.exception(u"COMMIT FAILED")
                if exiting:
                    # If we are exiting we don't propagate the error.
                    # The reason for the exit may be the reason this exception occurred.
                    self._logger.exception(u"Not propagating commit error, as we are exiting")
                    return
                else:
                    raise
            self._should_commit = False

            if vacuum:
                self._logger.info(u"Start vacuuming...")
                self.execute(u"VACUUM;")

            if not exiting:
                try:
                    self._logger.info(u"Beginning another transaction...")
                    self.execute(u"BEGIN;")
                except:
                    self._logger.exception(u"Failed to execute BEGIN")
                    raise
            else:
                self._logger.info(u"Exiting, not beginning another transaction")

        elif vacuum:
            self.execute(u"VACUUM;")

    def clean_db(self, vacuum=False, exiting=False):
        self.execute_write(u"DELETE FROM TorrentFiles WHERE torrent_id IN (SELECT torrent_id FROM CollectedTorrent)")
        self.execute_write(u"DELETE FROM Torrent WHERE name IS NULL"
                           u" AND torrent_id NOT IN (SELECT torrent_id FROM _ChannelTorrents)")

        if vacuum:
            self.commit_now(vacuum, exiting=exiting)

    def set_show_sql(self, switch):
        self._show_execute = switch

    # --------- generic functions -------------

    @blocking_call_on_reactor_thread
    def execute(self, sql, args=None):
        cur = self.get_cursor()

        if self._show_execute:
            thread_name = currentThread().getName()
            self._logger.info(u"===%s===\n%s\n-----\n%s\n======\n", thread_name, sql, args)

        try:
            if args is None:
                return cur.execute(sql)
            else:
                return cur.execute(sql, args)

        except Exception as msg:
            if str(msg).startswith(u"BusyError"):
                self._logger.error(u"cachedb: busylock error")

            else:
                thread_name = currentThread().getName()
                self._logger.exception(u"cachedb: ===%s===\nSQL Type: %s\n-----\n%s\n-----\n%s\n======\n",
                                       thread_name, type(sql), sql, args)

            raise msg

    @blocking_call_on_reactor_thread
    def executemany(self, sql, args=None):
        self._should_commit = True

        cur = self.get_cursor()
        if self._show_execute:
            thread_name = currentThread().getName()
            self._logger.info(u"===%s===\n%s\n-----\n%s\n======\n", thread_name, sql, args)

        try:
            if args is None:
                result = cur.executemany(sql)
            else:
                result = cur.executemany(sql, args)

            return result

        except Exception as msg:
            thread_name = currentThread().getName()
            self._logger.exception(u"===%s===\nSQL Type: %s\n-----\n%s\n-----\n%s\n======\n",
                                   thread_name, type(sql), sql, args)
            raise msg

    def execute_read(self, sql, args=None):
        return self.execute(sql, args)

    def execute_write(self, sql, args=None):
        self._should_commit = True

        self.execute(sql, args)

    def insert_or_ignore(self, table_name, **argv):
        if len(argv) == 1:
            sql = u'INSERT OR IGNORE INTO %s (%s) VALUES (?);' % (table_name, argv.keys()[0])
        else:
            questions = '?,' * len(argv)
            sql = u'INSERT OR IGNORE INTO %s %s VALUES (%s);' % (table_name, tuple(argv.keys()), questions[:-1])
        self.execute_write(sql, argv.values())

    def insert(self, table_name, **argv):
        if len(argv) == 1:
            sql = u'INSERT INTO %s (%s) VALUES (?);' % (table_name, argv.keys()[0])
        else:
            questions = '?,' * len(argv)
            sql = u'INSERT INTO %s %s VALUES (%s);' % (table_name, tuple(argv.keys()), questions[:-1])
        self.execute_write(sql, argv.values())

    # TODO: may remove this, only used by test_sqlitecachedb.py
    def insertMany(self, table_name, values, keys=None):
        """ values must be a list of tuples """

        questions = u'?,' * len(values[0])
        if keys is None:
            sql = u'INSERT INTO %s VALUES (%s);' % (table_name, questions[:-1])
        else:
            sql = u'INSERT INTO %s %s VALUES (%s);' % (table_name, tuple(keys), questions[:-1])
        self.executemany(sql, values)

    def update(self, table_name, where=None, **argv):
        assert len(argv) > 0, 'NO VALUES TO UPDATE SPECIFIED'
        if len(argv) > 0:
            sql = u'UPDATE %s SET ' % table_name
            arg = []
            for k, v in argv.iteritems():
                if isinstance(v, tuple):
                    sql += u'%s %s ?,' % (k, v[0])
                    arg.append(v[1])
                else:
                    sql += u'%s=?,' % k
                    arg.append(v)
            sql = sql[:-1]
            if where is not None:
                sql += u' WHERE %s' % where
            self.execute_write(sql, arg)

    def delete(self, table_name, **argv):
        sql = u'DELETE FROM %s WHERE ' % table_name
        arg = []
        for k, v in argv.iteritems():
            if isinstance(v, tuple):
                sql += u'%s %s ? AND ' % (k, v[0])
                arg.append(v[1])
            else:
                sql += u'%s=? AND ' % k
                arg.append(v)
        sql = sql[:-5]
        self.execute_write(sql, arg)

    # -------- Read Operations --------
    def size(self, table_name):
        num_rec_sql = u"SELECT count(*) FROM %s LIMIT 1" % table_name
        result = self.fetchone(num_rec_sql)
        return result

    @blocking_call_on_reactor_thread
    def fetchone(self, sql, args=None):
        find = self.execute_read(sql, args)
        if not find:
            return
        else:
            find = list(find)
            if len(find) > 0:
                if len(find) > 1:
                    self._logger.debug(
                        u"FetchONE resulted in many more rows than one, consider putting a LIMIT 1 in the sql statement %s, %s", sql, len(find))
                find = find[0]
            else:
                return
        if len(find) > 1:
            return find
        else:
            return find[0]

    @blocking_call_on_reactor_thread
    def fetchall(self, sql, args=None):
        res = self.execute_read(sql, args)
        if res is not None:
            find = list(res)
            return find
        else:
            return []  # should it return None?

    def getOne(self, table_name, value_name, where=None, conj=u"AND", **kw):
        """ value_name could be a string, a tuple of strings, or '*'
        """
        if isinstance(value_name, tuple):
            value_names = u",".join(value_name)
        elif isinstance(value_name, list):
            value_names = u",".join(value_name)
        else:
            value_names = value_name

        if isinstance(table_name, tuple):
            table_names = u",".join(table_name)
        elif isinstance(table_name, list):
            table_names = u",".join(table_name)
        else:
            table_names = table_name

        sql = u'SELECT %s FROM %s' % (value_names, table_names)

        if where or kw:
            sql += u' WHERE '
        if where:
            sql += where
            if kw:
                sql += u' %s ' % conj
        if kw:
            arg = []
            for k, v in kw.iteritems():
                if isinstance(v, tuple):
                    operator = v[0]
                    arg.append(v[1])
                else:
                    operator = "="
                    arg.append(v)
                sql += u' %s %s ? ' % (k, operator)
                sql += conj
            sql = sql[:-len(conj)]
        else:
            arg = None

        # print >> sys.stderr, 'SQL: %s %s' % (sql, arg)
        return self.fetchone(sql, arg)

    def getAll(self, table_name, value_name, where=None, group_by=None, having=None, order_by=None, limit=None,
               offset=None, conj=u"AND", **kw):
        """ value_name could be a string, or a tuple of strings
            order by is represented as order_by
            group by is represented as group_by
        """
        if isinstance(value_name, tuple):
            value_names = u",".join(value_name)
        elif isinstance(value_name, list):
            value_names = u",".join(value_name)
        else:
            value_names = value_name

        if isinstance(table_name, tuple):
            table_names = u",".join(table_name)
        elif isinstance(table_name, list):
            table_names = u",".join(table_name)
        else:
            table_names = table_name

        sql = u'SELECT %s FROM %s' % (value_names, table_names)

        if where or kw:
            sql += u' WHERE '
        if where:
            sql += where
            if kw:
                sql += u' %s ' % conj
        if kw:
            arg = []
            for k, v in kw.iteritems():
                if isinstance(v, tuple):
                    operator = v[0]
                    arg.append(v[1])
                else:
                    operator = u"="
                    arg.append(v)

                sql += u' %s %s ?' % (k, operator)
                sql += conj
            sql = sql[:-len(conj)]
        else:
            arg = None

        if group_by is not None:
            sql += u' GROUP BY ' + group_by
        if having is not None:
            sql += u' HAVING ' + having
        if order_by is not None:
            # you should add desc after order_by to reversely sort, i.e, 'last_seen desc' as order_by
            sql += u' ORDER BY ' + order_by
        if limit is not None:
            sql += u' LIMIT %d' % limit
        if offset is not None:
            sql += u' OFFSET %d' % offset

        try:
            return self.fetchall(sql, arg) or []
        except Exception as msg:
            self._logger.exception(u"Wrong getAll sql statement: %s", sql)
            raise Exception(msg)
