# vim: tabstop=4 shiftwidth=4 softtabstop=4

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

import eventlet
import functools

from heat.common import exception
from heat.engine import dependencies
from heat.common import identifier
from heat.engine import resource
from heat.engine import template
from heat.engine import timestamp
from heat.engine.parameters import Parameters
from heat.engine.template import Template
from heat.engine.clients import Clients
from heat.db import api as db_api

from heat.openstack.common import log as logging

logger = logging.getLogger(__name__)

(PARAM_STACK_NAME, PARAM_REGION) = ('AWS::StackName', 'AWS::Region')


class Stack(object):

    ACTIONS = (CREATE, DELETE, UPDATE, ROLLBACK
               ) = ('CREATE', 'DELETE', 'UPDATE', 'ROLLBACK')

    CREATE_IN_PROGRESS = 'CREATE_IN_PROGRESS'
    CREATE_FAILED = 'CREATE_FAILED'
    CREATE_COMPLETE = 'CREATE_COMPLETE'

    DELETE_IN_PROGRESS = 'DELETE_IN_PROGRESS'
    DELETE_FAILED = 'DELETE_FAILED'
    DELETE_COMPLETE = 'DELETE_COMPLETE'

    UPDATE_IN_PROGRESS = 'UPDATE_IN_PROGRESS'
    UPDATE_COMPLETE = 'UPDATE_COMPLETE'
    UPDATE_FAILED = 'UPDATE_FAILED'

    ROLLBACK_IN_PROGRESS = 'ROLLBACK_IN_PROGRESS'
    ROLLBACK_COMPLETE = 'ROLLBACK_COMPLETE'
    ROLLBACK_FAILED = 'ROLLBACK_FAILED'

    created_time = timestamp.Timestamp(db_api.stack_get, 'created_at')
    updated_time = timestamp.Timestamp(db_api.stack_get, 'updated_at')

    def __init__(self, context, stack_name, tmpl, parameters=None,
                 stack_id=None, state=None, state_description='',
                 timeout_mins=60, resolve_data=True, disable_rollback=False):
        '''
        Initialise from a context, name, Template object and (optionally)
        Parameters object. The database ID may also be initialised, if the
        stack is already in the database.
        '''

        if '/' in stack_name:
            raise ValueError(_('Stack name may not contain "/"'))

        self.id = stack_id
        self.context = context
        self.clients = Clients(context)
        self.t = tmpl
        self.name = stack_name
        self.state = state
        self.state_description = state_description
        self.timeout_mins = timeout_mins
        self.disable_rollback = disable_rollback

        if parameters is None:
            parameters = Parameters(self.name, self.t)
        self.parameters = parameters

        if resolve_data:
            self.outputs = self.resolve_static_data(self.t[template.OUTPUTS])
        else:
            self.outputs = {}

        template_resources = self.t[template.RESOURCES]
        self.resources = dict((name,
                               resource.Resource(name, data, self))
                              for (name, data) in template_resources.items())

        self.dependencies = self._get_dependencies(self.resources.itervalues())

    @staticmethod
    def _get_dependencies(resources):
        '''Return the dependency graph for a list of resources'''
        deps = dependencies.Dependencies()
        for resource in resources:
            resource.add_dependencies(deps)

        return deps

    @classmethod
    def load(cls, context, stack_id=None, stack=None, resolve_data=True):
        '''Retrieve a Stack from the database'''
        if stack is None:
            stack = db_api.stack_get(context, stack_id)
        if stack is None:
            message = 'No stack exists with id "%s"' % str(stack_id)
            raise exception.NotFound(message)

        template = Template.load(context, stack.raw_template_id)
        params = Parameters(stack.name, template, stack.parameters)
        stack = cls(context, stack.name, template, params,
                    stack.id, stack.status, stack.status_reason, stack.timeout,
                    resolve_data, stack.disable_rollback)

        return stack

    def store(self, owner=None):
        '''
        Store the stack in the database and return its ID
        If self.id is set, we update the existing stack
        '''
        new_creds = db_api.user_creds_create(self.context)

        s = {
            'name': self.name,
            'raw_template_id': self.t.store(self.context),
            'parameters': self.parameters.user_parameters(),
            'owner_id': owner and owner.id,
            'user_creds_id': new_creds.id,
            'username': self.context.username,
            'tenant': self.context.tenant_id,
            'status': self.state,
            'status_reason': self.state_description,
            'timeout': self.timeout_mins,
            'disable_rollback': self.disable_rollback,
        }
        if self.id:
            db_api.stack_update(self.context, self.id, s)
        else:
            new_s = db_api.stack_create(self.context, s)
            self.id = new_s.id

        return self.id

    def identifier(self):
        '''
        Return an identifier for this stack.
        '''
        return identifier.HeatIdentifier(self.context.tenant_id,
                                         self.name, self.id)

    def __iter__(self):
        '''
        Return an iterator over this template's resources in the order that
        they should be started.
        '''
        return iter(self.dependencies)

    def __reversed__(self):
        '''
        Return an iterator over this template's resources in the order that
        they should be stopped.
        '''
        return reversed(self.dependencies)

    def __len__(self):
        '''Return the number of resources'''
        return len(self.resources)

    def __getitem__(self, key):
        '''Get the resource with the specified name.'''
        return self.resources[key]

    def __setitem__(self, key, value):
        '''Set the resource with the specified name to a specific value'''
        self.resources[key] = value

    def __contains__(self, key):
        '''Determine whether the stack contains the specified resource'''
        return key in self.resources

    def keys(self):
        '''Return a list of resource keys for the stack'''
        return self.resources.keys()

    def __str__(self):
        '''Return a human-readable string representation of the stack'''
        return 'Stack "%s"' % self.name

    def validate(self):
        '''
        http://docs.amazonwebservices.com/AWSCloudFormation/latest/\
        APIReference/API_ValidateTemplate.html
        '''
        # TODO(sdake) Should return line number of invalid reference

        for res in self:
            try:
                result = res.validate()
            except Exception as ex:
                logger.exception('validate')
                result = str(ex)

            if result:
                return 'Malformed Query Response %s' % result

    def state_set(self, new_status, reason):
        '''Update the stack state in the database'''
        self.state = new_status
        self.state_description = reason

        if self.id is None:
            return

        stack = db_api.stack_get(self.context, self.id)
        stack.update_and_save({'status': new_status,
                               'status_reason': reason})

    def create(self):
        '''
        Create the stack and all of the resources.

        Creation will fail if it exceeds the specified timeout. The default is
        60 minutes, set in the constructor
        '''
        self.state_set(self.CREATE_IN_PROGRESS, 'Stack creation started')

        stack_status = self.CREATE_COMPLETE
        reason = 'Stack successfully created'
        res = None

        with eventlet.Timeout(self.timeout_mins * 60) as tmo:
            try:
                for res in self:
                    if stack_status != self.CREATE_FAILED:
                        result = res.create()
                        if result:
                            stack_status = self.CREATE_FAILED
                            reason = 'Resource %s failed with: %s' % (str(res),
                                                                      result)

                    else:
                        res.state_set(res.CREATE_FAILED,
                                      'Stack creation aborted')

            except eventlet.Timeout as t:
                if t is tmo:
                    stack_status = self.CREATE_FAILED
                    reason = 'Timed out waiting for %s' % str(res)
                else:
                    # not my timeout
                    raise

        self.state_set(stack_status, reason)

        if stack_status == self.CREATE_FAILED and not self.disable_rollback:
            self.delete(action=self.ROLLBACK)

    def update(self, newstack, action=UPDATE):
        '''
        Compare the current stack with newstack,
        and where necessary create/update/delete the resources until
        this stack aligns with newstack.

        Note update of existing stack resources depends on update
        being implemented in the underlying resource types

        Update will fail if it exceeds the specified timeout. The default is
        60 minutes, set in the constructor
        '''
        if action not in (self.UPDATE, self.ROLLBACK):
            logger.error("Unexpected action %s passed to update!" % action)
            self.state_set(self.UPDATE_FAILED, "Invalid action %s" % action)
            return

        if self.state not in (self.CREATE_COMPLETE, self.UPDATE_COMPLETE,
                              self.ROLLBACK_COMPLETE):
            if (action == self.ROLLBACK and
                    self.state == self.UPDATE_IN_PROGRESS):
                logger.debug("Starting update rollback for %s" % self.name)
            else:
                if action == self.UPDATE:
                    self.state_set(self.UPDATE_FAILED,
                                   'State invalid for update')
                else:
                    self.state_set(self.ROLLBACK_FAILED,
                                   'State invalid for rollback')
                return

        if action == self.UPDATE:
            self.state_set(self.UPDATE_IN_PROGRESS, 'Stack update started')
        else:
            self.state_set(self.ROLLBACK_IN_PROGRESS, 'Stack rollback started')

        # Now make the resources match the new stack definition
        with eventlet.Timeout(self.timeout_mins * 60) as tmo:
            try:
                # First delete any resources which are not in newstack
                for res in reversed(self):
                    if not res.name in newstack.keys():
                        logger.debug("resource %s not found in updated stack"
                                     % res.name + " definition, deleting")
                        result = res.destroy()
                        if result:
                            logger.error("Failed to remove %s : %s" %
                                         (res.name, result))
                            raise exception.ResourceUpdateFailed(
                                resource_name=res.name)
                        else:
                            del self.resources[res.name]
                            self.dependencies = self._get_dependencies(
                                self.resources.itervalues())

                # Then create any which are defined in newstack but not self
                for res in newstack:
                    if not res.name in self.keys():
                        logger.debug("resource %s not found in current stack"
                                     % res.name + " definition, adding")
                        res.stack = self
                        self[res.name] = res
                        self.dependencies = self._get_dependencies(
                            self.resources.itervalues())
                        result = self[res.name].create()
                        if result:
                            logger.error("Failed to add %s : %s" %
                                         (res.name, result))
                            raise exception.ResourceUpdateFailed(
                                resource_name=res.name)

                # Now (the hard part :) update existing resources
                # The Resource base class allows equality-test of resources,
                # based on the parsed template snippet for the resource.
                # If this  test fails, we call the underlying resource.update
                #
                # FIXME : Implement proper update logic for the resources
                # AWS define three update strategies, applied depending
                # on the resource and what is being updated within a
                # resource :
                # - Update with no interruption
                # - Update with some interruption
                # - Update requires replacement
                #
                # Currently all resource have a default handle_update method
                # which returns "requires replacement" (res.UPDATE_REPLACE)
                for res in newstack:
                    # Compare resolved pre/post update resource snippets,
                    # note the new resource snippet is resolved in the context
                    # of the existing stack (which is the stack being updated)
                    old_snippet = self.resolve_runtime_data(self[res.name].t)
                    new_snippet = self.resolve_runtime_data(res.t)

                    if old_snippet != new_snippet:
                        # Can fail if underlying resource class does not
                        # implement update logic or update requires replacement
                        retval = self[res.name].update(new_snippet)
                        if retval == self[res.name].UPDATE_COMPLETE:
                            logger.info("Resource %s for stack %s updated" %
                                        (res.name, self.name))
                        elif retval == self[res.name].UPDATE_REPLACE:
                            logger.info("Resource %s for stack %s" %
                                        (res.name, self.name) +
                                        " update requires replacement")
                            # Resource requires replacement for update
                            result = self[res.name].destroy()
                            if result:
                                logger.error("Failed to delete %s : %s" %
                                             (res.name, result))
                                raise exception.ResourceUpdateFailed(
                                    resource_name=res.name)
                            else:
                                res.stack = self
                                self[res.name] = res
                                self.dependencies = self._get_dependencies(
                                    self.resources.itervalues())
                                result = self[res.name].create()
                                if result:
                                    logger.error("Failed to create %s : %s" %
                                                 (res.name, result))
                                    raise exception.ResourceUpdateFailed(
                                        resource_name=res.name)
                        else:
                            logger.error("Failed to %s %s" %
                                         (action, res.name))
                            raise exception.ResourceUpdateFailed(
                                resource_name=res.name)

                # flip the template & parameters to the newstack values
                self.t = newstack.t
                self.parameters = newstack.parameters
                template_outputs = self.t[template.OUTPUTS]
                self.outputs = self.resolve_static_data(template_outputs)
                self.store()

                if action == self.UPDATE:
                    stack_status = self.UPDATE_COMPLETE
                    reason = 'Stack successfully updated'
                else:
                    stack_status = self.ROLLBACK_COMPLETE
                    reason = 'Stack rollback completed'

            except eventlet.Timeout as t:
                if t is tmo:
                    stack_status = self.UPDATE_FAILED
                    reason = 'Timed out waiting for %s' % str(res)
                else:
                    # not my timeout
                    raise
            except exception.ResourceUpdateFailed as e:
                reason = str(e) or "Error : %s" % type(e)

                if action == self.UPDATE:
                    stack_status = self.UPDATE_FAILED
                    # If rollback is enabled, we do another update, with the
                    # existing template, so we roll back to the original state
                    # Note - ensure nothing after the "flip the template..."
                    # section above can raise ResourceUpdateFailed or this
                    # will not work ;)
                    if self.disable_rollback:
                        stack_status = self.UPDATE_FAILED
                    else:
                        oldstack = Stack(self.context, self.name, self.t,
                                         self.parameters)
                        self.update(oldstack, action=self.ROLLBACK)
                        return
                else:
                    stack_status = self.ROLLBACK_FAILED

            self.state_set(stack_status, reason)

    def delete(self, action=DELETE):
        '''
        Delete all of the resources, and then the stack itself.
        The action parameter is used to differentiate between a user
        initiated delete and an automatic stack rollback after a failed
        create, which amount to the same thing, but the states are recorded
        differently.
        '''
        if action == self.DELETE:
            self.state_set(self.DELETE_IN_PROGRESS, 'Stack deletion started')
        elif action == self.ROLLBACK:
            self.state_set(self.ROLLBACK_IN_PROGRESS, 'Stack rollback started')
        else:
            logger.error("Unexpected action %s passed to delete!" % action)
            self.state_set(self.DELETE_FAILED, "Invalid action %s" % action)
            return

        failures = []
        for res in reversed(self):
            result = res.destroy()
            if result:
                logger.error('Failed to delete %s error: %s' % (str(res),
                                                                result))
                failures.append(str(res))

        if failures:
            if action == self.DELETE:
                self.state_set(self.DELETE_FAILED,
                               'Failed to delete ' + ', '.join(failures))
            elif action == self.ROLLBACK:
                self.state_set(self.ROLLBACK_FAILED,
                               'Failed to rollback ' + ', '.join(failures))
        else:
            if action == self.DELETE:
                self.state_set(self.DELETE_COMPLETE, 'Deleted successfully')
            elif action == self.ROLLBACK:
                self.state_set(self.ROLLBACK_COMPLETE, 'Rollback completed')
            db_api.stack_delete(self.context, self.id)

    def output(self, key):
        '''
        Get the value of the specified stack output.
        '''
        value = self.outputs[key].get('Value', '')
        return self.resolve_runtime_data(value)

    def restart_resource(self, resource_name):
        '''
        stop resource_name and all that depend on it
        start resource_name and all that depend on it
        '''
        deps = self.dependencies[self[resource_name]]
        failed = False

        for res in reversed(deps):
            try:
                res.destroy()
            except Exception as ex:
                failed = True
                logger.error('delete: %s' % str(ex))

        for res in deps:
            if not failed:
                try:
                    res.create()
                except Exception as ex:
                    logger.exception('create')
                    failed = True
            else:
                res.state_set(res.CREATE_FAILED, 'Resource restart aborted')
        # TODO(asalkeld) if any of this fails we Should
        # restart the whole stack

    def resolve_static_data(self, snippet):
        return resolve_static_data(self.t, self.parameters, snippet)

    def resolve_runtime_data(self, snippet):
        return resolve_runtime_data(self.t, self.resources, snippet)


def resolve_static_data(template, parameters, snippet):
    '''
    Resolve static parameters, map lookups, etc. in a template.

    Example:

    >>> template = Template(template_format.parse(template_path))
    >>> parameters = Parameters('stack', template, {'KeyName': 'my_key'})
    >>> resolve_static_data(template, parameters, {'Ref': 'KeyName'})
    'my_key'
    '''
    return transform(snippet,
                     [functools.partial(template.resolve_param_refs,
                                        parameters=parameters),
                      template.resolve_availability_zones,
                      template.resolve_find_in_map,
                      template.reduce_joins])


def resolve_runtime_data(template, resources, snippet):
    return transform(snippet,
                     [functools.partial(template.resolve_resource_refs,
                                        resources=resources),
                      functools.partial(template.resolve_attributes,
                                        resources=resources),
                      template.resolve_joins,
                      template.resolve_base64])


def transform(data, transformations):
    '''
    Apply each of the transformation functions in the supplied list to the data
    in turn.
    '''
    for t in transformations:
        data = t(data)
    return data
