# -*- 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.
#
# Authors: Benjamin Kampmann <benjamin@fluendo.com>
#          Olivier Tilloy <olivier@fluendo.com>

"""
Parse the results from the typefinder into objects and write them to the
database.
"""

from elisa.core.utils import defer

from elisa.plugins.database.video_parser import VideoParser
from elisa.plugins.database.models import File, Artist, MusicAlbum, \
    MusicTrack, Tag, Image, PhotoAlbum, Video, delete_file, \
    PICTURES_SECTION, MUSIC_SECTION, VIDEO_SECTION

from elisa.plugins.gstreamer.amp_master import GstMetadataAmpClient

from twisted.python.failure import Failure
from twisted.internet import task, reactor

from elisa.core.component import Component

import time, pkg_resources, datetime, os, re

general_keys = ['file_type', 'mime_type']
# metadata specific to gstreamer. currently the API of gst metadata requires that we
# specify all the gstreamer metadata we might want to have...
audio_keys = ['artist', 'album', 'song', 'duration', 'genre', 'track', 'date'
             # these keys are for Windows Media Files
             'WM/AlbumArtist', 'WM/AlbumTitle', 'WM/Year', 'WM/TrackNumber',
             'title'
            ]
image_keys = ['date-time-original', 'date-time-modified', 'date-time-digitized',\
        'capture-flash', 'capture-orientation', 'gps-altitude', 'date-time', 
        'gps-latitude', 'gps-longitude']

all_keys = audio_keys + image_keys + general_keys
global_request_dict = dict( [ (key, None) for key in all_keys ] )

class StopProcessing(Exception): pass

class DatabaseParser(Component):
    entry_point = 'elisa.plugins.database.processors'
    image_date_splitter = re.compile(r'[^0-9]+')

    commit_every_seconds = 10 # seconds to auto commit
    commit_every_files = 50 # number of files to auto commit
    # what ever is reached first will trigger the auto commit


    # how long should the parser wait after it processed a file. This delay is
    # *not* applied to files that are not updated.
    between_process_delay = 0.1 # value in seconds.

    def __init__(self):
        super(DatabaseParser, self).__init__()
        self._store = None
        self.metadata = None
        self._processors = []

        self._commit_delay = None
        self._commit_counter = 0

        self._video_parser = VideoParser()

    def initialize(self):
        def set_metadata(metadata):
            self.metadata = metadata
            return self

        def create_client(result):
            dfr = GstMetadataAmpClient.create()
            dfr.addCallback(set_metadata)
            return dfr

        def set_process_delay(result):
            self.between_process_delay = self.config.get('delay', \
                    self.between_process_delay)

            return result


        dfr = super(DatabaseParser, self).initialize()
        dfr.addCallback(self.load_processors)
        dfr.addCallback(create_client)
        dfr.addCallback(set_process_delay)
        dfr.addCallback(self._reset_autocommit_delay)
        return dfr

    def _set_store(self, store):
        self._store = store
        self._video_parser.store = store

    def _get_store(self):
        return self._store

    store = property(fget=_get_store, fset=_set_store)

    def load_processors(result, self):

        def failed(failure, name):
            self.warning("Creating %s failed: %s" % (name, failure))
            return None

        def iterate():
            it = pkg_resources.iter_entry_points(self.entry_point)
            for entry in it:
                name = entry.name
                klass = entry.load()
                self.debug("creating processor %s " % name)
                # FIXME: add configuration system
                # FIXME: re-enable!
                # yield klass.create({}).addCallback(
                #       self._processors.append).addErrback(failed, name)
                yield klass
    
        def sort(result):
            self._processors.sort(key=lambda proc: proc.rank)

        dfr = task.coiterate(iterate())
        return dfr.addCallback(sort)

    def clean(self):
        def clean_metadata(result):
            return self.metadata.clean()

        if self._commit_delay and not self._commit_delay.called:
            self._commit_delay.cancel()

        if self._commit_counter > 0:
            # we have stuff pending so commit it
            dfr = self.store.commit()
            dfr.addCallback(clean_metadata)
            return dfr

        dfr = clean_metadata(None)

        def parent_clean(result):
            return super(DatabaseParser, self).clean()

        dfr.addCallback(parent_clean)

        return dfr

    def mark_deleted(self, source_path):
        self.debug("Mark deleted %s", source_path)

        def delete(results):
            self.debug("Marking")
            return results.set(deleted=1)

        dfr = self.store.find(File, File.source == source_path)
        dfr.addCallback(delete)
        return dfr

    def delete_files(self, source_path, marked_only=True):
        """
        Delete file entries from the database that have the source set to the
        given C{source_path}. If C{marked_only} is set, only the file entries
        with the delete-marker are removed.

        @param source_path: the source path of the files
        @type source_path:  C{unicode}

        @keyword marked_only: delete only the file entries that are marked as
                              deleted
        @type marked_only:    C{bool}

        @rtype: L{elisa.core.utils.cancellable_defer.CancellableDeferred}
        @return: triggered whenever the deletion is done
        """
        def delete(result_set):
            def iterate(files):
                for obj in files:
                    yield delete_file(obj, self.store)

            return task.coiterate(iterate(result_set))

        if marked_only:
           dfr = self.store.find(File, File.deleted == 1, \
                File.source == source_path)
        else:
            dfr = self.store.find(File, File.source == source_path)

        dfr.addCallback(lambda result_set: result_set.all())
        dfr.addCallback(delete)
        return dfr

    def query_model(self, model, stat):
        self.debug("query for %s" % model.uri.path)

        dfr = self.get_or_create(File, 'path', model.uri.path)
        dfr.addCallback(self.process, model, stat)

        return dfr

    def gst_process(self, file, model, section):

        request_dict = dict(global_request_dict)
        request_dict['uri'] = model.uri

        dfr = self.metadata.get_metadata(request_dict)
        dfr.addBoth(self.update_modification_time, file)
        dfr.addCallback(self.parse_tags, file, section)
        return dfr

    def process(self, file, model, source):
        self.debug("processing %s", file.path)
        
        file.deleted = 0
        if file.modification_time >= model.mtime:
            return defer.succeed(True)

        if not file.source:
            file.source = source.root_uri.path

        def pre_process_failed(result, file, model):
            self.debug("using gst for %s", file.path)
            return self.gst_process(file, model)

        #dfr = self.pre_process(file, model)
        #dfr.addErrback(pre_process_failed, file, model)
        #dfr.addCallback(self.post_process, file, model)
        dfr = self.gst_process(file, model, source.section)
        dfr.addCallback(self._commit)
        dfr.addBoth(self._delay)
        return dfr

    def _delay(self, result_or_failure):
        dfr = defer.Deferred()
        reactor.callLater(self.between_process_delay, dfr.callback,
                result_or_failure)
        return dfr

    def _commit(self, result):
        self._commit_counter += 1
        if self._commit_counter >= self.commit_every_files:
            if self._commit_delay and not self._commit_delay.active():
                self._commit_delay.cancel()
            self._autocommit()

    def _reset_autocommit_delay(self, result):
        if self._commit_delay and self._commit_delay.active():
            # still active. reset it
            self._commit_delay.reset(self.commit_every_seconds)
        else:
            delay = reactor.callLater(self.commit_every_seconds, self._autocommit)
            self._commit_delay = delay

        return result

    def _autocommit(self):

        if not self._commit_counter:
            # nothing changed, do not commit
            self._reset_autocommit_delay(None)
            return

        # reset the commit counter
        self._commit_counter = 0

        dfr = self.store.commit()
        dfr.addCallback(self._reset_autocommit_delay)

    def pre_process(self, file, model):
        def check_result(result, result_dfr):
            if result:
                # the preprocessor told us that we are done
                result_dfr.callback(result)
                # stop the loop
                raise StopIteration()

        def processor_error(failure):
            self.info("Pre Processing failed: %s" % failure)
            # eat the error to go on
            return None
        
        def iterate(result_dfr):
            for processor in self._processors:
                self.debug("pre processing %s" % processor)
                dfr = processor.pre_process(file, model, self)
                dfr.addCallbacks(check_result, processor_error,
                    callbackArgs=(result_dfr,))
                yield dfr

            if not result_dfr.called:
                # nothing found, errback ...
                result_dfr.errback(False)

        def eat_stop(failure):
            if failure.type == StopIteration:
                # if we stopped it, eat the error and go on
                return None
            return failure

        result_dfr = defer.Deferred()
        dfr = task.coiterate(iterate(result_dfr))
        dfr.addErrback(eat_stop)
        return result_dfr

    def post_process(self, metadata, file, model):
        def iterate():
            for processor in self._processors:
                self.debug("post processing %s" % processor)
                yield processor.post_process(metadata, file, model, self)

        return task.coiterate(iterate())

    def update_modification_time(self, result, file, new_time=None):
        # whatever it is, change the modification time in the db 
        if not new_time:
            new_time = time.time()
        file.modification_time = new_time
        return result

    def get_or_create(self, klass, key, value, **optional_kw):
        """
        get the object of the given klass with the primary value set to
        c{value} or create it and add it to the database if it is not yet in the
        db.
        """

        def got(result, opt_kw):
            if result:
                # found
                return result

            obj = klass()
            setattr(obj, key, value)

            for opt_key, opt_value in opt_kw.iteritems():
                setattr(obj, opt_key, opt_value)

            return self.store.add(obj)

        dfr = self.store.get(klass, value)
        dfr.addCallback(got, optional_kw)
        return dfr

    def in_or_add(self, klass, key, value, reference_set):
        def found(result):
            return result.one()

        def got_one(result, reference_set):
            if result:
                return result

            dfr = self.get_or_create(klass, key, value)
            dfr.addCallback(reference_set.add)
            return dfr

        dfr = reference_set.find(getattr(klass, key) == value)
        dfr.addCallback(found)
        dfr.addCallback(got_one, reference_set)
        return dfr


    def make_sure_of_music_album(self, result, album_name, artist_name, \
                                 release_date=None):
        if release_date:
            return self.get_or_create(MusicAlbum, 'name', album_name,
                    release_date=release_date, artist_name=artist_name)

        return self.get_or_create(MusicAlbum, 'name', album_name,
                                  artist_name=artist_name)

    def add_artists(self, result, artist_names, artists_ref):
        def set_artists(artists, artists_ref):
            for artist in artists:
                dfr = self.in_or_add(Artist, 'name', unicode(artist), artists_ref)
                yield dfr
        
        dfr = task.coiterate(set_artists(artist_names, artists_ref))
        return dfr

    def set_tag(self, res, file, tag_name):
        dfr = self.get_or_create(Tag, 'name', tag_name)
        dfr.addCallback(self.in_or_add, file.tags)
        return dfr

    def parse_metadata_into_model(self, model, metadata, key_value_type):
        for gst_key, attr, typ in key_value_type:
            try:
                value = metadata[gst_key]
            except KeyError:
                value = None

            if not value is None:
                # when it is not None, we have to convert it into the right type
                # or storm will complain (raise a TypeError Exception). That
                # even applies for 'strings' if unicode is required. The only
                # other value we are allowed to set is the special None-Type
                # which is the same as the special 'NULL' Value in SQL.
                value = typ(value)

            setattr(model, attr, value)

    # the case of audio
    def parse_into_track(self, track, metadata):
        self.debug("parsing music track %s" % track.file_path)

        tags = [ ('song', 'title', unicode),
                 ('genre', 'genre', unicode),
                 ('duration', 'duration', float),
                 ('track', 'track_number', int),
               ]

        # stupid WMA 
        if metadata.get('song', None) is None:
            tags.append( ('title', 'title', unicode) )
           
        if metadata.get('track', None) is None:
            tags.append ( ('WM/TrackNumber', 'track_number', int) )
        
        self.parse_metadata_into_model(track, metadata, tags)

        album_name = metadata.get('album') or metadata.get('WM/AlbumTitle')
        if album_name:
            album_name = unicode(album_name)

        date = metadata.get('date')
        wm_year = metadata.get('WM/Year')

        track.album_name = album_name

        # first clear the artists list
        dfr = track.artists.clear()

        artists = metadata.get('artist') or metadata.get('WM/AlbumArtist')
        if artists:
            if isinstance(artists, basestring):
                artist_name = unicode(artists)
                artists = [artists]
            else:
                artist_name = unicode(artists[0])
        else:
            artist_name = u''

        if album_name:
            # make sure the album exists
            if date is not None:
                # and has the right date set
                date = datetime.datetime.fromtimestamp(date)
            elif wm_year is not None:
                date = datetime.datetime(int(wm_year), 1, 1)

            dfr.addCallback(self.make_sure_of_music_album, \
                            album_name, artist_name, release_date=date)

        if artists:
            dfr.addCallback(self.add_artists, artists, track.artists)
        return dfr

    # the case of videos
    def parse_into_video(self, video, metadata):
        self.debug("parsing video %s" % video.file_path)

        # We'll use the filename as video name until we can get a proper name
        video.name = os.path.basename(video.file_path)

        # We set the creation time as the date of the file
        stamp = os.stat(video.file_path).st_mtime
        video.creation_time = datetime.datetime.fromtimestamp(stamp)

        if 'duration' in metadata and metadata['duration']:
            video.duration = unicode(metadata['duration'])

        return self._video_parser.parse(video)

    # the case of images
    def parse_into_image(self, image, metadata):
        self.debug("parsing image %s" % image.file_path)
        tags =  (
                  ('capture-flash', 'with_flash', bool),
                  ('capture-orientation', 'orientation', int),
                  ('gps-altitude', 'gps_altitude', int),
                  ('gps-latitude', 'gps_latitude', unicode),
                  ('gps-longitude', 'gps_longitude', unicode),
                )

        self.parse_metadata_into_model(image, metadata, tags)

        # take care of the date. that might be tricky!
        for date_tag in ( 'date-time-original', 'date-time-digitized',
                          'date-time' , 'date-time-modified'):
            # select the first one that we can use for something
            date = metadata.get(date_tag, None)
            if not date:
                continue
            # we are a bit more failure resistent. The Spec says it has to be 
            # YYYY:MM:DD HH:MM:SS
            dates = self.image_date_splitter.split(date)

            if len(dates) != 6:
                continue

            date = datetime.datetime(*[int(r) for r in dates])
            image.shot_time = date
            break

        if not image.shot_time:
            # FIXME: ugly fallback to the modification date as the date the
            # photo was take in order to always allow us to sort them nice
            stamp = os.stat(image.file_path).st_mtime
            image.shot_time = datetime.datetime.fromtimestamp(stamp)

        if image.section & PICTURES_SECTION:
            album_name = os.path.basename(os.path.split(image.file_path)[0])

            if album_name == '':
                image.album_name = None
            else:
                image.album_name = album_name
                # make sure the album exists
                return self.get_or_create(PhotoAlbum, 'name', album_name)
        else:
            image.album_name = None

    def parse_tags(self, metadata, file, section=''):
        
        self.debug("got tags: %s" % metadata)

        # general case:
        file.mime_type = unicode(metadata.get('mime_type','')) or None

        dfr = None
        tag = None
        file_type = metadata.get('file_type', None)

        if file_type == 'video' and section == 'video':
            tag = u'video'
            dfr = self.get_or_create(Video, 'file_path', file.path)
            dfr.addCallback(self.parse_into_video, metadata)

        elif file_type == 'audio' and section == 'music':
            tag = u'audio'
            dfr = self.get_or_create(MusicTrack, 'file_path', file.path)
            dfr.addCallback(self.parse_into_track, metadata)

        elif file_type == 'image' and section == 'pictures':
            def update_section(image, section):
                section_masks = { 'pictures': PICTURES_SECTION,
                                 'music':    MUSIC_SECTION,
                                 'video':    VIDEO_SECTION,
                                }
                new_section = section_masks.get(section, 0)
                if image.section == None:
                    image.section = new_section
                else:
                    image.section |= new_section

                return image

            tag = u'image'
            dfr = self.get_or_create(Image, 'file_path', file.path)
            dfr.addCallback(update_section, section)
            dfr.addCallback(self.parse_into_image, metadata)

        if not dfr:
            return defer.succeed(None)

#        if tag:
            #dfr.addCallback(set_tag, file, tag)
        
        def return_metadata(res, metadata):
            return metadata

        return dfr.addCallback(return_metadata, metadata)
