# -*- coding: utf-8 -*-
# Moovida - Home multimedia server
# Copyright (C) 2006-2009 Fluendo Embedded S.L. (www.fluendo.com).
# All rights reserved.
#
# This file is available under one of two license agreements.
#
# This file is licensed under the GPL version 3.
# See "LICENSE.GPL" in the root of this distribution including a special
# exception to use Moovida with Fluendo's plugins.
#
# The GPL part of Moovida is also available under a commercial licensing
# agreement from Fluendo.
# See "LICENSE.Moovida" in the root directory of this distribution package
# for details on that license.
#
# Author: Benjamin Kampmann <benjamin@fluendo.com>

"""
helpers to bring the database to latest state of the art
"""

from elisa.core.common import application

from twisted.internet import task, defer
from distutils.version import LooseVersion

import time, re, platform, sys

DATABASE_LOG_TABLE_NAME = "database_updates_log"

SCHEMA = [
        "CREATE TABLE IF NOT EXISTS music_albums" \
                " (name VARCHAR COLLATE NOCASE PRIMARY KEY, cover_uri VARCHAR," \
                " release_date VARCHAR, artist_name VARCHAR);",
        "CREATE TABLE IF NOT EXISTS artists"
                " (name VARCHAR COLLATE NOCASE PRIMARY KEY, image_uri VARCHAR);",
        "CREATE TABLE IF NOT EXISTS track_artists" \
                " (artist_name VARCHAR, track_path VARCHAR," \
                " PRIMARY KEY (artist_name, track_path));",
        "CREATE TABLE IF NOT EXISTS music_tracks" \
                " (file_path VARCHAR PRIMARY KEY, title VARCHAR,"\
                " track_number INTEGER, album_name VARCHAR, "\
                " duration INTEGER, genre VARCHAR);",
        "CREATE TABLE IF NOT EXISTS tags"\
                " (name VARCHAR PRIMARY KEY);",
        "CREATE TABLE IF NOT EXISTS files" \
                " (path VARCHAR PRIMARY KEY, source VARCHAR," \
                " modification_time INTEGER DEFAULT 0, mime_type VARCHAR," \
                " hidden INTEGER DEFAULT 0," \
                " deleted INTEGER, playcount INTEGER DEFAULT 0, last_played" \
                " INTEGER DEFAULT 0);",
        "CREATE TABLE IF NOT EXISTS file_tags" \
                " (file_path VARCHAR, tag_name VARCHAR,"\
                " PRIMARY KEY(file_path, tag_name));",
        "CREATE TABLE IF NOT EXISTS images"\
                " (file_path VARCHAR PRIMARY KEY, size VARCHAR, " \
                " shot_time INTEGER, with_flash INTEGER DEFAULT 0," \
                " orientation INTEGER, gps_altitude INTEGER, " \
                " gps_latitude VARCHAR, gps_longitude VARCHAR," \
                " album_name VARCHAR, section INTEGER DEFAULT 0);",
        "CREATE TABLE IF NOT EXISTS photo_albums"\
                " (name VARCHAR COLLATE NOCASE PRIMARY KEY, preview_uri VARCHAR);",
        "CREATE TABLE IF NOT EXISTS videos"\
                " (file_path VARCHAR PRIMARY KEY, name VARCHAR,"\
                " creation_time INTEGER, size VARCHAR, duration VARCHAR,"\
                " codec VARCHAR, thumbnail_uri VARCHAR);",

        "CREATE TABLE IF NOT EXISTS movies"\
                " (file_path VARCHAR PRIMARY KEY, title VARCHAR COLLATE NOCASE,"\
                " release_date VARCHAR, backdrop_uri VARCHAR,"\
                " metadata_lang_code VARCHAR,"\
                " runtime INTEGER,"\
                " user_rating FLOAT, budget INTEGER, revenue INTEGER,"\
                " imdb_id TEXT,"\
                " cover_uri VARCHAR, short_overview VARCHAR);",

        "CREATE TABLE IF NOT EXISTS tvshows"\
                " (id INTEGER PRIMARY KEY, name VARCHAR COLLATE NOCASE,"\
                "  poster_uri VARCHAR, "\
                "  fanart_uri VARCHAR, "\
                "  filesystem_name VARCHAR, thetvdb_id INTEGER, "\
                "  metadata_lang_code VARCHAR);",

        "CREATE TABLE IF NOT EXISTS tvseasons"\
                " (id INTEGER PRIMARY KEY, number INTEGER,"\
                " tvshow_id INTEGER);",

        "CREATE TABLE IF NOT EXISTS tvepisodes"\
                " (file_path VARCHAR PRIMARY KEY,"\
                " name VARCHAR COLLATE NOCASE, guest_stars VARCHAR, overview VARCHAR,"\
                " number INTEGER, season_id INTEGER, poster_uri VARCHAR);",

        "CREATE TABLE IF NOT EXISTS tvseason_posters"\
                " (id INT PRIMARY KEY, uri VARCHAR,"\
                " show_id INTEGER, season_number INTEGER, lang_id VARCHAR);",

        # the table to store conditional upates:
        "CREATE TABLE %s (name VARCHAR PRIMARY KEY, update_stamp INTEGER);" % \
                DATABASE_LOG_TABLE_NAME,
        ]

class DatabaseUpdaterClassic(object):
    """
    This should not be touched with the last update in 0.5.19. It only brings
    the database to the state for that release. For everything newer the
    L{DatabaseUpdaterNG} should be used.
    """
    def __init__(self, store):
        self.store = store

    def get_columns(self, result):
        return [ x[1] for x in result ]

    def get_all(self, result):
        return result.get_all()

    def sqlexec(self, string):
        return self.store.execute(string)
        
    def get_table_info(self, table_name):
        dfr = self.sqlexec("PRAGMA table_info('%s')" % table_name)
        dfr.addCallback(self.get_all)
        dfr.addCallback(self.get_columns)
        return dfr

    def got_files(self, result):
        commit = False
        if not u'playcount' in result:
            self.sqlexec("ALTER TABLE files ADD playcount INTEGER DEFAULT 0;")
            commit = True
        if not u'last_played' in result:
            self.sqlexec("ALTER TABLE files ADD last_played INTEGER DEFAULT 0;")
            commit = True
        if u'hidden' not in result:
            self.sqlexec("ALTER TABLE files ADD hidden INTEGER DEFAULT 0;")
            commit = True

        if commit:
            return self.store.commit()

    def update_image_albums(self, result):
        # we remove the photo albums that are not related to images we
        # got from the pictures section. That fixes databases affected
        # by #259427.
        self.sqlexec("DELETE FROM photo_albums WHERE name NOT IN " \
                "(SELECT DISTINCT album_name FROM images "\
                "WHERE section=%d)" % PICTURES_SECTION)
        return self.store.commit()

    def update_image_section(self, result=None):
        def build_update_query(self, section, path_list):
            path = path_list[0]
            query = "UPDATE images SET section = %d" \
                    " WHERE file_path LIKE \"%s%%\"" % \
                                         (section, os.path.join(path, ''))
            for path in path_list[1:]:
                query += " OR file_path LIKE \"%s%%" % \
                                          os.path.join(path, '')
            return query
            # FIXME: blocks
        # FIXME: doesn't handle images that are in more than one
        # section
        directories_section = application.config.get_section \
                                                      ('directories')
        for section_name, section in (('music', MUSIC_SECTION),
                                      ('video', VIDEO_SECTION),
                                      ('pictures', PICTURES_SECTION)):
            path_list = directories_section.get(section_name, [])
            if path_list != []:
                query = build_update_query(section, path_list)
                self.sqlexec(query)

        dfr =  self.store.commit()
        dfr.addCallback(update_image_albums)
        return dfr

    def got_images(self, result):

        if not 'shot_time' in result:
            # shot_time is part of the first update we did for the image
            # table. as it is not there, we have to update the table
            # FIXME: this way to identify an Database Update is not nice
            self.sqlexec("ALTER TABLE images ADD shot_time INTEGER;")
            self.sqlexec("ALTER TABLE images ADD with_flash INTEGER DEFAULT 0;")
            self.sqlexec("ALTER TABLE images ADD orientation INTEGER;")
            self.sqlexec("ALTER TABLE images ADD gps_altitude INTEGER;")
            self.sqlexec("ALTER TABLE images ADD gps_latitude VARCHAR;")
            self.sqlexec("ALTER TABLE images ADD gps_longitude VARCHAR;")
            self.sqlexec("ALTER TABLE images ADD album_name VARCHAR;")
            self.sqlexec("ALTER TABLE images ADD section INTEGER DEFAULT 0;")
            dfr = self.store.commit()
            dfr.addCallback(self.update_image_section)
            return dfr

        elif not 'section' in result:
            self.sqlexec("ALTER TABLE images ADD section INTEGER DEFAULT 0;")
            dfr = self.store.commit()
            dfr.addCallback(salf.update_image_section)
            return dfr

    def got_favorites(self, result):
        if not 'type' in result:
            self.sqlexec("ALTER TABLE favorites ADD type TEXT;")
            return self.store.commit()

    def got_albums(self, result):
        if not u'release_date' in result:
            self.sqlexec("ALTER TABLE music_albums ADD release_date VARCHAR;")
            return self.store.commit()
    
    def got_videos(self, result):
        if not u'name' in result:
            self.sqlexec("ALTER TABLE videos ADD name VARCHAR;")
            return self.store.commit()
        if not u'creation_time' in result:
            self.sqlexec("ALTER TABLE videos ADD creation_time INTEGER;")
            return self.store.commit()

    def do_files_update(self, result=None):
        dfr = self.get_table_info('files')
        dfr.addCallback(self.got_files)
        return dfr

    def do_albums_update(self, old_res=None):
        dfr = self.get_table_info('music_albums')
        dfr.addCallback(self.got_albums)
        return dfr

    def do_images_update(self, old_res=None):
        dfr = self.get_table_info("images")
        dfr.addCallback(self.got_images)
        return dfr

    def do_favorites_update(self, old_res=None):
        dfr = self.get_table_info("favorites")
        dfr.addCallback(self.got_favorites)
        return dfr
    
    def do_videos_update(self, old_res=None):
        dfr = self.get_table_info("videos")
        dfr.addCallback(self.got_videos)
        return dfr

    def set_user_version(self, old_res):
        return self.sqlexec("PRAGMA user_version=1;")

    def update_to_latest(self):
        dfr = self.do_files_update()
        dfr.addCallback(self.do_albums_update)
        dfr.addCallback(self.do_images_update)
        dfr.addCallback(self.do_videos_update)
        dfr.addCallback(self.do_favorites_update)
        dfr.addCallback(self.set_user_version)
        return dfr

def _return_true(result):
    return True

def _find_typefinder(name):
    try:
        import gst
    except ImportError:
        # what is wrong with you?
        return

    reg = gst.registry_get_default()
    factories = reg.get_feature_list(gst.TypeFindFactory)

    for factory in factories:
        if factory.get_name() == name:
            return factory 

def msdoc_typefinder_fix(store):
    if _find_typefinder('application/msword') is None:
        # typefinder not yet there, wait with the update
        return defer.succeed(False)

    def delete_from_videos(result):
        return store.execute("DELETE FROM videos where file_path LIKE" \
            " '%.doc' or file_path LIKE '%.xls' or file_path LIKE '%.pps';")

    def delete_from_music_tracks(result):
        return store.execute("DELETE FROM music_tracks where file_path" \
            " LIKE '%.doc' or file_path LIKE '%.xls' or file_path LIKE '%.pps';")

    # typefinder found, invalidate the possible wrong ones: 
    invalidate_sql = "UPDATE files SET modification_time=0" \
        " where mime_type = 'video/mpeg' and (path LIKE '%.doc'" \
        " or path LIKE '%.xls' or path LIKE '%.pps');"

    dfr = store.execute(invalidate_sql)
    dfr.addCallback(delete_from_videos)
    dfr.addCallback(delete_from_music_tracks)
    dfr.addCallback(_return_true)
    return dfr

def dsstore_typefinder_fix(store):
    if _find_typefinder('application/octet-stream') is None:
        # typefinder not yet there, wait with the update
        return defer.succeed(False)

    def delete_from_videos(result):
        return store.execute("DELETE FROM videos WHERE file_path" \
                " LIKE '%DS_Store';")

    # typefinder found, invalidate the possible wrong ones
    invalidate_sql = "UPDATE files SET modification_time=0" \
            " where mime_type = 'video/mpeg' and path LIKE '%DS_Store';"

    dfr = store.execute(invalidate_sql)
    dfr.addCallback(delete_from_videos)
    dfr.addCallback(_return_true)
    return dfr

def gst_xp_non_ascii_chars_fix(store):
    # See https://bugs.launchpad.net/elisa/+bug/379439. If this is fixed, we
    # need to rescan files that weren't identified

    #FIXME: find a way to differentiate XP from Vista
    if platform.system() != 'Windows':
        # This bug only happenned on windows
        return defer.succeed(True)
    if sys.getwindowsversion()[0] >= 6: # Vista or above
        return defer.succeed(True)

    min_versions = [('elisa-plugin-gstreamer', '0.5'),
                    ('elisa-plugin-codecs', '0.1.100'),
                    ('elisa-plugin-poblesec', '0.14')]

    # return False if we don't have all the pieces needed to rescan properly
    for plugin_name, version in min_versions:
        min_version = LooseVersion(version)
        plugin = application.plugin_registry.get_plugin_by_name(plugin_name)
        current_version = LooseVersion(plugin.version)
        if current_version < min_version:
            return defer.succeed(False)

    remove_badly_scanned = "DELETE FROM files WHERE mime_type IS NULL;"

    dfr = store.execute(remove_badly_scanned)
    dfr.addCallback(_return_true)
    return dfr

class DatabaseUpdaterNG(object):
    """
    From release 0.5.19 we store the current database version inside the
    database and go step by step (aka. release by release) up to the version we
    currently need. That is done in this class. For everything before 0.5.19,
    the L{DatabaseUpdaterClassic} will be used
    """
    current_version = 6

    # FIXME: make this more dynamic, maybe with entry_points or something
    conditional_updates = {
            'msdoc_typefinder_fix' : msdoc_typefinder_fix,
            'dsstore_typefinder_fix' : dsstore_typefinder_fix,
            'gst_xp_non_ascii_chars_fix': gst_xp_non_ascii_chars_fix,
            }

    database_log_table = DATABASE_LOG_TABLE_NAME 

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

    def get_user_version(self):
        return self._get_pragma_version('user')

    def get_schema_version(self):
        return self._get_pragma_version('schema')

    def _update_done(self, res):
        return self.store.execute("PRAGMA user_version=%s" %
                self.current_version)

    def _get_pragma_version(self, lookup):
        def get_all(result):
            return result.get_all()

        def get_version(result):
            return result[0][0]

        dfr = self.store.execute("PRAGMA %s_version;" % lookup)
        dfr.addCallback(get_all)
        dfr.addCallback(get_version)
        return dfr

    def _schema_version_check(self, version):
        def _classic_run_done(result):
            # the classic run updates to version 1, after that we have to
            # upgrade to what ever the current is.
            return self.update_from(1)
            
        if version == 0:
            # nothing done yet
            return self.create_schema()
        else:
            # do the classic updates
            dfr = self._run_classic()
            dfr.addCallback(_classic_run_done)
            dfr.addCallback(self.do_conditional_updates)
            return dfr

    def _run_classic(self):
        classic = DatabaseUpdaterClassic(self.store)
        return classic.update_to_latest()

    def update_from(self, version):
        def iterate(version):
            for v in xrange(version + 1, self.current_version + 1):
                method = getattr(self, "_upgrade_to_%s" % v)
                yield method()

        dfr = task.coiterate(iterate(version))
        dfr.addCallback(self._update_done)
        return dfr

    def create_schema(self):
        def go_through(queries):
            for query in queries:
                dfr = self.store.execute(query)
                yield dfr

        dfr = task.coiterate(go_through(SCHEMA))
        dfr.addCallback(self._update_done)
        dfr.addCallback(lambda result: self.store.commit())

        return dfr

    def do_conditional_updates(self, old_result=None):

        def add_done(done, name):
            if not done:
                # the conditions didn't apply (yet)
                return

            sql = "INSERT INTO %s(name, update_stamp) VALUES('%s', %d);" % \
                    (self.database_log_table, name, int(time.time()))
            return self.store.execute(sql)

        def iterate(already_done):
            for name, callback in self.conditional_updates.iteritems():
                if name in already_done:
                    # we already did that one
                    continue

                dfr = callback(self.store)
                dfr.addCallback(add_done, name)
                yield dfr

        def start_looping(already_done):
            return task.coiterate(iterate(already_done))

        def got_result(result):
            return [ x[0] for x in result ]

        def get_result(result):
            return result.get_all()

        dfr = self.store.execute("SELECT name from %s;" % self.database_log_table)
        dfr.addCallback(get_result)
        dfr.addCallback(got_result)
        dfr.addCallback(start_looping)
        return dfr

    def update_db(self):
        def got_user_version(version):
            if version == self.current_version:
                # version is, o.k. but we want it to try the conditional
                # updates
                return self.do_conditional_updates()

            elif version == 0:
                # the case of version = 0 is special as it could mean both:
                #  - nothing is there yet, so we just create the current schema
                #  - it was the classic update stuff
                # to figure that out, we look up "schema_version" the internal
                # variable, where sqlite is counting the create-table statements

                dfr = self.get_schema_version()
                dfr.addCallback(self._schema_version_check)

            else:
                # in any other case: go the step wise upgrade up to the current
                # version
                dfr = self.update_from(version)
                dfr.addCallback(self.do_conditional_updates)

            return dfr

        dfr = self.get_user_version()
        dfr.addCallback(got_user_version)
        return dfr

    # specific update steps

    def _upgrade_to_2(self):
        """
        This method updates the database user version to number two. The new
        feature is "keeping track about conditional updates". This allows us to
        fix problems we have found in the database and keep trac about the fact
        that we did that fix so that we don't do it again.
        """
        create_statement =  "CREATE TABLE %s" \
                " (name VARCHAR PRIMARY KEY, update_stamp INTEGER);"

        dfr = self.store.execute(create_statement % self.database_log_table)
        return dfr

    def _upgrade_to_3(self):
        """
        Upgrades the database to version 3. The new tables are movies,
        tvseasons and tvepisodes.
        """
        stmts = ["CREATE TABLE IF NOT EXISTS movies"\
                 " (file_path VARCHAR PRIMARY KEY, title VARCHAR,"\
                 " release_date VARCHAR, backdrop_uri VARCHAR,"\
                 " metadata_lang_code VARCHAR,"\
                 " runtime INTEGER, crew BLOB, production_countries BLOB,"\
                 " user_rating FLOAT, budget INTEGER, revenue INTEGER,"\
                 " imdb_id TEXT,"\
                 " cover_uri VARCHAR, short_overview VARCHAR);",

                 "CREATE TABLE IF NOT EXISTS tvshows"\
                 " (id INTEGER PRIMARY KEY, name VARCHAR, poster_uri VARCHAR, "\
                 "  season_posters BLOB, thetvdb_id INTEGER, "\
                 "  filesystem_name VARCHAR, metadata_lang_code VARCHAR);",

                 "CREATE TABLE IF NOT EXISTS tvseasons"\
                 " (id INTEGER PRIMARY KEY, number INTEGER,"\
                 " tvshow_id INTEGER);",

                 "CREATE TABLE IF NOT EXISTS tvepisodes"\
                 " (file_path VARCHAR PRIMARY KEY,"\
                 " name VARCHAR, guest_stars VARCHAR, overview VARCHAR,"\
                 " number INTEGER, season_id INTEGER, poster_uri VARCHAR);",

                 # triggers

                 "DROP TRIGGER IF EXISTS \"file del\";",

                 "CREATE TRIGGER IF NOT EXISTS \"file del\" "\
                 "BEFORE DELETE ON files BEGIN "\
                 "DELETE FROM music_tracks WHERE file_path = old.path; "\
                 "DELETE FROM images WHERE file_path = old.path; "\
                 "DELETE FROM videos WHERE file_path = old.path; "\
                 "DELETE FROM movies WHERE file_path = old.path; "\
                 "DELETE FROM tvepisodes WHERE file_path = old.path; "\
                 "END",

                 "CREATE TRIGGER IF NOT EXISTS \"movie del\" "\
                 "BEFORE DELETE ON movies BEGIN "\
                 "DELETE FROM files WHERE path = old.file_path; "\
                 "END",

                 "CREATE TRIGGER IF NOT EXISTS \"season del\" "\
                 "AFTER DELETE ON tvepisodes "\
                 "WHEN (SELECT count(*) from tvepisodes WHERE season_id = "\
                 "old.season_id) = 0  BEGIN " \
                 "DELETE FROM tvseasons WHERE id = old.season_id; "\
                 "END",
                 "CREATE TRIGGER IF NOT EXISTS \"tvshow del\" "\
                 "AFTER DELETE ON tvseasons "\
                 "WHEN (SELECT count(*) from tvseasons WHERE tvshow_id = "\
                 "old.tvshow_id) = 0  BEGIN " \
                 "DELETE FROM tvshows WHERE id = old.tvshow_id; "\
                 "END",
                 "CREATE TRIGGER IF NOT EXISTS \"episode del\" "\
                 "BEFORE DELETE ON tvepisodes BEGIN "\
                 "DELETE FROM files WHERE path = old.file_path; "\
                 "END",

                 # set video files mtime to 0 to trigger a new scan
                 "UPDATE files set modification_time=0 where path in "\
                 " (select file_path from videos);"

                 ]


        def iterate_stmts():
            for stmt in stmts:
                yield self.store.execute(stmt)

        dfr = task.coiterate(iterate_stmts())
        return dfr

    def _upgrade_to_4(self):
        """
        Upgrades the database to version 4. TV Episodes poster uris
        are changed from:

        http://images.thetvdb.com/banners/episodes/79349-310842.jpg

        to:

        http://images.thetvdb.com/banners/episodes/79349/310842.jpg
        """
        from elisa.plugins.database.models import TVEpisode

        old_uri_re = "http://images.thetvdb.com/(.*)/(\d+)\-(\d+).(.*)"
        new_uri_re = r"http://images.thetvdb.com/\1/\2/\3.\4"

        def iterate(episodes):
            for episode in episodes:
                if episode.poster_uri:
                    episode.poster_uri = re.sub(old_uri_re, new_uri_re,
                                                episode.poster_uri)
                yield episode

        dfr = self.store.find(TVEpisode)
        dfr.addCallback(lambda result_set: result_set.all())
        dfr.addCallback(lambda episodes: task.coiterate(iterate(episodes)))
        dfr.addCallback(lambda result: self.store.commit())
        return dfr

    def _upgrade_to_5(self):
        """
        Upgrade the database to version 5.

        Remove all the deletion triggers, replaced by explicit deletion code in
        the database parser. This is because using triggers in the DB, Storm
        was not aware of objects being deleted and was therefore quite
        confused. See https://bugs.launchpad.net/elisa/+bug/322700.
        """
        triggers = ['file del', 'music track album del', 'del artist',
                    'music track artists del', 'image album del',
                    'season del', 'tvshow del', 'movie del', 'episode del']

        def iterate_triggers():
            for trigger in triggers:
                statement = 'DROP TRIGGER IF EXISTS "%s";' % trigger
                yield self.store.execute(statement)

        dfr = task.coiterate(iterate_triggers())
        dfr.addCallback(lambda result: self.store.commit())
        return dfr

    def _upgrade_to_6(self):
        """
        Remove the favorites table.

        The favorites plugin is being removed and will be rewritten from
        scratch when we decide to re-introduce this feature.
        """
        statement = 'DROP TABLE IF EXISTS favorites;'
        dfr = self.store.execute(statement)
        return dfr
