# Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file 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 collections
import logging
import sys
import time
from datetime import datetime

import botocore

from awscli.customizations.cloudformation import exceptions
from awscli.customizations.cloudformation.artifact_exporter import (
    mktempfile,
    parse_s3_url,
)

LOG = logging.getLogger(__name__)

ChangeSetResult = collections.namedtuple(
    "ChangeSetResult", ["changeset_id", "changeset_type"]
)


class Deployer:
    def __init__(
        self,
        cloudformation_client,
        changeset_prefix="awscli-cloudformation-package-deploy-",
    ):
        self._client = cloudformation_client
        self.changeset_prefix = changeset_prefix

    def has_stack(self, stack_name):
        """
        Checks if a CloudFormation stack with given name exists

        :param stack_name: Name or ID of the stack
        :return: True if stack exists. False otherwise
        """
        try:
            resp = self._client.describe_stacks(StackName=stack_name)
            if len(resp["Stacks"]) != 1:
                return False

            # When you run CreateChangeSet on a a stack that does not exist,
            # CloudFormation will create a stack and set it's status
            # REVIEW_IN_PROGRESS. However this stack is cannot be manipulated
            # by "update" commands. Under this circumstances, we treat like
            # this stack does not exist and call CreateChangeSet will
            # ChangeSetType set to CREATE and not UPDATE.
            stack = resp["Stacks"][0]
            return stack["StackStatus"] != "REVIEW_IN_PROGRESS"

        except botocore.exceptions.ClientError as e:
            # If a stack does not exist, describe_stacks will throw an
            # exception. Unfortunately we don't have a better way than parsing
            # the exception msg to understand the nature of this exception.
            msg = str(e)

            if f"Stack with id {stack_name} does not exist" in msg:
                LOG.debug(f"Stack with id {stack_name} does not exist")
                return False
            else:
                # We don't know anything about this exception. Don't handle
                LOG.debug("Unable to get stack details.", exc_info=e)
                raise e

    def create_changeset(
        self,
        stack_name,
        cfn_template,
        parameter_values,
        capabilities,
        role_arn,
        notification_arns,
        s3_uploader,
        tags,
    ):
        """
        Call Cloudformation to create a changeset and wait for it to complete

        :param stack_name: Name or ID of stack
        :param cfn_template: CloudFormation template string
        :param parameter_values: Template parameters object
        :param capabilities: Array of capabilities passed to CloudFormation
        :param tags: Array of tags passed to CloudFormation
        :return:
        """

        now = datetime.utcnow().isoformat()
        description = f"Created by AWS CLI at {now} UTC"

        # Each changeset will get a unique name based on time
        changeset_name = self.changeset_prefix + str(int(time.time()))

        if not self.has_stack(stack_name):
            changeset_type = "CREATE"
            # When creating a new stack, UsePreviousValue=True is invalid.
            # For such parameters, users should either override with new value,
            # or set a Default value in template to successfully create a stack.
            parameter_values = [
                x
                for x in parameter_values
                if not x.get("UsePreviousValue", False)
            ]
        else:
            changeset_type = "UPDATE"
            # UsePreviousValue not valid if parameter is new
            summary = self._client.get_template_summary(StackName=stack_name)
            existing_parameters = [
                parameter['ParameterKey']
                for parameter in summary['Parameters']
            ]
            parameter_values = [
                x
                for x in parameter_values
                if not (
                    x.get("UsePreviousValue", False)
                    and x["ParameterKey"] not in existing_parameters
                )
            ]

        kwargs = {
            'ChangeSetName': changeset_name,
            'StackName': stack_name,
            'TemplateBody': cfn_template,
            'ChangeSetType': changeset_type,
            'Parameters': parameter_values,
            'Capabilities': capabilities,
            'Description': description,
            'Tags': tags,
        }

        # If an S3 uploader is available, use TemplateURL to deploy rather than
        # TemplateBody. This is required for large templates.
        if s3_uploader:
            with mktempfile() as temporary_file:
                temporary_file.write(kwargs.pop('TemplateBody'))
                temporary_file.flush()
                url = s3_uploader.upload_with_dedup(
                    temporary_file.name, "template"
                )
                # TemplateUrl property requires S3 URL to be in path-style format
                parts = parse_s3_url(url, version_property="Version")
                kwargs['TemplateURL'] = s3_uploader.to_path_style_s3_url(
                    parts["Key"], parts.get("Version", None)
                )

        # don't set these arguments if not specified to use existing values
        if role_arn is not None:
            kwargs['RoleARN'] = role_arn
        if notification_arns is not None:
            kwargs['NotificationARNs'] = notification_arns
        try:
            resp = self._client.create_change_set(**kwargs)
            return ChangeSetResult(resp["Id"], changeset_type)
        except Exception as ex:
            LOG.debug("Unable to create changeset", exc_info=ex)
            raise ex

    def wait_for_changeset(self, changeset_id, stack_name):
        """
        Waits until the changeset creation completes

        :param changeset_id: ID or name of the changeset
        :param stack_name:   Stack name
        :return: Latest status of the create-change-set operation
        """
        sys.stdout.write("\nWaiting for changeset to be created..\n")
        sys.stdout.flush()

        # Wait for changeset to be created
        waiter = self._client.get_waiter("change_set_create_complete")
        # Poll every 5 seconds. Changeset creation should be fast
        waiter_config = {'Delay': 5}
        try:
            waiter.wait(
                ChangeSetName=changeset_id,
                StackName=stack_name,
                WaiterConfig=waiter_config,
            )
        except botocore.exceptions.WaiterError as ex:
            LOG.debug("Create changeset waiter exception", exc_info=ex)

            resp = ex.last_response
            status = resp["Status"]
            reason = resp["StatusReason"]

            if (
                status == "FAILED"
                and "The submitted information didn't contain changes."
                in reason
                or "No updates are to be performed" in reason
            ):
                raise exceptions.ChangeEmptyError(stack_name=stack_name)

            raise RuntimeError(
                f"Failed to create the changeset: {ex} "
                f"Status: {status}. Reason: {reason}"
            )

    def execute_changeset(
        self, changeset_id, stack_name, disable_rollback=False
    ):
        """
        Calls CloudFormation to execute changeset

        :param changeset_id: ID of the changeset
        :param stack_name: Name or ID of the stack
        :param disable_rollback: Disable rollback of all resource changes
        :return: Response from execute-change-set call
        """
        return self._client.execute_change_set(
            ChangeSetName=changeset_id,
            StackName=stack_name,
            DisableRollback=disable_rollback,
        )

    def wait_for_execute(self, stack_name, changeset_type):
        sys.stdout.write("Waiting for stack create/update to complete\n")
        sys.stdout.flush()

        # Pick the right waiter
        if changeset_type == "CREATE":
            waiter = self._client.get_waiter("stack_create_complete")
        elif changeset_type == "UPDATE":
            waiter = self._client.get_waiter("stack_update_complete")
        else:
            raise RuntimeError(f"Invalid changeset type {changeset_type}")

        # Poll every 30 seconds. Polling too frequently risks hitting rate limits
        # on CloudFormation's DescribeStacks API
        waiter_config = {
            'Delay': 30,
            'MaxAttempts': 120,
        }

        try:
            waiter.wait(StackName=stack_name, WaiterConfig=waiter_config)
        except botocore.exceptions.WaiterError as ex:
            LOG.debug("Execute changeset waiter exception", exc_info=ex)

            raise exceptions.DeployFailedError(stack_name=stack_name)

    def create_and_wait_for_changeset(
        self,
        stack_name,
        cfn_template,
        parameter_values,
        capabilities,
        role_arn,
        notification_arns,
        s3_uploader,
        tags,
    ):
        result = self.create_changeset(
            stack_name,
            cfn_template,
            parameter_values,
            capabilities,
            role_arn,
            notification_arns,
            s3_uploader,
            tags,
        )
        self.wait_for_changeset(result.changeset_id, stack_name)

        return result
