"""
  Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.

  Permission is hereby granted, free of charge, to any person obtaining a copy of this
  software and associated documentation files (the "Software"), to deal in the Software
  without restriction, including without limitation the rights to use, copy, modify,
  merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
  permit persons to whom the Software is furnished to do so.

  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
  INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
  PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
  HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
  OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
import os
import logging
from datetime import datetime
import traceback
import cfnlint.helpers
from cfnlint.decode.node import TemplateAttributeError

LOGGER = logging.getLogger(__name__)


class CloudFormationLintRule(object):
    """CloudFormation linter rules"""

    id = ''
    shortdesc = ''
    description = ''
    source_url = ''
    tags = []
    experimental = False

    logger = logging.getLogger(__name__)

    def __init__(self):
        self.resource_property_types = []
        self.resource_sub_property_types = []
        self.config = {}  # `-X E3012:strict=false`... Show more
        self.config_definition = {}

    def __repr__(self):
        return '%s: %s' % (self.id, self.shortdesc)

    def verbose(self):
        """Verbose output"""
        return '%s: %s\n%s' % (self.id, self.shortdesc, self.description)

    def initialize(self, cfn):
        """Initialize the rule"""

    def configure(self, configs=None):
        """ Set the configuration """

        # set defaults
        if isinstance(self.config_definition, dict):
            for config_name, config_values in self.config_definition.items():
                self.config[config_name] = config_values['default']

        if isinstance(configs, dict):
            for key, value in configs.items():
                if key in self.config_definition:
                    if self.config_definition[key]['type'] == 'boolean':
                        self.config[key] = cfnlint.helpers.bool_compare(value, True)
                    elif self.config_definition[key]['type'] == 'string':
                        self.config[key] = str(value)
                    elif self.config_definition[key]['type'] == 'integer':
                        self.config[key] = int(value)

    match = None
    match_resource_properties = None
    match_resource_sub_properties = None

    # pylint: disable=E0213
    def matching(match_type):
        """ Does Logging for match functions """
        def decorator(match_function):
            """ The Actual Decorator """
            def wrapper(self, filename, cfn, *args, **kwargs):
                """Wrapper"""
                matches = []

                if not getattr(self, match_type):
                    return []

                if match_type == 'match_resource_properties':
                    if args[1] not in self.resource_property_types:
                        return []
                elif match_type == 'match_resource_sub_properties':
                    if args[1] not in self.resource_sub_property_types:
                        return []

                start = datetime.now()
                LOGGER.debug('Starting match function for rule %s at %s', self.id, start)
                # pylint: disable=E1102
                results = match_function(self, filename, cfn, *args, **kwargs)
                LOGGER.debug('Complete match function for rule %s at %s.  Ran in %s',
                             self.id, datetime.now(), datetime.now() - start)
                LOGGER.debug('Results from rule %s are %s: ', self.id, results)

                if results:
                    for result in results:
                        linenumbers = cfn.get_location_yaml(cfn.template, result.path)
                        if linenumbers:
                            matches.append(Match(
                                linenumbers[0] + 1, linenumbers[1] + 1,
                                linenumbers[2] + 1, linenumbers[3] + 1,
                                filename, self, result.message, result))
                        else:
                            matches.append(Match(
                                1, 1,
                                1, 1,
                                filename, self, result.message, result))

                return matches
            return wrapper
        return decorator

    @matching('match')
    # pylint: disable=W0613
    def matchall(self, filename, cfn):
        """Match the entire file"""
        return self.match(cfn)  # pylint: disable=E1102

    @matching('match_resource_properties')
    # pylint: disable=W0613
    def matchall_resource_properties(self, filename, cfn, resource_properties, property_type, path):
        """ Check for resource properties type """
        return self.match_resource_properties(resource_properties, property_type, path, cfn)  # pylint: disable=E1102

    @matching('match_resource_sub_properties')
    # pylint: disable=W0613
    def matchall_resource_sub_properties(self, filename, cfn, resource_properties, property_type, path):
        """ Check for resource properties type """
        return self.match_resource_sub_properties(resource_properties, property_type, path, cfn)  # pylint: disable=E1102


class RulesCollection(object):
    """Collection of rules"""

    def __init__(self, ignore_rules=None, include_rules=None, configure_rules=None, include_experimental=False):
        self.rules = []

        # Whether "experimental" rules should be added
        self.include_experimental = include_experimental

        # Make Ignore Rules not required
        self.ignore_rules = ignore_rules or []
        self.include_rules = include_rules or []
        self.configure_rules = configure_rules or []
        # by default include 'W' and 'E'
        # 'I' has to be included manually for backwards compabitility
        self.include_rules.extend(['W', 'E'])

    def register(self, rule):
        """Register rules"""
        if self.is_rule_enabled(rule.id, rule.experimental):
            self.rules.append(rule)
            if rule.id in self.configure_rules:
                rule.configure(self.configure_rules[rule.id])

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

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

    def extend(self, more):
        """Extend rules"""
        for rule in more:
            if self.is_rule_enabled(rule.id, rule.experimental):
                self.rules.append(rule)
                if rule.id in self.configure_rules:
                    rule.configure(self.configure_rules[rule.id])

    def __repr__(self):
        return '\n'.join([rule.verbose()
                          for rule in sorted(self.rules, key=lambda x: x.id)])

    def is_rule_enabled(self, rule_id, experimental):
        """ Cheks if an individual rule is valid """
        # Evaluate experimental rules
        if experimental and not self.include_experimental:
            return False

        # Evaluate includes first:
        include_filter = False
        for include_rule in self.include_rules:
            if rule_id.startswith(include_rule):
                include_filter = True
        if not include_filter:
            return False
        # Allowing ignoring of rules based on prefix to ignore checks
        for ignore_rule in self.ignore_rules:
            if rule_id.startswith(ignore_rule) and ignore_rule:
                return False

        return True

    def run_check(self, check, filename, rule_id, *args):
        """ Run a check """
        try:
            return check(*args)
        except TemplateAttributeError as err:
            LOGGER.debug(str(err))
            return []
        except Exception as err:  # pylint: disable=W0703
            if self.is_rule_enabled('E0002', False):
                # In debug mode, print the error include complete stack trace
                if LOGGER.getEffectiveLevel() == logging.DEBUG:
                    error_message = traceback.format_exc()
                else:
                    error_message = str(err)
                message = 'Unknown exception while processing rule {}: {}'
                return [
                    Match(
                        1, 1, 1, 1,
                        filename, RuleError(), message.format(rule_id, error_message))]

    def resource_property(self, filename, cfn, path, properties, resource_type, property_type):
        """Run loops in resource checks for embedded properties"""
        matches = []
        property_spec = cfnlint.helpers.RESOURCE_SPECS['us-east-1'].get('PropertyTypes')
        if property_type == 'Tag':
            property_spec_name = 'Tag'
        else:
            property_spec_name = '%s.%s' % (resource_type, property_type)

        if property_spec_name in property_spec:
            for rule in self.rules:
                matches.extend(
                    self.run_check(
                        rule.matchall_resource_sub_properties, filename, rule.id,
                        filename, cfn, properties, property_spec_name, path
                    )
                )

            resource_spec_properties = property_spec.get(property_spec_name, {}).get('Properties')
            if not resource_spec_properties:
                if property_spec.get(property_spec_name, {}).get('Type') == 'List':
                    if isinstance(properties, list):
                        property_type = property_spec.get(property_spec_name, {}).get('ItemType')
                        for index, item in enumerate(properties):
                            matches.extend(self.resource_property(
                                filename, cfn,
                                path[:] + [index],
                                item, resource_type, property_type))
                return matches
            if isinstance(properties, dict):
                for resource_property, resource_property_value in properties.items():
                    property_path = path[:] + [resource_property]
                    resource_spec_property = resource_spec_properties.get(resource_property, {})
                    if resource_property not in resource_spec_properties:
                        if resource_property == 'Fn::If':
                            if isinstance(resource_property_value, list):
                                if len(resource_property_value) == 3:
                                    for index, c_value in enumerate(resource_property_value[1:]):
                                        matches.extend(self.resource_property(
                                            filename, cfn,
                                            property_path[:] + [index + 1],
                                            c_value, resource_type, property_type))
                        continue
                    if (resource_spec_property.get('Type') == 'List' and
                            not resource_spec_properties.get('PrimitiveItemType')):
                        if isinstance(resource_property_value, (list)):
                            for index, value in enumerate(resource_property_value):
                                matches.extend(self.resource_property(
                                    filename, cfn,
                                    property_path[:] + [index],
                                    value, resource_type, resource_spec_property.get('ItemType')
                                ))
                    elif resource_spec_property.get('Type'):
                        if isinstance(resource_property_value, (dict)):
                            matches.extend(self.resource_property(
                                filename, cfn,
                                property_path,
                                resource_property_value,
                                resource_type, resource_spec_property.get('Type')
                            ))

        return matches

    def run_resource(self, filename, cfn, resource_type, resource_properties, path):
        """Run loops in resource checks for embedded properties"""
        matches = []
        resource_spec = cfnlint.helpers.RESOURCE_SPECS['us-east-1'].get('ResourceTypes')
        if resource_properties and resource_type in resource_spec:
            resource_spec_properties = resource_spec.get(resource_type, {}).get('Properties')
            for resource_property, resource_property_value in resource_properties.items():
                resource_spec_property = resource_spec_properties.get(resource_property, {})
                if resource_property not in resource_spec_properties:
                    if resource_property == 'Fn::If':
                        if isinstance(resource_property_value, list):
                            if len(resource_property_value) == 3:
                                for index, c_resource_properties in enumerate(resource_property_value[1:]):
                                    matches.extend(self.run_resource(
                                        filename, cfn, resource_type, c_resource_properties,
                                        path[:] + [resource_property, index + 1]))
                    continue
                if (resource_spec_property.get('Type') == 'List' and
                        not resource_spec_properties.get('PrimitiveItemType')):
                    if isinstance(resource_property_value, (list)):
                        for index, value in enumerate(resource_property_value):
                            matches.extend(self.resource_property(
                                filename, cfn,
                                path[:] + [resource_property, index],
                                value, resource_type, resource_spec_property.get('ItemType')
                            ))
                elif resource_spec_property.get('Type'):
                    if isinstance(resource_property_value, (dict)):
                        matches.extend(
                            self.resource_property(
                                filename, cfn,
                                path[:] + [resource_property],
                                resource_property_value,
                                resource_type, resource_spec_property.get('Type')
                            ))

        return matches

    def run(self, filename, cfn):
        """Run rules"""
        matches = []
        for rule in self.rules:
            rule.initialize(cfn)

        for rule in self.rules:
            matches.extend(
                self.run_check(
                    rule.matchall, filename, rule.id, filename, cfn
                )
            )

        for resource_name, resource_attributes in cfn.get_resources().items():
            resource_type = resource_attributes.get('Type')
            resource_properties = resource_attributes.get('Properties', {})
            path = ['Resources', resource_name, 'Properties']
            for rule in self.rules:
                matches.extend(
                    self.run_check(
                        rule.matchall_resource_properties, filename, rule.id,
                        filename, cfn, resource_properties, resource_type, path
                    )
                )

            matches.extend(
                self.run_resource(
                    filename, cfn, resource_type, resource_properties, path))

        return matches

    def create_from_directory(self, rulesdir):
        """Create rules from directory"""
        result = []
        if rulesdir != '':
            result = cfnlint.helpers.load_plugins(os.path.expanduser(rulesdir))

        for rule in result:
            if rule.id in self.configure_rules:
                rule.configure(self.configure_rules[rule.id])

        self.extend(result)


class RuleMatch(object):
    """Rules Error"""

    def __init__(self, path, message, **kwargs):
        """Init"""
        self.path = path
        self.path_string = '/'.join(map(str, path))
        self.message = message
        for k, v in kwargs.items():
            setattr(self, k, v)

    def __eq__(self, item):
        """Override unique"""
        return ((self.path, self.message) == (item.path, item.message))

    def __hash__(self):
        """Hash for comparisons"""
        return hash((self.path, self.message))


class Match(object):  # pylint: disable=R0902
    """Match Classes"""

    def __init__(
            self, linenumber, columnnumber, linenumberend,
            columnnumberend, filename, rule, message=None, rulematch_obj=None):
        """Init"""
        self.linenumber = linenumber
        self.columnnumber = columnnumber
        self.linenumberend = linenumberend
        self.columnnumberend = columnnumberend
        self.filename = filename
        self.rule = rule
        self.message = message  # or rule.shortdesc
        if rulematch_obj:
            for k, v in vars(rulematch_obj).items():
                if not hasattr(self, k):
                    setattr(self, k, v)

    def __repr__(self):
        """Represent"""
        formatstr = u'[{0}] ({1}) matched {2}:{3}'
        return formatstr.format(self.rule, self.message,
                                self.filename, self.linenumber)

    def __eq__(self, item):
        """Override equal to compare matches"""
        return (
            (
                self.linenumber, self.columnnumber, self.rule.id, self.message
            ) ==
            (
                item.linenumber, item.columnnumber, item.rule.id, item.message
            ))


class ParseError(CloudFormationLintRule):
    """Parse Lint Rule"""
    id = 'E0000'
    shortdesc = 'Parsing error found when parsing the template'
    description = 'Checks for Null values and Duplicate values in resources'
    source_url = 'https://github.com/aws-cloudformation/cfn-python-lint'
    tags = ['base']


class TransformError(CloudFormationLintRule):
    """Transform Lint Rule"""
    id = 'E0001'
    shortdesc = 'Error found when transforming the template'
    description = 'Errors found when performing transformation on the template'
    source_url = 'https://github.com/aws-cloudformation/cfn-python-lint'
    tags = ['base', 'transform']


class RuleError(CloudFormationLintRule):
    """Rule processing Error"""
    id = 'E0002'
    shortdesc = 'Error processing rule on the template'
    description = 'Errors found when processing a rule on the template'
    source_url = 'https://github.com/aws-cloudformation/cfn-python-lint'
    tags = ['base', 'rule']
