#!/usr/bin/python
# Copyright: (c) 2018, Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type

ANSIBLE_METADATA = {'metadata_version': '1.1',
                    'status': ['preview'],
                    'supported_by': 'community'}


DOCUMENTATION = '''
---
module: cloudformation_stack_set
short_description: Manage groups of CloudFormation stacks
description:
     - Launches/updates/deletes AWS CloudFormation Stack Sets
notes:
     - To make an individual stack, you want the cloudformation module.
version_added: "2.7"
options:
  name:
    description:
      - name of the cloudformation stack set
    required: true
  description:
    description:
      - A description of what this stack set creates
  parameters:
    description:
      - A list of hashes of all the template variables for the stack. The value can be a string or a dict.
      - Dict can be used to set additional template parameter attributes like UsePreviousValue (see example).
    default: {}
  state:
    description:
      - If state is "present", stack will be created.  If state is "present" and if stack exists and template has changed, it will be updated.
        If state is "absent", stack will be removed.
    default: present
    choices: [ present, absent ]
  template:
    description:
      - The local path of the cloudformation template.
      - This must be the full path to the file, relative to the working directory. If using roles this may look
        like "roles/cloudformation/files/cloudformation-example.json".
      - If 'state' is 'present' and the stack does not exist yet, either 'template', 'template_body' or 'template_url'
        must be specified (but only one of them). If 'state' is present, the stack does exist, and neither 'template',
        'template_body' nor 'template_url' are specified, the previous template will be reused.
  template_body:
    description:
      - Template body. Use this to pass in the actual body of the Cloudformation template.
      - If 'state' is 'present' and the stack does not exist yet, either 'template', 'template_body' or 'template_url'
        must be specified (but only one of them). If 'state' is present, the stack does exist, and neither 'template',
        'template_body' nor 'template_url' are specified, the previous template will be reused.
  template_url:
    description:
      - Location of file containing the template body. The URL must point to a template (max size 307,200 bytes) located in an S3 bucket in the same region
        as the stack.
      - If 'state' is 'present' and the stack does not exist yet, either 'template', 'template_body' or 'template_url'
        must be specified (but only one of them). If 'state' is present, the stack does exist, and neither 'template',
        'template_body' nor 'template_url' are specified, the previous template will be reused.
  purge_stacks:
    description:
    - Only applicable when I(state=absent). Sets whether, when deleting a stack set, the stack instances should also be deleted.
    - By default, instances will be deleted. Set to 'no' or 'false' to keep stacks when stack set is deleted.
    type: bool
    default: true
  wait:
    description:
    - Whether or not to wait for stack operation to complete. This includes waiting for stack instances to reach UPDATE_COMPLETE status.
    - If you choose not to wait, this module will not notify when stack operations fail because it will not wait for them to finish.
    type: bool
    default: false
  wait_timeout:
    description:
    - How long to wait (in seconds) for stacks to complete create/update/delete operations.
    default: 900
  capabilities:
    description:
    - Capabilities allow stacks to create and modify IAM resources, which may include adding users or roles.
    - Currently the only available values are 'CAPABILITY_IAM' and 'CAPABILITY_NAMED_IAM'. Either or both may be provided.
    - >
        The following resources require that one or both of these parameters is specified: AWS::IAM::AccessKey,
        AWS::IAM::Group, AWS::IAM::InstanceProfile, AWS::IAM::Policy, AWS::IAM::Role, AWS::IAM::User, AWS::IAM::UserToGroupAddition
    choices:
    - 'CAPABILITY_IAM'
    - 'CAPABILITY_NAMED_IAM'
  regions:
    description:
    - A list of AWS regions to create instances of a stack in. The I(region) parameter chooses where the Stack Set is created, and I(regions)
      specifies the region for stack instances.
    - At least one region must be specified to create a stack set. On updates, if fewer regions are specified only the specified regions will
      have their stack instances updated.
  accounts:
    description:
    - A list of AWS accounts in which to create instance of CloudFormation stacks.
    - At least one region must be specified to create a stack set. On updates, if fewer regions are specified only the specified regions will
      have their stack instances updated.
  administration_role_arn:
    description:
    - ARN of the administration role, meaning the role that CloudFormation Stack Sets use to assume the roles in your child accounts.
    - This defaults to I(arn:aws:iam::{{ account ID }}:role/AWSCloudFormationStackSetAdministrationRole) where I({{ account ID }}) is replaced with the
      account number of the current IAM role/user/STS credentials.
    aliases:
    - admin_role_arn
    - admin_role
    - administration_role
  execution_role_name:
    description:
    - ARN of the execution role, meaning the role that CloudFormation Stack Sets assumes in your child accounts.
    - This MUST NOT be an ARN, and the roles must exist in each child account specified.
    - The default name for the execution role is I(AWSCloudFormationStackSetExecutionRole)
    aliases:
    - exec_role_name
    - exec_role
    - execution_role
  tags:
    description:
      - Dictionary of tags to associate with stack and its resources during stack creation. Can be updated later, updating tags removes previous entries.
  failure_tolerance:
    description:
    - Settings to change what is considered "failed" when running stack instance updates, and how many to do at a time.

author: "Ryan Scott Brown (@ryansb)"
extends_documentation_fragment:
- aws
- ec2
requirements: [ boto3>=1.6, botocore>=1.10.26 ]
'''

EXAMPLES = '''
- name: Create a stack set with instances in two accounts
  cloudformation_stack_set:
    name: my-stack
    description: Test stack in two accounts
    state: present
    template_url: https://s3.amazonaws.com/my-bucket/cloudformation.template
    accounts: [1234567890, 2345678901]
    regions:
    - us-east-1

- name: on subsequent calls, templates are optional but parameters and tags can be altered
  cloudformation_stack_set:
    name: my-stack
    state: present
    parameters:
      InstanceName: my_stacked_instance
    tags:
      foo: bar
      test: stack
    accounts: [1234567890, 2345678901]
    regions:
    - us-east-1

- name: The same type of update, but wait for the update to complete in all stacks
  cloudformation_stack_set:
    name: my-stack
    state: present
    wait: true
    parameters:
      InstanceName: my_restacked_instance
    tags:
      foo: bar
      test: stack
    accounts: [1234567890, 2345678901]
    regions:
    - us-east-1
'''

RETURN = '''
operations_log:
  type: list
  description: Most recent events in Cloudformation's event log. This may be from a previous run in some cases.
  returned: always
  sample:
  - action: CREATE
    creation_timestamp: '2018-06-18T17:40:46.372000+00:00'
    end_timestamp: '2018-06-18T17:41:24.560000+00:00'
    operation_id: Ansible-StackInstance-Create-0ff2af5b-251d-4fdb-8b89-1ee444eba8b8
    status: FAILED
    stack_instances:
    - account: '1234567890'
      region: us-east-1
      stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929
      status: OUTDATED
      status_reason: Account 1234567890 should have 'AWSCloudFormationStackSetAdministrationRole' role with trust relationship to CloudFormation service.

operations:
  description: All operations initiated by this run of the cloudformation_stack_set module
  returned: always
  type: list
  sample:
  - action: CREATE
    administration_role_arn: arn:aws:iam::1234567890:role/AWSCloudFormationStackSetAdministrationRole
    creation_timestamp: '2018-06-18T17:40:46.372000+00:00'
    end_timestamp: '2018-06-18T17:41:24.560000+00:00'
    execution_role_name: AWSCloudFormationStackSetExecutionRole
    operation_id: Ansible-StackInstance-Create-0ff2af5b-251d-4fdb-8b89-1ee444eba8b8
    operation_preferences:
      region_order:
      - us-east-1
      - us-east-2
    stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929
    status: FAILED
stack_instances:
  description: CloudFormation stack instances that are members of this stack set. This will also include their region and account ID.
  returned: state == present
  type: list
  sample:
    - account: '1234567890'
      region: us-east-1
      stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929
      status: OUTDATED
      status_reason: >
        Account 1234567890 should have 'AWSCloudFormationStackSetAdministrationRole' role with trust relationship to CloudFormation service.
    - account: '1234567890'
      region: us-east-2
      stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929
      status: OUTDATED
      status_reason: Cancelled since failure tolerance has exceeded
stack_set:
  type: dict
  description: Facts about the currently deployed stack set, its parameters, and its tags
  returned: state == present
  sample:
    administration_role_arn: arn:aws:iam::1234567890:role/AWSCloudFormationStackSetAdministrationRole
    capabilities: []
    description: test stack PRIME
    execution_role_name: AWSCloudFormationStackSetExecutionRole
    parameters: []
    stack_set_arn: arn:aws:cloudformation:us-east-1:1234567890:stackset/TestStackPrime:19f3f684-aae9-467-ba36-e09f92cf5929
    stack_set_id: TestStackPrime:19f3f684-aae9-4e67-ba36-e09f92cf5929
    stack_set_name: TestStackPrime
    status: ACTIVE
    tags:
      Some: Thing
      an: other
    template_body: |
      AWSTemplateFormatVersion: "2010-09-09"
      Parameters: {}
      Resources:
        Bukkit:
          Type: "AWS::S3::Bucket"
          Properties: {}
        other:
          Type: "AWS::SNS::Topic"
          Properties: {}

'''  # NOQA

import time
import datetime
import uuid
import itertools

try:
    import boto3
    import botocore.exceptions
    from botocore.exceptions import ClientError, BotoCoreError
except ImportError:
    # handled by AnsibleAWSModule
    pass

from ansible.module_utils.ec2 import AWSRetry, boto3_tag_list_to_ansible_dict, ansible_dict_to_boto3_tag_list, camel_dict_to_snake_dict
from ansible.module_utils.aws.core import AnsibleAWSModule, is_boto3_error_code
from ansible.module_utils._text import to_native


def create_stack_set(module, stack_params, cfn):
    try:
        cfn.create_stack_set(aws_retry=True, **stack_params)
        return await_stack_set_exists(cfn, stack_params['StackSetName'])
    except (ClientError, BotoCoreError) as err:
        module.fail_json_aws(err, msg="Failed to create stack set {0}.".format(stack_params.get('StackSetName')))


def update_stack_set(module, stack_params, cfn):
    # if the state is present and the stack already exists, we try to update it.
    # AWS will tell us if the stack template and parameters are the same and
    # don't need to be updated.
    try:
        cfn.update_stack_set(**stack_params)
    except is_boto3_error_code('StackSetNotFound') as err:  # pylint: disable=duplicate-except
        module.fail_json_aws(err, msg="Failed to find stack set. Check the name & region.")
    except is_boto3_error_code('StackInstanceNotFound') as err:  # pylint: disable=duplicate-except
        module.fail_json_aws(err, msg="One or more stack instances were not found for this stack set. Double check "
                             "the `accounts` and `regions` parameters.")
    except is_boto3_error_code('OperationInProgressException') as err:  # pylint: disable=duplicate-except
        module.fail_json_aws(
            err, msg="Another operation is already in progress on this stack set - please try again later. When making "
            "multiple cloudformation_stack_set calls, it's best to enable `wait: yes` to avoid unfinished op errors.")
    except (ClientError, BotoCoreError) as err:  # pylint: disable=duplicate-except
        module.fail_json_aws(err, msg="Could not update stack set.")
    if module.params.get('wait'):
        await_stack_set_operation(
            module, cfn, operation_id=stack_params['OperationId'],
            stack_set_name=stack_params['StackSetName'],
            max_wait=module.params.get('wait_timeout'),
        )

    return True


def compare_stack_instances(cfn, stack_set_name, accounts, regions):
    instance_list = cfn.list_stack_instances(
        aws_retry=True,
        StackSetName=stack_set_name,
    )['Summaries']
    desired_stack_instances = set(itertools.product(accounts, regions))
    existing_stack_instances = set((i['Account'], i['Region']) for i in instance_list)
    # new stacks, existing stacks, unspecified stacks
    return (desired_stack_instances - existing_stack_instances), existing_stack_instances, (existing_stack_instances - desired_stack_instances)


@AWSRetry.backoff(tries=3, delay=4)
def stack_set_facts(cfn, stack_set_name):
    try:
        ss = cfn.describe_stack_set(StackSetName=stack_set_name)['StackSet']
        ss['Tags'] = boto3_tag_list_to_ansible_dict(ss['Tags'])
        return ss
    except cfn.exceptions.from_code('StackSetNotFound'):
        # catch NotFound error before the retry kicks in to avoid waiting
        # if the stack does not exist
        return


def await_stack_set_operation(module, cfn, stack_set_name, operation_id, max_wait):
    wait_start = datetime.datetime.now()
    operation = None
    for i in range(max_wait // 15):
        try:
            operation = cfn.describe_stack_set_operation(StackSetName=stack_set_name, OperationId=operation_id)
            if operation['StackSetOperation']['Status'] not in ('RUNNING', 'STOPPING'):
                # Stack set has completed operation
                break
        except is_boto3_error_code('StackSetNotFound'):  # pylint: disable=duplicate-except
            pass
        except is_boto3_error_code('OperationNotFound'):  # pylint: disable=duplicate-except
            pass
        time.sleep(15)

    if operation and operation['StackSetOperation']['Status'] not in ('FAILED', 'STOPPED'):
        await_stack_instance_completion(
            module, cfn,
            stack_set_name=stack_set_name,
            # subtract however long we waited already
            max_wait=int(max_wait - (datetime.datetime.now() - wait_start).total_seconds()),
        )
    elif operation and operation['StackSetOperation']['Status'] in ('FAILED', 'STOPPED'):
        pass
    else:
        module.warn(
            "Timed out waiting for operation {0} on stack set {1} after {2} seconds. Returning unfinished operation".format(
                operation_id, stack_set_name, max_wait
            )
        )


def await_stack_instance_completion(module, cfn, stack_set_name, max_wait):
    to_await = None
    for i in range(max_wait // 15):
        try:
            stack_instances = cfn.list_stack_instances(StackSetName=stack_set_name)
            to_await = [inst for inst in stack_instances['Summaries']
                        if inst['Status'] != 'CURRENT']
            if not to_await:
                return stack_instances['Summaries']
        except is_boto3_error_code('StackSetNotFound'):  # pylint: disable=duplicate-except
            # this means the deletion beat us, or the stack set is not yet propagated
            pass
        time.sleep(15)

    module.warn(
        "Timed out waiting for stack set {0} instances {1} to complete after {2} seconds. Returning unfinished operation".format(
            stack_set_name, ', '.join(s['StackId'] for s in to_await), max_wait
        )
    )


def await_stack_set_exists(cfn, stack_set_name):
    # AWSRetry will retry on `NotFound` errors for us
    ss = cfn.describe_stack_set(StackSetName=stack_set_name, aws_retry=True)['StackSet']
    ss['Tags'] = boto3_tag_list_to_ansible_dict(ss['Tags'])
    return camel_dict_to_snake_dict(ss, ignore_list=('Tags',))


def describe_stack_tree(module, stack_set_name, operation_ids=None):
    cfn = module.client('cloudformation', retry_decorator=AWSRetry.jittered_backoff(retries=5, delay=3, max_delay=5))
    result = dict()
    result['stack_set'] = camel_dict_to_snake_dict(
        cfn.describe_stack_set(
            StackSetName=stack_set_name,
            aws_retry=True,
        )['StackSet']
    )
    result['stack_set']['tags'] = boto3_tag_list_to_ansible_dict(result['stack_set']['tags'])
    result['operations_log'] = sorted(
        camel_dict_to_snake_dict(
            cfn.list_stack_set_operations(
                StackSetName=stack_set_name,
                aws_retry=True,
            )
        )['summaries'],
        key=lambda x: x['creation_timestamp']
    )
    result['stack_instances'] = sorted(
        [
            camel_dict_to_snake_dict(i) for i in
            cfn.list_stack_instances(StackSetName=stack_set_name)['Summaries']
        ],
        key=lambda i: i['region'] + i['account']
    )

    if operation_ids:
        result['operations'] = []
        for op_id in operation_ids:
            try:
                result['operations'].append(camel_dict_to_snake_dict(
                    cfn.describe_stack_set_operation(
                        StackSetName=stack_set_name,
                        OperationId=op_id,
                    )['StackSetOperation']
                ))
            except is_boto3_error_code('OperationNotFoundException'):  # pylint: disable=duplicate-except
                pass
    return result


def get_operation_preferences(module):
    params = dict()
    if module.params.get('regions'):
        params['RegionOrder'] = list(module.params['regions'])
    for param, api_name in {
        'fail_count': 'FailureToleranceCount',
        'fail_percentage': 'FailureTolerancePercentage',
        'parallel_percentage': 'MaxConcurrentPercentage',
        'parallel_count': 'MaxConcurrentCount',
    }.items():
        if module.params.get('failure_tolerance', {}).get(param):
            params[api_name] = module.params.get('failure_tolerance', {}).get(param)
    return params


def main():
    argument_spec = dict(
        name=dict(required=True),
        description=dict(),
        wait=dict(type='bool', default=False),
        wait_timeout=dict(type='int', default=900),
        state=dict(default='present', choices=['present', 'absent']),
        purge_stacks=dict(type='bool', default=True),
        parameters=dict(type='dict', default={}),
        template=dict(type='path'),
        template_url=dict(),
        template_body=dict(),
        capabilities=dict(type='list', choices=['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM']),
        regions=dict(type='list'),
        accounts=dict(type='list'),
        failure_tolerance=dict(
            type='dict',
            default={},
            options=dict(
                fail_count=dict(type='int'),
                fail_percentage=dict(type='int'),
                parallel_percentage=dict(type='int'),
                parallel_count=dict(type='int'),
            ),
            mutually_exclusive=[
                ['fail_count', 'fail_percentage'],
                ['parallel_count', 'parallel_percentage'],
            ],
        ),
        administration_role_arn=dict(aliases=['admin_role_arn', 'administration_role', 'admin_role']),
        execution_role_name=dict(aliases=['execution_role', 'exec_role', 'exec_role_name']),
        tags=dict(type='dict'),
    )

    module = AnsibleAWSModule(
        argument_spec=argument_spec,
        mutually_exclusive=[['template_url', 'template', 'template_body']],
        supports_check_mode=True
    )
    if not (module.boto3_at_least('1.6.0') and module.botocore_at_least('1.10.26')):
        module.fail_json(msg="Boto3 or botocore version is too low. This module requires at least boto3 1.6 and botocore 1.10.26")

    # Wrap the cloudformation client methods that this module uses with
    # automatic backoff / retry for throttling error codes
    cfn = module.client('cloudformation', retry_decorator=AWSRetry.jittered_backoff(retries=10, delay=3, max_delay=30))
    existing_stack_set = stack_set_facts(cfn, module.params['name'])

    operation_uuid = to_native(uuid.uuid4())
    operation_ids = []
    # collect the parameters that are passed to boto3. Keeps us from having so many scalars floating around.
    stack_params = {}
    state = module.params['state']
    if state == 'present' and not module.params['accounts']:
        module.fail_json(
            msg="Can't create a stack set without choosing at least one account. "
                "To get the ID of the current account, use the aws_caller_facts module."
        )

    module.params['accounts'] = [to_native(a) for a in module.params['accounts']]

    stack_params['StackSetName'] = module.params['name']
    if module.params.get('description'):
        stack_params['Description'] = module.params['description']

    if module.params.get('capabilities'):
        stack_params['Capabilities'] = module.params['capabilities']

    if module.params['template'] is not None:
        with open(module.params['template'], 'r') as tpl:
            stack_params['TemplateBody'] = tpl.read()
    elif module.params['template_body'] is not None:
        stack_params['TemplateBody'] = module.params['template_body']
    elif module.params['template_url'] is not None:
        stack_params['TemplateURL'] = module.params['template_url']
    else:
        # no template is provided, but if the stack set exists already, we can use the existing one.
        if existing_stack_set:
            stack_params['UsePreviousTemplate'] = True
        else:
            module.fail_json(
                msg="The Stack Set {0} does not exist, and no template was provided. Provide one of `template`, "
                    "`template_body`, or `template_url`".format(module.params['name'])
            )

    stack_params['Parameters'] = []
    for k, v in module.params['parameters'].items():
        if isinstance(v, dict):
            # set parameter based on a dict to allow additional CFN Parameter Attributes
            param = dict(ParameterKey=k)

            if 'value' in v:
                param['ParameterValue'] = to_native(v['value'])

            if 'use_previous_value' in v and bool(v['use_previous_value']):
                param['UsePreviousValue'] = True
                param.pop('ParameterValue', None)

            stack_params['Parameters'].append(param)
        else:
            # allow default k/v configuration to set a template parameter
            stack_params['Parameters'].append({'ParameterKey': k, 'ParameterValue': str(v)})

    if module.params.get('tags') and isinstance(module.params.get('tags'), dict):
        stack_params['Tags'] = ansible_dict_to_boto3_tag_list(module.params['tags'])

    if module.params.get('administration_role_arn'):
        # TODO loosen the semantics here to autodetect the account ID and build the ARN
        stack_params['AdministrationRoleARN'] = module.params['administration_role_arn']
    if module.params.get('execution_role_name'):
        stack_params['ExecutionRoleName'] = module.params['execution_role_name']

    result = {}

    if module.check_mode:
        if state == 'absent' and existing_stack_set:
            module.exit_json(changed=True, msg='Stack set would be deleted', meta=[])
        elif state == 'absent' and not existing_stack_set:
            module.exit_json(changed=False, msg='Stack set doesn\'t exist', meta=[])
        elif state == 'present' and not existing_stack_set:
            module.exit_json(changed=True, msg='New stack set would be created', meta=[])
        elif state == 'present' and existing_stack_set:
            new_stacks, existing_stacks, unspecified_stacks = compare_stack_instances(
                cfn,
                module.params['name'],
                module.params['accounts'],
                module.params['regions'],
            )
            if new_stacks:
                module.exit_json(changed=True, msg='New stack instance(s) would be created', meta=[])
            elif unspecified_stacks and module.params.get('purge_stack_instances'):
                module.exit_json(changed=True, msg='Old stack instance(s) would be deleted', meta=[])
        else:
            # TODO: need to check the template and other settings for correct check mode
            module.exit_json(changed=False, msg='No changes detected', meta=[])

    changed = False
    if state == 'present':
        if not existing_stack_set:
            # on create this parameter has a different name, and cannot be referenced later in the job log
            stack_params['ClientRequestToken'] = 'Ansible-StackSet-Create-{0}'.format(operation_uuid)
            changed = True
            create_stack_set(module, stack_params, cfn)
        else:
            stack_params['OperationId'] = 'Ansible-StackSet-Update-{0}'.format(operation_uuid)
            operation_ids.append(stack_params['OperationId'])
            if module.params.get('regions'):
                stack_params['OperationPreferences'] = get_operation_preferences(module)
            changed |= update_stack_set(module, stack_params, cfn)

        # now create/update any appropriate stack instances
        new_stack_instances, existing_stack_instances, unspecified_stack_instances = compare_stack_instances(
            cfn,
            module.params['name'],
            module.params['accounts'],
            module.params['regions'],
        )
        if new_stack_instances:
            operation_ids.append('Ansible-StackInstance-Create-{0}'.format(operation_uuid))
            changed = True
            cfn.create_stack_instances(
                StackSetName=module.params['name'],
                Accounts=list(set(acct for acct, region in new_stack_instances)),
                Regions=list(set(region for acct, region in new_stack_instances)),
                OperationPreferences=get_operation_preferences(module),
                OperationId=operation_ids[-1],
            )
        else:
            operation_ids.append('Ansible-StackInstance-Update-{0}'.format(operation_uuid))
            cfn.update_stack_instances(
                StackSetName=module.params['name'],
                Accounts=list(set(acct for acct, region in existing_stack_instances)),
                Regions=list(set(region for acct, region in existing_stack_instances)),
                OperationPreferences=get_operation_preferences(module),
                OperationId=operation_ids[-1],
            )
        for op in operation_ids:
            await_stack_set_operation(
                module, cfn, operation_id=op,
                stack_set_name=module.params['name'],
                max_wait=module.params.get('wait_timeout'),
            )

    elif state == 'absent':
        if not existing_stack_set:
            module.exit_json(msg='Stack set {0} does not exist'.format(module.params['name']))
        if module.params.get('purge_stack_instances') is False:
            pass
        try:
            cfn.delete_stack_set(
                StackSetName=module.params['name'],
            )
            module.exit_json(msg='Stack set {0} deleted'.format(module.params['name']))
        except is_boto3_error_code('OperationInProgressException') as e:  # pylint: disable=duplicate-except
            module.fail_json_aws(e, msg='Cannot delete stack {0} while there is an operation in progress'.format(module.params['name']))
        except is_boto3_error_code('StackSetNotEmptyException'):  # pylint: disable=duplicate-except
            delete_instances_op = 'Ansible-StackInstance-Delete-{0}'.format(operation_uuid)
            cfn.delete_stack_instances(
                StackSetName=module.params['name'],
                Accounts=module.params['accounts'],
                Regions=module.params['regions'],
                RetainStacks=(not module.params.get('purge_stacks')),
                OperationId=delete_instances_op
            )
            await_stack_set_operation(
                module, cfn, operation_id=delete_instances_op,
                stack_set_name=stack_params['StackSetName'],
                max_wait=module.params.get('wait_timeout'),
            )
            try:
                cfn.delete_stack_set(
                    StackSetName=module.params['name'],
                )
            except is_boto3_error_code('StackSetNotEmptyException') as exc:  # pylint: disable=duplicate-except
                # this time, it is likely that either the delete failed or there are more stacks.
                instances = cfn.list_stack_instances(
                    StackSetName=module.params['name'],
                )
                stack_states = ', '.join('(account={Account}, region={Region}, state={Status})'.format(**i) for i in instances['Summaries'])
                module.fail_json_aws(exc, msg='Could not purge all stacks, or not all accounts/regions were chosen for deletion: ' + stack_states)
            module.exit_json(changed=True, msg='Stack set {0} deleted'.format(module.params['name']))

    result.update(**describe_stack_tree(module, stack_params['StackSetName'], operation_ids=operation_ids))
    if any(o['status'] == 'FAILED' for o in result['operations']):
        module.fail_json(msg="One or more operations failed to execute", **result)
    module.exit_json(changed=changed, **result)


if __name__ == '__main__':
    main()
