from __future__ import unicode_literals
import collections
import functools
import logging

from moto.autoscaling import models as autoscaling_models
from moto.ec2 import models as ec2_models
from moto.elb import models as elb_models
from moto.iam import models as iam_models
from moto.rds import models as rds_models
from moto.redshift import models as redshift_models
from moto.route53 import models as route53_models
from moto.sns import models as sns_models
from moto.sqs import models as sqs_models
from .utils import random_suffix
from .exceptions import MissingParameterError, UnformattedGetAttTemplateException
from boto.cloudformation.stack import Output
from boto.exception import BotoServerError

MODEL_MAP = {
    "AWS::AutoScaling::AutoScalingGroup": autoscaling_models.FakeAutoScalingGroup,
    "AWS::AutoScaling::LaunchConfiguration": autoscaling_models.FakeLaunchConfiguration,
    "AWS::EC2::EIP": ec2_models.ElasticAddress,
    "AWS::EC2::Instance": ec2_models.Instance,
    "AWS::EC2::InternetGateway": ec2_models.InternetGateway,
    "AWS::EC2::NetworkInterface": ec2_models.NetworkInterface,
    "AWS::EC2::Route": ec2_models.Route,
    "AWS::EC2::RouteTable": ec2_models.RouteTable,
    "AWS::EC2::SecurityGroup": ec2_models.SecurityGroup,
    "AWS::EC2::SecurityGroupIngress": ec2_models.SecurityGroupIngress,
    "AWS::EC2::Subnet": ec2_models.Subnet,
    "AWS::EC2::SubnetRouteTableAssociation": ec2_models.SubnetRouteTableAssociation,
    "AWS::EC2::Volume": ec2_models.Volume,
    "AWS::EC2::VolumeAttachment": ec2_models.VolumeAttachment,
    "AWS::EC2::VPC": ec2_models.VPC,
    "AWS::EC2::VPCGatewayAttachment": ec2_models.VPCGatewayAttachment,
    "AWS::EC2::VPCPeeringConnection": ec2_models.VPCPeeringConnection,
    "AWS::ElasticLoadBalancing::LoadBalancer": elb_models.FakeLoadBalancer,
    "AWS::IAM::InstanceProfile": iam_models.InstanceProfile,
    "AWS::IAM::Role": iam_models.Role,
    "AWS::RDS::DBInstance": rds_models.Database,
    "AWS::RDS::DBSecurityGroup": rds_models.SecurityGroup,
    "AWS::RDS::DBSubnetGroup": rds_models.SubnetGroup,
    "AWS::Redshift::Cluster": redshift_models.Cluster,
    "AWS::Redshift::ClusterParameterGroup": redshift_models.ParameterGroup,
    "AWS::Redshift::ClusterSubnetGroup": redshift_models.SubnetGroup,
    "AWS::Route53::HealthCheck": route53_models.HealthCheck,
    "AWS::Route53::HostedZone": route53_models.FakeZone,
    "AWS::Route53::RecordSet": route53_models.RecordSet,
    "AWS::Route53::RecordSetGroup": route53_models.RecordSetGroup,
    "AWS::SNS::Topic": sns_models.Topic,
    "AWS::SQS::Queue": sqs_models.Queue,
}

# http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-name.html
NAME_TYPE_MAP = {
    "AWS::CloudWatch::Alarm": "Alarm",
    "AWS::DynamoDB::Table": "TableName",
    "AWS::ElastiCache::CacheCluster": "ClusterName",
    "AWS::ElasticBeanstalk::Application": "ApplicationName",
    "AWS::ElasticBeanstalk::Environment": "EnvironmentName",
    "AWS::ElasticLoadBalancing::LoadBalancer": "LoadBalancerName",
    "AWS::RDS::DBInstance": "DBInstanceIdentifier",
    "AWS::S3::Bucket": "BucketName",
    "AWS::SNS::Topic": "TopicName",
    "AWS::SQS::Queue": "QueueName"
}

# Just ignore these models types for now
NULL_MODELS = [
    "AWS::CloudFormation::WaitCondition",
    "AWS::CloudFormation::WaitConditionHandle",
]

logger = logging.getLogger("moto")


class LazyDict(dict):
    def __getitem__(self, key):
        val = dict.__getitem__(self, key)
        if callable(val):
            val = val()
            self[key] = val
        return val


def clean_json(resource_json, resources_map):
    """
    Cleanup the a resource dict. For now, this just means replacing any Ref node
    with the corresponding physical_resource_id.

    Eventually, this is where we would add things like function parsing (fn::)
    """
    if isinstance(resource_json, dict):
        if 'Ref' in resource_json:
            # Parse resource reference
            resource = resources_map[resource_json['Ref']]
            if hasattr(resource, 'physical_resource_id'):
                return resource.physical_resource_id
            else:
                return resource

        if "Fn::FindInMap" in resource_json:
            map_name = resource_json["Fn::FindInMap"][0]
            map_path = resource_json["Fn::FindInMap"][1:]
            result = resources_map[map_name]
            for path in map_path:
                result = result[clean_json(path, resources_map)]
            return result

        if 'Fn::GetAtt' in resource_json:
            resource = resources_map[resource_json['Fn::GetAtt'][0]]
            if resource is None:
                return resource_json
            try:
                return resource.get_cfn_attribute(resource_json['Fn::GetAtt'][1])
            except NotImplementedError as n:
                logger.warning(n.message.format(resource_json['Fn::GetAtt'][0]))
            except UnformattedGetAttTemplateException:
                raise BotoServerError(
                    UnformattedGetAttTemplateException.status_code,
                    'Bad Request',
                    UnformattedGetAttTemplateException.description.format(
                        resource_json['Fn::GetAtt'][0], resource_json['Fn::GetAtt'][1]))

        if 'Fn::If' in resource_json:
            condition_name, true_value, false_value = resource_json['Fn::If']
            if resources_map[condition_name]:
                return clean_json(true_value, resources_map)
            else:
                return clean_json(false_value, resources_map)

        if 'Fn::Join' in resource_json:
            join_list = []
            for val in resource_json['Fn::Join'][1]:
                cleaned_val = clean_json(val, resources_map)
                join_list.append('{0}'.format(cleaned_val) if cleaned_val else '{0}'.format(val))
            return resource_json['Fn::Join'][0].join(join_list)

        cleaned_json = {}
        for key, value in resource_json.items():
            cleaned_json[key] = clean_json(value, resources_map)
        return cleaned_json
    elif isinstance(resource_json, list):
        return [clean_json(val, resources_map) for val in resource_json]
    else:
        return resource_json


def resource_class_from_type(resource_type):
    if resource_type in NULL_MODELS:
        return None
    if resource_type not in MODEL_MAP:
        logger.warning("No Moto CloudFormation support for %s", resource_type)
        return None
    return MODEL_MAP.get(resource_type)


def resource_name_property_from_type(resource_type):
    return NAME_TYPE_MAP.get(resource_type)


def parse_resource(logical_id, resource_json, resources_map):
    resource_type = resource_json['Type']
    resource_class = resource_class_from_type(resource_type)
    if not resource_class:
        return None

    resource_json = clean_json(resource_json, resources_map)
    resource_name_property = resource_name_property_from_type(resource_type)
    if resource_name_property:
        if 'Properties' not in resource_json:
            resource_json['Properties'] = dict()
        if resource_name_property not in resource_json['Properties']:
            resource_json['Properties'][resource_name_property] = '{0}-{1}-{2}'.format(
                resources_map.get('AWS::StackName'),
                logical_id,
                random_suffix())
        resource_name = resource_json['Properties'][resource_name_property]
    else:
        resource_name = '{0}-{1}-{2}'.format(resources_map.get('AWS::StackName'),
                                             logical_id,
                                             random_suffix())
    return resource_class, resource_json, resource_name


def parse_and_create_resource(logical_id, resource_json, resources_map, region_name):
    condition = resource_json.get('Condition')
    if condition and not resources_map[condition]:
        # If this has a False condition, don't create the resource
        return None

    resource_type = resource_json['Type']
    resource_tuple = parse_resource(logical_id, resource_json, resources_map)
    if not resource_tuple:
        return None
    resource_class, resource_json, resource_name = resource_tuple
    resource = resource_class.create_from_cloudformation_json(resource_name, resource_json, region_name)
    resource.type = resource_type
    resource.logical_resource_id = logical_id
    return resource


def parse_and_update_resource(logical_id, resource_json, resources_map, region_name):
    resource_class, resource_json, resource_name = parse_resource(logical_id, resource_json, resources_map)
    resource = resource_class.update_from_cloudformation_json(resource_name, resource_json, region_name)
    return resource


def parse_and_delete_resource(logical_id, resource_json, resources_map, region_name):
    resource_class, resource_json, resource_name = parse_resource(logical_id, resource_json, resources_map)
    resource_class.delete_from_cloudformation_json(resource_name, resource_json, region_name)
    return None


def parse_condition(condition, resources_map, condition_map):
    if isinstance(condition, bool):
        return condition

    condition_operator = list(condition.keys())[0]

    condition_values = []
    for value in list(condition.values())[0]:
        # Check if we are referencing another Condition
        if 'Condition' in value:
            condition_values.append(condition_map[value['Condition']])
        else:
            condition_values.append(clean_json(value, resources_map))

    if condition_operator == "Fn::Equals":
        return condition_values[0] == condition_values[1]
    elif condition_operator == "Fn::Not":
        return not parse_condition(condition_values[0], resources_map, condition_map)
    elif condition_operator == "Fn::And":
        return all([
            parse_condition(condition_value, resources_map, condition_map)
            for condition_value
            in condition_values])
    elif condition_operator == "Fn::Or":
        return any([
            parse_condition(condition_value, resources_map, condition_map)
            for condition_value
            in condition_values])


def parse_output(output_logical_id, output_json, resources_map):
    output_json = clean_json(output_json, resources_map)
    output = Output()
    output.key = output_logical_id
    output.value = output_json['Value']
    output.description = output_json.get('Description')
    return output


class ResourceMap(collections.Mapping):
    """
    This is a lazy loading map for resources. This allows us to create resources
    without needing to create a full dependency tree. Upon creation, each
    each resources is passed this lazy map that it can grab dependencies from.
    """

    def __init__(self, stack_id, stack_name, parameters, region_name, template):
        self._template = template
        self._resource_json_map = template['Resources']
        self._region_name = region_name
        self.input_parameters = parameters
        self.resolved_parameters = {}

        # Create the default resources
        self._parsed_resources = {
            "AWS::AccountId": "123456789012",
            "AWS::Region": self._region_name,
            "AWS::StackId": stack_id,
            "AWS::StackName": stack_name,
            "AWS::NoValue": None,
        }

    def __getitem__(self, key):
        resource_logical_id = key

        if resource_logical_id in self._parsed_resources:
            return self._parsed_resources[resource_logical_id]
        else:
            resource_json = self._resource_json_map.get(resource_logical_id)
            new_resource = parse_and_create_resource(resource_logical_id, resource_json, self, self._region_name)
            self._parsed_resources[resource_logical_id] = new_resource
            return new_resource

    def __iter__(self):
        return iter(self.resources)

    def __len__(self):
        return len(self._resource_json_map)

    @property
    def resources(self):
        return self._resource_json_map.keys()

    def load_mapping(self):
        self._parsed_resources.update(self._template.get('Mappings', {}))

    def load_parameters(self):
        parameter_slots = self._template.get('Parameters', {})
        for parameter_name, parameter in parameter_slots.items():
            # Set the default values.
            self.resolved_parameters[parameter_name] = parameter.get('Default')

        # Set any input parameters that were passed
        for key, value in self.input_parameters.items():
            if key in self.resolved_parameters:
                self.resolved_parameters[key] = value

        # Check if there are any non-default params that were not passed input params
        for key, value in self.resolved_parameters.items():
            if value is None:
                raise MissingParameterError(key)

        self._parsed_resources.update(self.resolved_parameters)

    def load_conditions(self):
        conditions = self._template.get('Conditions', {})
        lazy_condition_map = LazyDict()
        for condition_name, condition in conditions.items():
            lazy_condition_map[condition_name] = functools.partial(parse_condition,
                condition, self._parsed_resources, lazy_condition_map)

        for condition_name in lazy_condition_map:
            self._parsed_resources[condition_name] = lazy_condition_map[condition_name]

    def create(self):
        self.load_mapping()
        self.load_parameters()
        self.load_conditions()

        # Since this is a lazy map, to create every object we just need to
        # iterate through self.
        tags = {'aws:cloudformation:stack-name': self.get('AWS::StackName'),
                'aws:cloudformation:stack-id': self.get('AWS::StackId')}
        for resource in self.resources:
            self[resource]
            if isinstance(self[resource], ec2_models.TaggedEC2Resource):
                tags['aws:cloudformation:logical-id'] = resource
                ec2_models.ec2_backends[self._region_name].create_tags([self[resource].physical_resource_id], tags)

    def update(self, template):
        self.load_mapping()
        self.load_parameters()
        self.load_conditions()

        old_template = self._resource_json_map
        new_template = template['Resources']
        self._resource_json_map = new_template

        new_resource_names = set(new_template) - set(old_template)
        for resource_name in new_resource_names:
            resource_json = new_template[resource_name]
            new_resource = parse_and_create_resource(resource_name, resource_json, self, self._region_name)
            self._parsed_resources[resource_name] = new_resource

        removed_resource_nams = set(old_template) - set(new_template)
        for resource_name in removed_resource_nams:
            resource_json = old_template[resource_name]
            parse_and_delete_resource(resource_name, resource_json, self, self._region_name)
            self._parsed_resources.pop(resource_name)

        for resource_name in new_template:
            if resource_name in old_template and new_template[resource_name] != old_template[resource_name]:
                resource_json = new_template[resource_name]

                changed_resource = parse_and_update_resource(resource_name, resource_json, self, self._region_name)
                self._parsed_resources[resource_name] = changed_resource


class OutputMap(collections.Mapping):
    def __init__(self, resources, template):
        self._template = template
        self._output_json_map = template.get('Outputs')

        # Create the default resources
        self._resource_map = resources
        self._parsed_outputs = dict()

    def __getitem__(self, key):
        output_logical_id = key

        if output_logical_id in self._parsed_outputs:
            return self._parsed_outputs[output_logical_id]
        else:
            output_json = self._output_json_map.get(output_logical_id)
            new_output = parse_output(output_logical_id, output_json, self._resource_map)
            self._parsed_outputs[output_logical_id] = new_output
            return new_output

    def __iter__(self):
        return iter(self.outputs)

    def __len__(self):
        return len(self._output_json_map)

    @property
    def outputs(self):
        return self._output_json_map.keys() if self._output_json_map else []

    def create(self):
        for output in self.outputs:
            self[output]
