import json
import logging
import traceback
import xmltodict
from flask import Flask, request
from requests.models import Response
from localstack.utils.aws import aws_stack
from localstack.utils.common import (
    parse_request_data, short_uid, long_uid, clone, clone_safe, select_attributes,
    timestamp_millis, recurse_object)
from localstack.utils.cloudformation import template_deployer, template_preparer
from localstack.services.generic_proxy import RegionBackend
from localstack.utils.aws.aws_responses import (
    requests_response_xml, requests_to_flask_response, flask_error_response_xml)

APP_NAME = 'cloudformation_api'
app = Flask(APP_NAME)

LOG = logging.getLogger(__name__)

XMLNS_CF = 'http://cloudformation.amazonaws.com/doc/2010-05-15/'


class CloudFormationRegion(RegionBackend):

    def __init__(self):
        # maps stack ID to stack details
        self.stacks = {}

    @property
    def exports(self):
        exports = []
        output_keys = {}
        for stack_id, stack in self.stacks.items():
            for output in stack.outputs:
                export_name = output.get('ExportName')
                if not export_name:
                    continue
                if export_name in output_keys:
                    # TODO: raise exception on stack creation in case of duplicate exports
                    LOG.warning('Found duplicate export name %s in stacks: %s %s' % (
                        export_name, output_keys[export_name], stack.stack_id))
                entry = {
                    'ExportingStackId': stack.stack_id,
                    'Name': export_name,
                    'Value': output['OutputValue']
                }
                exports.append(entry)
                output_keys[export_name] = stack.stack_id
        return exports


class Stack(object):
    def __init__(self, metadata=None, template={}):
        self.metadata = metadata or {}
        self.template = template or {}
        self._template_raw = clone_safe(self.template)
        self.template_original = clone_safe(self.template)
        # initialize resources
        for resource_id, resource in self.template_resources.items():
            resource['LogicalResourceId'] = self.template_original['Resources'][resource_id]['LogicalResourceId'] = (
                resource.get('LogicalResourceId') or resource_id)
        # initialize stack template attributes
        self.template['StackId'] = self.metadata['StackId'] = (self.metadata.get('StackId') or
            aws_stack.cloudformation_stack_arn(self.stack_name, short_uid()))
        self.template['Parameters'] = self.template.get('Parameters') or {}
        self.template['Outputs'] = self.template.get('Outputs') or {}
        # initialize metadata
        self.metadata['Parameters'] = self.metadata.get('Parameters') or []
        self.metadata['StackStatus'] = 'CREATE_IN_PROGRESS'
        self.metadata['CreationTime'] = self.metadata.get('CreationTime') or timestamp_millis()
        # maps resource id to resource state
        self.resource_states = {}
        # maps resource id to moto resource class instance (TODO: remove in the future)
        self.moto_resource_statuses = {}
        # list of stack events
        self.events = []
        # list of stack change sets
        self.change_sets = []
        # initialize parameters
        for i in range(1, 100):
            key = 'Parameters.member.%s.ParameterKey' % i
            value = 'Parameters.member.%s.ParameterValue' % i
            key = self.metadata.get(key)
            value = self.metadata.get(value)
            if not key:
                break
            self.metadata['Parameters'].append({'ParameterKey': key, 'ParameterValue': value})

    def describe_details(self):
        attrs = ['StackId', 'StackName', 'Description', 'StackStatusReason',
            'StackStatus', 'Capabilities', 'ParentId', 'RootId', 'RoleARN',
            'CreationTime', 'DeletionTime', 'LastUpdatedTime', 'ChangeSetId']
        result = select_attributes(self.metadata, attrs)
        result['Tags'] = self.tags
        result['Outputs'] = self.outputs
        result['Parameters'] = self.stack_parameters()
        for attr in ['Capabilities', 'Tags', 'Outputs', 'Parameters']:
            result[attr] = {'member': result.get(attr, [])}
        return result

    def set_stack_status(self, status):
        self.metadata['StackStatus'] = status
        self.add_stack_event(self.stack_name, self.stack_id, status)

    def add_stack_event(self, resource_id, physical_res_id, status):
        event = {
            'EventId': long_uid(),
            'Timestamp': timestamp_millis(),
            'StackId': self.stack_id,
            'StackName': self.stack_name,
            'LogicalResourceId': resource_id,
            'PhysicalResourceId': physical_res_id,
            'ResourceStatus': status,
            'ResourceType': 'AWS::CloudFormation::Stack'
        }
        self.events.insert(0, event)

    def set_resource_status(self, resource_id, status, physical_res_id=None):
        resource = self.resources[resource_id]
        state = self.resource_states[resource_id] = self.resource_states.get(resource_id) or {}
        attr_defaults = (('LogicalResourceId', resource_id), ('PhysicalResourceId', physical_res_id))
        for res in [resource, state]:
            for attr, default in attr_defaults:
                res[attr] = res.get(attr) or default
        state['ResourceStatus'] = status
        state['StackName'] = state.get('StackName') or self.stack_name
        state['StackId'] = state.get('StackId') or self.stack_id
        state['ResourceType'] = state.get('ResourceType') or self.resources[resource_id].get('Type')
        self.add_stack_event(resource_id, physical_res_id, status)

    def resource_status(self, resource_id):
        result = self._lookup(self.resource_states, resource_id)
        return result

    @property
    def stack_name(self):
        return self.metadata['StackName']

    @property
    def stack_id(self):
        return self.metadata['StackId']

    @property
    def resources(self):
        """ Return dict of resources, parameters, conditions, and other stack metadata. """

        def add_params(defaults=True):
            for param in self.stack_parameters(defaults=defaults):
                if param['ParameterKey'] not in result:
                    props = {'Value': param['ParameterValue']}
                    result[param['ParameterKey']] = {
                        'Type': 'Parameter',
                        'LogicalResourceId': param['ParameterKey'],
                        'Properties': props
                    }

        result = dict(self.template_resources)

        add_params(defaults=False)

        for name, value in self.conditions.items():
            if name not in result:
                result[name] = {'Type': 'Parameter', 'LogicalResourceId': name, 'Properties': {'Value': value}}
        for name, value in self.mappings.items():
            if name not in result:
                result[name] = {'Type': 'Parameter', 'LogicalResourceId': name, 'Properties': {'Value': value}}

        add_params(defaults=True)

        return result

    @property
    def template_resources(self):
        return self.template['Resources']

    @property
    def tags(self):
        return aws_stack.extract_tags(self.metadata)

    @property
    def imports(self):
        def _collect(o, **kwargs):
            if isinstance(o, dict):
                import_val = o.get('Fn::ImportValue')
                if import_val:
                    result.add(import_val)
            return o
        result = set()
        recurse_object(self.resources, _collect)
        return result

    @property
    def outputs(self):
        result = []
        # first, fetch the outputs of nested child stacks
        for stack in self.nested_stacks:
            result.extend(stack.outputs)
        # now, fetch the outputs of this stack
        for k, details in self.template.get('Outputs', {}).items():
            value = None
            try:
                template_deployer.resolve_refs_recursively(self.stack_name, details, self.resources)
                value = details['Value']
            except Exception as e:
                LOG.debug('Unable to resolve references in stack outputs: %s - %s' % (details, e))
            exports = details.get('Export') or {}
            export = exports.get('Name')
            description = details.get('Description')
            entry = {'OutputKey': k, 'OutputValue': value, 'Description': description, 'ExportName': export}
            result.append(entry)
        return result

    def stack_parameters(self, defaults=True):
        result = {}
        # add default template parameter values
        if defaults:
            for key, value in self.template_parameters.items():
                result[key] = {'ParameterKey': key, 'ParameterValue': value.get('Default')}
        # add stack parameters
        result.update({p['ParameterKey']: p for p in self.metadata['Parameters']})
        # add parameters of change sets
        for change_set in self.change_sets:
            result.update({p['ParameterKey']: p for p in change_set.metadata['Parameters']})
        result = list(result.values())
        return result

    @property
    def template_parameters(self):
        return self.template['Parameters']

    @property
    def conditions(self):
        return self.template.get('Conditions', {})

    @property
    def mappings(self):
        return self.template.get('Mappings', {})

    @property
    def exports_map(self):
        result = {}
        for export in CloudFormationRegion.get().exports:
            result[export['Name']] = export
        return result

    @property
    def nested_stacks(self):
        """ Return a list of nested stacks that have been deployed by this stack. """
        result = [r for r in self.template_resources.values() if r['Type'] == 'AWS::CloudFormation::Stack']
        result = [find_stack(r['Properties'].get('StackName')) for r in result]
        result = [r for r in result if r]
        return result

    @property
    def status(self):
        return self.metadata['StackStatus']

    @property
    def resource_types(self):
        return [r.get('Type') for r in self.template_resources.values()]

    def resource(self, resource_id):
        return self._lookup(self.resources, resource_id)

    def _lookup(self, resource_map, resource_id):
        resource = resource_map.get(resource_id)
        if not resource:
            raise Exception('Unable to find details for resource "%s" in stack "%s"' % (resource_id, self.stack_name))
        return resource

    def copy(self):
        return Stack(metadata=dict(self.metadata), template=dict(self.template))


class StackChangeSet(Stack):
    def __init__(self, params={}, template={}):
        super(StackChangeSet, self).__init__(params, template)
        name = self.metadata['ChangeSetName']
        if not self.metadata.get('ChangeSetId'):
            self.metadata['ChangeSetId'] = aws_stack.cf_change_set_arn(name, change_set_id=short_uid())

        stack = self.stack = find_stack(self.metadata['StackName'])
        self.metadata['StackId'] = stack.stack_id
        self.metadata['Status'] = 'CREATE_PENDING'

    @property
    def change_set_id(self):
        return self.metadata['ChangeSetId']

    @property
    def change_set_name(self):
        return self.metadata['ChangeSetName']

    @property
    def resources(self):
        result = dict(self.stack.resources)
        result.update(self.resources)
        return result

    @property
    def changes(self):
        result = self.metadata['Changes'] = self.metadata.get('Changes', [])
        return result


# --------------
# API ENDPOINTS
# --------------

def create_stack(req_params):
    state = CloudFormationRegion.get()
    template_deployer.prepare_template_body(req_params)
    template = template_preparer.parse_template(req_params['TemplateBody'])
    stack_name = template['StackName'] = req_params.get('StackName')
    stack = Stack(req_params, template)

    # find existing stack with same name, and remove it if this stack is in DELETED state
    existing = ([s for s in state.stacks.values() if s.stack_name == stack_name] or [None])[0]
    if existing:
        if 'DELETE' not in existing.status:
            return error_response('Stack named "%s" already exists with status "%s"' % (
                stack_name, existing.status), code=400, code_string='ValidationError')
        state.stacks.pop(existing.stack_id)

    state.stacks[stack.stack_id] = stack
    LOG.debug('Creating stack "%s" with %s resources ...' % (stack.stack_name, len(stack.template_resources)))
    deployer = template_deployer.TemplateDeployer(stack)
    try:
        deployer.deploy_stack()
    except Exception as e:
        stack.set_stack_status('CREATE_FAILED')
        msg = 'Unable to create stack "%s": %s' % (stack.stack_name, e)
        LOG.debug('%s %s' % (msg, traceback.format_exc()))
        return error_response(msg, code=400, code_string='ValidationError')
    result = {'StackId': stack.stack_id}
    return result


def delete_stack(req_params):
    stack_name = req_params.get('StackName')
    stack = find_stack(stack_name)
    deployer = template_deployer.TemplateDeployer(stack)
    deployer.delete_stack()
    return {}


def update_stack(req_params):
    stack_name = req_params.get('StackName')
    stack = find_stack(stack_name)
    if not stack:
        return error_response('Unable to update non-existing stack "%s"' % stack_name,
            code=404, code_string='ValidationError')
    template_preparer.prepare_template_body(req_params)
    template = template_preparer.parse_template(req_params['TemplateBody'])
    new_stack = Stack(req_params, template)
    deployer = template_deployer.TemplateDeployer(stack)
    try:
        deployer.update_stack(new_stack)
    except Exception as e:
        stack.set_stack_status('UPDATE_FAILED')
        msg = 'Unable to update stack "%s": %s' % (stack_name, e)
        LOG.debug('%s %s' % (msg, traceback.format_exc()))
        return error_response(msg, code=400, code_string='ValidationError')
    result = {'StackId': stack.stack_id}
    return result


def describe_stacks(req_params):
    state = CloudFormationRegion.get()
    stack_name = req_params.get('StackName')
    stacks = [s.describe_details() for s in state.stacks.values() if stack_name in [None, s.stack_name, s.stack_id]]
    if stack_name and not stacks:
        return error_response('Stack with id %s does not exist' % stack_name,
            code=400, code_string='ValidationError')
    result = {'Stacks': {'member': stacks}}
    return result


def list_stacks(req_params):
    state = CloudFormationRegion.get()
    filter = req_params.get('StackStatusFilter')
    stacks = [s.describe_details() for s in state.stacks.values() if filter in [None, s.status]]
    attrs = ['StackId', 'StackName', 'TemplateDescription', 'CreationTime', 'LastUpdatedTime', 'DeletionTime',
        'StackStatus', 'StackStatusReason', 'ParentId', 'RootId', 'DriftInformation']
    stacks = [select_attributes(stack, attrs) for stack in stacks]
    result = {'StackSummaries': {'member': stacks}}
    return result


def describe_stack_resource(req_params):
    stack_name = req_params.get('StackName')
    resource_id = req_params.get('LogicalResourceId')
    stack = find_stack(stack_name)
    if not stack:
        return stack_not_found_error(stack_name)
    details = stack.resource_status(resource_id)
    result = {'StackResourceDetail': details}
    return result


def describe_stack_resources(req_params):
    stack_name = req_params.get('StackName')
    resource_id = req_params.get('LogicalResourceId')
    phys_resource_id = req_params.get('PhysicalResourceId')
    if phys_resource_id and stack_name:
        return error_response('Cannot specify both StackName and PhysicalResourceId')
    # TODO: filter stack by PhysicalResourceId!
    stack = find_stack(stack_name)
    if not stack:
        return stack_not_found_error(stack_name)
    statuses = [stack.resource_status(res_id) for res_id, _ in stack.resource_states.items() if
        resource_id in [res_id, None]]
    return {'StackResources': {'member': statuses}}


def list_stack_resources(req_params):
    result = describe_stack_resources(req_params)
    if not isinstance(result, dict):
        return result
    result = {'StackResourceSummaries': result.pop('StackResources')}
    return result


def create_change_set(req_params):
    stack_name = req_params.get('StackName')
    template_deployer.prepare_template_body(req_params)
    template = template_preparer.parse_template(req_params.pop('TemplateBody'))
    template['StackName'] = stack_name
    template['ChangeSetName'] = req_params.get('ChangeSetName')
    stack = existing = find_stack(stack_name)
    if not existing:
        # automatically create (empty) stack if none exists yet
        state = CloudFormationRegion.get()
        empty_stack_template = dict(template)
        empty_stack_template['Resources'] = {}
        req_params_copy = clone_stack_params(req_params)
        stack = Stack(req_params_copy, empty_stack_template)
        state.stacks[stack.stack_id] = stack
        stack.set_stack_status('CREATE_COMPLETE')
    change_set = StackChangeSet(req_params, template)
    deployer = template_deployer.TemplateDeployer(stack)
    deployer.construct_changes(stack, change_set, change_set_id=change_set.change_set_id, append_to_changeset=True)
    stack.change_sets.append(change_set)
    change_set.metadata['Status'] = 'CREATE_COMPLETE'
    return {'StackId': change_set.stack_id, 'Id': change_set.change_set_id}


def execute_change_set(req_params):
    stack_name = req_params.get('StackName')
    cs_name = req_params.get('ChangeSetName')
    change_set = find_change_set(cs_name, stack_name=stack_name)
    if not change_set:
        return error_response('Unable to find change set "%s" for stack "%s"' % (cs_name, stack_name))
    LOG.debug('Executing change set "%s" for stack "%s" with %s resources ...' % (
        cs_name, stack_name, len(change_set.template_resources)))
    deployer = template_deployer.TemplateDeployer(change_set.stack)
    deployer.apply_change_set(change_set)
    change_set.stack.metadata['ChangeSetId'] = change_set.change_set_id
    return {}


def list_change_sets(req_params):
    stack_name = req_params.get('StackName')
    stack = find_stack(stack_name)
    if not stack:
        return error_response('Unable to find stack "%s"' % stack_name)
    result = [cs.metadata for cs in stack.change_sets]
    result = {'Summaries': {'member': result}}
    return result


def describe_change_set(req_params):
    stack_name = req_params.get('StackName')
    cs_name = req_params.get('ChangeSetName')
    change_set = find_change_set(cs_name, stack_name=stack_name)
    if not change_set:
        return error_response('Unable to find change set "%s" for stack "%s"' % (cs_name, stack_name))
    return change_set.metadata


def list_exports(req_params):
    state = CloudFormationRegion.get()
    result = {'Exports': {'member': state.exports}}
    return result


def list_imports(req_params):
    state = CloudFormationRegion.get()
    export_name = req_params.get('ExportName')
    importing_stack_names = []
    for stack in state.stacks.values():
        if export_name in stack.imports:
            importing_stack_names.append(stack.stack_name)
    result = {'Imports': {'member': importing_stack_names}}
    return result


def validate_template(req_params):
    try:
        result = template_preparer.validate_template(req_params)
        result = '<tmp>%s</tmp>' % result
        result = xmltodict.parse(result)['tmp']
        return result
    except Exception as err:
        return error_response('Template Validation Error: %s' % err)


def describe_stack_events(req_params):
    stack_name = req_params.get('StackName')
    state = CloudFormationRegion.get()
    events = []
    for stack_id, stack in state.stacks.items():
        if stack_name in [None, stack.stack_name, stack.stack_id]:
            events.extend(stack.events)
    return {'StackEvents': {'member': events}}


def delete_change_set(req_params):
    stack_name = req_params.get('StackName')
    cs_name = req_params.get('ChangeSetName')
    change_set = find_change_set(cs_name, stack_name=stack_name)
    if not change_set:
        return error_response('Unable to find change set "%s" for stack "%s"' % (cs_name, stack_name))
    change_set.stack.change_sets = [cs for cs in change_set.stack.change_sets if cs.change_set_name != cs_name]
    return {}


def get_template(req_params):
    stack_name = req_params.get('StackName')
    cs_name = req_params.get('ChangeSetName')
    stack = find_stack(stack_name)
    if cs_name:
        stack = find_change_set(stack_name, cs_name)
    if not stack:
        return stack_not_found_error(stack_name)
    result = {'TemplateBody': json.dumps(stack._template_raw)}
    return result


def get_template_summary(req_params):
    stack_name = req_params.get('StackName')
    stack = None
    if stack_name:
        stack = find_stack(stack_name)
        if not stack:
            return stack_not_found_error(stack_name)
    else:
        template_deployer.prepare_template_body(req_params)
        template = template_preparer.parse_template(req_params['TemplateBody'])
        req_params['StackName'] = 'tmp-stack'
        stack = Stack(req_params, template)
    result = stack.describe_details()
    id_summaries = {}
    for resource_id, resource in stack.template_resources.items():
        res_type = resource['Type']
        id_summaries[res_type] = id_summaries.get(res_type) or []
        id_summaries[res_type].append(resource_id)
    result['ResourceTypes'] = list(id_summaries.keys())
    result['ResourceIdentifierSummaries'] = [
        {'ResourceType': key, 'LogicalResourceIds': {'member': values}} for key, values in id_summaries.items()]
    return result


# -----------------
# MAIN ENTRY POINT
# -----------------

@app.route('/', methods=['POST'])
def handle_request():
    data = request.get_data()
    req_params = parse_request_data(request.method, request.path, data)
    action = req_params.get('Action', '')

    func = ENDPOINTS.get(action)
    if not func:
        return '', 404
    result = func(req_params)

    result = _response(action, result)
    return result


ENDPOINTS = {
    'CreateChangeSet': create_change_set,
    'CreateStack': create_stack,
    'DeleteChangeSet': delete_change_set,
    'DeleteStack': delete_stack,
    'DescribeChangeSet': describe_change_set,
    'DescribeStackEvents': describe_stack_events,
    'DescribeStackResource': describe_stack_resource,
    'DescribeStackResources': describe_stack_resources,
    'DescribeStacks': describe_stacks,
    'ExecuteChangeSet': execute_change_set,
    'GetTemplate': get_template,
    'GetTemplateSummary': get_template_summary,
    'ListChangeSets': list_change_sets,
    'ListExports': list_exports,
    'ListImports': list_imports,
    'ListStacks': list_stacks,
    'ListStackResources': list_stack_resources,
    'UpdateStack': update_stack,
    'ValidateTemplate': validate_template
}


# ---------------
# UTIL FUNCTIONS
# ---------------

def error_response(*args, **kwargs):
    kwargs['xmlns'] = kwargs.get('xmlns') or XMLNS_CF
    return flask_error_response_xml(*args, **kwargs)


def stack_not_found_error(stack_name):
    return error_response('Unable to find stack named "%s"' % stack_name,
        code=404, code_string='ResourceNotFoundException')


def clone_stack_params(stack_params):
    try:
        return clone(stack_params)
    except Exception as e:
        LOG.info('Unable to clone stack parameters: %s' % e)
        return stack_params


def find_stack(stack_name):
    state = CloudFormationRegion.get()
    return ([s for s in state.stacks.values() if stack_name in [s.stack_name, s.stack_id]] or [None])[0]


def find_change_set(cs_name, stack_name=None):
    state = CloudFormationRegion.get()
    stack = find_stack(stack_name)
    stacks = [stack] if stack else state.stacks.values()
    result = [cs for s in stacks for cs in s.change_sets if cs_name in [cs.change_set_id, cs.change_set_name]]
    return (result or [None])[0]


def _response(action, result):
    if isinstance(result, (dict, str)):
        result = requests_response_xml(action, result, xmlns=XMLNS_CF)
    if isinstance(result, Response):
        result = requests_to_flask_response(result)
    return result


def serve(port, quiet=True):
    from localstack.services import generic_proxy  # moved here to fix circular import errors
    return generic_proxy.serve_flask_app(app=app, port=port, quiet=quiet)
