# -*- 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>

"""
models with (in)direct database access
"""
from twisted.internet import task

from elisa.core.media_uri import MediaUri
from elisa.core.utils.i18n import install_translation

from elisa.extern.storm_wrapper import wrapper

# Note: avoid Pickle and List like the plague, they eat all your cpu
from storm.locals import Unicode, Int, Date, DateTime, Bool, \
     And, Not, Select, Float, Reference, ReferenceSet

from elisa.core.utils.i18n import get_current_locale
from elisa.core import media_uri, common, log
from elisa.core.utils import defer

import os

from elisa.plugins.base.models.media import PlayableModel

from twisted.internet import task

_ = install_translation('database')

PICTURES_SECTION = 1 << 0
MUSIC_SECTION    = 1 << 1
VIDEO_SECTION    = 1 << 2


class MusicAlbum(object):
    """
    Represents one album
    """
    __storm_table__ = "music_albums"
    name = Unicode(primary=True)
    # URI of a picture file for the album's cover
    # eg: http://ecx.images-amazon.com/images/I/510D51P8YKL._SL500_.jpg
    cover_uri = Unicode()
    release_date = DateTime()
    artist_name = Unicode()

    source_label = _('Your Computer')
    source_icon = None

    def get_tracks(self):
        dfr = self.tracks.find()
        dfr.addCallback(lambda rs: rs.all())
        dfr.addCallback(lambda items: sorted(items, key=lambda a: a.file_path.lower()))
        return dfr

    def get_artist_name(self):
        """
        Retrieve the artist name associated with the first track of the album.
        If there's no track or no artist is found, empty string is returned.
        """
        return defer.succeed(self.artist_name)

    def get_local_coverart(self):
        config = common.application.config
        section = config.get_section('music_library')
        if section is None:
            section = {'coverart_extensions': ['jpeg', 'jpg', 'png', 'gif'],
                       'coverart_filenames': ['front', 'cover', 'poster']}
            config.set_section('music_library', section)
        extensions = section['coverart_extensions']
        filenames = section['coverart_filenames']

        def iterate_candidates(track_path):
            directory = os.path.dirname(track_path)
            found = None
            for ext in extensions:
                for filename in filenames:
                    candidate = os.path.join(directory,
                                             "%s.%s" % (filename, ext))
                    if os.path.isfile(candidate) and os.access(candidate,
                                                               os.R_OK):
                        found = MediaUri("file://%s" % candidate)
                        break
                if found:
                    break
            return found

        def check_album_directory(result):
            return iterate_candidates(result)

        def got_tracks(tracks):
            if len(tracks) > 0:
                return tracks[0].file_path
            else:
                return None

        dfr = self.tracks.find()
        dfr.addCallback(lambda rs: rs.all())
        dfr.addCallback(got_tracks)
        dfr.addCallback(check_album_directory)
        return dfr

class PhotoAlbum(object):
    __storm_table__ = "photo_albums"
    name = Unicode(primary=True)
    preview_uri = Unicode() # a uri to a generated preview 

class Artist(object):
    """
    Represents an artist
    """
    __storm_table__ = 'artists'
    # FIXME: this class is not very precise
    name = Unicode(primary=True)
    # URI of a picture file that represents the artist
    # eg: http://www.discogs.com/image/A-56260-1189986364.jpeg
    image_uri = Unicode()

    def get_tracks(self):
        dfr = self.tracks.find()
        dfr.addCallback(lambda rs: rs.all())
        dfr.addCallback(lambda items: sorted(items, key=lambda a: a.file_path.lower()))
        return dfr

class TrackArtist(object):
    """
    weak table to map artists to tracks
    """
    __storm_table__ = 'track_artists'
    __storm_primary__ = "track_path", "artist_name"

    artist_name = Unicode()
    track_path = Unicode()


class MusicTrack(object):
    """
    Represents one music track
    """
    __storm_table__ = 'music_tracks'
    # and exactly one file
    file_path = Unicode(primary=True)
    # it has a title
    title = Unicode()
    # track number
    track_number = Int()
    # duration
    duration = Int()
    # and is exactly in one album
    album_name = Unicode()
    # genre
    genre = Unicode()

    source_label = _('Your Computer')
    source_icon = None

    def get_album(self):
        """
        Return a deferred that will return the album containing the track as an
        instance of L{elisa.plugins.base.models.audio.AlbumModel}.

        @rtype:  L{elisa.core.utils.defer.Deferred}
        """
        return self.album

    def get_artists(self):
        """
        Return a deferred that will return the list of artist names for the
        track as C{unicode} instances.

        @rtype:  L{elisa.core.utils.defer.Deferred}
        """
        def values(result):
            return result.values(Artist.name)

        def set_distinct(result_set):
            result_set.config(distinct=True)
            return result_set

        deferred = self.artists.find()
        deferred.addCallback(set_distinct)
        deferred.addCallback(values)
        return deferred

    def get_playable_model(self):
        """
        Return a deferred that will return an instance of
        L{elisa.plugins.base.models.media.PlayableModel} for the track.

        @rtype:  L{elisa.core.utils.defer.Deferred}
        """
        model = PlayableModel()
        model.uri = media_uri.MediaUri({'scheme' : 'file',
                                        'path' : self.file_path})
        model.title = self.title
        return defer.succeed(model)

class Image(object):
    """
    represent an image
    """
    __storm_table__ = 'images'
    file_path = Unicode(primary=True)
    size = Unicode()
    # the time when the image was shot
    shot_time = DateTime()
    # is it done with flash
    with_flash = Bool()
    # orientation of the image
    orientation = Int()


    # GPS informations: where was the image made?
    gps_altitude = Int()
    gps_latitude = Unicode()
    gps_longitude = Unicode()

    # and is exactly in one album
    album_name = Unicode()

    # OR'ed value with the following masks:
    # PICTURES_SECTION, MUSIC_SECTION, VIDEO_SECTION
    section = Int()


class PlayableMixin(object):

    def get_playable_model(self):
        playable_model = PlayableModel()
        playable_model.uri = media_uri.MediaUri({'scheme' : 'file',
                                                 'path' : self.file_path})
        dfr = defer.succeed(playable_model)
        return dfr

class Video(PlayableMixin):
    """
    represents a video
    """
    __storm_table__ = 'videos'
    file_path = Unicode(primary=True)
    name = Unicode()
    creation_time = DateTime()
    duration = Unicode()
    size = Unicode()
    codec = Unicode()
    thumbnail_uri = Unicode()

    @classmethod
    def unclassified_videos(cls, *args):
        store = common.application.store
        is_movie = Video.file_path.is_in(Select(Movie.file_path, distinct=True))
        is_tv_episode = Video.file_path.is_in(Select(TVEpisode.file_path,
                                                     distinct=True))
        results = store.find(Video, And(Not(is_movie), Not(is_tv_episode), *args))
        return results

class File(object):
    """
    represente a file
    """
    __storm_table__ = 'files'
    path = Unicode(primary=True)
    source = Unicode()
    mime_type = Unicode()
    modification_time = Int()
    deleted = Int()
    playcount = Int()
    last_played = Int()
    hidden = Bool()


class Movie(PlayableMixin):
    __storm_table__ = "movies"
    file_path = Unicode(primary=True) # refs: Video
    short_overview = Unicode()
    title = Unicode()
    release_date = Date()
    # URI of a picture file for the movie's cover
    # eg: http://www.themoviedb.org/image/posters/5389/Batman_forever.jpg
    cover_uri = Unicode()
    # URI of a picture file for the movie's backdrop
    # eg: http://www.themoviedb.org/image/backdrops/5389/Batman_forever.jpg
    backdrop_uri = Unicode()
    metadata_lang_code = Unicode()
    runtime = Int() # in minutes
    user_rating = Float()
    budget = Int()
    revenue = Int()
    imdb_id = Unicode()

class TVSeasonPoster(object):
    __storm_table__ = "tvseason_posters"
    # we could have uri as primary key if we were sure we couldn't have the
    # same uri for two posters
    id = Int(primary=True)
    uri = Unicode()
    show_id = Int()
    season_number = Int()
    lang_id = Unicode()

class TVShow(object):
    __storm_table__ = "tvshows"
    id = Int(primary=True)
    thetvdb_id = Int()
    name = Unicode()
    filesystem_name = Unicode()
    metadata_lang_code = Unicode()
    poster_uri = Unicode()
    fanart_uri = Unicode()

class TVSeason(object):
    __storm_table__ = "tvseasons"
    id = Int(primary=True)
    number = Int()
    tvshow_id = Int() # refs: TVShow

    def poster_uri(self):
        #FIXME: find out why self.posters.find() return a list of None
        def got_posters(posters):
            lang_code = unicode(get_current_locale().split('_')[0])
            for lang in (lang_code, u'en'):
                for lang_id, season_number, uri in posters:
                    if lang_id == lang and season_number == self.number:
                        return media_uri.MediaUri(uri)

            return None
        
        dfr = self.posters.values(TVSeasonPoster.lang_id,
                                  TVSeasonPoster.season_number,
                                  TVSeasonPoster.uri)
        dfr.addCallback(got_posters)
        return dfr

class TVEpisode(PlayableMixin):
    __storm_table__ = "tvepisodes"
    file_path = Unicode(primary=True) # refs: Video
    number = Int()
    season_id = Int() #refs: TVSeason
    name = Unicode()
    guest_stars = Unicode()
    overview = Unicode()
    poster_uri = Unicode()

class Tag(object):
    """
    representation of a TAG
    """
    __storm_table__ = "tags"
    name = Unicode(primary=True)


class FileTags(object):
    """
    map multiple tags to multiple Files
    """
    __storm_table__ = 'file_tags'
    __storm_primary__ = "file_path", "tag_name"

    tag_name = Unicode()
    file_path = Unicode()


# links for music/audio
Artist.tracks= wrapper.DeferredReferenceSet(Artist.name,
        TrackArtist.artist_name,
        TrackArtist.track_path,
        MusicTrack.file_path)

MusicTrack.album = wrapper.DeferredReference(MusicTrack.album_name,
        MusicAlbum.name)
MusicTrack.artists = wrapper.DeferredReferenceSet(MusicTrack.file_path,
        TrackArtist.track_path, TrackArtist.artist_name, Artist.name)
MusicAlbum.tracks = wrapper.DeferredReferenceSet(MusicAlbum.name,
        MusicTrack.album_name)

# links for image/photo
Image.album = wrapper.DeferredReference(Image.album_name, PhotoAlbum.name)
PhotoAlbum.photos = wrapper.DeferredReferenceSet(PhotoAlbum.name, Image.album_name)

# link types to file
MusicTrack.file = wrapper.DeferredReference(MusicTrack.file_path, File.path)
Image.file = wrapper.DeferredReference(Image.file_path, File.path)
Video.file = wrapper.DeferredReference(Video.file_path, File.path)
Movie.file = wrapper.DeferredReference(Movie.file_path, File.path)
TVEpisode.file = wrapper.DeferredReference(TVEpisode.file_path, File.path)

Movie.video = wrapper.DeferredReference(Movie.file_path, Video.file_path)
TVEpisode.video = wrapper.DeferredReference(TVEpisode.file_path,
                                            Video.file_path)

# link file to more specific types
File.music_track = wrapper.DeferredReference(File.path, MusicTrack.file_path)
File.image = wrapper.DeferredReference(File.path, Image.file_path)
File.tags = wrapper.DeferredReferenceSet(File.path, FileTags.file_path,
        FileTags.tag_name, Tag.name)

Tag.files = wrapper.DeferredReferenceSet(Tag.name, FileTags.tag_name,
        FileTags.file_path, File.path)

# links for tvshows/seasons/episodes
TVSeason.tvshow = wrapper.DeferredReference(TVSeason.tvshow_id, TVShow.id)
TVEpisode.season = wrapper.DeferredReference(TVEpisode.season_id, TVSeason.id)
TVShow.seasons = wrapper.DeferredReferenceSet(TVShow.id, TVSeason.tvshow_id)
TVSeason.episodes = wrapper.DeferredReferenceSet(TVSeason.id, TVEpisode.season_id)
TVSeason.posters = wrapper.DeferredReferenceSet(TVSeason.tvshow_id,
                                                TVSeasonPoster.show_id,
                                                TVSeason.number,
                                                TVSeasonPoster.season_number)


###############################################################################
# Public functions that act as database triggers, used to enforce database
# consistency when removing objects.
#
# WARNING: Do not directly remove database objects from the store
# (e.g. store.remove(obj) or obj.remove()), use the following functions
# instead.
#
# Whenever the database schema is updated the following functions should be
# carefully updated to reflect the changes.
###############################################################################


def delete_file(file_obj, store):
    """
    Delete a File object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param file_obj: file object to be removed from the database
    @type file_obj:  L{elisa.plugins.database.models.File}
    @param store:    the store the file object belongs to
    @type store:     L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(file_obj))

    def remove_object(result, delete_func):
        if result is None:
            return defer.succeed(False)

        delete_dfr = delete_func(result, store)
        delete_dfr.addCallback(lambda result: True)
        return delete_dfr

    # File objects may be referenced by MusicTracks, Images or Videos
    lookup = iter(((MusicTrack, delete_music_track),
                   (Image, delete_image),
                   (Video, delete_video)))

    def continue_lookup(result, lookup):
        if result:
            return defer.succeed(None)

        # No object of the corresponding type deleted, try the next type
        try:
            cls, delete_func = lookup.next()
        except StopIteration:
            return defer.succeed(None)
        else:
            dfr = store.get(cls, file_obj.path)
            dfr.addCallback(remove_object, delete_func)
            dfr.addCallback(continue_lookup, lookup)
            return dfr

    def remove_file(result):
        return store.remove(file_obj)

    dfr = continue_lookup(False, lookup)
    dfr.addCallback(remove_file)
    return dfr


def delete_music_track(track, store):
    """
    Delete a MusicTrack object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param track: music track object to be removed from the database
    @type track:  L{elisa.plugins.database.models.MusicTrack}
    @param store: the store the music track object belongs to
    @type store:  L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(track))

    file_path = track.file_path
    album_name = track.album_name

    # If the track was the last one in its album, also remove the album.

    def count_remaining_tracks_in_album(result):
        count_dfr = store.find(MusicTrack, album_name=album_name)
        count_dfr.addCallback(lambda result_set: result_set.count())
        return count_dfr

    def maybe_remove_album(remaining_tracks_in_album_count):
        if remaining_tracks_in_album_count == 0:
            get_dfr = store.get(MusicAlbum, album_name)
            get_dfr.addCallback(delete_music_album, store)
            return get_dfr
        else:
            return defer.succeed(None)

    # Remove all the track-artists links for this track.

    def find_track_artists(result):
        find_dfr = store.find(TrackArtist, track_path=file_path)
        find_dfr.addCallback(lambda result_set: result_set.all())
        return find_dfr

    def remove_track_artists(result_set):
        def iterate(track_artists):
            for track_artist in track_artists:
                yield delete_track_artist(track_artist, store)

        return task.coiterate(iterate(result_set))

    dfr = store.remove(track)
    if album_name is not None:
        dfr.addCallback(count_remaining_tracks_in_album)
        dfr.addCallback(maybe_remove_album)
    dfr.addCallback(find_track_artists)
    dfr.addCallback(remove_track_artists)
    return dfr


def delete_music_album(album, store):
    """
    Delete a MusicAlbum object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param album: music album object to be removed from the database
    @type album:  L{elisa.plugins.database.models.MusicAlbum}
    @param store: the store the music album object belongs to
    @type store:  L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(album))

    # Remove all the tracks in the album.

    # FIXME: the way the schema is built today, this is fundamentally broken.
    # Indeed the name of the album being its primary key, there will be only
    # one album for e.g. all albums called "Greatest Hits" (potentially a lot
    # of them). Deleting the album will delete all tracks of all albums of this
    # name. See https://bugs.launchpad.net/elisa/+bug/351614

    def remove_tracks(result_set):
        removed = (len(result_set) > 0)
        def iterate(tracks):
            for track in tracks:
                yield delete_music_track(track, store)

        dfr = task.coiterate(iterate(result_set))
        dfr.addCallback(lambda result: removed)
        return dfr

    def remove_album(result):
        # Deleting all the tracks in the album will automatically trigger the
        # deletion of the album itself, except in the case where there are no
        # tracks associated with this album.
        if result:
            return defer.succeed(None)

        return store.remove(album)

    dfr = store.find(MusicTrack, album_name=album.name)
    dfr.addCallback(lambda result_set: result_set.all())
    dfr.addCallback(remove_tracks)
    dfr.addCallback(remove_album)
    return dfr


def delete_track_artist(track_artist, store):
    """
    Delete a TrackArtist object from the DB, maintaining database consistency
    by cascade-removing all the back-references.

    @param track_artist: track artist object to be removed from the database
    @type track_artist:  L{elisa.plugins.database.models.TrackArtist}
    @param store:        the store the track artist object belongs to
    @type store:         L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(track_artist))

    artist_name = track_artist.artist_name

    # If the track-artist link was the last one for its artist, also remove the
    # artist.

    def count_remaining_track_artists_for_artist(result):
        count_dfr = store.find(TrackArtist, artist_name=artist_name)
        count_dfr.addCallback(lambda result_set: result_set.count())
        return count_dfr

    def maybe_remove_artist(remaining_track_artists_for_artist_count):
        if remaining_track_artists_for_artist_count == 0:
            get_dfr = store.get(Artist, artist_name)
            get_dfr.addCallback(delete_artist, store)
            return get_dfr
        else:
            return defer.succeed(None)

    dfr = store.remove(track_artist)
    dfr.addCallback(count_remaining_track_artists_for_artist)
    dfr.addCallback(maybe_remove_artist)
    return dfr


def delete_artist(artist, store):
    """
    Delete an Artist object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param artist: artist object to be removed from the database
    @type artist:  L{elisa.plugins.database.models.Artist}
    @param store:  the store the artist object belongs to
    @type store:   L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(artist))

    # Remove all the track-artist links for this artist.

    def remove_track_artists(result_set):
        removed = (len(result_set) > 0)
        def iterate(track_artists):
            for track_artist in track_artists:
                yield delete_track_artist(track_artist, store)

        dfr = task.coiterate(iterate(result_set))
        dfr.addCallback(lambda result: removed)
        return dfr

    def remove_artist(result):
        # Deleting all the track_artists linked with the artist will
        # automatically trigger the deletion of the artist itself, except in
        # the case where there are no track_artists associated with this
        # artist.
        if result:
            return defer.succeed(None)

        return store.remove(artist)

    dfr = store.find(TrackArtist, artist_name=artist.name)
    dfr.addCallback(lambda result_set: result_set.all())
    dfr.addCallback(remove_track_artists)
    dfr.addCallback(remove_artist)
    return dfr


def delete_image(image, store):
    """
    Delete an Image object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param image: image object to be removed from the database
    @type image:  L{elisa.plugins.database.models.Image}
    @param store: the store the image object belongs to
    @type store:  L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(image))

    album_name = image.album_name

    # If the image was the last one in its album, also remove the album.

    def count_remaining_images_in_album(result):
        count_dfr = store.find(Image, album_name=album_name)
        count_dfr.addCallback(lambda result_set: result_set.count())
        return count_dfr

    def maybe_remove_album(remaining_images_in_album_count):
        if remaining_images_in_album_count == 0:
            get_dfr = store.get(PhotoAlbum, album_name)
            get_dfr.addCallback(delete_photo_album, store)
            return get_dfr
        else:
            return defer.succeed(None)

    dfr = store.remove(image)
    if album_name is not None:
        dfr.addCallback(count_remaining_images_in_album)
        dfr.addCallback(maybe_remove_album)
    return dfr


def delete_photo_album(album, store):
    """
    Delete a PhotoAlbum object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param album: photo album object to be removed from the database
    @type album:  L{elisa.plugins.database.models.PhotoAlbum}
    @param store: the store the photo album object belongs to
    @type store:  L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(album))

    # Remove all the images in this album.

    def remove_images(result_set):
        removed = (len(result_set) > 0)
        def iterate(images):
            for image in images:
                yield delete_image(image, store)

        dfr = task.coiterate(iterate(result_set))
        dfr.addCallback(lambda result: removed)
        return dfr

    def remove_album(result):
        # Deleting all the images in the album will automatically trigger the
        # deletion of the album itself, except in the case where there are no
        # images associated with this album.
        if result:
            return defer.succeed(None)

        return store.remove(album)

    dfr = store.find(Image, album_name=album.name)
    dfr.addCallback(lambda result_set: result_set.all())
    dfr.addCallback(remove_images)
    dfr.addCallback(remove_album)
    return dfr


def delete_video(video, store):
    """
    Delete a Video object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param video: video object to be removed from the database
    @type video:  L{elisa.plugins.database.models.Video}
    @param store: the store the video object belongs to
    @type store:  L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(video))

    file_path = video.file_path

    def remove_object(result, delete_func):
        if result is None:
            return defer.succeed(False)

        delete_dfr = delete_func(result, store)
        delete_dfr.addCallback(lambda result: True)
        return delete_dfr

    # Video objects may be referenced by Movies or TVEpisodes
    lookup = iter(((Movie, delete_movie),
                   (TVEpisode, delete_tvepisode)))

    def continue_lookup(result, lookup):
        if result:
            return defer.succeed(None)

        # No object of the corresponding type deleted, try the next type
        try:
            cls, delete_func = lookup.next()
        except StopIteration:
            return defer.succeed(None)
        else:
            dfr = store.get(cls, file_path)
            dfr.addCallback(remove_object, delete_func)
            dfr.addCallback(continue_lookup, lookup)
            return dfr

    dfr = store.remove(video)
    dfr.addCallback(lambda result: continue_lookup(False, lookup))
    return dfr


def delete_movie(movie, store):
    """
    Delete a Movie object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param movie: movie object to be removed from the database
    @type movie:  L{elisa.plugins.database.models.Movie}
    @param store: the store the movie object belongs to
    @type store:  L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(movie))
    # Note: in the current schema, there are no back references, so this
    # goes down to a simple store.remove(movie). This method is here for
    # code maintainability in the case the schema evolves.
    return store.remove(movie)


def delete_tvepisode(episode, store):
    """
    Delete a TVEpisode object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param episode: TV episode object to be removed from the database
    @type episode:  L{elisa.plugins.database.models.TVEpisode}
    @param store:   the store the TV episode object belongs to
    @type store:    L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(episode))

    season_id = episode.season_id

    # If the episode was the last one in its season, also remove the season.

    def count_remaining_episodes_in_season(result):
        count_dfr = store.find(TVEpisode, season_id=season_id)
        count_dfr.addCallback(lambda result_set: result_set.count())
        return count_dfr

    def maybe_remove_season(remaining_episodes_in_season_count):
        if remaining_episodes_in_season_count == 0:
            get_dfr = store.get(TVSeason, season_id)
            get_dfr.addCallback(delete_tvseason, store)
            return get_dfr
        else:
            return defer.succeed(None)

    dfr = store.remove(episode)
    dfr.addCallback(count_remaining_episodes_in_season)
    dfr.addCallback(maybe_remove_season)
    return dfr


def delete_tvseason(season, store):
    """
    Delete a TVSeason object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param season: TV season object to be removed from the database
    @type season:  L{elisa.plugins.database.models.TVSeason}
    @param store:  the store the TV season object belongs to
    @type store:   L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(season))

    show_id = season.tvshow_id

    # Remove all the episodes in this season.

    def remove_episodes(result_set):
        removed = (len(result_set) > 0)
        def iterate(episodes):
            for episode in episodes:
                yield delete_tvepisode(episode, store)

        dfr = task.coiterate(iterate(result_set))
        dfr.addCallback(lambda result: removed)
        return dfr

    def remove_season(result):
        # Deleting all the episodes in the season will automatically trigger
        # the deletion of the season itself, except in the case where there are
        # no episodes in the season.
        removed = not result
        if result:
            return defer.succeed(removed)

        remove_dfr = store.remove(season)
        remove_dfr.addCallback(lambda result: removed)
        return remove_dfr

    # If the season was the last one in its show, also remove the show.

    def count_remaining_seasons_in_show(removed):
        if not removed:
            # The deletion of the season had already been automatically
            # triggered by deleting its last episode, therefore the show has
            # also already been removed where needed.
            return defer.succeed(-1)

        count_dfr = store.find(TVSeason, tvshow_id=show_id)
        count_dfr.addCallback(lambda result_set: result_set.count())
        return count_dfr

    def maybe_remove_show(remaining_seasons_in_show_count):
        if remaining_seasons_in_show_count == 0:
            get_dfr = store.get(TVShow, show_id)
            get_dfr.addCallback(delete_tvshow, store)
            return get_dfr
        else:
            return defer.succeed(None)

    dfr = store.find(TVEpisode, season_id=season.id)
    dfr.addCallback(lambda result_set: result_set.all())
    dfr.addCallback(remove_episodes)
    dfr.addCallback(remove_season)
    dfr.addCallback(count_remaining_seasons_in_show)
    dfr.addCallback(maybe_remove_show)
    return dfr


def delete_tvshow(show, store):
    """
    Delete a TVShow object from the DB, maintaining database consistency by
    cascade-removing all the back-references.

    @param show:  TV show object to be removed from the database
    @type show:   L{elisa.plugins.database.models.TVShow}
    @param store: the store the TV show object belongs to
    @type store:  L{elisa.extern.twisted_storm.store.DeferredStore}

    @return: a deferred fired when the cascade deletion is complete
    @rtype:  L{elisa.core.utils.defer.Deferred}
    """
    log.debug('database', 'Deleting %s' % str(show))

    # Remove all the seasons in this show.

    def remove_seasons(result_set):
        removed = (len(result_set) > 0)
        def iterate(seasons):
            for season in seasons:
                yield delete_tvseason(season, store)

        dfr = task.coiterate(iterate(result_set))
        dfr.addCallback(lambda result: removed)
        return dfr

    def remove_show(result):
        # Deleting all the seasons in the show will automatically trigger the
        # deletion of the show itself, except in the case where there are no
        # seasons associated with this show.
        if result:
            return defer.succeed(None)

        return store.remove(show)

    dfr = store.find(TVSeason, tvshow_id=show.id)
    dfr.addCallback(lambda result_set: result_set.all())
    dfr.addCallback(remove_seasons)
    dfr.addCallback(remove_show)
    return dfr
