# -*- coding: utf-8 -*-
# Elisa - Home multimedia server
# Copyright (C) 2006-2008 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 Elisa with Fluendo's plugins.
#
# The GPL part of Elisa is also available under a commercial licensing
# agreement from Fluendo.
# See "LICENSE.Elisa" in the root directory of this distribution package
# for details on that license.

"""
Plugins load/management support
"""


__maintainer__ = 'Philippe Normand <philippe@fluendo.com>'

from elisa.core import __version__ as elisa_version
from elisa.core import log, config
from elisa.core.plugin import Plugin
from elisa.core.component import ComponentError, UnMetDependency, InitializeFailure
from elisa.core.component import UnSupportedPlatform
from elisa.core.utils import classinit, misc
from elisa.core import common
from elisa.extern import path
from sets import Set
import pkg_resources
import os, sys, re, gc
import inspect
import types
import new
from twisted.internet import defer

DEFAULT_PLUGIN_DIR = os.path.expanduser('~/.elisa/plugins')

# FIXME: why is this shortcut not in common ?
def get_component_class(component_path):
    """ This is a shortcut to L{elisa.core.plugin_registry.PluginRegistry.get_component_class}

    @rtype: L{elisa.core.component.Component} class
    """
    plugin_registry = common.application.plugin_registry
    component_class = plugin_registry.get_component_class(component_path)
    return component_class

class PluginNotFound(Exception):

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

    def __str__(self):
        return "Plugin %r not found" % self.plugin_name

class ComponentNotFound(Exception):

    def __init__(self, component_name):
        Exception.__init__(self)
        self.component_name = component_name

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

class PluginRegistry(log.Loggable):
    """
    The PluginRegistry is responsible to find all the Plugins. Plugins
    enabled in the Application config file will be loaded by
    instantiating their class.

    The registry can create components by first searching them by name
    in Plugins component registries and instantiating them directly.
    Component instances are not handled by the PluginRegistry.

    @ivar _plugin_classes:   Plugin classes found by the Registry
    @type _plugin_classes:   dict mapping Plugin names to Plugin classes
    @ivar _plugin_instances: Plugins currently instantiated
    @type _plugin_instances: L{elisa.core.plugin.Plugin} list
    @ivar _app_config:       Application's config
    @type _app_config:       L{elisa.core.config.Config}
    """

    # Allows property fget/fset/fdel/doc overriding
    __metaclass__ = classinit.ClassInitMeta
    __classinit__ = classinit.build_properties

    def __init__(self, application_config):
        """ Initialize the PluginRegistry instance variables and the
        local plugin directory.

        @param application_config: Application's config
        @type application_config:  L{elisa.core.config.Config}
        """
        log.Loggable.__init__(self)
        self.debug("Creating")

        self._plugin_directories = []
        self._plugin_classes = {}
        self._plugin_instances = {}
        self._app_config = application_config
        self._init_local_plugins_dir()
        
    def _init_local_plugins_dir(self):
        """
        Register the local plugins directory in pkg_resources. The
        directory name is found in the application's config in
        'plugins_dir' option which is in the 'general' section
        """
        default = DEFAULT_PLUGIN_DIR
        
        if not os.path.exists(default):
            try:
                os.makedirs(default)
            except OSError, e:
                self.warning("Could not make directory %r: %s", default, e)
                default = ''
            else:
                default_pkg = os.path.join(default, '__init__.py')
                if not os.path.exists(default_pkg):
                    try:
                        open(default_pkg, 'w').close()
                    except IOError, error:
                        self.warning("Could not create %r: %r", default_pkg,
                                     error)
                        default = ''

        directories = misc.env_var_explode_list('ELISA_PLUGIN_PATH', default)
        for directory in directories:
            if not directory:
                continue

            directory = os.path.expanduser(directory)

            if directory.endswith(os.path.sep):
                directory = directory[:-1]

            if not os.path.exists(os.path.join(directory, '__init__.py')):
                self.warning("__init__.py file missing in %r; "
                             "skipping plugin directory registration",
                             directory)
            else:
                self.info("Plugin directory: %r", directory)
                self._plugin_directories.append(directory)

        if default not in self._plugin_directories:
            self.info("Adding default plugins directory: %r", default)
            self._plugin_directories.insert(0, default)

    def _init_environment(self, plugin_directories):
        if not plugin_directories:
            plugin_directories = self._plugin_directories

        self.info("Initializing environment for plugin directories %s",
                  plugin_directories)
        
        # set up our custom environment where plugins are located
        environment = pkg_resources.Environment(plugin_directories)

        working_set = pkg_resources.working_set

        # cope with old versions of setuptools
        if hasattr(working_set, 'find_plugins'):
            distributions, errors = working_set.find_plugins(environment)
            map(working_set.add, distributions)
            if errors:
                self.warning("Couldn't load: %s" % errors)
        else:
            self.warning("Please upgrade setuptools if you want to "\
                         "be able to use additional plugins  "\
                         "from %r", plugin_directories)

    def plugins__get(self):
        return self._plugin_instances

    def plugin_classes__get(self):
        return self._plugin_classes

    def register_plugin(self, plugin_class):
        """ Add the given plugin class to our internal plugins list.

        @param plugin_class: Plugin's class
        @type plugin_class:  class
        """
        plugin_class.load_config()
        if common.application and common.application.translator:
            plugin_class.load_translations(common.application.translator)

        try:
            errors = plugin_class.initialize()
        except (UnMetDependency,InitializeFailure), error:
            self.warning(error)
        else:
            name = plugin_class.name
            self._plugin_classes[name] = plugin_class
            self.info("Loaded the plugin %r version %r from %r", name,
                      plugin_class.version, plugin_class.directory)

            names = plugin_class.components.keys()
            names.sort()
            component_names = ', '.join(names)
            self.debug("Components available in %r plugin: %s", name,
                       component_names)

    def unregister_plugin(self, plugin_class):
        """ Remove the given plugin class from our internal plugins list.

        @param plugin_class: Plugin's class
        @type plugin_class:  class
        """
        plugin_name = plugin_class.name

        # TODO: remove plugin's instance if it exists

        # remove the plugin_class from self._plugin_classes
        if plugin_name in self._plugin_classes:
            del self._plugin_classes[plugin_name]
            self.info("Unloaded the plugin '%s'" % plugin_name)

    def get_plugin_with_name(self, name):
        """
        Look for the plugin with given name, if it's not found,
        instantiate it and return it. If no plugin with such name is
        found, raise an exception PluginNotFound.

        @param name: name of the Plugin to look for
        @type name:  string
        @rtype:      L{elisa.core.plugin.Plugin}
        @raise PluginNotFound: if the plugin could not be found
        """
        # first try in instantiated plugins list
        plugin = self._plugin_instances.get(name)

        # try to instantiate a new Plugin class
        if plugin == None:
            plugin_class = self._plugin_classes.get(name)

            if plugin_class != None:
                plugin = plugin_class()
                self._plugin_instances[name] = plugin
            else:
                raise PluginNotFound(name)

        return plugin

    def load_plugins(self, plugin_directories=None):
        """
        Find and register all Plugin classes found by setuptools and
        in the plugins directory.

        @keyword plugin_directories: list of directories to scan for
                                     plugins default:
                                     [DEFAULT_PLUGIN_DIR,] + $ELISA_PLUGIN_PATH
        @type plugin_directories:    list of strings
        """
        self._init_environment(plugin_directories=plugin_directories)
        
        self._register_core()
        
        # load plugins registered via various means
        self._load_eggs()
##         self._load_packages()
##         self._load_modules()
        self._load_by_config()

        self._check_plugin_dependencies() 

    def _register_core(self):
        plugin_class = new.classobj('Core', (Plugin,),{})
        plugin_class.name = 'core'
        plugin_class.version = elisa_version
        plugin_class.plugin_dependencies = []

        import elisa.core
        directory = os.path.dirname(elisa.core.__file__)
        plugin_class.directory = directory

        elisa_root = os.path.dirname(os.path.dirname(directory))

        dist = None
        distribution_file = ""
        try:
            dist = pkg_resources.require('elisa')[0]
        except pkg_resources.DistributionNotFound:
            if os.path.isdir(elisa_root):
                dists = [ distribution for distribution in
                          pkg_resources.find_distributions(elisa_root, True)
                          if distribution.project_name == 'elisa' and
                          distribution.version == elisa_version ]
                if dists:
                    dist = dists[0]

        if dist and os.path.isfile(dist.location):
            distribution_file = dist.location

        plugin_class.distribution_file = distribution_file
        
        self.register_plugin(plugin_class)

    def _check_plugin_dependencies(self):
        plugin_classes = self._plugin_classes.copy()
        for plugin_name, plugin_class in plugin_classes.iteritems():
            try:
                self.check_interplugin_dependencies(plugin_class)
            except UnMetDependency, exception:
                msg = "plugin %r is missing the following" \
                      " dependencies: %s" \
                       % (plugin_name, exception.error_message)
                self.warning(msg)
                continue

            # for each component, check eventual inter-component deps
            components = plugin_class.components.copy()
            for component_name, informations in components.iteritems():
                try:
                    self.check_intercomponent_dependencies(plugin_class,
                                                           component_name)
                except UnMetDependency, exception:
                    msg = "component %r is missing the following" \
                          " dependencies: %s" \
                          % (component_name, exception.error_message)
                    self.warning(msg)
                    continue

    def check_interplugin_dependencies(self, plugin_class):
        """ 
        Check that all the plugins that L{plugin_class} depends on are loaded.

        @param plugin_class: Plugin's class
        @type plugin_class:  class
        
        @raises: L{elisa.core.component.UnMetDependency} if at least one
                 dependency is not satisfied; the list of unmet dependencies
                 is passed.
        """
        unmet_dependencies = []

        for dependency in plugin_class.plugin_dependencies:
            if dependency not in self._plugin_classes.keys():
                unmet_dependencies.append(dependency)

        if len(unmet_dependencies) > 0:
            raise UnMetDependency(plugin_class, unmet_dependencies)
        
    def check_intercomponent_dependencies(self, plugin, component_name):
        """ 
        Check that all the components that L{component} depends on are
        available.

        @param plugin:         plugin containing the tested component
        @type plugin:          Plugin
        @param component_name: component which dependencies are to be checked
        @type component_name:  string
        
        @raises: L{elisa.core.component.UnMetDependency} if at least one
                 dependency is not satisfied; the list of unmet dependencies
                 is passed.
        """
        informations = plugin.components[component_name]
        dependencies = informations.get('component_dependencies',[])
        unmet_dependencies = []

        for dependency in dependencies:
            dependency_plugin, dependency_component = dependency.split(':')
            dependency_plugin_class = self._plugin_classes.get(dependency_plugin)
            if not dependency_plugin_class or \
               dependency_component not in dependency_plugin_class.components:
                unmet_dependencies.append(dependency)

        if len(unmet_dependencies) > 0:
            raise UnMetDependency(component_name, unmet_dependencies)

    def _load_eggs(self):
        entrypoints = list(pkg_resources.iter_entry_points('elisa.plugins'))
        entrypoints.sort(key=lambda e: e.name)

        # load plugins registered via setuptools
        for entrypoint in entrypoints:
            try:
                py_object = entrypoint.load()
            except Exception, exc:
                if common.application:
                    trace_path = common.application.log_traceback()
                    self.warning("Error loading plugin %r. A full traceback can"\
                                 " be found here: %s" % (entrypoint.module_name,
                                                         trace_path))
                else:
                    self.warning("Error loading plugin: " + str(exc))
                continue
            
            full_path = inspect.getsourcefile(py_object)
            plugin_dir = os.path.dirname(full_path)

            distribution_file = ""
            if os.path.isfile(entrypoint.dist.location):
                distribution_file = entrypoint.dist.location
            
            if type(py_object) == types.ModuleType:
                module_name = entrypoint.module_name
                dirname = os.path.basename(os.path.split(full_path)[0])
                plugin_class = new.classobj(dirname.title(), (Plugin,),{})
                plugin_class.__module__ = module_name
                
                # TODO: should that be configurable?
                plugin_conf = 'plugin.conf'
                
                config_path = plugin_class.get_resource_file(plugin_conf)
                if not os.path.exists(config_path):
                    self.warning("Plugin config file not found for %r plugin",
                                 entrypoint.name)
                    continue
                plugin_class.config_file = config_path
            else:
                plugin_class = py_object

            plugin_class.directory = plugin_dir
            plugin_class.distribution_file = distribution_file
            
            assert issubclass(plugin_class, Plugin), \
                   '%r is not a valid Plugin!' % plugin_class

            #plugin_class.enabled = entrypoint.name in plugin_names
            self.register_plugin(plugin_class)

    def _load_packages(self):
        # TODO: load plugin packages
        pass

    def _load_modules(self):
        # load .py module files plugins
        modules = []
        for plugins_dir in self._plugin_directories:
            try:
                for filename in path.path(plugins_dir).walkfiles('*.py',
                                                                 errors='ignore'):
                    parent = filename.parent

                    if parent not in sys.path:
                        sys.path.append(parent)

                    module_filename = os.path.basename(filename)
                    module_name, _ = os.path.splitext(module_filename)

                    if type(module_filename) == unicode:
                        module_filename = module_filename.encode('utf-8')
                    try:
                        module = __import__(module_name, {}, {},
                                            [module_filename,])
                    except Exception, error:
                        trace_path = common.application.log_traceback()
                        self.warning("Could not import module %s. The" \
                                     " traceback can be found at %s" % (
                                        filename, trace_path))
                    else:
                        modules.append(module)

                    sys.path.remove(parent)
            except OSError, error:
                self.warning(error)
                continue
                
        for module in modules:
            for symbol_name in dir(module):
                symbol = getattr(module, symbol_name)
                if inspect.isclass(symbol) and symbol != Plugin and \
                       issubclass(symbol, Plugin):
                    #symbol.enabled = symbol.name in plugin_names
                    self.register_plugin(symbol)

    def _load_by_config(self):
        for plugins_dir in self._plugin_directories:
            try:
                for config_file in path.path(plugins_dir).walkfiles('*.conf',
                                                                    errors='ignore'):
                    plugin_config = config.Config(config_file)
                    general = plugin_config.get_section('general',
                                                        default={})
                    i18n = general.get('i18n', None)

                    name = general.get('name')
                    if name and name not in self._plugin_classes:
                        plugin_class = new.classobj(name.title(),
                                                    (Plugin,),{})
                        plugin_class.config_file = config_file
                        plugin_class.i18n = i18n
                        plugin_class.directory = os.path.dirname(config_file)
                        self.register_plugin(plugin_class)
            except OSError, error:
                self.warning(error)
                
    def unload_plugins(self):
        plugin_classes = self._plugin_classes.values()[:]
        for plugin_class in plugin_classes:
            self.unregister_plugin(plugin_class)
        gc.collect()

    def create_component(self, component_path):
        """ Use the PluginRegistry to to create a Component.

        component_path format should be like this::

          plugin_name:component_name[:instance_id]

        The plugin_name is the name of the plugin sub-class to
        load. See L{elisa.core.plugin.Plugin.name}.

        The component_name is the name of the Component sub-class to
        load. See L{elisa.core.component.Component.name}.

        @param component_path: information to locate the Component to load
        @type component_path:  string
        @rtype:                L{elisa.core.component.Component}
        @raise InitializeFailure: When the component failed to initialize
        @raise ComponentNotFound: When the component could not be found in any plugin
        @raise UnMetDependency:   When the component misses a dependency
        """
        component = None

        self.debug("Trying to create %r component" % component_path)

        infos = self._get_component_infos(component_path)

        ComponentClass, plugin, component_path_tuple = infos

        if ComponentClass:
            ComponentClass.name = component_path_tuple[1]

            # bind the Component to its plugin early as it's needed by
            # check_dependencies()
            if not ComponentClass.plugin:
                ComponentClass.plugin = plugin

            instance_id = component_path_tuple[2]
            self.log("Instantiating Component %r from %r",
                     ComponentClass.name, component_path_tuple[0])
            component = ComponentClass()

            # component.name = component_name
            component.path = component_path_tuple[3]
            component.id = instance_id
            component.load_config(self._app_config)
            res = component.initialize()
            if isinstance(res, defer.Deferred):
                # NOTE: do this here instead of using maybeDeferred because
                # callers don't expect a deferred. A lot of tests are calling
                # create_component so this is the least invasive way of doing
                # this.
                def component_initialize_done(result):
                    self.info("Component %s loaded" % component.name)

                    return component

                self.info("Component %s still loading" % component.name)
                res.addCallback(component_initialize_done)

                return res

            self.info("Component %s loaded" % component.name)
        else:
            raise ComponentNotFound(component_path)

        return component

    def _get_component_infos(self, component_path):
        """
        Get information about a component given its path. This method
        doesn't instantiate Components, it just looks for Component
        classes.

        @param component_path: information to locate the Component to load
        @type component_path:  string
        @rtype:                (L{elisa.core.component.Component}, L{elisa.core.plugin.Plugin}, tuple)
        """
        ComponentClass = None
        plugin = None

        component_path_tuple = self._split_component_path(component_path)

        if component_path_tuple:

            plugin_name, component_name = component_path_tuple[0:2]

            plugin = self.get_plugin_with_name(plugin_name)
            self.debug("Found plugin %r, searching for Component %r",
                       plugin_name, component_name)

            # lazily check plugin's python deps
            plugin.check_dependencies()

            ComponentClass = self._import_component(plugin, component_name)

        return (ComponentClass, plugin, component_path_tuple)

    def _import_component(self, plugin, component_name):
        """
        """
        Class = None
        component = plugin.components.get(component_name,{})
        path = component.get('path')
        if path:

            if isinstance(path, basestring):
                # relative.path:ComponentClass

                # lazily check the dependencies of the Component before trying
                # to import it.
                plugin.check_component_dependencies(component_name)
                
                mod_class = path.split(':')
                plugin_path = plugin.directory
                old_path = sys.path[:]
                sys.path.insert(0, os.path.abspath(os.path.dirname(plugin_path)))
                package_name = os.path.basename(plugin_path)
                if package_name:
                    full_path = "%s.%s" % (package_name,mod_class[0])
                else:
                    full_path = mod_class[0]
                try:
                    module = __import__(full_path,
                                        {}, {}, [mod_class[1]])
                    Class = getattr(module, mod_class[1])
                except (ImportError, AttributeError), error:
                    trace_path = common.application.log_traceback()
                    self.warning("Could not import component %s.%s. A full"\
                                 " traceback can be found at %s" %
                                 (plugin.name, path, trace_path))
                    sys.path = old_path
                else:
                    self.debug("Imported %r from %s", Class, plugin_path)
                sys.path = old_path

            else:
                Class = path

        return Class

    def get_component_class(self, component_path):
        """ Retrieve the class of a Component given its path.

        @param component_path: information to locate the Component
        @type component_path:  string
        @rtype: L{elisa.core.component.Component} class
        """
        self.debug("Retrieving component class of %r", component_path)
        infos = self._get_component_infos(component_path)
        ComponentClass, plugin, path_tuple = infos

        return ComponentClass


    def _split_component_path(self, component_path):
        """split the component path:
            plugin_name:component_name[:instance_id]

        add default instance_id (0) if missing

        @param component_path: information to locate the Component to load
        @type component_path:  string
        @returns:              tuple of 4 elements :
                               (plugin_name, component_name, instance_id, adjusted_component_path)
                               return empty tuple if the intance is not valid
        @rtype:                tuple
        """
        component_path_tuple = ()
        if component_path:
            plugin_name = None
            parts = component_path.split(':')

            if len(parts) < 2:
                #FIXME: raise exception ?
                self.warning("Syntax error in %r. Components must be "\
                             "specified with plugin_name:component_name"\
                             " syntax" % component_path)
            elif len(parts) == 2:
                plugin_name, component_name = parts
                instance_id = 0
                #component_path += ':0'
            elif len(parts) == 3:
                plugin_name, component_name, instance_id = parts
                try:
                    instance_id = int(instance_id)
                except:
                    self.warning("Syntax error in %r. Components must "\
                                 "have a numerical instance_id", component_path)
                    plugin_name = None

            if plugin_name:
                component_path_tuple = (plugin_name, component_name,
                                        instance_id, component_path)
        else:
            #FIXME: raise exception ?
            self.warning("Empty component_path (%r), nothing to create then",
                         component_path)

        return component_path_tuple
