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

import os
import sys
# gst is imported later, because we might not have it yet
#import gst

import pkg_resources
from pkg_resources import DistributionNotFound

import shutil

from twisted.internet import task
from twisted.python import reflect
from twisted.python.failure import Failure

from elisa.core import common, __version__ as core_version
from elisa.core import default_config
from elisa.core.component import ComponentError
from elisa.core.components.message import Message
from elisa.core.log import Loggable
from elisa.core.utils import defer, locale_helper
from elisa.core.utils.internet import get_page

from elisa.extern import enum

try:
    import simplejson as json
except ImportError:
    try:
        import json
    except ImportError:
        json = None

try:
    from hashlib import md5
except ImportError:
    # hashlib is new in Python 2.5
    from md5 import md5


default_plugin_dirs = []
# pkg_resources expects the dir to be in the system encoding
_config_dir = default_config.CONFIG_DIR.encode(locale_helper.system_encoding())
default_plugin_dirs.append(os.path.join(_config_dir, 'plugins'))

_default_plugin_cache = os.path.join(default_config.CONFIG_DIR, 'plugins.cache')


def is_development_egg(dist):
    """
    Check if the distribution is a development egg.

    Development eggs store egg-info and python code in the same toplevel
    directory.

    @param dist: plugin distribution 
    @type dist: C{Distribution}
    """
    return os.path.isdir(dist.location) and \
            not os.path.isdir(os.path.join(dist.location, 'elisa'))

def get_plugin_toplevel_directory(dist):
    """
    Get the top level directory of a plugin distribution.

    Regular eggs and development eggs store files in different locations. Use
    this function to access the top level directory of a plugin eg::
    
        toplevel_directory = get_plugin_toplevel_directory(dist)
        sub_directory = '%s/%s' % (toplevel_directory, 'sub')
        requirement = pkg_resources.Requirement.parse(dist.project_name)
        if pkg_resources.resource_isdir(requirement, sub_directory):
            real_sub_path = pkg_resources.resource_filename(requirement,
                    sub_directory)

    @param dist: plugin distribution
    @type dist: C{Distribution}
    """
    assert dist.project_name.startswith('elisa-plugin-')

    if is_development_egg(dist):
        # development eggs contain egg-info and code in the same dir
        return ''

    # installed eggs have everything under elisa/plugins/plugin_name (stripping
    # elisa-plugin- from project_name)
    src_directory = dist.project_name[13:]
    # some plugins have a dash in their name (like the apple-trailers one)
    # and code is hosted in same directory name but the dashes are
    # replaced by underscores
    src_directory = src_directory.replace('-', '_')
    return 'elisa/plugins/%s' % src_directory


class InvalidComponentPath(ComponentError):
    def __init__(self, component_name):
        super(InvalidComponentPath, self).__init__(component_name)

    def __str__(self):
        return "Invalid component path %s" % self.component_name

class ComponentNotFound(ComponentError):
    def __init__(self, component_name):
        super(ComponentNotFound, self).__init__(component_name)

    def __str__(self):
        return "Component %r not found" % self.component_name

class PluginNotFound(Exception):
    pass

class PluginAlreadyEnabled(Exception):
    pass

class PluginAlreadyDisabled(Exception):
    pass

class DeserializationError(Exception):
    pass

class PluginStatusMessage(Message):
    """
    A plugin has been enabled or disabled.

    @ivar action: C{ActionType.ENABLED} or C{ActionType.DISABLED}
    @type action: C{ActionType}
    @ivar plugin_name: name of the plugin
    @type plugin_name: C{str}
    """
    ActionType = enum.Enum('DISABLED', 'ENABLED')

    def __init__(self, plugin_name, action):
        self.plugin_name = plugin_name
        self.action = action

    def __str__(self):
        return '<PluginStatusMessage %s %s>' % (self.plugin_name, self.action)

class PluginRegistry(Loggable):

    """
    The plugin registry handles plugins in Elisa as long as it is running.

    DOCME more.
    """

    config_section_name = 'plugin_registry'
    default_config = \
        {'repository': 'http://plugins.moovida.com/plugin_list',
         'update_plugin_cache': True,
         'auto_update_plugins': True,
         'auto_install_new_recommended_plugins': True}
    config_doc = \
        {'repository': 'The plugin repository to query for new plugins and ' \
            'plugin updates.',
         'update_plugin_cache': 'Whether to periodically query the plugin ' \
            'repository to update the plugin cache. If False, automatic ' \
            'plugin updates and downloading of new recommended plugins will ' \
            'be deactivated.',
         'auto_update_plugins': 'Whether to silently install all available ' \
            'plugin updates. Ignored if update_plugin_cache is False.',
         'auto_install_new_recommended_plugins': 'Whether to silently ' \
            'install all new recommended plugins available. Ignored if ' \
            'update_plugin_cache is False.'}

    def __init__(self, config=None, plugin_dirs=None):
        super(PluginRegistry, self).__init__()

        if plugin_dirs is None:
            from elisa.core.launcher import plugin_directories
            plugin_dirs = plugin_directories

        self.plugin_dirs = plugin_dirs

        self._plugin_status = {}
        self._downloadable_plugins = []

        self._plugin_status_changed_callbacks = []

        self._create_config_section(config)

    def _create_config_section(self, config):
        self.config = config.get_section(self.config_section_name)
        if self.config is None:
            # There is no section for the plugin registry, create one
            config.set_section(self.config_section_name, self.default_config,
                               doc=self.config_doc)
            self.config = config.get_section(self.config_section_name)

        for key, value in self.default_config.iteritems():
            self.config.setdefault(key, value)

    # here be the dragons
    def _deactivate_dist(self, working_set, name='elisa'):
        # the list of deactivated distributions
        dists = []
        # the paths containing the deactivated distributions
        paths = []

        for dist in list(working_set):
            if not dist.key.startswith(name):
                continue

            dists.append(dist)

            self.debug('Removing dist %s [%s]' % (dist, dist.location))
            # what we want to do here is this:
            # working_set.entry_keys[dist.location].remove(dist.key)
            # unfortunately some versions of pkg_resources don't normalize
            # path entries so the same distribution could be under different paths
            entries = [(path_entry, keys) 
                    for path_entry, keys in working_set.entry_keys.iteritems()
                    if dist.key in keys]
            for entry, keys in entries:
                keys.remove(dist.key)

            del working_set.by_key[dist.key]

            if dist.location not in paths:
                # this is the first distribution installed in dist.location that
                # we deactivate
                paths.append(dist.location)

                # dist_paths is a list of sys.path-like entries that we need to
                # remove dist.location from
                dist_paths = [working_set.entries]

                if working_set.entries is not sys.path:
                    # make sure that we remove the location from sys.path
                    dist_paths.append(sys.path)

                # for uninstalled distributions, we add the location to
                # elisa.plugins.__path__ (or elisa.__path__ for core) so we need
                # to remove it here
                if is_development_egg(dist):
                    self._fix_uninstalled_plugin(dist, 'remove')

                for entries in dist_paths:
                    # remove dist.location from the working set, it will be added
                    # back later when the first distribution in dist.location is
                    # activated.

                    try:
                        entries.remove(dist.location)
                    except ValueError:
                        # dist.location is always normalized but "entries"
                        # contains unnormalized paths. This usually happens on
                        # windows.
                        normalized_entries = [pkg_resources.normalize_path(entry)
                                for entry in entries]
                        try:
                            index = normalized_entries.index(dist.location)
                            del entries[index]
                        except ValueError:
                            pass

        return dists, paths

    def _fix_uninstalled_plugin(self, dist, action):
        assert action in ('add', 'remove')

        # dist is an uncompressed egg that contains the modules
        # directly under dist.location and not under
        # dist.location/elisa/plugins. With our current layout this
        # means all the plugins when running elisa uninstalled.
        self.info('%s is an uninstalled plugin' % dist)

        normalized_location = pkg_resources.normalize_path(dist.location)
        parent = os.path.dirname(normalized_location)
        path = None
        if dist.key == 'elisa':
            import elisa
            path = elisa.__path__
        else:
            import elisa.plugins
            path = elisa.plugins.__path__

        if action == 'add':
            if parent not in path:
                path.append(parent)
        elif action == 'remove':
            try:
                path.remove(parent)
            except ValueError:
                pass

    def _add_gstreamer_path(self, path):
        # imported here because we may not have it before loading the binary
        # plugins.
        import gst
        registry = gst.registry_get_default()
        registry.add_path(path)
        registry.scan_path(path)

    def _path_hacks_workaround(self):
        """
        This is one more ugly hack to sys.path to compensate for the change of
        order of sys.path resulting from load_plugins().
        This is to work around https://bugs.launchpad.net/elisa/+bug/324444
        """
        # The way we do it is by moving the python-support dirs to the end of
        # sys.path.
        for path in sys.path:
            if 'python-support' in path:
                sys.path.remove(path)
                sys.path.append(path)

    def load_plugins(self, disabled_plugins=[]):
        """
        Load plugins from self.plugin_dirs.

        @attention: This function should be called as early as possible at
                    startup, B{before} using any plugin.

        @note: This function runs without returning to the reactor for as
               long as it takes. There is no point in making it return before
               it is done as the plugin environment needs to be set up before
               any other part of elisa can run correctly.

        @note: By default, all the available plugins are enabled.

        @param disabled_plugins: a list of plugins that should be disabled
        @type disabled_plugins:  C{list} of C{str}
        """
        self.info('loading plugins from %s' % self.plugin_dirs)

        # deactivate the plugins in the current working set so we can activate
        # new versions in self.plugin_dirs
        old_dists, paths = self._deactivate_dist(pkg_resources.working_set, 'elisa')

        import elisa.plugins
        # empty elisa.plugins.__path__, it will be populated again when we add
        # back the plugins
        elisa.plugins.__path__ = []

        # paths contains a list with the paths of the distributions that we
        # deactivated in deactivated_dist(). When load_plugins() is called for
        # the first time it contains the path of the plugins installed system
        # wide (if there is one).
        plugin_dirs = self.plugin_dirs + paths

        env = pkg_resources.Environment(plugin_dirs)
        distributions, errors = pkg_resources.working_set.find_plugins(env)

        for dist, error in errors.iteritems():
            if isinstance(error, DistributionNotFound):
                self.warning('plugin %s has the following '
                        'unmet dependencies: %s' % (dist.project_name, error))
            else:
                self.warning('plugin %s version conflict: %s' %
                        (dist.project_name, error))

        def _plugin_priority(dist):
            # return the priority of a plugin, to sort them on whether they
            # are binary extensions or not, then considering if they are in
            # the installed directory or in a user/ELISA_PLUGINS directory.

            # FIXME: what about binary plugins that depend on other binary
            # plugins? (pgm would depend on gst).

            priority = 0
            location = os.path.normcase(os.path.normpath(dist.location))
            for plugin_dir in self.plugin_dirs:
                norm_dir = os.path.normcase(os.path.normpath(plugin_dir))
                if location.startswith(norm_dir):
                    priority |= 1
                    break

            if dist.key != 'elisa' \
                           and not dist.key.startswith('elisa-plugin-') \
                           and dist.has_metadata('native_libs.txt'):
                priority |= 2

            if dist.key == 'gstreamer':
                # the gstreamer plugin is needed by other binary plugins,
                # therefore it needs a higher priority
                priority |= 4

            elif dist.key == 'elisa':
                # the core should have the highest priority since all plugins
                # supposedly depend on it.
                priority |= 8

            return priority
            
        distributions.sort(key=_plugin_priority, reverse=True)

        def _is_an_elisa_plugin(dist):
            # Determine whether a given distribution is a plugin to be loaded
            # in Moovida. Such candidates are:
            #  - the core (elisa)
            #  - normal plugins (elisa-plugin-.*)
            #  - binary plugins (e.g. gstreamer, on windows)
            # We cannot rely on the presence of the native_libs.txt metadata in
            # the distribution as other completely unrelated distributions may
            # match this criterion
            # (see http://bugs.launchpad.net/elisa/+bug/383662).
            # Note that the _clean_ way to do this would be for all plugins to
            # ship a specific metadata identifying itself as a Moovida plugin.
            whitelist = ['gstreamer'] # Add here other binary plugins
            key = dist.key
            if key == 'elisa':
                return True
            elif key.startswith('elisa-plugin-'):
                return True
            elif key in whitelist:
                return True
            else:
                return False

        # add the plugins to the active working set
        for dist in distributions:
            if _is_an_elisa_plugin(dist):
                try:
                    self.load_plugin(dist)
                except OSError:
                    continue

                self._plugin_status[dist.key] = (dist.key not in disabled_plugins)


        self._path_hacks_workaround()

        self.info('loaded %d plugins' % len(self._plugin_status))

    def load_plugin(self, plugin):
        """
        Load a given plugin in elisa.

        @param plugin: the plugin to load
        @type plugin:  L{pkg_resources.Distribution}
        """
        self.info('Loading %s %s [%s]' % (plugin.key, plugin.version,
                                             plugin.location))

        # Check if the plugin is platform specific. If so, do not load it if
        # the target platform does not match.
        self.get_plugin_metadata(plugin)
        if hasattr(plugin, 'platforms') and len(plugin.platforms) > 0:
            current_platform = pkg_resources.get_platform()
            compatible = False
            for platform in plugin.platforms:
                if current_platform.startswith(platform):
                    compatible = True
                    break
            if not compatible:
                raise OSError('%s cannot be used on this platform.' % \
                              plugin.key)

        # Do some extra work if the plugin is an uncompressed, uninstalled
        # plugin.
        if is_development_egg(plugin):
            self._fix_uninstalled_plugin(plugin, 'add')

        # Add the distribution to the working set.
        pkg_resources.working_set.add(plugin)

        if plugin.key == 'elisa':
            # The core doesn't need extra processing (no elisa.plugins path
            # black magic, doesn't ship gstreamer plugins, no hooks).
            return

        # We emptied elisa.plugins.__path__ before loading the plugins, add the
        # current plugin's root if needed. pkg_resources takes care of it for
        # eggs but for some reason it doesn't for uncompressed plugins.
        plugins_path = os.path.join(plugin.location, 'elisa', 'plugins')
        if os.path.exists(plugins_path) and os.path.isdir(plugins_path):
            import elisa.plugins
            if plugins_path not in elisa.plugins.__path__:
                elisa.plugins.__path__.append(plugins_path)

        # Plugins can ship gstreamer plugins in a directory called 'gstreamer'
        # under the top level plugin directory.
        if plugin.project_name.startswith('elisa-plugin'):
            # FIXME: handle that in the load hook of the relevant plugins
            toplevel_directory = get_plugin_toplevel_directory(plugin)
            # pkg_resources.resource_filename() expects slashes regardless of the
            # value of os.sep
            gstreamer_directory = '%s/%s' % (toplevel_directory, 'gstreamer')
            requirement = pkg_resources.Requirement.parse(plugin.project_name)
            if pkg_resources.resource_isdir(requirement, gstreamer_directory):
                gst_path = pkg_resources.resource_filename(requirement,
                                                           gstreamer_directory)
                # gstreamer's registry supports unicode, let's use it,
                # especially for Windows where gst_path can
                # contain accentuated characters (if home directory
                # contains some and the gstreamer plugin is located in
                # home directory).
                gst_path = gst_path.decode(locale_helper.system_encoding())
                self._add_gstreamer_path(gst_path)
        else:
            plugin.activate()

        self._plugin_status[plugin.key] = False

        # we don't use ._call_hook because we want this to be blocking
        try:
            load_hook = plugin.load_entry_point('elisa.core.plugin_registry',
                                                'load')
        except ImportError:
            pass
        else:
            load_hook()


    def unload_plugin(self, plugin):
        """
        Unload a given plugin from elisa.

        @param plugin: the plugin to unload
        @type plugin:  L{pkg_resources.Distribution}

        @return:       a deferred fired when the plugin is fully unloaded
        @rtype:        L{twisted.internet.defer.Deferred}
        """
        # First disable the plugin if needed.
        def failed_to_disable(failure):
            failure.trap(PluginAlreadyDisabled)

        dfr = self.disable_plugin(plugin.key)
        dfr.addErrback(failed_to_disable)

        def unload(result):
            self.info('Unloading plugin %s' % plugin.key)
            # TODO: actually unload the plugin.
            return result

        dfr.addCallback(unload)
        return dfr

    def register_plugin_status_changed_callback(self, callback):
        """
        Register a callback to be fired upon (de)activation of a plugin.
        This callback will be passed the plugin (L{pkg_resources.Distribution})
        and the new status (C{bool}, C{True} for enabled, C{False} for
        disabled) as parameters, and should return a deferred. The plugin
        status will be considered as changed (and the corresponding message
        emitted) only when all the resulting deferreds of the registered
        callbacks have fired (or errored).

        @param callback: a callback
        @type callback:  C{callable}
        """
        self._plugin_status_changed_callbacks.append(callback)

    def unregister_plugin_status_changed_callback(self, callback):
        """
        Unregister a callback that was previously registered to be fired upon
        (de)activation of a plugin.

        @param callback: a callback previously registered
        @type callback:  C{callable}

        @raise ValueError: if the callback is not registered
        """
        self._plugin_status_changed_callbacks.remove(callback)

    def _fire_registered_callbacks(self, result, plugin, status):

        def print_warning(failure, callback):
            self.warning("Callback %s(%s, %s) failed: %s" %
                    (callback, plugin, status, failure))

        def iterate_registered_callbacks():
            for callback in self._plugin_status_changed_callbacks:
                dfr = callback(plugin, status)
                dfr.addErrback(print_warning, callback)
                yield dfr

        return task.coiterate(iterate_registered_callbacks())

    def enable_plugins(self):
        """
        Enable all the plugins that should be enabled.

        @attention: this method can be called only once and should be called
                    upon startup of the application, after L{load_plugins}.

        @return:       a deferred fired when all plugins have been enabled
        @rtype:        L{twisted.internet.defer.Deferred}
        """
        def iterate_plugins():
            for plugin_name, enabled in self._plugin_status.iteritems():
                if not enabled:            
                    continue
                # enable_plugin() expects the plugin to be disabled
                self._plugin_status[plugin_name] = False
                yield self.enable_plugin(plugin_name)

        return task.coiterate(iterate_plugins())

    def enable_plugin(self, plugin_name):
        """
        Enable a plugin.

        @param plugin_name: the name of the plugin to enable
        @type plugin_name:  C{str}

        @return:            a deferred fired when the plugin is enabled
        @rtype:             L{twisted.internet.defer.Deferred}
        """
        plugin = self.get_plugin_by_name(plugin_name)

        def update_configuration(result):
            config = common.application.config
            disabled_plugins = config.get_option('disabled_plugins',
                                                 section='general',
                                                 default=[])
            try:
                disabled_plugins.remove(plugin_name)
            except ValueError:
                pass
            else:
                config.set_option('disabled_plugins', disabled_plugins,
                                  section='general')
            return result

        def send_message(result):
            message = PluginStatusMessage(plugin_name,
                    PluginStatusMessage.ActionType.ENABLED)
            common.application.bus.send_message(message)

        try:
            if self._plugin_status[plugin_name]:
                return defer.fail(PluginAlreadyEnabled(plugin_name))
        except KeyError:
            return defer.fail(PluginNotFound(plugin_name))

        self.info('Enabling plugin %s' % plugin_name)
        self._plugin_status[plugin_name] = True 

        dfr = self._call_hook(plugin_name, 'enable')
        dfr.addCallback(update_configuration)
        dfr.addCallback(self._fire_registered_callbacks, plugin, True)
        dfr.addCallback(send_message)
        return dfr

    def disable_plugin(self, plugin_name, permanent=False):
        """
        Disable a plugin.

        @param plugin_name: the name of the plugin to disable
        @type plugin_name:  C{str}
        @param permanent: whether the plugin should be disabled permanently
                            (in the configuration), or only for this run
        @type  permanent: C{bool}

        @return:            a deferred fired when the plugin is disabled
        @rtype:             L{twisted.internet.defer.Deferred}
        """
        plugin = self.get_plugin_by_name(plugin_name)

        def update_configuration(result):
            config = common.application.config
            disabled_plugins = config.get_option('disabled_plugins',
                                                 section='general',
                                                 default=[])
            if plugin_name not in disabled_plugins:
                disabled_plugins.append(plugin_name)
                config.set_option('disabled_plugins', disabled_plugins,
                                  section='general')
            return result

        def send_message(result):
            message = PluginStatusMessage(plugin_name,
                    PluginStatusMessage.ActionType.DISABLED)
            common.application.bus.send_message(message)

        try:
            if not self._plugin_status[plugin_name]:
                return defer.fail(PluginAlreadyDisabled(plugin_name))
        except KeyError:
            return defer.fail(PluginNotFound(plugin_name))

        self.info('Disabling plugin %s' % plugin_name)
        self._plugin_status[plugin_name] = False

        dfr = self._call_hook(plugin_name, 'disable')
        if permanent:
            dfr.addCallback(update_configuration)
        dfr.addCallback(self._fire_registered_callbacks, plugin, False)
        dfr.addCallback(send_message)
        return dfr

    def get_plugins(self):
        """
        Get the list of available plugins.

        This call returns (plugin_name, status) tuples, where status is C{True}
        if the plugin is enabled, C{False} otherwise.

        @return: a generator yielding (plugin_name, status) tuples
        @rtype:  C{generator}
        """
        return self._plugin_status.iteritems()

    def get_enabled_plugins(self):
        """
        Get the list of enabled plugins.

        @return: generator yielding plugin names
        @rtype: C{generator}
        """
        for name, status in self._plugin_status.iteritems():
            if status == False:
                continue

            yield name

    def get_plugin_names(self):
        """
        Get the names of the installed plugins.

        @return: a generator yielding plugin names
        @rtype:  C{generator}
        """
        return self._plugin_status.iterkeys()

    def get_plugin_by_name(self, plugin_name):
        """
        Return the plugin matching a given name.

        @param plugin_name: the name of the plugin
        @type plugin_name:  C{str}

        @return: the plugin, or C{None} if no plugin matches the given name
        @rtype:  L{pkg_resources.Distribution}
        """
        requirement = pkg_resources.Requirement.parse(plugin_name)
        return pkg_resources.working_set.find(requirement)

    def get_plugin_metadata(self, plugin):
        """
        Read and populate the metadata of a plugin.

        @param plugin: a plugin
        @type plugin:  L{pkg_resources.Distribution}
        """
        # FIXME: isn't there a simpler way to read the plugin standard's
        # metadata?
        metadata = 'PKG-INFO'
        attributes = {'Author': 'author', 'Summary': 'summary',
                      'License': 'license', 'Description': 'description'}
        if not plugin.has_metadata(metadata):
            return
        lines = plugin.get_metadata_lines(metadata)
        for line in lines:
            try:
                key, value = line.split(':', 1)
            except ValueError:
                continue
            else:
                value = value.strip()
                if value == 'UNKNOWN':
                    value = None
                if key == 'Platform':
                    # There may be several compatible platforms
                    if value is None:
                        plugin.platforms = []
                        continue
                    try:
                        plugin.platforms.append(value)
                    except AttributeError:
                        plugin.platforms = [value]
                    continue
                for attr_key, attr in attributes.iteritems():
                    if key == attr_key:
                        setattr(plugin, attr, value)
                        attributes.pop(key)
                        break

    def _get(self, uri):
        # This method is meant to be monkey-patched in unit tests.
        return get_page(uri)

    def _deserialize_cache(self, cache):
        """
        Here the actual deserialization is done.

        The present file format is json, but that can be changed without
        affecting the rest of the system.

        @param cache: the serialized data
        @type cache: C{str}
        @return: the C{list} of plugins
        @rtype: C{list} of C{dict}
        """
        if json is None:
            raise DeserializationError("Couldn't import json.")

        try:
            return json.JSONDecoder().decode(cache)
        except ValueError:
            raise DeserializationError("Error while deserializing cache.")

    @property
    def plugin_cache(self):
        return _default_plugin_cache

    @property
    def plugin_repository(self):
        return self.config.get('repository')

    def reload_cache(self):
        """
        Load the cached information about downloadable plugins.

        @return: whether the loading went well.
        @rtype: C{bool}
        """
        try:
            cache = open(self.plugin_cache).read()
        except IOError:
            self.warning("Cannot read cache file '%s'" % self.plugin_cache)
            return False

        try:
            self._downloadable_plugins = self._deserialize_cache(cache)
            return True
        except DeserializationError:
            self._downloadable_plugins = []
            return False

    def update_cache(self):
        """
        Update the cached information about downloadable plugins.

        At present only one remote, hardcoded repository is supported.

        @return: a deferred triggered when the cache is updated
        @rtype:  L{elisa.core.utils.defer.Deferred}
        """
        def deserialize_cache(result):
            try:
                self._downloadable_plugins = self._deserialize_cache(result)
            except DeserializationError, error:
                return defer.fail(error)
            else:
                return result

        def store_cache(result):
            cache = open(self.plugin_cache, 'wb')
            cache.write(result)
            cache.close()

            self.log("Stored the plugin information to cache file %s" % \
                     self.plugin_cache)

            return self.plugin_cache

        def error(failure):
            self.warning("Couldn't update the cache: %s" % failure)
            return failure

        uri = '%s?elisa_version=%s' % (self.plugin_repository, core_version)
        dfr = self._get(uri)
        dfr.addCallbacks(deserialize_cache, error)
        dfr.addCallbacks(store_cache, error)
        return dfr

    def get_downloadable_plugins(self, reload_cache=False):
        """
        The list of downloadable plugins.

        Each plugin is represented as a Python dictionary.

        @param reload_cache: whether to reload the local cache from disk
        @type reload_cache: C{bool}
        @return: a C{list} of one C{dict} per plugin.
        @rtype: C{list}
        """
        if reload_cache:
            self.reload_cache()
        return self._downloadable_plugins

    def download_plugin(self, plugin):
        """
        Download one plugin.

        A plugin is represented with a dictionary. Some expected keys are
        listed at http://www.moovida.com/wiki/Specs/PluginsMetadata. Here we
        just rely on 'egg_name' and 'uri'. If they change, this code will
        simply break.

        @param plugin: the plugin dictionary
        @type plugin:  C{dict}

        @return:       a deferred triggered when done, reporting the path to
                       the downloaded egg file
        @rtype:        L{elisa.core.utils.defer.Deferred}
        """
        def check_integrity(egg_data):
            try:
                ref_checksum = plugin['checksum']
            except KeyError:
                # Do not trust a plugin without a checksum
                ref_checksum = None
            checksum = md5(egg_data).hexdigest()
            if checksum != ref_checksum:
                msg = 'Egg integrity verification failed for %s.' % \
                      str(plugin['egg_name'])
                self.warning(msg)
                return Failure(ValueError(msg))
            return egg_data

        def store_plugin(egg_data):
            plugin_dir = default_plugin_dirs[0]
            if not os.path.exists(plugin_dir):
                os.mkdir(plugin_dir)
            plugin_path = os.path.join(plugin_dir, str(plugin['egg_name']))
            plugin_file = open(plugin_path, 'wb')
            plugin_file.write(egg_data)
            plugin_file.close()

            self.log("Plugin downloaded and stored into %s" % plugin_path)

            return plugin_path

        def error(failure):
            self.warning("Couldn't download plugin: %s" % failure)
            return failure

        dfr = self._get(str(plugin['uri']))
        dfr.addCallbacks(check_integrity, error)
        dfr.addCallbacks(store_plugin, error)
        return dfr

    def install_plugin(self, egg_file, plugin_name):
        """
        Install a plugin from a local egg file.

        If needed, the egg file will be copied over to the local plugins
        directory and the older version of the plugin will be unloaded.
        The plugin will then be loaded and enabled.

        @param egg_file:    the full path to the egg file on disk
        @type egg_file:     C{str}
        @param plugin_name: the internal name of the plugin
        @type plugin_name:  C{str}

        @return: a deferred fired when the plugin is installed
        @rtype:  L{elisa.core.utils.defer.Deferred}
        """
        # Copy the egg_file to the local plugins directory if needed
        plugins_dir = default_plugin_dirs[0]
        dirname = os.path.dirname(egg_file)
        basename = os.path.basename(egg_file)
        if dirname != plugins_dir:
            try:
                shutil.copyfile(egg_file, os.path.join(plugins_dir, basename))
            except (shutil.Error, IOError), error:
                msg = 'Failed to install %s: %s.' % (basename, error)
                self.warning(msg)
                return defer.fail(error)

        # Unload the currently loaded older version of the plugin
        if plugin_name in list(self.get_plugin_names()):
            plugin = self.get_plugin_by_name(plugin_name)
            dfr = self.unload_plugin(plugin)
        else:
            dfr = defer.succeed(None)

        def load_and_enable_plugin(result):
            # Load the new version of the plugin
            pkg_resources.working_set.add_entry(egg_file)
            plugin = self.get_plugin_by_name(plugin_name)
            self.load_plugin(plugin)
            # And enable it
            enable_dfr = self.enable_plugin(plugin_name)
            return enable_dfr

        dfr.addCallback(load_and_enable_plugin)
        return dfr

    def update_plugin(self, plugin_dict):
        """
        Update an installed plugin for which a newer version is available in
        the plugin repository.

        Updating a plugin will disable the current version if needed, unload
        it, download the new version, load it and enable it.

        @attention: this code assumes that the plugin to update is currently
                    installed and that the plugin repository actually provides
                    a newer version. Behaviour outside of these constraints is
                    undefined.

        @param plugin_dict: a dictionary representing the plugin as returned by
                            L{get_downloadable_plugins}
        @type plugin_dict:  C{dict}

        @return:            a deferred fired when the update is complete
        @rtype:             L{elisa.core.utils.defer.Deferred}
        """
        plugin_name = plugin_dict['name']
        self.info('Updating %s' % plugin_name)
        dfr = self.download_plugin(plugin_dict)
        dfr.addCallback(self.install_plugin, plugin_name)
        return dfr

    def install_new_recommended_plugins(self):
        """
        Download and install all the recommended plugins.

        This will typically happen when first running Elisa after installation,
        if the user selected the 'Install Recommended Plugins' option in the
        installer.

        @return: a deferred fired when the installation is complete
        @rtype:  L{elisa.core.utils.defer.Deferred}
        """
        def filter_recommended_plugins(plugin_dicts, recommended):
            installed = list(self.get_plugin_names())
            for plugin_dict in plugin_dicts:
                if plugin_dict['quality'] == 'recommended' and \
                    plugin_dict['name'] not in installed:
                    recommended.append(plugin_dict)
                yield None

        def get_plugin_list(cache_file):
            plugin_dicts = self.get_downloadable_plugins(reload_cache=True)
            recommended = []
            dfr = task.coiterate(filter_recommended_plugins(plugin_dicts,
                                                            recommended))
            dfr.addCallback(lambda result: recommended)
            return dfr

        def download_and_install_plugin(plugin_dict):
            def _install_failed(failure):
                # Log and swallow the error
                self.warning('Failed to install %s: %s.' % \
                             (plugin_dict['name'], failure))
                return None

            def _install_plugin(egg_file):
                install_dfr = self.install_plugin(egg_file,
                                                  plugin_dict['name'])
                install_dfr.addErrback(_install_failed)
                return install_dfr

            def _download_failed(failure):
                # Log and swallow the error
                self.warning('Downloading %s failed. Skipping.' % \
                             plugin_dict['name'])
                return None

            dfr = self.download_plugin(plugin_dict)
            dfr.addCallbacks(_install_plugin, _download_failed)
            return dfr

        def download_and_install_plugins(plugin_dicts):
            def iterate_plugins(plugin_dicts):
                for plugin_dict in plugin_dicts:
                    yield download_and_install_plugin(plugin_dict)

            return task.coiterate(iterate_plugins(plugin_dicts))

        dfr = self.update_cache()
        dfr.addCallback(get_plugin_list)
        dfr.addCallback(download_and_install_plugins)
        return dfr

    def create_component(self, path, config=None, **kwargs):
        """
        Create a component given its path.

        The path is in module:Component syntax, eg
        elisa.plugins.my_plugin:MyComponent.

        @param path: the component path
        @type path: C{str}
        @param config: the configuration to set for the component
        @type config: L{elisa.core.config.Config}
        @return: an instance of the component identified by C{path}
        @rtype: L{elisa.core.component.Component} or a subclass
        """

        if not path.startswith('elisa.'):
            # a shortcut so we don't have to specify the whole path in the conf
            # file
            path = 'elisa.plugins.' + path

        self.debug('creating component %s' % path)

        try:
            module, klass = path.split(':', 1)
        except ValueError:
            return defer.fail(InvalidComponentPath(path))

        try:
            component_class = reflect.namedAny('%s.%s' % (module, klass))
        except:
            # it's ok to catch everything here and errback
            return defer.fail()

        self.debug('got class %s, calling create()'
                % reflect.qual(component_class))
        return component_class.create(config, **kwargs)

    def _call_hook(self, plugin_name, hook_name):
        plugin = self.get_plugin_by_name(plugin_name)

        try:
            hook = plugin.load_entry_point('elisa.core.plugin_registry',
                                           hook_name)
        except ImportError:
            dfr = defer.succeed(None)
        else:
            dfr = hook()

        return dfr
