# -*- coding: utf-8 -*-
# Moovida - Home multimedia server
# Copyright (C) 2007-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>

"""
The implementation of a merging search engine for elisa.
"""

from elisa.core import common
from elisa.core.components.resource_provider import ResourceProvider
from elisa.core.plugin_registry import PluginStatusMessage

from elisa.core.utils import defer
from elisa.core.utils.cancellable_defer import cancellable_deferred_iterator, \
     cancellable_coiterate

from elisa.plugins.search.models import MusicSearchResultModel, \
     VideosSearchResultModel, PicturesSearchResultModel

import pkg_resources

from twisted.internet import task


SEARCH_PATH_TO_MODEL = {'music' : MusicSearchResultModel,
                        'videos': VideosSearchResultModel,
                        'pictures': PicturesSearchResultModel}

class SearchMetaresourceProvider(ResourceProvider):

    # the entry point to find the 'Searchers'
    entry_point = 'elisa.plugins.search.searchers'

    supported_uri = '^elisa://search/(%s).*' % \
            '|'.join(SEARCH_PATH_TO_MODEL.keys())

    default_config = {'default_searcher' : 'DBSearcher'}
    config_doc = {'default_searcher':
                  'The default searcher is the searcher that is always ' \
                  'asked first for the search result and fills the reference' \
                  ' model with data' }

    def __init__(self):
        super(SearchMetaresourceProvider, self).__init__()
        self._searchers_by_path = {}
        self._default_searcher = None

    def initialize(self):
        bus = common.application.bus
        bus.register(self._plugin_status_changed_cb, PluginStatusMessage)
        return super(SearchMetaresourceProvider, self).initialize()

    def clean(self):
        bus = common.application.bus
        bus.unregister(self._plugin_status_changed_cb)
        return super(SearchMetaresourceProvider, self).clean()

    def _plugin_status_changed_cb(self, message, sender):
        # Notification that the status of a plugin has changed.
        plugin_registry = common.application.plugin_registry
        plugin = plugin_registry.get_plugin_by_name(message.plugin_name)
        searchers = plugin.get_entry_map(self.entry_point)
        if searchers:
            # It may mean that a new plugin has been installed, in
            # which case we want to add it(s) searcher(s) in our
            # cache.
            if message.action == message.ActionType.ENABLED:
                return self._load_searchers(searchers)
            else:
                # It may also mean that a plugin has been deactivated,
                # in which case want to remove it(s) searcher(s) from
                # our cache.
                return self._unload_searchers(searchers)

    def _add_searcher(self, searcher, name):
        searcher.name = name.lower()
        if name == self.config['default_searcher']:
            self._default_searcher = searcher
            return

        searchers = self._searchers_by_path
        for path in searcher.paths:
            # this is fairly quick
            searchers.setdefault(path, []).append(searcher)

    def _remove_searcher(self, name):
        for key in self._searchers_by_path.keys():
            searchers = self._searchers_by_path[key]
            searchers = [searcher for searcher in searchers
                         if searcher.name != name.lower()]
            if searchers:
                self._searchers_by_path[key] = searchers
            else:
                del self._searchers_by_path[key]

    def _load_searchers(self, searchers):
        """
        Load a set of searchers. This private method is called when a
        new plugin containing searchers has been enabled by the plugin
        registry.

        @param searchers: searchers to register
        @type searchers: C{dict} of C{str} (searcher name),
                         C{pkg_resources.EntryPoint}
        @rtype: C{elisa.core.utils.cancellable_defer.CancellableDeferred}
        """
        plugin_registry = common.application.plugin_registry

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

        def iterate():
            for name, entry in searchers.iteritems():
                self.debug("creating searcher %s" % name)
                path = "%s:%s" % (entry.module_name, ".".join(entry.attrs))
                dfr = plugin_registry.create_component(path)
                dfr.addCallback(self._add_searcher, name)
                dfr.addErrback(failed, name)
                yield dfr

        return task.coiterate(iterate())

    def _unload_searchers(self, searchers):
        """
        Unload a set of searchers. This private method is called when a
        plugin containing searchers has been disabled by the plugin
        registry.

        @param searchers: searchers to unregister
        @type searchers: C{dict} of C{str} (searcher name),
                         C{pkg_resources.EntryPoint}
        @rtype: C{elisa.core.utils.cancellable_defer.CancellableDeferred}
        """

        def iterate():
            for name in searchers.keys():
                self.debug("Unloading searcher %s" % name)
                yield self._remove_searcher(name)

        return task.coiterate(iterate())

    def get(self, uri, context_model=None):
        """
        Make a search request. The context model is ingored.
        """

        # uri.path always starts with a slash
        search_path = uri.path[1:].split('/')[0]

        try:
            model = SEARCH_PATH_TO_MODEL[search_path]()
        except KeyError:
            msg = "Don't know how to handle search path %s" % search_path
            return None, defer.fail(NotImplementedError(msg))

        only_searchers = uri.get_param('only_searchers', None)
        default_searcher = uri.get_param('only_default', 'false').lower()

        selected_searchers = []
        if only_searchers is not None:
            selected_searchers = only_searchers.split(',')

        if self._default_searcher and (
                    len(selected_searchers) == 0 or \
                        self._default_searcher.name in selected_searchers):
                dfr = self._default_searcher.search(uri, model)
        else:
            dfr = defer.succeed(None)

        if default_searcher != 'true':

            dfr.addCallback(self._call_searchers, search_path,
                    selected_searchers, uri, model)

        elif len(selected_searchers):
            msg = "You can only pass only one option of only_default and" \
                    " only_searchers"
            return None, defer.fail(TypeError(msg))


        dfr.addCallback(self._model_returner, model)

        return model, dfr

    def _model_returner(self, result, model):
        # the API of get says we have to return the model in the last callback
        return model

    def _call_searchers(self, old_result, search_type, selected_searchers, uri, model):
        try:
            searchers = self._searchers_by_path[search_type]
        except KeyError:
            # no searchers found
            return

        if selected_searchers:
            # there are only certain searchers allowed
            searchers = filter(lambda s: s.name in selected_searchers,
                    searchers)

        def failed(failure, uri):
            if failure.type is defer.CancelledError:
                return
            msg = "A part of the search for %s failed: %s. Skipping"
            self.warning(msg % (uri, failure))
            return None


        # the place to store the dfrs that we receive from the searchers
        searchers_dfrs = []

        @cancellable_deferred_iterator
        def iterate(searchers):
            for searcher in searchers:
                cur_dfr = searcher.search(uri, model)
                cur_dfr.addErrback(failed, uri)

                # we just append it to the list of searcher_dfrs as we don't
                # want to wait for any of them before requesting the next one
                searchers_dfrs.append(cur_dfr)
                yield None

        def cancel_list(deferred):
            # cancel all the deferreds that we got from the searchers
            for search_dfr in searchers_dfrs:
                try:
                    search_dfr.cancel()
                except (defer.AlreadyCalledError, AttributeError):
                    # might already called or is an ordinary deferred so it has
                    # no cancel method
                    pass
            
            # remove the reference we have to the searchers dfrs
            searchers_dfrs[:] = []

        def done(result):
            # after the iteration over the searchers we want to wait for the
            # searchers deferreds before callbacking. So we put them into a
            # DeferredList
            dfr_list = defer.DeferredList(searchers_dfrs)

            # and make it still cancellable by wrapping that it in a
            # CancellableDeferred
            dfr_lst_cancellable = defer.Deferred(cancel_list)
            dfr_list.chainDeferred(dfr_lst_cancellable)
            return dfr_lst_cancellable


        # set up the iteration
        iterator = iterate(searchers)
        iterator_dfr = task.coiterate(iter(iterator))

        def cancelled(deferred):
            # stop the iteration
            iterator.cancel()
            # and cancel any searchers_dfrs that might be out there
            cancel_list(deferred)

        # wrap the iterator_dfr into a CancellableDeferred so that it can be
        # cancelled from inside another CancelableDeferred
        cancellable = defer.Deferred(cancelled)
        iterator_dfr.chainDeferred(cancellable)
        cancellable.addCallback(done)

        return cancellable

    def _clean_searchers(self, res):
        all_searchers = []
        for searchers in self._searchers_by_path.values():
            all_searchers.extend(searchers)

        dfrs = []
        for searcher in set(all_searchers):
            dfrs.append(searcher.clean())

        return defer.DeferredList(dfrs)

    def clean(self):
        dfr = super(SearchMetaresourceProvider, self).clean()
        dfr.addCallback(self._clean_searchers)
        return dfr
