import time
import json
import boto3
import logging
from datetime import timedelta, datetime
from botocore.exceptions import BotoCoreError, ClientError

from cfn_sphere.exceptions import CfnStackActionFailedException
from cfn_sphere.util import with_boto_retry, get_logger, timed, get_pretty_stack_outputs, \
    get_pretty_parameters_string, get_cfn_api_server_time
from cfn_sphere.exceptions import CfnSphereBotoError

logging.getLogger('boto').setLevel(logging.FATAL)


class CloudFormationStack(object):
    def __init__(self, template, parameters, name, region, timeout=600, tags=None, service_role=None,
                 stack_policy=None, failure_action=None, disable_rollback=False, termination_protection=False):
        self.template = template
        self.parameters = parameters
        self.tags = {} if tags is None else tags
        self.name = name
        self.region = region
        self.timeout = timeout
        self.service_role = service_role
        self.stack_policy = stack_policy
        self.failure_action = failure_action
        self.disable_rollback = disable_rollback
        self.termination_protection = termination_protection

    def __str__(self):
        return str(vars(self))

    def get_parameters_list(self):
        return [{"ParameterKey": str(key), "ParameterValue": str(value)} for key, value in self.parameters.items()]

    def get_tags_list(self):
        return [{"Key": key, "Value": value} for key, value in self.tags.items()]


class CloudFormation(object):
    @with_boto_retry()
    def __init__(self, region="eu-west-1"):
        self.logger = get_logger()
        self.client = boto3.client('cloudformation', region_name=region)
        self.resource = boto3.resource('cloudformation', region_name=region)

    def get_stack(self, stack_name):
        """
        Get stack resource representation for a given stack_name
        This doesn't actually call the AWS API but only creates a stack stub lazy loading it's content
        :param stack_name: str:
        :return: boto3.resources.factory.cloudformation.Stack
        :raise CfnSphereBotoError:
        """
        return self.resource.Stack(stack_name)

    @timed
    @with_boto_retry()
    def get_stacks(self):
        """
        Get all stacks
        :return: List(boto3.resources.factory.cloudformation.Stack)
        :raise CfnSphereBotoError:
        """
        try:
            return list(self.resource.stacks.all())
        except (BotoCoreError, ClientError) as e:
            raise CfnSphereBotoError(e)

    @timed
    @with_boto_retry()
    def get_stack_description(self, stack_name):
        """
        Get a stacks descriptions
        :param stack_name: string
        :return dict
        :raise CfnSphereBotoError:
        """
        try:
            return self.client.describe_stacks(StackName=stack_name)["Stacks"][0]
        except (BotoCoreError, ClientError) as e:
            raise CfnSphereBotoError(e)

    @timed
    @with_boto_retry()
    def get_stack_descriptions(self):
        """
        Get all stacks stack descriptions
        :return List(dict)
        :raise CfnSphereBotoError:
        """
        try:
            stacks = []
            for page in self.client.get_paginator('describe_stacks').paginate():
                stacks += page["Stacks"]
            return stacks
        except (BotoCoreError, ClientError) as e:
            raise CfnSphereBotoError(e)

    @with_boto_retry()
    def stack_exists(self, stack_name):
        """
        Check if a stack exists for given stack_name

        :param stack_name: str
        :return: bool
        """
        try:
            if self.get_stack(stack_name).stack_status:
                return True
            else:
                return False
        except (BotoCoreError, ClientError) as e:
            if self.is_boto_stack_does_not_exist_exception(e):
                return False
            else:
                raise CfnSphereBotoError(e)

    @with_boto_retry()
    def get_stack_events(self, stack_name):
        """
        Get recent stack events for a given stack_name
        :param stack_name: str
        :return: list(dict)
        """
        try:
            paginator = self.client.get_paginator('describe_stack_events')
            pages = paginator.paginate(StackName=stack_name, PaginationConfig={'MaxItems': 100})
            return next(iter(pages))["StackEvents"]
        except (BotoCoreError, ClientError) as e:
            raise CfnSphereBotoError(e)

    @timed
    @with_boto_retry()
    def get_stack_names(self):
        """
        Get a list of stack names
        :return: list(str)
        """
        return [stack.stack_name for stack in self.get_stacks()]

    @timed
    def get_stacks_dict(self):
        """
        Get a dict containing all stacks with their name as key and {parameters, outputs} as value
        :return: dict
        """
        stacks_dict = {}
        for stack in self.get_stack_descriptions():
            stacks_dict[stack["StackName"]] = {"parameters": stack.get("Parameters", []),
                                               "outputs": stack.get("Outputs", [])}
        return stacks_dict

    def get_stack_outputs(self, stack):
        """
        Get outputs for a specific stack
        :param stack: cfn_sphere.aws.cfn.CloudFormationStack
        :return: list(dict)
        """
        return self.get_stack_description(stack.name).get("Outputs", [])

    def get_stacks_outputs(self):
        """
        Get a dict of all available stack outputs
        :return: dict(dict(output-key, output-value))
        """
        stack_outputs = {}
        stack_descriptions = self.get_stack_descriptions()

        for stack_description in stack_descriptions:

            if stack_description.get("Outputs"):
                stack_name = stack_description["StackName"]
                outputs = {}

                for output in stack_description["Outputs"]:
                    key = output["OutputKey"]
                    value = output["OutputValue"]
                    outputs[key] = value

                stack_outputs[stack_name] = outputs

        return stack_outputs

    @with_boto_retry()
    def validate_stack_is_ready_for_action(self, stack):
        """
        Check if a stack is in a state capable for modification actions

        :param stack: cfn_sphere.aws.cfn.CloudFormationStack
        :raise CfnStackActionFailedException: if the stack is in an invalid state
        """
        cfn_stack = self.get_stack(stack.name)

        valid_states = ["CREATE_COMPLETE", "UPDATE_COMPLETE", "IMPORT_COMPLETE", "ROLLBACK_COMPLETE", "UPDATE_ROLLBACK_COMPLETE"]

        if cfn_stack.stack_status not in valid_states:
            raise CfnStackActionFailedException(
                "Stack {0} is in '{1}' state.".format(cfn_stack.stack_name, cfn_stack.stack_status))

    @with_boto_retry()
    def get_stack_state(self, stack_name):
        """
        Get stack status
        :param stack_name: str
        :return: str: stack status
        :raise CfnSphereBotoError:
        """
        return self.get_stack(stack_name).stack_status

    @with_boto_retry()
    def get_stack_parameters_dict(self, stack_name):
        """
        Get a stacks parameters
        :param stack_name: str
        :return: dict
        """
        parameters = {}
        stack = self.get_stack(stack_name)

        for parameter in stack.parameters:
            parameters[parameter["ParameterKey"]] = parameter["ParameterValue"]

        return parameters

    @staticmethod
    def is_boto_no_update_required_exception(exception):
        """
        Return true if the given exception means that a stack doesn't require an update
        :param exception: Exception
        :return: bool
        """
        if isinstance(exception, ClientError):
            if exception.response["Error"]["Message"] == "No updates are to be performed.":
                return True
            else:
                return False
        else:
            return False

    @staticmethod
    def is_boto_stack_does_not_exist_exception(exception):
        """
        Return true if the given exception means that a stack does not exist
        :param exception: Exception
        :return: bool
        """
        if isinstance(exception, ClientError):
            message = exception.response["Error"]["Message"]
            if message.startswith("Stack") and message.endswith("does not exist"):
                return True
            else:
                return False
        else:
            return False

    @with_boto_retry()
    def _set_stack_policy(self, stack):
        """
        Set cloudformation stack policy
        :param stack: cfn_sphere.aws.cfn.CloudFormationStack
        """
        kwargs = {"StackName": stack.name}

        if stack.stack_policy:
            kwargs["StackPolicyBody"] = json.dumps(stack.stack_policy)

        self.client.set_stack_policy(**kwargs)

    @with_boto_retry()
    def _create_stack(self, stack):
        """
        Create cloudformation stack
        :param stack: cfn_sphere.aws.cfn.CloudFormationStack
        """
        kwargs = {
            "StackName": stack.name,
            "TemplateBody": stack.template.get_template_json(),
            "Parameters": stack.get_parameters_list(),
            "Capabilities": [
                'CAPABILITY_IAM',
                'CAPABILITY_NAMED_IAM'
            ],
            "Tags": stack.get_tags_list()
        }

        if stack.service_role:
            kwargs["RoleARN"] = stack.service_role
        if stack.stack_policy:
            kwargs["StackPolicyBody"] = json.dumps(stack.stack_policy)
        if stack.failure_action:
            kwargs["OnFailure"] = stack.failure_action
        if stack.disable_rollback:
            kwargs["DisableRollback"] = bool(stack.disable_rollback)
        if stack.termination_protection:
            kwargs["EnableTerminationProtection"] = bool(stack.termination_protection)

        self.client.create_stack(**kwargs)

    @with_boto_retry()
    def _update_stack(self, stack):
        """
        Update cloudformation stack
        :param stack: cfn_sphere.aws.cfn.CloudFormationStack
        """
        kwargs = {
            "StackName": stack.name,
            "TemplateBody": stack.template.get_template_json(),
            "Parameters": stack.get_parameters_list(),
            "Capabilities": [
                'CAPABILITY_IAM',
                'CAPABILITY_NAMED_IAM'
            ],
            "Tags": stack.get_tags_list()
        }

        if stack.service_role:
            kwargs["RoleARN"] = stack.service_role
        if stack.stack_policy:
            stack_policy = json.dumps(stack.stack_policy)
            kwargs["StackPolicyBody"] = stack_policy
            kwargs["StackPolicyDuringUpdateBody"] = stack_policy

        self.client.update_stack(**kwargs)

    @with_boto_retry()
    def _delete_stack(self, stack):
        """
        Delete cloudformation stack
        :param stack: cfn_sphere.aws.cfn.CloudFormationStack
        """
        kwargs = {
            "StackName": stack.name
        }

        if stack.service_role:
            kwargs["RoleARN"] = stack.service_role

        self.client.delete_stack(**kwargs)

    def create_stack(self, stack):
        self.logger.debug("Creating stack: {0}".format(stack))
        assert isinstance(stack, CloudFormationStack)

        try:
            stack_parameters_string = get_pretty_parameters_string(stack)

            self.logger.info(
                "Creating stack {0} ({1}) with parameters:\n{2}".format(stack.name,
                                                                        stack.template.name,
                                                                        stack_parameters_string))
            self._create_stack(stack)

            self.wait_for_stack_action_to_complete(stack.name, "create", stack.timeout)

            stack_outputs = get_pretty_stack_outputs(self.get_stack_outputs(stack))
            if stack_outputs:
                self.logger.info("Create completed for {0} with outputs: \n{1}".format(stack.name, stack_outputs))
            else:
                self.logger.info("Create completed for {0}".format(stack.name))
        except (BotoCoreError, ClientError, CfnSphereBotoError) as e:
            raise CfnStackActionFailedException("Could not create {0}: {1}".format(stack.name, e))

    def update_stack(self, stack):
        self.logger.debug("Updating stack: {0}".format(stack))
        assert isinstance(stack, CloudFormationStack)

        try:
            stack_parameters_string = get_pretty_parameters_string(stack)

            try:
                self._update_stack(stack)
            except ClientError as e:

                if self.is_boto_no_update_required_exception(e):
                    self.logger.info("Stack {0} does not need an update".format(stack.name))
                    return
                else:
                    self.logger.info(
                        "Updating stack {0} ({1}) with parameters:\n{2}".format(stack.name,
                                                                                stack.template.name,
                                                                                stack_parameters_string))
                    raise

            self.logger.info(
                "Updating stack {0} ({1}) with parameters:\n{2}".format(stack.name,
                                                                        stack.template.name,
                                                                        stack_parameters_string))

            self.wait_for_stack_action_to_complete(stack.name, "update", stack.timeout)

            stack_outputs = get_pretty_stack_outputs(self.get_stack_outputs(stack))
            if stack_outputs:
                self.logger.info("Update completed for {0} with outputs: \n{1}".format(stack.name, stack_outputs))
            else:
                self.logger.info("Update completed for {0}".format(stack.name))
        except (BotoCoreError, ClientError, CfnSphereBotoError) as e:
            raise CfnStackActionFailedException("Could not update {0}: {1}".format(stack.name, e))

    def delete_stack(self, stack):
        self.logger.debug("Deleting stack: {0}".format(stack))
        assert isinstance(stack, CloudFormationStack)

        try:
            self.logger.info("Deleting stack {0}".format(stack.name))
            self._delete_stack(stack)

            try:
                self.wait_for_stack_action_to_complete(stack.name, "delete", stack.timeout)
            except CfnSphereBotoError as e:
                if self.is_boto_stack_does_not_exist_exception(e.boto_exception):
                    pass
                else:
                    raise

            self.logger.info("Deletion completed for {0}".format(stack.name))
        except (BotoCoreError, ClientError, CfnSphereBotoError) as e:
            raise CfnStackActionFailedException("Could not delete {0}: {1}".format(stack.name, e))

    def wait_for_stack_action_to_complete(self, stack_name, action, timeout):
        allowed_actions = ["create", "update", "delete"]
        assert action.lower() in allowed_actions, "action argument must be one of {0}".format(allowed_actions)

        time_jitter_window = timedelta(seconds=10)
        minimum_event_timestamp = get_cfn_api_server_time() - time_jitter_window
        expected_start_event_state = action.upper() + "_IN_PROGRESS"

        start_event = self.wait_for_stack_event(stack_name,
                                                expected_start_event_state,
                                                minimum_event_timestamp,
                                                timeout=120)

        self.logger.info("Stack {0} started".format(action))

        minimum_event_timestamp = start_event["Timestamp"]
        expected_complete_event_state = action.upper() + "_COMPLETE"

        end_event = self.wait_for_stack_event(stack_name,
                                              expected_complete_event_state,
                                              minimum_event_timestamp,
                                              timeout)

        elapsed = end_event["Timestamp"] - start_event["Timestamp"]
        self.logger.info("Stack {0} completed after {1}s".format(action, elapsed.seconds))

    def wait_for_stack_event(self, stack_name, expected_event_status, valid_from_timestamp, timeout):
        """
        Wait for a new stack event. Return it if it has the expected status
        :param stack_name: str
        :param expected_event_status: str
        :param valid_from_timestamp: timestamp
        :param timeout: int
        :return: boto3 stack event
        :raise CfnStackActionFailedException:
        """
        self.logger.debug("Waiting for {0} events, newer than {1}".format(expected_event_status,
                                                                          valid_from_timestamp))

        seen_event_ids = []
        start = datetime.now()
        while datetime.now() < (start + timedelta(seconds=int(timeout))):

            events = self.get_stack_events(stack_name)
            events.reverse()

            for event in events:
                if event["EventId"] not in seen_event_ids:
                    seen_event_ids.append(event["EventId"])
                    event = self.handle_stack_event(event, valid_from_timestamp, expected_event_status, stack_name)

                    if event:
                        return event

            time.sleep(10)
        raise CfnStackActionFailedException(
            "Timeout occurred waiting for '{0}' on stack {1}".format(expected_event_status, stack_name))

    def handle_stack_event(self, event, valid_from_timestamp, expected_stack_event_status, stack_name):
        """
        Handle stack event. Return it if it has the expected status
        :param event: raw event
        :param valid_from_timestamp: earliest timestamp from which the event is considered relevant
        :param expected_stack_event_status:
        :param stack_name: the relevant stacks name
        :return: boto3 stack event if it has expected status | None
        :raise CfnStackActionFailedException:
        """
        if event["Timestamp"] > valid_from_timestamp:

            if event["ResourceType"] == "AWS::CloudFormation::Stack" and event["LogicalResourceId"] == stack_name:
                self.logger.debug("Raw event: {0}".format(event))

                if event["ResourceStatus"] == expected_stack_event_status:
                    return event

                if event["ResourceStatus"].endswith("_FAILED"):
                    raise CfnStackActionFailedException("Stack is in {0} state".format(event["ResourceStatus"]))

                if event["ResourceStatus"].endswith("ROLLBACK_IN_PROGRESS"):
                    self.logger.error("Failed to create stack (Reason: {0})".format(event["ResourceStatusReason"]))
                    return None

                if event["ResourceStatus"].endswith("ROLLBACK_COMPLETE"):
                    raise CfnStackActionFailedException("Rollback occured")
            else:
                if event["ResourceStatus"].endswith("_FAILED"):
                    self.logger.error("Failed to create {0} (Reason: {1})".format(event["LogicalResourceId"],
                                                                                  event["ResourceStatusReason"]))
                    return None
                else:
                    status_reason = event.get("ResourceStatusReason", None)
                    status_reason_string = " ({0})".format(status_reason) if status_reason else ""
                    event_string = "{0} {1}: {2}{3}".format(event["StackName"],
                                                            event["LogicalResourceId"],
                                                            event["ResourceStatus"],
                                                            status_reason_string)

                    self.logger.info(event_string)
                    return None

    def validate_template(self, template):
        """
        Validate template
        :param template: CloudFormationTemplate
        :return: boolean (true if valid)
        """
        try:
            self.client.validate_template(TemplateBody=template.get_template_json())
            return True
        except (BotoCoreError, ClientError) as e:
            raise CfnSphereBotoError(e)


if __name__ == "__main__":
    cfn = CloudFormation()
    cfn.logger.setLevel(logging.DEBUG)
    stack = CloudFormationStack(None, {}, "pulse-report", "eu-west-1")
    print(cfn.get_stack_outputs(stack))