#==============================================================================
# Copyright 2011 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#==============================================================================
"""
A library for building an installation from metadata

Classes:
Contractor - orchestrates the build process
Carpenter - does the concrete work of applying metadata to the installation
Tool - performs a specific task on an installation
ToolError - a base exception type for all tools

CloudFormationCarpenter - Orchestrates a non-delegated installation
YumTool - installs packages via yum

"""
from cfnbootstrap import platform_utils
from cfnbootstrap.apt_tool import AptTool
from cfnbootstrap.auth import AuthenticationConfig
from cfnbootstrap.command_tool import CommandTool
from cfnbootstrap.construction_errors import BuildError, NoSuchConfigSetError, \
    NoSuchConfigurationError, CircularConfigSetDependencyError
from cfnbootstrap.file_tool import FileTool
from cfnbootstrap.lang_package_tools import PythonTool, GemTool
from cfnbootstrap.msi_tool import MsiTool
from cfnbootstrap.rpm_tools import RpmTool, YumTool
from cfnbootstrap.service_tools import SysVInitTool, WindowsServiceTool
from cfnbootstrap.sources_tool import SourcesTool
from cfnbootstrap.user_group_tools import GroupTool, UserTool
import cfnbootstrap.json_file_manager as JsonFileManager 
import collections
import contextlib
import logging
import operator
import os.path
import sys
import time

log = logging.getLogger("cfn.init")
cmd_log = logging.getLogger("cfn.init.cmd")

class WorkLog(object):
    """
    Keeps track of pending work, and can resume from the last known point
    Useful for commands that cause restarts
    """

    def __init__(self, dbname='resume_db.json'):
        if os.name == 'nt':
            self._json_db_dir = os.path.expandvars(r'${SystemDrive}\cfn\cfn-init')
        else:
            self._json_db_dir = '/var/lib/cfn-init'

        if not os.path.isdir(self._json_db_dir) and not os.path.exists(self._json_db_dir):
            os.makedirs(self._json_db_dir, 0700)

        if not os.path.isdir(self._json_db_dir):
            print >> sys.stderr, "Could not create %s to store the work log" % self._json_db_dir
            logging.error("Could not create %s to store the work log", self._json_db_dir)

        self._dbname = dbname
        self._jsonConverter = JsonFileManager.Converter([ConfigDefinition])

    def clear(self):
        JsonFileManager.create(self._json_db_dir, self._dbname);

    def clear_except_metadata(self):
        json_data = JsonFileManager.read(self._json_db_dir, self._dbname)
        metadata = json_data.get('metadata', None)
        json_data = {}
        if metadata != None:
            json_data['metadata'] = metadata
        JsonFileManager.write(self._json_db_dir, self._dbname, json_data)

    def put(self, key, data):
        json_data = JsonFileManager.read(self._json_db_dir, self._dbname)
        if data:
            json_data[key] = self._jsonConverter.serialize(data)
        elif key in json_data:
            del json_data[key]
        JsonFileManager.write(self._json_db_dir, self._dbname, json_data)

    def has_key(self, key):
        json_data = JsonFileManager.read(self._json_db_dir, self._dbname)
        return key in json_data

    def get(self, key, default=None):
        json_data = JsonFileManager.read(self._json_db_dir, self._dbname)
        if key in json_data:
            return self._jsonConverter.deserialize(json_data[key])
        else:
            return default

    def delete(self, key):
        json_data = JsonFileManager.read(self._json_db_dir, self._dbname)
        if key in json_data:
            del json_data[key]
            JsonFileManager.write(self._json_db_dir, self._dbname, json_data)

    def pop(self, key):
        json_data = JsonFileManager.read(self._json_db_dir, self._dbname)
        if key in json_data:
            value = self._jsonConverter.deserialize(json_data[key])
            ret_val = value.popleft()
            if not value:
                del json_data[key]
            else:
                json_data[key] = self._jsonConverter.serialize(value)
            JsonFileManager.write(self._json_db_dir, self._dbname, json_data)
            return ret_val
        else:
            return None

    def build(self, metadata, configSets):
        self.put('metadata', metadata)
        platform_utils.set_reboot_trigger()
        Contractor(metadata).build(configSets, self)

    def run_commands(self):
        cmd_tool = CommandTool()
        while self.has_key('commands'):
            next_cmd = self.pop('commands')
            changes = collections.defaultdict(list)
            changes.update(self.get('changes', {}))
            cmd_options = next_cmd[1]
            command_changes = cmd_tool.apply({next_cmd[0]:cmd_options})
            changes['commands'].extend(command_changes)
            self.put('changes', changes)
            if not command_changes:
                log.info("Not waiting as command did not execute")
            else:
                wait = CommandTool.get_wait(cmd_options)
                if wait < 0:
                    log.info("Waiting indefinitely for command to reboot")
                    sys.exit(0)
                elif wait > 0:
                    log.info("Waiting %s seconds for reboot", wait)
                    time.sleep(wait)
            
        for manager, services in self.get('services', {}).iteritems():
            if manager in CloudFormationCarpenter._serviceTools:
                CloudFormationCarpenter._serviceTools[manager]().apply(services, self.get('changes', collections.defaultdict(list)))
            else:
                log.warn("Unsupported service manager: %s", manager)

        if self.has_key('changes'):
            self.delete('changes')

        if self.has_key('services'):
            self.delete('services')

    def resume(self):
        log.debug("Starting resume")
        platform_utils.set_reboot_trigger()

        self.run_commands()

        contractor = Contractor(self.get('metadata'))

        #TODO: apply services when supported by Windows

        while self.has_key('configs'):
            next_config = self.pop('configs')
            log.debug("Resuming config: %s", next_config.name)
            contractor.run_config(next_config, self)

        if self.has_key('configSets'):
            remaining_sets = self.get('configSets')
            log.debug("Resuming configSets: %s", remaining_sets)
            contractor.build(remaining_sets, self)
        else:
            self.clear()
            platform_utils.clear_reboot_trigger()

        log.debug("Resume completed")


class CloudFormationCarpenter(object):
    """
    Takes a model and uses tools to make it reality
    """

    _packageTools = { "yum" : YumTool,
                      "rubygems" : GemTool,
                      "python" : PythonTool,
                      "rpm" : RpmTool,
                      "apt" : AptTool,
                      "msi" : MsiTool }

    _pkgOrder = ["msi", "dpkg", "rpm", "apt", "yum"]

    _serviceTools = { "sysvinit" : SysVInitTool, "windows" : WindowsServiceTool }

    @staticmethod
    def _pkgsort(x, y):
        order = CloudFormationCarpenter._pkgOrder
        if x[0] in order and y[0] in order:
            return cmp(order.index(x[0]), order.index(y[0]))
        elif x[0] in order:
            return -1
        elif y[0] in order:
            return 1
        else:
            return cmp(x[0].lower(), y[0].lower())

    def __init__(self, config, auth_config):
        self._config = config
        self._auth_config = auth_config

    def build(self, worklog):
        changes = collections.defaultdict(list)

        changes['packages'] = collections.defaultdict(list)
        if self._config.packages:
            for manager, packages in sorted(self._config.packages.iteritems(), cmp=CloudFormationCarpenter._pkgsort):
                if manager in CloudFormationCarpenter._packageTools:
                    changes['packages'][manager] = CloudFormationCarpenter._packageTools[manager]().apply(packages, self._auth_config)
                else:
                    log.warn('Unsupported package manager: %s', manager)
        else:
            log.debug("No packages specified")

        if self._config.groups:
            changes['groups'] = GroupTool().apply(self._config.groups)
        else:
            log.debug("No groups specified")

        if self._config.users:
            changes['users'] = UserTool().apply(self._config.users)
        else:
            log.debug("No users specified")

        if self._config.sources:
            changes['sources'] = SourcesTool().apply(self._config.sources, self._auth_config)
        else:
            log.debug("No sources specified")

        if self._config.files:
            changes['files'] = FileTool().apply(self._config.files, self._auth_config)
        else:
            log.debug("No files specified")

        if self._config.commands:
            if os.name=='nt':
                worklog.put('changes', changes)
                worklog.put('commands', collections.deque(sorted(self._config.commands.iteritems(), key=operator.itemgetter(0))))
            else:
                changes['commands'] = CommandTool().apply(self._config.commands)
        else:
            log.debug("No commands specified")

        if self._config.services:
            if os.name=='nt':
                worklog.put('services', self._config.services)
            else:
                for manager, services in self._config.services.iteritems():
                    if manager in CloudFormationCarpenter._serviceTools:
                        CloudFormationCarpenter._serviceTools[manager]().apply(services, changes)
                    else:
                        log.warn("Unsupported service manager: %s", manager)
        else:
            log.debug("No services specified")

class ConfigDefinition(object):
    """
    Encapsulates one config definition
    """

    def __init__(self, name, model):
        self._name = name
        self._files = model.get("files")
        self._packages = model.get("packages")
        self._services = model.get("services")
        self._sources = model.get("sources")
        self._commands = model.get("commands")
        self._users = model.get("users")
        self._groups = model.get("groups")

    @property
    def name(self):
        return self._name

    @property
    def files(self):
        return self._files

    @property
    def packages(self):
        return self._packages

    @property
    def services(self):
        return self._services

    @property
    def sources(self):
        return self._sources

    @property
    def commands(self):
        return self._commands

    @property
    def users(self):
        return self._users

    @property
    def groups(self):
        return self._groups

    def __str__(self):
        return 'Config(%s)' % self._name

    def serialize(self, marker):
        return {marker: self.__dict__}

    @classmethod
    def from_json(cls, json_data):
        model = {}
        for field in json_data:
            prop = field[1:]
            if field == '_name':
                name = json_data[field]
            else:
                model[prop] = json_data[field]
        return cls(name, model)

class ConfigSetRef(object):
    """
    Encapsulates a ref to a ConfigSet
    """

    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

    def __str__(self):
        return 'ConfigSet(%s)' % self._name

class ConfigSet(object):
    """
    A list of ConfigDefinition or ConfigSetRef objects with their dependencies
    """

    def __init__(self, configDef=None):
        """
        Arguments:
        configDef - optional ConfigDefinition|ConfigSetRef to initialize this list with (handy for 1-member lists)
        """
        self._defs = [] if not configDef else [configDef]
        self._dependencies = set() if (not configDef or isinstance(configDef, ConfigDefinition)) else set([configDef.name])

    def addConfigDef(self, configDef):
        if isinstance(configDef, ConfigSetRef):
            self._dependencies.add(configDef.name)
        self._defs.append(configDef)

    def extend(self, configDefList):
        for cd in configDefList.configDefs:
            self.addConfigDef(cd)

    @property
    def dependencies(self):
        return self._dependencies

    @property
    def configDefs(self):
        return self._defs

    def __str__(self):
        return 'ConfigSet of: %s' % ','.join(self._defs)

class Contractor(object):
    """
    Take in a metadata model and force the environment to match it, returning nothing.

    Processes configSets if they exist; otherwise, invents a virtual configSet named
    "default" with one config of "config"

    """

    _configKey = "AWS::CloudFormation::Init"
    _authKey = "AWS::CloudFormation::Authentication"
    _configSetsKey = "configSets"

    def __init__(self, model):
        initModel = model.get(Contractor._configKey)
        if not initModel:
            raise ValueError("Metadata does not contain '%s'" % Contractor._configKey)

        if not Contractor._configSetsKey in initModel:
            self._configSets = { 'default' : [ConfigDefinition("config", initModel.get("config", dict()))]}
        else:
            configSetsDef = initModel[Contractor._configSetsKey]
            if not isinstance(configSetsDef, dict):
                raise ValueError("%s should be a mapping of name to list" % Contractor._configSetsKey)

            self._processConfigSetsDefinition(configSetsDef, initModel)

        self._auth_config = AuthenticationConfig(model.get(Contractor._authKey, {}))

    def _processConfigSetsDefinition(self, configSetsDef, model):
        """
        Parse a set of configSets from the model and collapse them, validating there are no cycles
        and that all references are valid.
        """

        # This builds both a map of the uncollapsed config sets
        # as well as a lookup and reverse lookup table
        # so we can traverse the graph and detect cycles
        # in a not-terrible time

        rawConfigSets = {}
        dependencyTree = {} # maps configSets to the configSets they depend on
        reverseDependencyTree = collections.defaultdict(set) # maps configSets to the configSets that depend on them
        roots = set() # the roots of the configSets graph -- configSets without dependencies
        for configSetName, configList in configSetsDef.iteritems():
            processedList = self._processConfigList(configList, model)
            if processedList.dependencies:
                dependencyTree[configSetName] = set(processedList.dependencies)
                for dependency in processedList.dependencies:
                    reverseDependencyTree[dependency].add(configSetName)
            else:
                roots.add(configSetName)

            rawConfigSets[configSetName] = list(processedList.configDefs)

        if not roots:
            raise CircularConfigSetDependencyError("No configSets exist without references; this creates a circular dependency and is not allowed")

        self._configSets = {}
        # use a traditional (Kahn) topological sort to traverse the configSets in dependency order
        # http://en.wikipedia.org/wiki/Topological_sort#Algorithms has a nice description
        while roots:
            configSet = roots.pop()
            self._configSets[configSet] = self._collapse(configSet, rawConfigSets[configSet])
            for dependent in reverseDependencyTree.pop(configSet, []):
                dependencyTree[dependent].remove(configSet)
                if not dependencyTree[dependent]:
                    roots.add(dependent)
                    del dependencyTree[dependent]

        if dependencyTree:
            raise CircularConfigSetDependencyError("At least one circular dependency detected; this is not allowed. Culprits: " + ', '.join(dependencyTree.keys()))


    def _collapse(self, configSetName, configList):
        """
        Transform ConfigSetRefs into the contents of the ConfigSets they reference, returning a list of only ConfigDefinition objects
        """
        returnList = []

        for config in configList:
            if isinstance(config, ConfigDefinition):
                returnList.append(config)
            else:
                if not config.name in self._configSets:
                    raise ValueError("ConfigSet %s referenced ConfigSet %s but it is not defined" % (configSetName, config.name))
                returnList.extend(self._configSets[config.name])

        return returnList

    def _processConfigList(self, configList, model):
        """
        Processes a parsed-JSON list of config definitions, returning a ConfigSet

        Handles both references ({"ConfigSet" : "name"}) and plain config names
        so users can define simple ConfigSets without using lists, and so we can recurse simply
        """

        if isinstance(configList, basestring):
            if not configList in model:
                raise NoSuchConfigurationError("No configuration found with name: %s" % configList)
            return ConfigSet(ConfigDefinition(configList, model[configList]))

        if isinstance(configList, dict):
            if not 'ConfigSet' in configList:
                raise ValueError("Config definitions must be either a config name or a reference in the format {'ConfigSet':<config set name>}")
            setName = configList['ConfigSet']
            if not setName in model[Contractor._configSetsKey]:
                raise ValueError("Configuration set %s was referenced but not defined" % setName)
            return ConfigSet(ConfigSetRef(setName))

        returnSet = ConfigSet()
        for configDef in configList:
            returnSet.extend(self._processConfigList(configDef, model))

        return returnSet


    def build(self, configSets, worklog):
        """Does the work described by each configSet, in order, returning nothing"""

        worklog.clear_except_metadata()

        configSets = collections.deque(configSets)
        log.info("Running configSets: %s", ', '.join(configSets))

        while configSets:
            configSetName = configSets.popleft()
            if not configSetName in self._configSets:
                raise NoSuchConfigSetError("Error: no ConfigSet named %s exists" % configSetName)

            worklog.put('configSets', configSets)

            configSet = collections.deque(self._configSets[configSetName])
            log.info("Running configSet %s", configSetName)
            cmd_log.info("*"*60)
            cmd_log.info("ConfigSet %s", configSetName)
            while configSet:
                config = configSet.popleft()

                worklog.put('configs', configSet)

                self.run_config(config, worklog)

        log.info("ConfigSets completed")
        worklog.clear()
        platform_utils.clear_reboot_trigger()

    def run_config(self, config, worklog):
        log.info("Running config %s", config.name)
        cmd_log.info("+"*60)
        cmd_log.info("Config %s", config.name)
        
        try:
            CloudFormationCarpenter(config, self._auth_config).build(worklog)

            worklog.run_commands()
        except BuildError, e:
            log.exception("Error encountered during build of %s: %s", config.name, str(e))
            raise

    @classmethod
    def metadataValid(cls, metadata):
        return metadata and cls._configKey in metadata and metadata[cls._configKey]

    @property
    def configs(self):
        return dict(self._configSets)
