"""
Jinja loading utils to enable a more powerful backend for jinja templates
"""

import itertools
import logging
import os.path
import pprint
import re
import shlex
import time
import uuid
import warnings
from collections import OrderedDict
from collections.abc import Hashable
from functools import wraps
from xml.dom import minidom
from xml.etree.ElementTree import Element, SubElement, tostring

import jinja2
from jinja2 import BaseLoader, TemplateNotFound, nodes
from jinja2.environment import TemplateModule
from jinja2.exceptions import TemplateRuntimeError
from jinja2.ext import Extension

import salt.utils.data
import salt.utils.files
import salt.utils.json
import salt.utils.stringutils
import salt.utils.url
import salt.utils.yaml
from salt.exceptions import TemplateError
from salt.utils.decorators.jinja import jinja_filter, jinja_global, jinja_test
from salt.utils.versions import Version

try:
    from markupsafe import Markup
except ImportError:
    # jinja < 3.1
    from jinja2 import Markup  # pylint: disable=no-name-in-module

log = logging.getLogger(__name__)

__all__ = ["SaltCacheLoader", "SerializerExtension"]

GLOBAL_UUID = uuid.UUID("91633EBF-1C86-5E33-935A-28061F4B480E")
JINJA_VERSION = Version(jinja2.__version__)


class SaltCacheLoader(BaseLoader):
    """
    A special jinja Template Loader for salt.
    Requested templates are always fetched from the server
    to guarantee that the file is up to date.
    Templates are cached like regular salt states
    and only loaded once per loader instance.
    """

    def __init__(
        self,
        opts,
        saltenv="base",
        encoding="utf-8",
        pillar_rend=False,
        _file_client=None,
    ):
        self.opts = opts
        self.saltenv = saltenv
        self.encoding = encoding
        self.pillar_rend = pillar_rend
        if self.pillar_rend:
            if saltenv not in self.opts["pillar_roots"]:
                self.searchpath = []
            else:
                self.searchpath = opts["pillar_roots"][saltenv]
        else:
            self.searchpath = [os.path.join(opts["cachedir"], "files", saltenv)]
        log.debug("Jinja search path: %s", self.searchpath)
        self.cached = []
        self._file_client = _file_client
        self._close_file_client = _file_client is None

    def file_client(self):
        """
        Return a file client. Instantiates on first call.
        """
        # If there was no file_client passed to the class, create a cache_client
        # and use that. This avoids opening a new file_client every time this
        # class is instantiated
        if (
            self._file_client is None
            or not hasattr(self._file_client, "opts")
            or self._file_client.opts["file_roots"] != self.opts["file_roots"]
        ):
            import salt.fileclient

            self._file_client = salt.fileclient.get_file_client(
                self.opts, self.pillar_rend
            )
            self._close_file_client = True
        return self._file_client

    def cache_file(self, template):
        """
        Cache a file from the salt master
        """
        saltpath = salt.utils.url.create(template)
        fcl = self.file_client()
        return fcl.get_file(saltpath, "", True, self.saltenv)

    def check_cache(self, template):
        """
        Cache a file only once
        """
        if template not in self.cached:
            ret = self.cache_file(template)
            if ret is not False:
                self.cached.append(template)

    def get_source(self, environment, template):
        """
        Salt-specific loader to find imported jinja files.

        Jinja imports will be interpreted as originating from the top
        of each of the directories in the searchpath when the template
        name does not begin with './' or '../'.  When a template name
        begins with './' or '../' then the import will be relative to
        the importing file.

        """
        # FIXME: somewhere do separator replacement: '\\' => '/'
        _template = template
        if template.split("/", 1)[0] in ("..", "."):
            is_relative = True
        else:
            is_relative = False
        # checks for relative '..' paths that step-out of file_roots
        if is_relative:
            # Starts with a relative path indicator
            if not environment or "tpldir" not in environment.globals:
                log.warning(
                    'Relative path "%s" cannot be resolved without an environment',
                    template,
                )
                raise TemplateNotFound(template)
            base_path = environment.globals["tpldir"]
            _template = os.path.normpath("/".join((base_path, _template)))
            if _template.split("/", 1)[0] == "..":
                log.warning(
                    'Discarded template path "%s": attempts to'
                    " ascend outside of salt://",
                    template,
                )
                raise TemplateNotFound(template)
            # local file clients should pass the dot-expanded relative path
            # when it's an absolute local filesystem location
            if environment.globals.get("opts", {}).get(
                "file_client"
            ) == "local" and os.path.isabs(base_path):
                _template = os.path.relpath(_template, base_path)

        self.check_cache(_template)

        if environment and template:
            tpldir = os.path.dirname(_template).replace("\\", "/")
            tplfile = _template
            if is_relative:
                tpldir = environment.globals.get("tpldir", tpldir)
                tplfile = template
            tpldata = {
                "tplfile": tplfile,
                "tpldir": "." if tpldir == "" else tpldir,
                "tpldot": tpldir.replace("/", "."),
            }
            environment.globals.update(tpldata)

        if _template in self.cached or os.path.exists(_template):
            # pylint: disable=cell-var-from-loop
            for spath in self.searchpath:
                filepath = os.path.join(spath, _template)
                try:
                    with salt.utils.files.fopen(filepath, "rb") as ifile:
                        contents = ifile.read().decode(self.encoding)
                        mtime = os.path.getmtime(filepath)

                        def uptodate():
                            try:
                                return os.path.getmtime(filepath) == mtime
                            except OSError:
                                return False

                        return contents, filepath, uptodate
                except OSError:
                    # there is no file under current path
                    continue
            # pylint: enable=cell-var-from-loop

        # there is no template file within searchpaths
        raise TemplateNotFound(template)

    def destroy(self):
        if self._close_file_client is False:
            return
        if self._file_client is None:
            return
        file_client = self._file_client
        self._file_client = None

        try:
            file_client.destroy()
        except AttributeError:
            # PillarClient and LocalClient objects do not have a destroy method
            pass

    def __enter__(self):
        self.file_client()
        return self

    def __exit__(self, *args):
        self.destroy()


class PrintableDict(OrderedDict):
    """
    Ensures that dict str() and repr() are YAML friendly.

    .. code-block:: python

        mapping = OrderedDict([('a', 'b'), ('c', None)])
        print mapping
        # OrderedDict([('a', 'b'), ('c', None)])

        decorated = PrintableDict(mapping)
        print decorated
        # {'a': 'b', 'c': None}
    """

    def __str__(self):
        output = []
        for key, value in self.items():
            if isinstance(value, str):
                # keeps quotes around strings
                output.append(f"{key!r}: {value!r}")
            else:
                # let default output
                output.append(f"{key!r}: {value!s}")
        return "{" + ", ".join(output) + "}"

    def __repr__(self):  # pylint: disable=W0221
        output = []
        for key, value in self.items():
            # Raw string formatter required here because this is a repr
            # function.
            output.append(f"{key!r}: {value!r}")
        return "{" + ", ".join(output) + "}"


# Additional globals
@jinja_global("raise")
def jinja_raise(msg):
    raise TemplateError(msg)


# Additional tests
@jinja_test("match")
def test_match(txt, rgx, ignorecase=False, multiline=False):
    """Returns true if a sequence of chars matches a pattern."""
    flag = 0
    if ignorecase:
        flag |= re.I
    if multiline:
        flag |= re.M
    compiled_rgx = re.compile(rgx, flag)
    return True if compiled_rgx.match(txt) else False


@jinja_test("equalto")
def test_equalto(value, other):
    """Returns true if two values are equal."""
    return value == other


# Additional filters
@jinja_filter("skip")
def skip_filter(data):
    """
    Suppress data output

    .. code-block:: yaml

        {% my_string = "foo" %}

        {{ my_string|skip }}

    will be rendered as empty string,

    """
    return ""


@jinja_filter("sequence")
def ensure_sequence_filter(data):
    """
    Ensure sequenced data.

    **sequence**

        ensure that parsed data is a sequence

    .. code-block:: jinja

        {% set my_string = "foo" %}
        {% set my_list = ["bar", ] %}
        {% set my_dict = {"baz": "qux"} %}

        {{ my_string|sequence|first }}
        {{ my_list|sequence|first }}
        {{ my_dict|sequence|first }}


    will be rendered as:

    .. code-block:: yaml

        foo
        bar
        baz
    """
    if not isinstance(data, (list, tuple, set, dict)):
        return [data]
    return data


@jinja_filter("to_bool")
def to_bool(val):
    """
    Returns the logical value.

    .. code-block:: jinja

        {{ 'yes' | to_bool }}

    will be rendered as:

    .. code-block:: text

        True
    """
    if val is None:
        return False
    if isinstance(val, bool):
        return val
    if isinstance(val, (str, (str,))):
        return val.lower() in ("yes", "1", "true")
    if isinstance(val, int):
        return val > 0
    if not isinstance(val, Hashable):
        return len(val) > 0
    return False


@jinja_filter("indent")
def indent(s, width=4, first=False, blank=False, indentfirst=None):
    """
    A ported version of the "indent" filter containing a fix for indenting Markup
    objects. If the minion has Jinja version 2.11 or newer, the "indent" filter
    from upstream will be used, and this one will be ignored.
    """
    if indentfirst is not None:
        warnings.warn(
            "The 'indentfirst' argument is renamed to 'first' and will"
            " be removed in Jinja 3.0.",
            DeprecationWarning,
            stacklevel=2,
        )
        first = indentfirst

    indention = " " * width
    newline = "\n"

    if isinstance(s, Markup):
        indention = Markup(indention)
        newline = Markup(newline)

    s += newline  # this quirk is necessary for splitlines method

    if blank:
        rv = (newline + indention).join(s.splitlines())
    else:
        lines = s.splitlines()
        rv = lines.pop(0)

        if lines:
            rv += newline + newline.join(
                indention + line if line else line for line in lines
            )

    if first:
        rv = indention + rv

    return rv


@jinja_filter("tojson")
def tojson(val, indent=None, **options):
    """
    Implementation of tojson filter (only present in Jinja 2.9 and later).
    Unlike the Jinja built-in filter, this allows arbitrary options to be
    passed in to the underlying JSON library.
    """
    options.setdefault("ensure_ascii", True)
    if indent is not None:
        options["indent"] = indent
    return (
        salt.utils.json.dumps(val, **options)
        .replace("<", "\\u003c")
        .replace(">", "\\u003e")
        .replace("&", "\\u0026")
        .replace("'", "\\u0027")
    )


@jinja_filter("quote")
def quote(txt):
    """
    Wraps a text around quotes.

    .. code-block:: jinja

        {% set my_text = 'my_text' %}
        {{ my_text | quote }}

    will be rendered as:

    .. code-block:: text

        'my_text'
    """
    return shlex.quote(txt)


@jinja_filter()
def regex_escape(value):
    return re.escape(value)


@jinja_filter("regex_search")
def regex_search(txt, rgx, ignorecase=False, multiline=False):
    """
    Searches for a pattern in the text.

    .. code-block:: jinja

        {% set my_text = 'abcd' %}
        {{ my_text | regex_search('^(.*)BC(.*)$', ignorecase=True) }}

    will be rendered as:

    .. code-block:: text

        ('a', 'd')
    """
    flag = 0
    if ignorecase:
        flag |= re.I
    if multiline:
        flag |= re.M
    obj = re.search(rgx, txt, flag)
    if not obj:
        return
    return obj.groups()


@jinja_filter("regex_match")
def regex_match(txt, rgx, ignorecase=False, multiline=False):
    """
    Searches for a pattern in the text.

    .. code-block:: jinja

        {% set my_text = 'abcd' %}
        {{ my_text | regex_match('^(.*)BC(.*)$', ignorecase=True) }}

    will be rendered as:

    .. code-block:: text

        ('a', 'd')
    """
    flag = 0
    if ignorecase:
        flag |= re.I
    if multiline:
        flag |= re.M
    obj = re.match(rgx, txt, flag)
    if not obj:
        return
    return obj.groups()


@jinja_filter("regex_replace")
def regex_replace(txt, rgx, val, ignorecase=False, multiline=False):
    r"""
    Searches for a pattern and replaces with a sequence of characters.

    .. code-block:: jinja

        {% set my_text = 'lets replace spaces' %}
        {{ my_text | regex_replace('\s+', '__') }}

    will be rendered as:

    .. code-block:: text

        lets__replace__spaces
    """
    flag = 0
    if ignorecase:
        flag |= re.I
    if multiline:
        flag |= re.M
    compiled_rgx = re.compile(rgx, flag)
    return compiled_rgx.sub(val, txt)


@jinja_filter("uuid")
def uuid_(val):
    """
    Returns a UUID corresponding to the value passed as argument.

    .. code-block:: jinja

        {{ 'example' | uuid }}

    will be rendered as:

    .. code-block:: text

        f4efeff8-c219-578a-bad7-3dc280612ec8
    """
    return str(uuid.uuid5(GLOBAL_UUID, salt.utils.stringutils.to_str(val)))


### List-related filters


@jinja_filter()
def unique(values):
    """
    Removes duplicates from a list.

    .. code-block:: jinja

        {% set my_list = ['a', 'b', 'c', 'a', 'b'] -%}
        {{ my_list | unique }}

    will be rendered as:

    .. code-block:: text

        ['a', 'b', 'c']
    """
    ret = None
    if isinstance(values, Hashable):
        ret = set(values)
    else:
        ret = []
        for value in values:
            if value not in ret:
                ret.append(value)
    return ret


@jinja_filter("min")
def lst_min(obj):
    """
    Returns the min value.

    .. code-block:: jinja

        {% set my_list = [1,2,3,4] -%}
        {{ my_list | min }}

    will be rendered as:

    .. code-block:: text

        1
    """
    return min(obj)


@jinja_filter("max")
def lst_max(obj):
    """
    Returns the max value.

    .. code-block:: jinja

        {% my_list = [1,2,3,4] -%}
        {{ set my_list | max }}

    will be rendered as:

    .. code-block:: text

        4
    """
    return max(obj)


@jinja_filter("avg")
def lst_avg(lst):
    """
    Returns the average value of a list.

    .. code-block:: jinja

        {% my_list = [1,2,3,4] -%}
        {{ set my_list | avg }}

    will be rendered as:

    .. code-block:: yaml

        2.5
    """
    if not isinstance(lst, Hashable):
        return float(sum(lst) / len(lst))
    return float(lst)


@jinja_filter("union")
def union(lst1, lst2):
    """
    Returns the union of two lists.

    .. code-block:: jinja

        {% my_list = [1,2,3,4] -%}
        {{ set my_list | union([2, 4, 6]) }}

    will be rendered as:

    .. code-block:: text

        [1, 2, 3, 4, 6]
    """
    if isinstance(lst1, Hashable) and isinstance(lst2, Hashable):
        return set(lst1) | set(lst2)
    return unique(lst1 + lst2)


@jinja_filter("intersect")
def intersect(lst1, lst2):
    """
    Returns the intersection of two lists.

    .. code-block:: jinja

        {% my_list = [1,2,3,4] -%}
        {{ set my_list | intersect([2, 4, 6]) }}

    will be rendered as:

    .. code-block:: text

        [2, 4]
    """
    if isinstance(lst1, Hashable) and isinstance(lst2, Hashable):
        return set(lst1) & set(lst2)
    return unique([ele for ele in lst1 if ele in lst2])


@jinja_filter("difference")
def difference(lst1, lst2):
    """
    Returns the difference of two lists.

    .. code-block:: jinja

        {% my_list = [1,2,3,4] -%}
        {{ set my_list | difference([2, 4, 6]) }}

    will be rendered as:

    .. code-block:: text

        [1, 3, 6]
    """
    if isinstance(lst1, Hashable) and isinstance(lst2, Hashable):
        return set(lst1) - set(lst2)
    return unique([ele for ele in lst1 if ele not in lst2])


@jinja_filter("symmetric_difference")
def symmetric_difference(lst1, lst2):
    """
    Returns the symmetric difference of two lists.

    .. code-block:: jinja

        {% my_list = [1,2,3,4] -%}
        {{ set my_list | symmetric_difference([2, 4, 6]) }}

    will be rendered as:

    .. code-block:: text

        [1, 3]
    """
    if isinstance(lst1, Hashable) and isinstance(lst2, Hashable):
        return set(lst1) ^ set(lst2)
    return unique(
        [ele for ele in union(lst1, lst2) if ele not in intersect(lst1, lst2)]
    )


@jinja_filter("method_call")
def method_call(obj, f_name, *f_args, **f_kwargs):
    return getattr(obj, f_name, lambda *args, **kwargs: None)(*f_args, **f_kwargs)


try:
    pass_context = jinja2.pass_context
except AttributeError:
    # Old and deprecated method
    pass_context = jinja2.contextfunction


@pass_context
def show_full_context(ctx):
    return salt.utils.data.simple_types_filter(
        {key: value for key, value in ctx.items()}
    )


class SerializerExtension(Extension):
    '''
    Yaml and Json manipulation.

    **Format filters**

    Allows jsonifying or yamlifying any data structure. For example, this dataset:

    .. code-block:: python

        data = {
            'foo': True,
            'bar': 42,
            'baz': [1, 2, 3],
            'qux': 2.0
        }

    .. code-block:: jinja

        yaml = {{ data|yaml }}
        json = {{ data|json }}
        python = {{ data|python }}
        xml  = {{ {'root_node': data}|xml }}

    will be rendered as::

        yaml = {bar: 42, baz: [1, 2, 3], foo: true, qux: 2.0}
        json = {"baz": [1, 2, 3], "foo": true, "bar": 42, "qux": 2.0}
        python = {'bar': 42, 'baz': [1, 2, 3], 'foo': True, 'qux': 2.0}
        xml = """<<?xml version="1.0" ?>
                 <root_node bar="42" foo="True" qux="2.0">
                  <baz>1</baz>
                  <baz>2</baz>
                  <baz>3</baz>
                 </root_node>"""

    The yaml filter takes an optional flow_style parameter to control the
    default-flow-style parameter of the YAML dumper.

    .. code-block:: jinja

        {{ data|yaml(False) }}

    will be rendered as:

    .. code-block:: yaml

        bar: 42
        baz:
          - 1
          - 2
          - 3
        foo: true
        qux: 2.0

    **Load filters**

    Strings and variables can be deserialized with **load_yaml** and
    **load_json** tags and filters. It allows one to manipulate data directly
    in templates, easily:

    .. code-block:: jinja

        {%- set yaml_src = "{foo: it works}"|load_yaml %}
        {%- set json_src = '{"bar": "for real"}'|load_json %}
        Dude, {{ yaml_src.foo }} {{ json_src.bar }}!

    will be rendered as::

        Dude, it works for real!

    **Load tags**

    Salt implements ``load_yaml`` and ``load_json`` tags. They work like
    the `import tag`_, except that the document is also deserialized.

    Syntaxes are ``{% load_yaml as [VARIABLE] %}[YOUR DATA]{% endload %}``
    and ``{% load_json as [VARIABLE] %}[YOUR DATA]{% endload %}``

    For example:

    .. code-block:: jinja

        {% load_yaml as yaml_src %}
            foo: it works
        {% endload %}
        {% load_json as json_src %}
            {
                "bar": "for real"
            }
        {% endload %}
        Dude, {{ yaml_src.foo }} {{ json_src.bar }}!

    will be rendered as::

        Dude, it works for real!

    **Import tags**

    External files can be imported and made available as a Jinja variable.

    .. code-block:: jinja

        {% import_yaml "myfile.yml" as myfile %}
        {% import_json "defaults.json" as defaults %}
        {% import_text "completeworksofshakespeare.txt" as poems %}

    **Catalog**

    ``import_*`` and ``load_*`` tags will automatically expose their
    target variable to import. This feature makes catalog of data to
    handle.

    for example:

    .. code-block:: jinja

        # doc1.sls
        {% load_yaml as var1 %}
            foo: it works
        {% endload %}
        {% load_yaml as var2 %}
            bar: for real
        {% endload %}

    .. code-block:: jinja

        # doc2.sls
        {% from "doc1.sls" import var1, var2 as local2 %}
        {{ var1.foo }} {{ local2.bar }}

    ** Escape Filters **

    .. versionadded:: 2017.7.0

    Allows escaping of strings so they can be interpreted literally by another
    function.

    For example:

    .. code-block:: jinja

        regex_escape = {{ 'https://example.com?foo=bar%20baz' | regex_escape }}

    will be rendered as::

        regex_escape = https\\:\\/\\/example\\.com\\?foo\\=bar\\%20baz

    ** Set Theory Filters **

    .. versionadded:: 2017.7.0

    Performs set math using Jinja filters.

    For example:

    .. code-block:: jinja

        unique = {{ ['foo', 'foo', 'bar'] | unique }}

    will be rendered as::

        unique = ['foo', 'bar']

    ** Salt State Parameter Format Filters **

    .. versionadded:: 3005

    Renders a formatted multi-line YAML string from a Python dictionary. Each
    key/value pair in the dictionary will be added as a single-key dictionary
    to a list that will then be sent to the YAML formatter.

    For example:

    .. code-block:: jinja

        {% set thing_params = {
            "name": "thing",
            "changes": True,
            "warnings": "OMG! Stuff is happening!"
           }
        %}

        thing:
          test.configurable_test_state:
            {{ thing_params | dict_to_sls_yaml_params | indent }}

    will be rendered as::

    .. code-block:: yaml

        thing:
          test.configurable_test_state:
            - name: thing
            - changes: true
            - warnings: OMG! Stuff is happening!

    .. _`import tag`: https://jinja.palletsprojects.com/en/2.11.x/templates/#import
    '''

    tags = {
        "load_yaml",
        "load_json",
        "import_yaml",
        "import_json",
        "load_text",
        "import_text",
        "profile",
    }

    def __init__(self, environment):
        super().__init__(environment)
        self.environment.filters.update(
            {
                "yaml": self.format_yaml,
                "json": self.format_json,
                "xml": self.format_xml,
                "python": self.format_python,
                "load_yaml": self.load_yaml,
                "load_json": self.load_json,
                "load_text": self.load_text,
                "dict_to_sls_yaml_params": self.dict_to_sls_yaml_params,
                "combinations": itertools.combinations,
                "combinations_with_replacement": itertools.combinations_with_replacement,
                "compress": itertools.compress,
                "permutations": itertools.permutations,
                "product": itertools.product,
                "zip": zip,
                "zip_longest": itertools.zip_longest,
            }
        )

        if self.environment.finalize is None:
            self.environment.finalize = self.finalizer
        else:
            finalizer = self.environment.finalize

            @wraps(finalizer)
            def wrapper(self, data):
                return finalizer(self.finalizer(data))

            self.environment.finalize = wrapper

    def finalizer(self, data):
        """
        Ensure that printed mappings are YAML friendly.
        """

        def explore(data):
            if isinstance(data, (dict, OrderedDict)):
                return PrintableDict(
                    [(key, explore(value)) for key, value in data.items()]
                )
            elif isinstance(data, (list, tuple, set)):
                return data.__class__([explore(value) for value in data])
            return data

        return explore(data)

    def format_json(self, value, sort_keys=True, indent=None):
        json_txt = salt.utils.json.dumps(
            value, sort_keys=sort_keys, indent=indent
        ).strip()
        try:
            return Markup(json_txt)
        except UnicodeDecodeError:
            return Markup(salt.utils.stringutils.to_unicode(json_txt))

    def format_yaml(self, value, flow_style=True):
        yaml_txt = salt.utils.yaml.safe_dump(
            value, default_flow_style=flow_style
        ).strip()
        if yaml_txt.endswith("\n..."):
            yaml_txt = yaml_txt[: len(yaml_txt) - 4]
        try:
            return Markup(yaml_txt)
        except UnicodeDecodeError:
            return Markup(salt.utils.stringutils.to_unicode(yaml_txt))

    def format_xml(self, value):
        """Render a formatted multi-line XML string from a complex Python
        data structure. Supports tag attributes and nested dicts/lists.

        :param value: Complex data structure representing XML contents
        :returns: Formatted XML string rendered with newlines and indentation
        :rtype: str
        """

        def normalize_iter(value):
            if isinstance(value, (list, tuple)):
                if isinstance(value[0], str):
                    xmlval = value
                else:
                    xmlval = []
            elif isinstance(value, dict):
                xmlval = list(value.items())
            else:
                raise TemplateRuntimeError(
                    "Value is not a dict or list. Cannot render as XML"
                )
            return xmlval

        def recurse_tree(xmliter, element=None):
            sub = None
            for tag, attrs in xmliter:
                if isinstance(attrs, list):
                    for attr in attrs:
                        recurse_tree(((tag, attr),), element)
                elif element is not None:
                    sub = SubElement(element, tag)
                else:
                    sub = Element(tag)
                if isinstance(attrs, (str, int, bool, float)):
                    sub.text = str(attrs)
                    continue
                if isinstance(attrs, dict):
                    sub.attrib = {
                        attr: str(val)
                        for attr, val in attrs.items()
                        if not isinstance(val, (dict, list))
                    }
                for tag, val in [
                    item
                    for item in normalize_iter(attrs)
                    if isinstance(item[1], (dict, list))
                ]:
                    recurse_tree(((tag, val),), sub)
            return sub

        return Markup(
            minidom.parseString(
                tostring(recurse_tree(normalize_iter(value)))
            ).toprettyxml(indent=" ")
        )

    def format_python(self, value):
        return Markup(pprint.pformat(value).strip())

    def load_yaml(self, value):
        if isinstance(value, TemplateModule):
            value = str(value)
        try:
            return salt.utils.data.decode(salt.utils.yaml.safe_load(value))
        except salt.utils.yaml.YAMLError as exc:
            msg = "Encountered error loading yaml: "
            try:
                # Reported line is off by one, add 1 to correct it
                line = exc.problem_mark.line + 1
                buf = exc.problem_mark.buffer
                problem = exc.problem
            except AttributeError:
                # No context information available in the exception, fall back
                # to the stringified version of the exception.
                msg += str(exc)
            else:
                msg += f"{problem}\n"
                msg += salt.utils.stringutils.get_context(
                    buf, line, marker="    <======================"
                )
            raise TemplateRuntimeError(msg)
        except AttributeError:
            raise TemplateRuntimeError(f"Unable to load yaml from {value}")

    def load_json(self, value):
        if isinstance(value, TemplateModule):
            value = str(value)
        try:
            return salt.utils.json.loads(value)
        except (ValueError, TypeError, AttributeError):
            raise TemplateRuntimeError(f"Unable to load json from {value}")

    def load_text(self, value):
        if isinstance(value, TemplateModule):
            value = str(value)

        return value

    _load_parsers = {"load_yaml", "load_json", "load_text"}
    _import_parsers = {"import_yaml", "import_json", "import_text"}

    def parse(self, parser):
        if parser.stream.current.value in self._load_parsers:
            return self.parse_load(parser)
        elif parser.stream.current.value in self._import_parsers:
            return self.parse_import(
                parser, parser.stream.current.value.split("_", 1)[1]
            )
        elif parser.stream.current.value == "profile":
            return self.parse_profile(parser)

        parser.fail(
            "Unknown format " + parser.stream.current.value,
            parser.stream.current.lineno,
        )

    # pylint: disable=E1120,E1121
    def parse_profile(self, parser):
        lineno = next(parser.stream).lineno
        parser.stream.expect("name:as")
        label = parser.parse_expression()
        body = parser.parse_statements(["name:endprofile"], drop_needle=True)
        return self._parse_profile_block(parser, label, "profile block", body, lineno)

    def _create_profile_id(self, parser):
        return f"_salt_profile_{parser.free_identifier().name}"

    def _profile_start(self, label, source):
        return (label, source, time.time())

    def _profile_end(self, label, source, previous_time):
        log.profile(
            "Time (in seconds) to render %s '%s': %s",
            source,
            label,
            time.time() - previous_time,
        )

    def _parse_profile_block(self, parser, label, source, body, lineno):
        profile_id = self._create_profile_id(parser)
        ret = (
            [
                nodes.Assign(
                    nodes.Name(profile_id, "store").set_lineno(lineno),
                    self.call_method(
                        "_profile_start",
                        dyn_args=nodes.List([label, nodes.Const(source)]).set_lineno(
                            lineno
                        ),
                    ).set_lineno(lineno),
                ).set_lineno(lineno),
            ]
            + body
            + [
                nodes.ExprStmt(
                    self.call_method(
                        "_profile_end", dyn_args=nodes.Name(profile_id, "load")
                    ),
                ).set_lineno(lineno),
            ]
        )
        return ret

    def parse_load(self, parser):
        filter_name = parser.stream.current.value
        lineno = next(parser.stream).lineno
        if filter_name not in self.environment.filters:
            parser.fail(f"Unable to parse {filter_name}", lineno)

        parser.stream.expect("name:as")
        target = parser.parse_assign_target()
        macro_name = "_" + parser.free_identifier().name
        macro_body = parser.parse_statements(("name:endload",), drop_needle=True)

        return [
            nodes.Macro(macro_name, [], [], macro_body).set_lineno(lineno),
            nodes.Assign(
                target,
                nodes.Filter(
                    nodes.Call(
                        nodes.Name(macro_name, "load").set_lineno(lineno),
                        [],
                        [],
                        None,
                        None,
                    ).set_lineno(lineno),
                    filter_name,
                    [],
                    [],
                    None,
                    None,
                ).set_lineno(lineno),
            ).set_lineno(lineno),
        ]

    def parse_import(self, parser, converter):
        import_node = parser.parse_import()
        target = import_node.target
        lineno = import_node.lineno

        body = [
            import_node,
            nodes.Assign(
                nodes.Name(target, "store").set_lineno(lineno),
                nodes.Filter(
                    nodes.Name(target, "load").set_lineno(lineno),
                    f"load_{converter}",
                    [],
                    [],
                    None,
                    None,
                ).set_lineno(lineno),
            ).set_lineno(lineno),
        ]
        return self._parse_profile_block(
            parser, import_node.template, f"import_{converter}", body, lineno
        )

    def dict_to_sls_yaml_params(self, value, flow_style=False):
        """
        .. versionadded:: 3005

        Render a formatted multi-line YAML string from a Python dictionary. Each
        key/value pair in the dictionary will be added as a single-key dictionary
        to a list that will then be sent to the YAML formatter.

        :param value: Python dictionary representing Salt state parameters

        :param flow_style: Setting flow_style to False will enforce indentation
                           mode

        :returns: Formatted SLS YAML string rendered with newlines and
                  indentation
        """
        return self.format_yaml(
            [{key: val} for key, val in value.items()], flow_style=flow_style
        )
