"""
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: MIT-0
"""
from functools import reduce  # pylint: disable=redefined-builtin
import re
import copy
import six
from cfnlint.rules import CloudFormationLintRule
from cfnlint.rules import RuleMatch


class SubNeeded(CloudFormationLintRule):
    """Check if a substitution string exists without a substitution function"""
    id = 'E1029'
    shortdesc = 'Sub is required if a variable is used in a string'
    description = 'If a substitution variable exists in a string but isn\'t wrapped with the Fn::Sub function the deployment will fail.'
    source_url = 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html'
    tags = ['functions', 'sub']

    exceptions = ['TemplateBody']

    def __init__(self):
        """Init"""
        super(SubNeeded, self).__init__()
        self.config_definition = {
            'custom_excludes': {
                'default': '',
                'type': 'string'
            }
        }
        self.configure()
        self.subParameterRegex = re.compile(r'(\$\{[A-Za-z0-9_:\.]+\})')

    def _match_values(self, cfnelem, path):
        """Recursively search for values matching the searchRegex"""
        values = []
        if isinstance(cfnelem, dict):
            for key in cfnelem:
                pathprop = path[:]
                pathprop.append(key)
                values.extend(self._match_values(cfnelem[key], pathprop))
        elif isinstance(cfnelem, list):
            for index, item in enumerate(cfnelem):
                pathprop = path[:]
                pathprop.append(index)
                values.extend(self._match_values(item, pathprop))
        else:
            # Leaf node
            if isinstance(cfnelem, six.string_types):  # and re.match(searchRegex, cfnelem):
                for variable in re.findall(self.subParameterRegex, cfnelem):
                    values.append(path + [variable])

        return values

    def match_values(self, cfn):
        """
            Search for values in all parts of the templates that match the searchRegex
        """
        results = []
        results.extend(self._match_values(cfn.template, []))
        # Globals are removed during a transform.  They need to be checked manually
        results.extend(self._match_values(cfn.template.get('Globals', {}), []))
        return results

    def _api_exceptions(self, value):
        """ Key value exceptions """
        parameter_search = re.compile(r'^\$\{stageVariables\..*\}$')
        return re.match(parameter_search, value)

    def _variable_custom_excluded(self, value):
        """ User-defined exceptions for variables, anywhere in the file """
        custom_excludes = self.config['custom_excludes']
        if custom_excludes:
            custom_search = re.compile(custom_excludes)
            return re.match(custom_search, value)
        return False

    def match(self, cfn):
        matches = []

        refs = cfn.get_valid_refs()
        getatts = cfn.get_valid_getatts()

        # Get a list of paths to every leaf node string containing at least one ${parameter}
        parameter_string_paths = self.match_values(cfn)
        # We want to search all of the paths to check if each one contains an 'Fn::Sub'
        for parameter_string_path in parameter_string_paths:
            # Get variable
            var = parameter_string_path[-1]

            # Step Function State Machine has a Definition Substitution that allows usage of special variables outside of a !Sub
            # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-stepfunctions-statemachine-definitionsubstitutions.html

            if 'DefinitionString' in parameter_string_path:
                modified_parameter_string_path = copy.copy(parameter_string_path)
                index = parameter_string_path.index('DefinitionString')
                modified_parameter_string_path[index] = 'DefinitionSubstitutions'
                modified_parameter_string_path = modified_parameter_string_path[:index+1]
                modified_parameter_string_path.append(var[2:-1])
                if reduce(lambda c, k: c.get(k, {}), modified_parameter_string_path, cfn.template):
                    continue

            # Exclude variables that match custom exclude filters, if configured
            # (for third-party tools that pre-process templates before uploading them to AWS)
            if self._variable_custom_excluded(var):
                continue

            # Exclude literals (https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html)
            if var.startswith('${!'):
                continue

            var_stripped = var[2:-1].strip()

            # If we didn't find an 'Fn::Sub' it means a string containing a ${parameter} may not be evaluated correctly
            if not 'Fn::Sub' in parameter_string_path and parameter_string_path[-2] not in self.exceptions:
                if (var_stripped in refs or var_stripped in getatts) or 'DefinitionString' in parameter_string_path:
                    # Remove the last item (the variable) to prevent multiple errors on 1 line errors
                    path = parameter_string_path[:-1]
                    message = 'Found an embedded parameter "{}" outside of an "Fn::Sub" at {}'.format(
                        var, '/'.join(map(str, path)))
                    matches.append(RuleMatch(path, message))

        return matches
