#!/usr/bin/python
# -*- coding: utf-8 -*-

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

DOCUMENTATION = r"""
---
module: cloudformation_stack_set
version_added: 1.0.0
short_description: Manage groups of CloudFormation stacks
description:
  - Launches/updates/deletes AWS CloudFormation Stack Sets.
notes:
  - To make an individual stack, you want the M(amazon.aws.cloudformation) module.
options:
  name:
    description:
      - Name of the CloudFormation stack set.
    required: true
    type: str
  description:
    description:
      - A description of what this stack set creates.
    type: str
  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: {}
    type: dict
  state:
    description:
      - If I(state=present), stack will be created.  If I(state=present) and if stack exists and template has changed, it will be updated.
        If I(state=absent), stack will be removed.
    default: present
    choices: [ present, absent ]
    type: str
  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 C(roles/cloudformation/files/cloudformation-example.json).
      - If I(state=present) and the stack does not exist yet, either I(template), I(template_body) or I(template_url)
        must be specified (but only one of them).
      - If I(state=present), the stack does exist, and neither I(template), I(template_body) nor I(template_url)
        are specified, the previous template will be reused.
    type: path
  template_body:
    description:
      - Template body. Use this to pass in the actual body of the CloudFormation template.
      - If I(state=present) and the stack does not exist yet, either I(template), I(template_body) or I(template_url)
        must be specified (but only one of them).
      - If I(state=present), the stack does exist, and neither I(template), I(template_body) nor I(template_url)
        are specified, the previous template will be reused.
    type: str
  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 I(state=present) and the stack does not exist yet, either I(template), I(template_body) or I(template_url)
        must be specified (but only one of them).
      - If I(state=present), the stack does exist, and neither I(template), I(template_body) nor I(template_url)
        are specified, the previous template will be reused.
    type: str
  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. To keep stacks when stack set is deleted set I(purge_stacks=false).
    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
    type: int
  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
    type: list
    elements: str
    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.
    type: list
    elements: str
  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.
    type: list
    elements: str
  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 C(arn:aws:iam::{{ account ID }}:role/AWSCloudFormationStackSetAdministrationRole) where C({{ account ID }}) is replaced with the
      account number of the current IAM role/user/STS credentials.
    aliases:
    - admin_role_arn
    - admin_role
    - administration_role
    type: str
  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 C(AWSCloudFormationStackSetExecutionRole)
    aliases:
    - exec_role_name
    - exec_role
    - execution_role
    type: str
  tags:
    description:
      - Dictionary of tags to associate with stack and its resources during stack creation.
      - Can be updated later, updating tags removes previous entries.
    type: dict
  failure_tolerance:
    description:
    - Settings to change what is considered "failed" when running stack instance updates, and how many to do at a time.
    type: dict
    default: {}
    suboptions:
      fail_count:
        description:
        - The number of accounts, per region, for which this operation can fail before CloudFormation
          stops the operation in that region.
        - You must specify one of I(fail_count) and I(fail_percentage).
        type: int
      fail_percentage:
        type: int
        description:
        - The percentage of accounts, per region, for which this stack operation can fail before CloudFormation
          stops the operation in that region.
        - You must specify one of I(fail_count) and I(fail_percentage).
      parallel_percentage:
        type: int
        description:
        - The maximum percentage of accounts in which to perform this operation at one time.
        - You must specify one of I(parallel_count) and I(parallel_percentage).
        - Note that this setting lets you specify the maximum for operations.
          For large deployments, under certain circumstances the actual percentage may be lower.
      parallel_count:
        type: int
        description:
        - The maximum number of accounts in which to perform this operation at one time.
        - I(parallel_count) may be at most one more than the I(fail_count).
        - You must specify one of I(parallel_count) and I(parallel_percentage).
        - Note that this setting lets you specify the maximum for operations.
          For large deployments, under certain circumstances the actual count may be lower.

author:
  - "Ryan Scott Brown (@ryansb)"
extends_documentation_fragment:
  - amazon.aws.common.modules
  - amazon.aws.region.modules
  - amazon.aws.boto3
"""

EXAMPLES = r"""
- name: Create a stack set with instances in two accounts
  community.aws.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:
      - 123456789012
      - 234567890123
    regions:
      - us-east-1

- name: on subsequent calls, templates are optional but parameters and tags can be altered
  community.aws.cloudformation_stack_set:
    name: my-stack
    state: present
    parameters:
      InstanceName: my_stacked_instance
    tags:
      foo: bar
      test: stack
    accounts:
      - 123456789012
      - 234567890123
    regions:
      - us-east-1

- name: The same type of update, but wait for the update to complete in all stacks
  community.aws.cloudformation_stack_set:
    name: my-stack
    state: present
    wait: true
    parameters:
      InstanceName: my_restacked_instance
    tags:
      foo: bar
      test: stack
    accounts:
      - 123456789012
      - 234567890123
    regions:
      - us-east-1

- name: Register new accounts (create new stack instances) with an existing stack set.
  community.aws.cloudformation_stack_set:
    name: my-stack
    state: present
    wait: true
    parameters:
      InstanceName: my_restacked_instance
    tags:
      foo: bar
      test: stack
    accounts:
      - 123456789012
      - 234567890123
      - 345678901234
    regions:
      - us-east-1
"""

RETURN = r"""
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: {}
"""

import datetime
import itertools
import time
import uuid

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

from ansible.module_utils._text import to_native
from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict

from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code
from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry
from ansible_collections.amazon.aws.plugins.module_utils.tagging import ansible_dict_to_boto3_tag_list
from ansible_collections.amazon.aws.plugins.module_utils.tagging import boto3_tag_list_to_ansible_dict

from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule


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=f"Failed to create stack set {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: true` 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.jittered_backoff(retries=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"):
        # Return None if the stack doesn't 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(
            f"Timed out waiting for operation {operation_id} on stack set {stack_set_name} after {max_wait} seconds."
            " Returning unfinished operation"
        )


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(
        f"Timed out waiting for stack set {stack_set_name} instances {', '.join(s['StackId'] for s in to_await)} to"
        f" complete after {max_wait} seconds. Returning unfinished operation"
    )


def await_stack_set_exists(cfn, stack_set_name):
    # AWSRetry will retry on `StackSetNotFound` 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):
    jittered_backoff_decorator = AWSRetry.jittered_backoff(
        retries=5, delay=3, max_delay=5, catch_extra_error_codes=["StackSetNotFound"]
    )
    cfn = module.client("cloudformation", retry_decorator=jittered_backoff_decorator)
    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", elements="str", choices=["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"]),
        regions=dict(type="list", elements="str"),
        accounts=dict(type="list", elements="str"),
        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,
    )

    # Wrap the cloudformation client methods that this module uses with
    # automatic backoff / retry for throttling error codes
    jittered_backoff_decorator = AWSRetry.jittered_backoff(
        retries=10, delay=3, max_delay=30, catch_extra_error_codes=["StackSetNotFound"]
    )
    cfn = module.client("cloudformation", retry_decorator=jittered_backoff_decorator)
    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_info 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=(
                    f"The Stack Set {module.params['name']} does not exist, and no template was provided. Provide one"
                    " of `template`, `template_body`, or `template_url`"
                )
            )

    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"] = f"Ansible-StackSet-Create-{operation_uuid}"
            changed = True
            create_stack_set(module, stack_params, cfn)
        else:
            stack_params["OperationId"] = f"Ansible-StackSet-Update-{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)

            await_stack_set_operation(
                module,
                cfn,
                operation_id=stack_params["OperationId"],
                stack_set_name=stack_params["StackSetName"],
                max_wait=module.params.get("wait_timeout"),
            )

        # 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(f"Ansible-StackInstance-Create-{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(f"Ansible-StackInstance-Update-{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=f"Stack set {module.params['name']} does not exist")
        if module.params.get("purge_stack_instances") is False:
            pass
        try:
            cfn.delete_stack_set(
                StackSetName=module.params["name"],
            )
            module.exit_json(msg=f"Stack set {module.params['name']} deleted")
        except is_boto3_error_code("OperationInProgressException") as e:  # pylint: disable=duplicate-except
            module.fail_json_aws(
                e, msg=f"Cannot delete stack {module.params['name']} while there is an operation in progress"
            )
        except is_boto3_error_code("StackSetNotEmptyException"):  # pylint: disable=duplicate-except
            delete_instances_op = f"Ansible-StackInstance-Delete-{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=f"Stack set {module.params['name']} deleted")

    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()
