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

"""
Component base class. Plugins create them. This is where the Plugins
logic stands.
"""


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

from elisa.core.utils import classinit, misc
from elisa.core import common
from elisa.core import log
import os, re
import platform
from distutils.version import LooseVersion

class ComponentError(Exception):
    """
    Generic exception raised when Component related error
    happens. Specialized exceptions inherit from it.

    @ivar component_name: the name of the Component which issued the error
    @type component_name: string
    @ivar error_message:  human (or developer) readable explanation of the error
    @type error_message:  string
    """

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

    def __str__(self):
        return "In component %s: %s" % (self.component_name, self.error_message)

class UnSupportedPlatform(ComponentError):
    """
    Raised when the component does not support user's platform
    """

    def __str__(self):
        error = "Platform not supported for %r; supported are: %r"
        return error % (self.component_name, self.error_message)

class UnMetDependency(ComponentError):
    """
    Raised by Component.check_dependencies() when it finds an un-met
    dependency.
    """

    def __str__(self):
        return "Un-met dependency for %s: %s" % (self.component_name,
                                                 self.error_message)

class InitializeFailure(ComponentError):
    """
    Raised by Component.initialize() when it fails to initialize
    internal variables of the component.
    """

    def __str__(self):
        return "Component %s failed to initialize: %s" % (self.component_name,
                                                          self.error_message)

def parse_dependency(dependency):
    """
    Scan an input string and detect dependency name, requirement sign
    (>,<,...) and version::

       >>> parse_dependency("pgm >= 0.3")
       >>> ("pgm", ">=", "0.3")

    @param dependency: the dependency requirement to parse
    @type dependency:  string
    @rtype: 3-tuple (name: string, sign: string, version: string)
    """
    pattern = "(?P<name>[\.\w+]*)\s*(?P<sign>[><=]+)*\s*(?P<version>[0-9\.\-]+)*"
    match = re.match(pattern, dependency)
    name, sign, version = '', '', ''
    if match:
        group = match.groupdict()
        name = group.get('name', '')
        sign = group.get('sign') or  ''
        version = group.get('version') or ''
    return (name, sign, version)

def _check_version(component_name, module_name, module, sign, required_version):
    """
    Check the version of the given module, given its version
    requirements, for the given component name.

    @param component_name:   name of the component we are checking module's
                             version for
    @type component_name:    string
    @param module_name:      name of the module we're checking version for
    @type module_name:       string
    @param module:           module we're checking version for
    @type module:            object
    @param sign:             requirement sign (one of >,<,==,>=,<=)
    @type sign:              string
    @param required_version: expected version of the module
    @type required_version:  string
    @raises UnMetDependency: when the required version of the module was not found.
    """
    if sign == '=':
        sign = '=='

    sign_map = { '<': (-1,), '<=': (-1, 0),
                 '>': (1,), '>=': (1, 0),
                 '==': (0,) }

    module_version = None

    # try to guess version from various module attributes
    version_attributes = ('__version__', 'version')
    for attr in version_attributes:
        try:
            value = getattr(module, attr)
        except AttributeError:
            continue
        if callable(value):
            module_version = value()
        else:
            module_version = value
        if module_version:
            break

    if module_version:
        if isinstance(module_version, basestring):
            module_version = LooseVersion(module_version)
        else:
            module_version = LooseVersion('.'.join([str(c)
                                                    for c in module_version]))
        compared = cmp(module_version, required_version)
        expected = sign_map.get(sign,())
        
        if compared not in expected:
            msg = "Version %s %s of %s is required. %s found" % (sign,
                                                                 required_version,
                                                                 module_name,
                                                                 module_version)
            raise UnMetDependency(component_name, msg)
    return module_version

def check_platforms(component_path, platforms):
    """
    Check if the platforms list complies with the host's platform, for
    the given component name.

    @param component_path:       name of the component we are checking
                                 platform for
    @type component_path:        string
    @param platforms:            list of platforms supported by the component
    @type platforms:             list
    @raises UnSupportedPlatform: when none of component's supported platforms
                                 complies with host platform
    @returns:                    the valud names for current platform
    @rtype:                      list of strings
    """
    user_platform = set([os.name, platform.system().lower()])
    
    if ':' in component_path:
        log_category = component_path.split(':')[0]
    else:
        log_category = component_path
        
    if platforms:
        log.debug(log_category, "Checking supported platforms: %r",
                  platforms)
        if not user_platform.intersection(platforms):
            raise UnSupportedPlatform(component_path, platforms)
    return user_platform

def check_python_dependencies(component_path, deps):
    """
    Verify that the Python dependencies are correctly installed for
    the given component name. We first try to import each dependency
    (using __import__) and then we check if the version matches the
    required one specified in the dependency string.
    
    @param component_path:   name of the component we are checking python
                             dependencies for
    @type component_path:    string
    @param deps:             list of component's dependencies, as strings
    @type deps:              list
    @raises UnMetDependency: when the required version of the module was not found.
    @rtype:                  bool
    """
    if deps:
        if ':' in component_path:
            log_category = component_path.split(':')[0]
        else:
            log_category = component_path
            
        log.debug(log_category, "Checking dependencies: %r", deps)
        for dep in deps:
            module_name, sign, version = parse_dependency(dep)
            try:
                module = __import__(module_name)
            except ImportError:
                raise UnMetDependency(component_path, module_name)
            if version:
                _check_version(component_path, module_name, module, sign,
                               version)
    return True

class Component(log.Loggable):
    """
    A Component is a simple object created by Plugins. Each Component has:

    @cvar name:           Component's name
    @type name:           string
    @cvar id:             Component's config id
    @type id:             int
    @cvar plugin:         Plugin instance of the component
    @type plugin:         L{elisa.core.plugin.Plugin}
    @cvar default_config: used when nothing found in Application's config
    @type default_config: dict
    @cvar config_doc:     documentation for each option of the default
                          configuration. Keys should be same as the ones in
                          default_config and values should be strings
    @type config_doc:     dict
    @ivar config:         Component's configuration
    @type config:         L{elisa.extern.configobj.ConfigObj}
    @ivar path:           unique string identifying the instance:
                          plugin_name:component_name:instance_id
    @type path:           string
    """

    __metaclass__ = classinit.ClassInitMeta
    __classinit__ = classinit.build_properties

    default_config = {}

    config_doc = {}

    id = 0
    plugin = None
    path = None

    config = None

    checked = False

    def __init__(self):
        """ Lazily set L{name} from class name styled with underscores
        (class ComponentBar -> name component_bar. Also set log
        category based on component name, with a 'comp_' prefix.
        """
        if not hasattr(self.__class__, 'name'):
            self.name = misc.un_camelify(self.__class__.__name__)

        # configure the log category based on my name
        self.log_category = 'comp_%s' % self.name

        super(Component, self).__init__()

    def initialize(self):
        """
        Initialize various variables internal to the Component.

        This method is called by the plugin_registry after the
        component's config has been loaded.

        Override this method if you need to perform some
        initializations that would normally go in Component's
        constructor but can't be done there because they require
        access to the component's config.

        @raise InitializeFailure: when some internal initialization fails
        """

    def clean(self):
        """
        Perform some cleanups and save the Component config back to
        application's config. This method should be called by the
        L{elisa.core.manager.Manager} holding the component reference
        when it stops itself.
        """
        application = common.application
        if application and application.config:
            self.save_config(application.config)

    def load_config(self, application_config):
        """
        Load the component's configuration. If none found, create
        it using the default config stored in `default_config`
        Component attribute.

        @param application_config: the Application's Config
        @type application_config: L{elisa.core.config.Config}
        """
        self.debug('Component %s is loading its config' % self.name)
        plugin_name = ""
        c_section_name = '%s:%s' % (self.name, self.id)
        if self.plugin:
            plugin_name = "%s" % self.plugin.name
            c_section_name = '%s:%s:%s' % (plugin_name, self.name, self.id)

        component_section = application_config.get_section(c_section_name,{})

        # look for component.name option if instance id is 0
        if self.id == 0 and not component_section:
            c_section_name = c_section_name[:-2]
            component_section = application_config.get_section(c_section_name,
                                                               {})

        if not component_section:
            component_section = self.default_config
            application_config.set_section(c_section_name, self.default_config,
                                           doc=self.config_doc)

        else:
            for option, value in self.default_config.iteritems():
                if option not in component_section:
                    component_section[option] = value

        self.config = component_section

    def save_config(self, application_config):
        """
        Store the Component's config in the Application's config,
        which can be saved back to a file later on.

        @param application_config: the Application's Config to which save
                                   our config
        @type application_config:  L{elisa.core.application.Application}
        """
        self.debug('Component %s is saving its config' % self.name)
        if self.config:
            # warn of un-documented options
            config_option_names = set(self.config.keys())
            documented_options = set(self.config_doc.keys())
            intersection = list(config_option_names - documented_options)
            if intersection:
                msg = "Undocumented options for %r: %r" % (self.name,
                                                           intersection)
                self.info(msg)

            if self.plugin:
                section_name = "%s:%s" % (self.plugin.name, self.name)
            else:
                section_name = self.name
            if self.id:
                section_name += ":%s" % self.id
            application_config.set_section(section_name, self.config,
                                           doc=self.config_doc)
