#    copyright 2009 Thomas Gideon
#
#    This file is part of flashbake.
#
#    flashbake is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    flashbake is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with flashbake.  If not, see <http://www.gnu.org/licenses/>.
 
'''  __init__.py - Shared classes and functions for the flashbake package.'''
 
from flashbake.plugins import PluginError, PLUGIN_ERRORS
from flashbake.compat import relpath, next_, iglob
from types import *
import commands
import flashbake.plugins #@UnresolvedImport
import glob
import logging
import os
import os.path
import re
import sys #@Reimport
import __builtin__
 
__version__ = '0.27'
 
class ConfigError(Exception):
    pass
 
 
class ControlConfig:
    """ Accumulates options from a control file for use by the core modules as
        well as for the plugins.  Also handles boot strapping the configured
        plugins. """
    def __init__(self):
        self.initialized = False
        self.dry_run = False
        self.extra_props = dict()
 
        self.prop_types = dict()
 
        self.plugin_names = list()
        self.msg_plugins = list()
        self.file_plugins = list()
        self.notify_plugins = list()
 
        self.git_path = None
        self.git_origin = None
        self.project_name = None
 
    def capture(self, line):
        ''' Parse a line from the control file if it is relevant to plugin configuration. '''
        # grab comments but don't do anything
        if line.startswith('#'):
            return True
 
        # grab blanks but don't do anything
        if len(line.strip()) == 0:
            return True
 
        if line.find(':') > 0:
            prop_tokens = line.split(':', 1)
            prop_name = prop_tokens[0].strip()
            prop_value = prop_tokens[1].strip()
 
            if 'plugins' == prop_name:
                self.add_plugins(prop_value.split(','))
                return True
 
            # hang onto any extra propeties in case plugins use them
            if not prop_name in self.__dict__:
                self.extra_props[prop_name] = prop_value;
                return True
 
            try:
                if prop_name in self.prop_types:
                    prop_value = self.prop_types[prop_name](prop_value)
                self.__dict__[prop_name] = prop_value
            except:
                raise ConfigError(
                        'The value, %s, for option, %s, could not be parse as %s.'
                        % (prop_value, prop_name, self.prop_types[prop_name]))
 
            return True
 
        return False
 
    def init(self):
        """ Do any property clean up, after parsing but before use """
        if self.initialized == True:
            return
 
        self.initialized = True
 
        if len(self.plugin_names) == 0:
            raise ConfigError('No plugins configured!')
 
        self.share_property('git_path')
        self.share_property('project_name')
 
        all_plugins = list()
        with_deps = dict()
        for plugin_name in self.plugin_names:
            logging.debug("initalizing plugin: %s" % plugin_name)
            try:
                plugin = self.create_plugin(plugin_name)
                if len(plugin.dependencies()) == 0:
                    all_plugins.append(plugin)
                else:
                    dep = Dependency(plugin)
                    dep.map(with_deps)
                if isinstance(plugin, flashbake.plugins.AbstractMessagePlugin):
                    logging.debug("Message Plugin: %s" % plugin_name)
                    # TODO add notion of dependency for ordering
                    if 'flashbake.plugins.location:Location' == plugin_name:
                        self.msg_plugins.insert(0, plugin)
                    else:
                        self.msg_plugins.append(plugin)
                if isinstance(plugin, flashbake.plugins.AbstractFilePlugin):
                    logging.debug("File Plugin: %s" % plugin_name)
                    self.file_plugins.append(plugin)
                if isinstance(plugin, flashbake.plugins.AbstractNotifyPlugin):
                    logging.debug('Notify Plugin: %s' % plugin_name)
                    self.notify_plugins.append(plugin)
            except PluginError, e:
                # re-raise critical plugin error
                if not e.reason == PLUGIN_ERRORS.ignorable_error: #@UndefinedVariable
                    raise e
                # allow ignorable errors through with a warning
                logging.warning('Skipping plugin, %s, ignorable error: %s' %
                        (plugin_name, e.name))
 
        for plugin in all_plugins:
            plugin.share_properties(self)
            if plugin.plugin_spec in with_deps:
                for dep in with_deps[plugin.plugin_spec]:
                    dep.satisfy(plugin, all_plugins)
 
        if len(Dependency.all) > 0:
            logging.error('Unsatisfied dependencies!')
 
        for plugin in all_plugins:
            plugin.capture_properties(self)
            plugin.init(self)
 
    def share_property(self, name, type=None):
        """ Declare a shared property, this way multiple plugins can share some
            value through the config object. """
        if name in self.__dict__:
            return
 
        value = None
 
        if name in self.extra_props:
            value = self.extra_props[name]
            del self.extra_props[name]
 
            if type != None:
                try:
                    value = type(value)
                except:
                    raise ConfigError('Problem parsing %s for option %s'
                            % (name, value))
 
        self.__dict__[name] = value
 
    def add_plugins(self, plugin_names):
        # use a comprehension to ensure uniqueness
        [self.__add_last(inbound_name) for inbound_name in plugin_names]
 
    def create_plugin(self, plugin_spec):
        """ Initialize a plugin, including vetting that it meets the correct
            protocol; not private so it can be used in testing. """
        if plugin_spec.find(':') < 0:
            logging.debug('Plugin spec not validly formed, %s.' % plugin_spec)
            raise PluginError(PLUGIN_ERRORS.invalid_plugin, plugin_spec) #@UndefinedVariable
 
        tokens = plugin_spec.split(':')
        module_name = tokens[0]
        plugin_name = tokens[1]
 
        try:
            __import__(module_name)
        except ImportError:
            logging.warn('Invalid module, %s' % plugin_name)
            raise PluginError(PLUGIN_ERRORS.unknown_plugin, plugin_spec) #@UndefinedVariable
 
        try:
            plugin_class = self.__forname(module_name, plugin_name)
            plugin = plugin_class(plugin_spec)
        except Exception, e:
            logging.debug(e)
            logging.debug('Couldn\'t load class %s' % plugin_spec)
            raise PluginError(PLUGIN_ERRORS.unknown_plugin, plugin_spec) #@UndefinedVariable
        is_message_plugin = isinstance(plugin, flashbake.plugins.AbstractMessagePlugin)
        is_file_plugin = isinstance(plugin, flashbake.plugins.AbstractFilePlugin)
        is_notify_plugin = isinstance(plugin, flashbake.plugins.AbstractNotifyPlugin)
        if not is_message_plugin and not is_file_plugin and not is_notify_plugin:
            raise PluginError(PLUGIN_ERRORS.invalid_type, plugin_spec) #@UndefinedVariable
        if is_message_plugin:
            self.__checkattr(plugin_spec, plugin, 'connectable', bool)
            self.__checkattr(plugin_spec, plugin, 'addcontext', MethodType)
        if is_file_plugin:
            self.__checkattr(plugin_spec, plugin, 'pre_process', MethodType)
        if is_notify_plugin:
            self.__checkattr(plugin_spec, plugin, 'warn', MethodType)
 
        return plugin
 
    def __add_last(self, plugin_name):
        if plugin_name in self.plugin_names:
            self.plugin_names.remove(plugin_name)
        self.plugin_names.append(plugin_name)
 
    def __checkattr(self, plugin_spec, plugin, name, expected_type):
        try:
            attrib = eval('plugin.%s' % name)
        except AttributeError:
            raise PluginError(PLUGIN_ERRORS.missing_attribute, plugin_spec, name) #@UndefinedVariable
 
        if not isinstance(attrib, expected_type):
            raise PluginError(PLUGIN_ERRORS.invalid_attribute, plugin_spec, name) #@UndefinedVariable
 
 
    # with thanks to Ben Snider
    # http://www.bensnider.com/2008/02/27/dynamically-import-and-instantiate-python-classes/
    def __forname(self, module_name, plugin_name):
        ''' Returns a class of "plugin_name" from module "module_name". '''
        __import__(module_name)
        module = sys.modules[module_name]
        classobj = getattr(module, plugin_name)
        return classobj
 
 
class Dependency:
    all = list()
    def __init__(self, plugin):
        self.plugin
        self.dep_count = len(plugin.dependencies)
 
    def map(self, dep_map):
        for spec in self.plugin.dependencies():
            if spec not in dep_map:
                dep_map[spec] = list()
            dep_map[spec].append(self)
 
    def satisfy(self, plugin, all_plugins):
        self.dep_count -= 1
        if self.dep_count == 0:
            pos = all_plugins.index(plugin)
            all_plugins.insert(pos + 1)
            all.remove(self)
 
 
class HotFiles:
    """
    Track the files as they are parsed and manipulated with regards to their git
    status and the dot-control file.
    """
    def __init__(self, project_dir):
        self.project_dir = os.path.realpath(project_dir)
        self.linked_files = dict()
        self.outside_files = set()
        self.control_files = set()
        self.not_exists = set()
        self.to_add = set()
        self.globs = dict()
        self.deleted = set()
 
    def addfile(self, filename):
        to_expand = os.path.join(self.project_dir, filename)
        file_exists = False
        logging.debug('%s: %s'
               % (filename, glob.glob(to_expand)))
 
        pattern = re.compile('(\[.+\]|\*|\?)')
        if pattern.search(filename):
            glob_re = re.sub('\*', '.*', filename)
            glob_re = re.sub('\?', '.', glob_re)
            self.globs[filename] = glob_re
 
        for expanded_file in iglob(to_expand):
            # track whether iglob iterates at all, if it does not, then the line
            # didn't expand to anything meaningful
            if not file_exists:
                file_exists = True
 
            # skip the file if some previous glob hit it
            if (expanded_file in self.outside_files
                    or expanded_file in self.linked_files.keys()):
                continue
 
            # the commit code expects a relative path
            rel_file = self.__make_rel(expanded_file)
 
            # skip the file if some previous glob hit it
            if rel_file in self.control_files:
                continue
 
            # checking this after removing the expanded project directory
            # catches absolute paths to files outside the project directory
            if rel_file == expanded_file:
                self.outside_files.add(expanded_file)
                continue
 
            link = self.__check_link(expanded_file)
 
            if link == None:
                self.control_files.add(rel_file)
            else:
                self.linked_files[expanded_file] = link
 
        if not file_exists:
            self.putabsent(filename)
 
    def contains(self, filename):
        return filename in self.control_files
 
    def remove(self, filename):
        if filename in  self.control_files:
            self.control_files.remove(filename)
 
    def putabsent(self, filename):
        self.not_exists.add(filename)
 
    def putneedsadd(self, filename):
        self.to_add.add(filename)
 
    def put_deleted(self, filename):
        def __in_target(file_spec):
            return file_spec in self.not_exists
        to_delete = self.from_glob(filename)
        logging.debug('To delete after matching %s' % to_delete)
        to_delete.append(filename)
        to_delete = filter(__in_target, to_delete)
        [self.not_exists.remove(file_spec) for file_spec in to_delete]
        self.deleted.add(filename)
 
    def from_glob(self, filename):
        """ Returns any original glob-based file specifications from the control file that would match
            the input filename.  Useful for file plugins that add their own globs and need to correlate
            actual files that match their globs. """
        def __match(file_tuple):
            return re.match(file_tuple[1], filename) != None
        matches = filter(__match, self.globs.iteritems())
        matches = dict(matches)
        return matches.keys()
 
    def warnproblems(self):
        # print warnings for linked files
        for filename in self.linked_files.keys():
            logging.info('%s is a link or its directory path contains a link.' % filename)
        # print warnings for files outside the project
        for filename in self.outside_files:
            logging.info('%s is outside the project directory.' % filename)
        # print warnings for files that do not exists
        for filename in self.not_exists:
            logging.info('%s does not exist.' % filename)
        # print warnings for files that were once under version control but have been deleted
        for filename in self.deleted:
            logging.info('%s has been deleted from version control.' % filename)
 
    def addorphans(self, git_obj, control_config):
        if len(self.to_add) == 0:
            return
 
        message_file = flashbake.context.buildmessagefile(control_config)
 
        to_commit = list()
        for orphan in self.to_add:
            logging.debug('Adding %s.' % orphan)
            add_output = git_obj.add(orphan)
            logging.debug('Add output, %s' % add_output)
            to_commit.append(orphan)
 
        logging.info('Adding new files, %s.' % to_commit)
        # consolidate the commit to be friendly to how git normally works
        if not control_config.dry_run:
            commit_output = git_obj.commit(message_file, to_commit)
            logging.debug('Commit output, %s' % commit_output)
 
        os.remove(message_file)
 
    def needs_warning(self):
        return (len(self.not_exists) > 0
               or len(self.linked_files) > 0
               or len(self.outside_files) > 0
               or len(self.deleted) > 0)
 
    def __check_link(self, filename):
        # add, above, makes sure filename is always relative
        if os.path.islink(filename):
            return filename
        directory = os.path.dirname(filename)
 
        while (len(directory) > 0):
            # stop at the project directory, if it is in the path
            if directory == self.project_dir:
                break
            # stop at root, as a safety check though it should not happen
            if directory == os.sep:
                break
            if os.path.islink(directory):
                return directory
            directory = os.path.dirname(directory)
        return None
 
    def __make_rel(self, filepath):
        return self.__drop_prefix(self.project_dir, filepath)
 
    def __drop_prefix(self, prefix, filepath):
        return relpath(filepath, prefix)
 
def find_executable(executable):
    ex_paths = (os.path.join(path, executable) for path in \
                    os.getenv('PATH').split(os.pathsep))
    paths = (ex_path for ex_path in ex_paths \
                 if os.path.exists(ex_path))
    return next_(paths, None)
 
def executable_available(executable):
    return find_executable(executable) != None