"""
Render the pillar data
"""

import collections
import copy
import datetime
import fnmatch
import logging
import sys
import time
import traceback
from collections import OrderedDict

import salt.cache
import salt.channel.client
import salt.fileclient
import salt.loader
import salt.minion
import salt.utils.args
import salt.utils.crypt
import salt.utils.data
import salt.utils.dictupdate
import salt.utils.master
import salt.utils.secret
import salt.utils.url
from salt.exceptions import SaltClientError
from salt.template import compile_template
from salt.version import __version__

log = logging.getLogger(__name__)


def get_pillar(
    opts,
    grains,
    minion_id,
    saltenv=None,
    ext=None,
    funcs=None,
    pillar_override=None,
    pillarenv=None,
    extra_minion_data=None,
    clean_cache=False,
):
    """
    Return the correct pillar driver based on the file_client option
    """
    # When file_client is 'local' this makes the minion masterless
    # but sometimes we want the minion to read its files from the local
    # filesystem instead of asking for them from the master, but still
    # get commands from the master.
    # To enable this functionality set file_client=local and
    # use_master_when_local=True in the minion config.  Then here we override
    # the file client to be 'remote' for getting pillar.  If we don't do this
    # then the minion never sends the event that the master uses to update
    # its minion_data_cache.  If the master doesn't update the minion_data_cache
    # then the SSE salt-master plugin won't see any grains for those minions.
    file_client = opts["file_client"]
    if opts.get("master_type") == "disable" and file_client == "remote":
        file_client = "local"
    elif file_client == "local" and opts.get("use_master_when_local"):
        file_client = "remote"

    ptype = {"remote": RemotePillar, "local": Pillar}.get(file_client, Pillar)
    # If local pillar and we're caching, run through the cache system first
    if opts.get("pillar_cache") or opts.get("minion_data_cache"):
        log.debug("get_pillar using pillar cache with ext: %s", ext)
        return PillarCache(
            opts,
            grains,
            minion_id,
            saltenv,
            ext=ext,
            functions=funcs,
            pillar_override=pillar_override,
            pillarenv=pillarenv,
            clean_cache=clean_cache,
            extra_minion_data=extra_minion_data,
        )
    return ptype(
        opts,
        grains,
        minion_id,
        saltenv,
        ext,
        functions=funcs,
        pillar_override=pillar_override,
        pillarenv=pillarenv,
        extra_minion_data=extra_minion_data,
    )


# TODO: migrate everyone to this one!
def get_async_pillar(
    opts,
    grains,
    minion_id,
    saltenv=None,
    ext=None,
    funcs=None,
    pillar_override=None,
    pillarenv=None,
    extra_minion_data=None,
    clean_cache=False,
):
    """
    Return the correct pillar driver based on the file_client option
    """
    file_client = opts["file_client"]
    if opts.get("master_type") == "disable" and file_client == "remote":
        file_client = "local"
    elif file_client == "local" and opts.get("use_master_when_local"):
        file_client = "remote"
    ptype = {"remote": AsyncRemotePillar, "local": AsyncPillar}.get(
        file_client, AsyncPillar
    )
    if file_client == "remote":
        # AsyncPillar does not currently support calls to PillarCache
        # clean_cache is a kwarg for PillarCache
        return ptype(
            opts,
            grains,
            minion_id,
            saltenv,
            ext,
            functions=funcs,
            pillar_override=pillar_override,
            pillarenv=pillarenv,
            extra_minion_data=extra_minion_data,
            clean_cache=clean_cache,
        )
    return ptype(
        opts,
        grains,
        minion_id,
        saltenv,
        ext,
        functions=funcs,
        pillar_override=pillar_override,
        pillarenv=pillarenv,
        extra_minion_data=extra_minion_data,
    )


class RemotePillarMixin:
    """
    Common remote pillar functionality
    """

    def get_ext_pillar_extra_minion_data(self, opts):
        """
        Returns the extra data from the minion's opts dict (the config file).

        This data will be passed to external pillar functions.
        """

        def get_subconfig(opts_key):
            """
            Returns a dict containing the opts key subtree, while maintaining
            the opts structure
            """
            ret_dict = aux_dict = {}
            config_val = opts
            subkeys = opts_key.split(":")
            # Build an empty dict with the opts path
            for subkey in subkeys[:-1]:
                aux_dict[subkey] = {}
                aux_dict = aux_dict[subkey]
                if not config_val.get(subkey):
                    # The subkey is not in the config
                    return {}
                config_val = config_val[subkey]
            if subkeys[-1] not in config_val:
                return {}
            aux_dict[subkeys[-1]] = config_val[subkeys[-1]]
            return ret_dict

        extra_data = {}
        if "pass_to_ext_pillars" in opts:
            if not isinstance(opts["pass_to_ext_pillars"], list):
                log.exception("'pass_to_ext_pillars' config is malformed.")
                raise SaltClientError("'pass_to_ext_pillars' config is malformed.")
            for key in opts["pass_to_ext_pillars"]:
                salt.utils.dictupdate.update(
                    extra_data,
                    get_subconfig(key),
                    recursive_update=True,
                    merge_lists=True,
                )
        log.trace("ext_pillar_extra_data = %s", extra_data)
        return extra_data

    def validate_return(self, data):
        if not isinstance(data, dict):
            msg = "Got a bad pillar from master, type {}, expecting dict: {}".format(
                type(data).__name__, data
            )
            log.error(msg)
            # raise an exception! Pillar isn't empty, we can't sync it!
            raise SaltClientError(msg)


class AsyncRemotePillar(RemotePillarMixin):
    """
    Get the pillar from the master
    """

    def __init__(
        self,
        opts,
        grains,
        minion_id,
        saltenv,
        ext=None,
        functions=None,
        pillar_override=None,
        pillarenv=None,
        extra_minion_data=None,
        clean_cache=False,
    ):
        self.opts = opts
        self.opts["saltenv"] = saltenv
        self.ext = ext
        self.grains = grains
        self.minion_id = minion_id
        self.channel = salt.channel.client.AsyncReqChannel.factory(opts)
        if pillarenv is not None:
            self.opts["pillarenv"] = pillarenv
        self.pillar_override = pillar_override or {}
        if not isinstance(self.pillar_override, dict):
            self.pillar_override = {}
            log.error("Pillar data must be a dictionary")
        self.extra_minion_data = extra_minion_data or {}
        if not isinstance(self.extra_minion_data, dict):
            self.extra_minion_data = {}
            log.error("Extra minion data must be a dictionary")
        salt.utils.dictupdate.update(
            self.extra_minion_data,
            self.get_ext_pillar_extra_minion_data(opts),
            recursive_update=True,
            merge_lists=True,
        )
        self._closing = False
        self.clean_cache = clean_cache

    async def compile_pillar(self):
        """
        Return a future which will contain the pillar data from the master
        """
        load = {
            "id": self.minion_id,
            "grains": self.grains,
            "saltenv": self.opts["saltenv"],
            "pillarenv": self.opts["pillarenv"],
            "pillar_override": self.pillar_override,
            "extra_minion_data": self.extra_minion_data,
            "ver": "2",
            "cmd": "_pillar",
        }
        if self.clean_cache:
            load["clean_cache"] = self.clean_cache
        if self.ext:
            load["ext"] = self.ext
        start = time.monotonic()
        try:
            ret_pillar = await self.channel.crypted_transfer_decode_dictentry(
                load,
                dictkey="pillar",
            )
        except salt.crypt.AuthenticationError as exc:
            log.error(exc.message)
            raise SaltClientError("Exception getting pillar.")
        except salt.exceptions.SaltReqTimeoutError:
            raise SaltClientError(
                f"Pillar timed out after {int(time.monotonic() - start)} seconds"
            )
        except Exception:  # pylint: disable=broad-except
            log.exception("Exception getting pillar:")
            raise SaltClientError("Exception getting pillar.")
        self.validate_return(ret_pillar)
        ret_pillar = salt.utils.secret.hide(ret_pillar)
        return ret_pillar

    def destroy(self):
        if self._closing:
            return

        self._closing = True
        self.channel.close()

    # pylint: disable=W1701
    def __del__(self):
        self.destroy()

    # pylint: enable=W1701


class RemotePillar(RemotePillarMixin):
    """
    Get the pillar from the master
    """

    def __init__(
        self,
        opts,
        grains,
        minion_id,
        saltenv,
        ext=None,
        functions=None,
        pillar_override=None,
        pillarenv=None,
        extra_minion_data=None,
    ):
        self.opts = opts
        self.opts["saltenv"] = saltenv
        self.ext = ext
        self.grains = grains
        self.minion_id = minion_id
        self.channel = salt.channel.client.ReqChannel.factory(opts)
        if pillarenv is not None:
            self.opts["pillarenv"] = pillarenv
        self.pillar_override = pillar_override or {}
        if not isinstance(self.pillar_override, dict):
            self.pillar_override = {}
            log.error("Pillar data must be a dictionary")
        self.extra_minion_data = extra_minion_data or {}
        if not isinstance(self.extra_minion_data, dict):
            self.extra_minion_data = {}
            log.error("Extra minion data must be a dictionary")
        salt.utils.dictupdate.update(
            self.extra_minion_data,
            self.get_ext_pillar_extra_minion_data(opts),
            recursive_update=True,
            merge_lists=True,
        )
        self._closing = False

    def compile_pillar(self):
        """
        Return the pillar data from the master
        """
        load = {
            "id": self.minion_id,
            "grains": self.grains,
            "saltenv": self.opts["saltenv"],
            "pillarenv": self.opts["pillarenv"],
            "pillar_override": self.pillar_override,
            "extra_minion_data": self.extra_minion_data,
            "ver": "2",
            "cmd": "_pillar",
        }
        if self.ext:
            load["ext"] = self.ext

        start = time.monotonic()
        try:
            ret_pillar = self.channel.crypted_transfer_decode_dictentry(
                load,
                dictkey="pillar",
            )
        except salt.crypt.AuthenticationError as exc:
            log.error(exc.message)
            raise SaltClientError("Exception getting pillar.")
        except salt.exceptions.SaltReqTimeoutError:
            raise SaltClientError(
                f"Pillar timed out after {int(time.monotonic() - start)} seconds"
            )
        except Exception:  # pylint: disable=broad-except
            log.exception("Exception getting pillar:")
            raise SaltClientError("Exception getting pillar.")
        self.validate_return(ret_pillar)
        return salt.utils.secret.hide(ret_pillar)

    def destroy(self):
        if hasattr(self, "_closing") and self._closing:
            return

        self._closing = True
        self.channel.close()

    # pylint: disable=W1701
    def __del__(self):
        self.destroy()

    # pylint: enable=W1701


class Pillar:
    """
    Read over the pillar top files and render the pillar data
    """

    def __init__(
        self,
        opts,
        grains,
        minion_id,
        saltenv,
        ext=None,
        functions=None,
        pillar_override=None,
        pillarenv=None,
        extra_minion_data=None,
    ):
        self.minion_id = minion_id
        self.ext = ext
        if pillarenv is None:
            if opts.get("pillarenv_from_saltenv", False):
                opts["pillarenv"] = saltenv
        # use the local file client
        self.opts = self.__gen_opts(opts, grains, saltenv=saltenv, pillarenv=pillarenv)
        self.saltenv = saltenv
        self.client = salt.fileclient.get_file_client(self.opts, True)
        self.fileclient = salt.fileclient.get_file_client(self.opts, False)
        self.avail = self.__gather_avail()
        self.pillar_data = self.opts.get("pillar", {})
        if not isinstance(self.pillar_data, dict):
            self.pillar_data = {}
        else:
            # Ensure we have a plain dict and not a proxy into opts
            self.pillar_data = dict(self.pillar_data)

        if opts.get("file_client", "") == "local" and not opts.get(
            "use_master_when_local", False
        ):
            opts["grains"] = grains

        # if we didn't pass in functions, lets load them
        if functions is None:
            utils = salt.loader.utils(opts, file_client=self.client)
            if opts.get("file_client", "") == "local":
                self.functions = salt.loader.minion_mods(
                    opts,
                    utils=utils,
                    file_client=salt.fileclient.ContextlessFileClient(self.fileclient),
                    pillar=self.pillar_data,
                )
            else:
                self.functions = salt.loader.minion_mods(
                    self.opts,
                    utils=utils,
                    file_client=salt.fileclient.ContextlessFileClient(self.fileclient),
                    pillar=self.pillar_data,
                )
        else:
            self.functions = functions
            if hasattr(self.functions, "pack"):
                self.functions.pack["__pillar__"] = self.pillar_data

        self.opts["minion_id"] = minion_id
        self.matchers = salt.loader.matchers(self.opts, pillar=self.pillar_data)
        if hasattr(self.matchers, "pack"):
            self.matchers.pack["__pillar__"] = self.pillar_data
        self.rend = salt.loader.render(
            self.opts,
            self.functions,
            self.client,
            file_client=self.client,
            pillar=self.pillar_data,
        )
        ext_pillar_opts = copy.deepcopy(self.opts)
        # Keep the incoming opts ID intact, ie, the master id
        if "id" in opts:
            ext_pillar_opts["id"] = opts["id"]
        self.merge_strategy = "smart"
        if opts.get("pillar_source_merging_strategy"):
            self.merge_strategy = opts["pillar_source_merging_strategy"]

        self.ext_pillars = salt.loader.pillars(
            ext_pillar_opts, self.functions, pillar=self.pillar_data
        )
        if opts.get("extension_modules"):
            for loader in (self.ext_pillars, self.matchers):
                if hasattr(loader, "_refresh_file_mapping"):
                    loader._refresh_file_mapping()
                elif hasattr(loader, "_dict") and hasattr(
                    loader._dict, "_refresh_file_mapping"
                ):
                    loader._dict._refresh_file_mapping()
        self.ignored_pillars = {}
        self.pillar_override = pillar_override or {}
        if not isinstance(self.pillar_override, dict):
            self.pillar_override = {}
            log.error("Pillar data must be a dictionary")
        self.extra_minion_data = extra_minion_data or {}
        if not isinstance(self.extra_minion_data, dict):
            self.extra_minion_data = {}
            log.error("Extra minion data must be a dictionary")
        self._closing = False

    def __valid_on_demand_ext_pillar(self, opts):
        """
        Check to see if the on demand external pillar is allowed.

        If this check fails self.ext is set to None, this is important to
        prevent an on-demand pillare from being rendered when it should not be
        allowed.
        """
        if not isinstance(self.ext, dict):
            log.error("On-demand pillar %s is not formatted as a dictionary", self.ext)
            self.ext = None
            return False

        on_demand = opts.get("on_demand_ext_pillar", [])
        try:
            invalid_on_demand = {x for x in self.ext if x not in on_demand}
        except TypeError:
            # Prevent traceback when on_demand_ext_pillar option is malformed
            log.error(
                "The 'on_demand_ext_pillar' configuration option is "
                "malformed, it should be a list of ext_pillar module names"
            )
            self.ext = None
            return False

        if invalid_on_demand:
            log.error(
                "The following ext_pillar modules are not allowed for "
                "on-demand pillar data: %s. Valid on-demand ext_pillar "
                "modules are: %s. The valid modules can be adjusted by "
                "setting the 'on_demand_ext_pillar' config option.",
                ", ".join(sorted(invalid_on_demand)),
                ", ".join(on_demand),
            )
            self.ext = None
            return False
        return True

    def __gather_avail(self):
        """
        Gather the lists of available sls data from the master
        """
        avail = {}
        for saltenv in self._get_envs():
            avail[saltenv] = self.client.list_states(saltenv)
        return avail

    def __gen_opts(self, opts_in, grains, saltenv=None, ext=None, pillarenv=None):
        """
        The options need to be altered to conform to the file client
        """
        opts = copy.deepcopy(opts_in)
        opts["file_client"] = "local"
        if not grains:
            opts["grains"] = {}
        else:
            opts["grains"] = grains
        # Allow minion/CLI saltenv/pillarenv to take precedence over master
        opts["saltenv"] = saltenv if saltenv is not None else opts.get("saltenv")
        opts["pillarenv"] = (
            pillarenv if pillarenv is not None else opts.get("pillarenv")
        )
        opts["id"] = self.minion_id
        if opts["state_top"].startswith("salt://"):
            opts["state_top"] = opts["state_top"]
        elif opts["state_top"].startswith("/"):
            opts["state_top"] = salt.utils.url.create(opts["state_top"][1:])
        else:
            opts["state_top"] = salt.utils.url.create(opts["state_top"])
        if self.ext and self.__valid_on_demand_ext_pillar(opts):
            if "ext_pillar" in opts:
                opts["ext_pillar"].append(self.ext)
            else:
                opts["ext_pillar"] = [self.ext]
        if "__env__" in opts["pillar_roots"]:
            env = opts.get("pillarenv") or opts.get("saltenv") or "base"
            if env not in opts["pillar_roots"]:
                log.debug(
                    "pillar environment '%s' maps to __env__ pillar_roots directory",
                    env,
                )
                opts["pillar_roots"][env] = opts["pillar_roots"].pop("__env__")
                for idx, root in enumerate(opts["pillar_roots"][env]):
                    opts["pillar_roots"][env][idx] = opts["pillar_roots"][env][
                        idx
                    ].replace("__env__", env)
            else:
                log.debug(
                    "pillar_roots __env__ ignored (environment '%s' found in pillar_roots)",
                    env,
                )
                opts["pillar_roots"].pop("__env__")
        return opts

    def _get_envs(self):
        """
        Pull the file server environments out of the master options
        """
        envs = ["base"]
        if "pillar_roots" in self.opts:
            envs.extend([x for x in list(self.opts["pillar_roots"]) if x not in envs])
        return envs

    def get_tops(self):
        """
        Gather the top files
        """
        tops = collections.defaultdict(list)
        include = collections.defaultdict(list)
        done = collections.defaultdict(list)
        errors = []
        # Gather initial top files
        try:
            saltenvs = set()
            if self.opts["pillarenv"]:
                # If the specified pillarenv is not present in the available
                # pillar environments, do not cache the pillar top file.
                if self.opts["pillarenv"] not in self.opts["pillar_roots"]:
                    log.debug(
                        "pillarenv '%s' not found in the configured pillar "
                        "environments (%s)",
                        self.opts["pillarenv"],
                        ", ".join(self.opts["pillar_roots"]),
                    )
                else:
                    saltenvs.add(self.opts["pillarenv"])
            else:
                saltenvs.update(self._get_envs())
                if self.opts.get("pillar_source_merging_strategy", None) == "none":
                    saltenvs &= {self.saltenv or "base"}

            for saltenv in saltenvs:
                top = self.client.cache_file(self.opts["state_top"], saltenv)
                if top:
                    tops[saltenv].append(
                        compile_template(
                            top,
                            self.rend,
                            self.opts["renderer"],
                            self.opts["renderer_blacklist"],
                            self.opts["renderer_whitelist"],
                            saltenv=saltenv,
                            _pillar_rend=True,
                        )
                    )
        except Exception as exc:  # pylint: disable=broad-except
            errors.append(f"Rendering Primary Top file failed, render error:\n{exc}")
            log.exception("Pillar rendering failed for minion %s", self.minion_id)

        # Search initial top files for includes
        for saltenv, ctops in tops.items():
            for ctop in ctops:
                if "include" not in ctop:
                    continue
                for sls in ctop["include"]:
                    include[saltenv].append(sls)
                ctop.pop("include")
        # Go through the includes and pull out the extra tops and add them
        while include:
            pops = []
            for saltenv, states in include.items():
                pops.append(saltenv)
                if not states:
                    continue
                for sls in states:
                    if sls in done[saltenv]:
                        continue
                    try:
                        tops[saltenv].append(
                            compile_template(
                                self.client.get_state(sls, saltenv).get("dest", False),
                                self.rend,
                                self.opts["renderer"],
                                self.opts["renderer_blacklist"],
                                self.opts["renderer_whitelist"],
                                saltenv=saltenv,
                                _pillar_rend=True,
                            )
                        )
                    except Exception as exc:  # pylint: disable=broad-except
                        errors.append(
                            "Rendering Top file {} failed, render error:\n{}".format(
                                sls, exc
                            )
                        )
                    done[saltenv].append(sls)
            for saltenv in pops:
                if saltenv in include:
                    include.pop(saltenv)

        return tops, errors

    def merge_tops(self, tops):
        """
        Cleanly merge the top files
        """
        top = collections.defaultdict(OrderedDict)
        orders = collections.defaultdict(OrderedDict)
        for ctops in tops.values():
            for ctop in ctops:
                for saltenv, targets in ctop.items():
                    if saltenv == "include":
                        continue
                    for tgt in targets:
                        matches = []
                        states = OrderedDict()
                        orders[saltenv][tgt] = 0
                        ignore_missing = False
                        for comp in ctop[saltenv][tgt]:
                            if isinstance(comp, dict):
                                if "match" in comp:
                                    matches.append(comp)
                                if "order" in comp:
                                    order = comp["order"]
                                    if not isinstance(order, int):
                                        try:
                                            order = int(order)
                                        except ValueError:
                                            order = 0
                                    orders[saltenv][tgt] = order
                                if comp.get("ignore_missing", False):
                                    ignore_missing = True
                            if isinstance(comp, str):
                                states[comp] = True
                        if ignore_missing:
                            if saltenv not in self.ignored_pillars:
                                self.ignored_pillars[saltenv] = []
                            self.ignored_pillars[saltenv].extend(states.keys())
                        top[saltenv][tgt] = matches
                        top[saltenv][tgt].extend(states)
        return self.sort_top_targets(top, orders)

    def sort_top_targets(self, top, orders):
        """
        Returns the sorted high data from the merged top files
        """
        sorted_top = collections.defaultdict(OrderedDict)
        # pylint: disable=cell-var-from-loop
        for saltenv, targets in top.items():
            sorted_targets = sorted(targets, key=lambda target: orders[saltenv][target])
            for target in sorted_targets:
                sorted_top[saltenv][target] = targets[target]
        # pylint: enable=cell-var-from-loop
        return sorted_top

    def get_top(self):
        """
        Returns the high data derived from the top file
        """
        tops, errors = self.get_tops()
        try:
            merged_tops = self.merge_tops(tops)
        except TypeError as err:
            merged_tops = OrderedDict()
            errors.append("Error encountered while rendering pillar top file.")
        return merged_tops, errors

    def top_matches(self, top, reload=False):
        """
        Search through the top high data for matches and return the states
        that this minion needs to execute.

        Returns:
        {'saltenv': ['state1', 'state2', ...]}

        reload
            Reload the matcher loader
        """
        matches = {}
        if reload:
            self.matchers = salt.loader.matchers(self.opts, pillar=self.pillar_data)
            self._update_loader_packs()
        for saltenv, body in top.items():
            if self.opts["pillarenv"]:
                if saltenv != self.opts["pillarenv"]:
                    continue
            for match, data in body.items():
                if self.matchers["confirm_top.confirm_top"](
                    match,
                    data,
                    self.opts.get("nodegroups", {}),
                ):
                    if saltenv not in matches:
                        matches[saltenv] = env_matches = []
                    else:
                        env_matches = matches[saltenv]
                    for item in data:
                        if isinstance(item, str) and item not in env_matches:
                            env_matches.append(item)
        return matches

    def render_pstate(self, sls, saltenv, mods, defaults=None):
        """
        Collect a single pillar sls file and render it
        """
        if defaults is None:
            defaults = {}
        err = ""
        errors = []
        state_data = self.client.get_state(sls, saltenv)
        fn_ = state_data.get("dest", False)
        if not fn_:
            if sls in self.ignored_pillars.get(saltenv, []):
                log.debug(
                    "Skipping ignored and missing SLS '%s' in environment '%s'",
                    sls,
                    saltenv,
                )
                return None, mods, errors
            elif self.opts["pillar_roots"].get(saltenv):
                msg = (
                    "Specified SLS '{}' in environment '{}' is not"
                    " available on the salt master".format(sls, saltenv)
                )
                log.error(msg)
                errors.append(msg)
            else:
                msg = "Specified SLS '{}' in environment '{}' was not found. ".format(
                    sls, saltenv
                )
                if self.opts.get("__git_pillar", False) is True:
                    msg += (
                        "This is likely caused by a git_pillar top file "
                        "containing an environment other than the one for the "
                        "branch in which it resides. Each git_pillar "
                        "branch/tag must have its own top file."
                    )
                else:
                    msg += (
                        "This could be because SLS '{0}' is in an "
                        "environment other than '{1}', but '{1}' is "
                        "included in that environment's Pillar top file. It "
                        "could also be due to environment '{1}' not being "
                        "defined in 'pillar_roots'.".format(sls, saltenv)
                    )
                log.debug(msg)
                # return state, mods, errors
                return None, mods, errors
        state = None
        try:
            state = compile_template(
                fn_,
                self.rend,
                self.opts["renderer"],
                self.opts["renderer_blacklist"],
                self.opts["renderer_whitelist"],
                saltenv,
                sls,
                _pillar_rend=True,
                **defaults,
            )
        except Exception as exc:  # pylint: disable=broad-except
            msg = f"Rendering SLS '{sls}' failed, render error:\n{exc}"
            log.critical(msg, exc_info=True)
            if self.opts.get("pillar_safe_render_error", True):
                errors.append(
                    "Rendering SLS '{}' failed. Please see master log for "
                    "details.".format(sls)
                )
            else:
                errors.append(msg)
        mods[sls] = state
        nstate = None
        if state:
            if not isinstance(state, dict):
                msg = f"SLS '{sls}' does not render to a dictionary"
                log.error(msg)
                errors.append(msg)
            else:
                if "include" in state:
                    if not isinstance(state["include"], list):
                        msg = (
                            "Include Declaration in SLS '{}' is not "
                            "formed as a list".format(sls)
                        )
                        log.error(msg)
                        errors.append(msg)
                    else:
                        # render included state(s)
                        include_states = []
                        for sub_sls in state.pop("include"):
                            if isinstance(sub_sls, dict):
                                sub_sls, v = next(iter(sub_sls.items()))
                                defaults = v.get("defaults", {})
                                key = v.get("key", None)
                            else:
                                key = None
                            try:
                                matched_pstates = fnmatch.filter(
                                    self.avail[saltenv],
                                    sub_sls.lstrip(".").replace("/", "."),
                                )
                                if sub_sls.startswith("."):
                                    if state_data.get("source", "").endswith(
                                        "/init.sls"
                                    ):
                                        include_parts = sls.split(".")
                                    else:
                                        include_parts = sls.split(".")[:-1]
                                    sub_sls = ".".join(include_parts + [sub_sls[1:]])
                                matches = fnmatch.filter(
                                    self.avail[saltenv],
                                    sub_sls,
                                )
                                matched_pstates.extend(matches)
                            except KeyError:
                                errors.extend(
                                    [
                                        "No matching pillar environment for environment"
                                        " '{}' found".format(saltenv)
                                    ]
                                )
                                matched_pstates = [sub_sls]
                            # If matched_pstates is empty, set to sub_sls
                            if len(matched_pstates) < 1:
                                matched_pstates = [sub_sls]
                            for m_sub_sls in matched_pstates:
                                if m_sub_sls not in mods:
                                    nstate, mods, err = self.render_pstate(
                                        m_sub_sls, saltenv, mods, defaults
                                    )
                                else:
                                    nstate = mods[m_sub_sls]
                                if nstate:
                                    if key:
                                        # If key is x:y, convert it to {x: {y: nstate}}
                                        for key_fragment in reversed(key.split(":")):
                                            nstate = {key_fragment: nstate}
                                    if not self.opts.get(
                                        "pillar_includes_override_sls", False
                                    ):
                                        include_states.append(nstate)
                                    else:
                                        state = salt.utils.dictupdate.merge(
                                            state,
                                            nstate,
                                            self.merge_strategy,
                                            self.opts.get("renderer", "yaml"),
                                            self.opts.get("pillar_merge_lists", False),
                                        )
                                if err:
                                    errors += err
                        if not self.opts.get("pillar_includes_override_sls", False):
                            # merge included state(s) with the current state
                            # merged last to ensure that its values are
                            # authoritative.
                            include_states.append(state)
                            state = None
                            for s in include_states:
                                if state is None:
                                    state = s
                                else:
                                    state = salt.utils.dictupdate.merge(
                                        state,
                                        s,
                                        self.merge_strategy,
                                        self.opts.get("renderer", "yaml"),
                                        self.opts.get("pillar_merge_lists", False),
                                    )
        return state, mods, errors

    def render_pillar(self, matches, errors=None):
        """
        Extract the sls pillar files from the matches and render them into the
        pillar
        """
        _token = salt.utils.secret.mask_pillar.set(False)
        try:
            pillar = copy.copy(self.pillar_override)
            if errors is None:
                errors = []
            for saltenv, pstates in matches.items():
                pstatefiles = []
                mods = {}
                for sls_match in pstates:
                    matched_pstates = []
                    try:
                        matched_pstates = fnmatch.filter(self.avail[saltenv], sls_match)
                    except KeyError:
                        errors.extend(
                            [
                                "No matching pillar environment for environment "
                                "'{}' found".format(saltenv)
                            ]
                        )
                    if matched_pstates:
                        pstatefiles.extend(matched_pstates)
                    else:
                        pstatefiles.append(sls_match)

                for sls in pstatefiles:
                    pstate, mods, err = self.render_pstate(sls, saltenv, mods)

                    if err:
                        errors += err

                    if pstate is not None:
                        if not isinstance(pstate, dict):
                            log.error(
                                "The rendered pillar sls file, '%s' state did "
                                "not return the expected data format. This is "
                                "a sign of a malformed pillar sls file. Returned "
                                "errors: %s",
                                sls,
                                ", ".join([f"'{e}'" for e in errors]),
                            )
                            continue
                        pillar = salt.utils.dictupdate.merge(
                            pillar,
                            pstate,
                            self.merge_strategy,
                            self.opts.get("renderer", "yaml"),
                            self.opts.get("pillar_merge_lists", False),
                        )

            return pillar, errors
        finally:
            salt.utils.secret.mask_pillar.reset(_token)

    def _external_pillar_data(self, pillar, val, key):
        """
        Builds actual pillar data structure and updates the ``pillar`` variable
        """
        ext = None
        args = salt.utils.args.get_function_argspec(self.ext_pillars[key]).args

        if isinstance(val, dict):
            if ("extra_minion_data" in args) and self.extra_minion_data:
                ext = self.ext_pillars[key](
                    self.minion_id,
                    pillar,
                    extra_minion_data=self.extra_minion_data,
                    **val,
                )
            else:
                ext = self.ext_pillars[key](self.minion_id, pillar, **val)
        elif isinstance(val, list):
            if ("extra_minion_data" in args) and self.extra_minion_data:
                ext = self.ext_pillars[key](
                    self.minion_id,
                    pillar,
                    *val,
                    extra_minion_data=self.extra_minion_data,
                )
            else:
                ext = self.ext_pillars[key](self.minion_id, pillar, *val)
        else:
            if ("extra_minion_data" in args) and self.extra_minion_data:
                ext = self.ext_pillars[key](
                    self.minion_id,
                    pillar,
                    val,
                    extra_minion_data=self.extra_minion_data,
                )
            else:
                ext = self.ext_pillars[key](self.minion_id, pillar, val)
        return ext

    def ext_pillar(self, pillar, errors=None):
        """
        Render the external pillar data
        """
        if errors is None:
            errors = []
        try:
            # Make sure that on-demand git_pillar is fetched before we try to
            # compile the pillar data. git_pillar will fetch a remote when
            # the git ext_pillar() func is run, but only for masterless.
            if self.ext and "git" in self.ext and self.opts.get("__role") != "minion":
                # Avoid circular import
                import salt.pillar.git_pillar as git_pillar
                import salt.utils.gitfs as gitfs

                git_pillar = gitfs.GitPillar(
                    self.opts,
                    self.ext["git"],
                    per_remote_overrides=git_pillar.PER_REMOTE_OVERRIDES,
                    per_remote_only=git_pillar.PER_REMOTE_ONLY,
                    global_only=git_pillar.GLOBAL_ONLY,
                )
                git_pillar.fetch_remotes()
        except TypeError:
            # Handle malformed ext_pillar
            pass
        if "ext_pillar" not in self.opts:
            return pillar, errors
        if not isinstance(self.opts["ext_pillar"], list):
            errors.append('The "ext_pillar" option is malformed')
            log.critical(errors[-1])
            return pillar, errors
        ext = None
        # Bring in CLI pillar data
        if self.pillar_override:
            pillar = salt.utils.dictupdate.merge(
                pillar,
                self.pillar_override,
                self.merge_strategy,
                self.opts.get("renderer", "yaml"),
                self.opts.get("pillar_merge_lists", False),
            )

        for run in self.opts["ext_pillar"]:
            if not isinstance(run, dict):
                errors.append('The "ext_pillar" option is malformed')
                log.critical(errors[-1])
                return {}, errors
            if next(iter(run.keys())) in self.opts.get("exclude_ext_pillar", []):
                continue
            for key, val in run.items():
                if key not in self.ext_pillars:
                    log.critical(
                        "ext_pillar interface named %s is unavailable. Make sure it is placed in the correct "
                        "directory/location. Check https://docs.saltstack.com/en/latest/ref/configuration/master.html#extension-modules for details.",
                        key,
                    )
                    continue
                try:
                    ext = self._external_pillar_data(pillar, val, key)
                except Exception as exc:  # pylint: disable=broad-except
                    errors.append(
                        "Failed to load ext_pillar {}: {}".format(
                            key,
                            exc,
                        )
                    )
                    log.error(
                        "Exception caught loading ext_pillar '%s':\n%s",
                        key,
                        "".join(traceback.format_tb(sys.exc_info()[2])),
                    )
            if ext:
                pillar = salt.utils.dictupdate.merge(
                    pillar,
                    ext,
                    self.merge_strategy,
                    self.opts.get("renderer", "yaml"),
                    self.opts.get("pillar_merge_lists", False),
                )
                ext = None
        return pillar, errors

    def compile_pillar(self, ext=True):
        """
        Render the pillar data and return
        """
        top, top_errors = self.get_top()
        if ext:
            if self.opts.get("ext_pillar_first", False):
                pillar, errors = self.ext_pillar(self.pillar_override)
                self.pillar_data.update(pillar)
                self._update_loader_packs()
                self.rend = salt.loader.render(
                    self.opts,
                    self.functions,
                    self.client,
                    file_client=self.client,
                    pillar=self.pillar_data,
                )
                matches = self.top_matches(top, reload=True)
                pillar, errors = self.render_pillar(matches, errors=errors)
                pillar = salt.utils.dictupdate.merge(
                    self.pillar_data,
                    pillar,
                    self.merge_strategy,
                    self.opts.get("renderer", "yaml"),
                    self.opts.get("pillar_merge_lists", False),
                )
                self.pillar_data.update(pillar)
                self._update_loader_packs()
            else:
                matches = self.top_matches(top)
                pillar, errors = self.render_pillar(matches)
                self.pillar_data.update(pillar)
                self._update_loader_packs()
                pillar, errors = self.ext_pillar(self.pillar_data, errors=errors)
                self.pillar_data.update(pillar)
                self._update_loader_packs()
        else:
            matches = self.top_matches(top)
            pillar, errors = self.render_pillar(matches)
            self.pillar_data.update(pillar)
            self._update_loader_packs()

        pillar = self.pillar_data
        errors.extend(top_errors)
        if self.opts.get("pillar_opts", False):
            mopts = {}
            for key, val in self.opts.items():
                if key in ("pillar", "__context__", "functions", "matchers", "rend"):
                    continue
                mopts[key] = val
            mopts["saltversion"] = __version__
            pillar["master"] = mopts
        if "pillar" in self.opts and self.opts.get("ssh_merge_pillar", False):
            pillar = salt.utils.dictupdate.merge(
                self.opts["pillar"],
                pillar,
                self.merge_strategy,
                self.opts.get("renderer", "yaml"),
                self.opts.get("pillar_merge_lists", False),
            )
        if errors:
            for error in errors:
                log.critical("Pillar render error: %s", error)
            pillar["_errors"] = errors

        if self.pillar_override:
            pillar = salt.utils.dictupdate.merge(
                pillar,
                self.pillar_override,
                self.merge_strategy,
                self.opts.get("renderer", "yaml"),
                self.opts.get("pillar_merge_lists", False),
            )

        decrypt_errors = self.decrypt_pillar(pillar)
        if decrypt_errors:
            pillar.setdefault("_errors", []).extend(decrypt_errors)
        self.pillar_data.update(pillar)
        self._update_loader_packs()
        return pillar

    def _update_loader_packs(self):
        """
        Update the loader packs with the current pillar data
        """
        for loader in (self.functions, self.matchers):
            if hasattr(loader, "pack"):
                if "__pillar__" in loader.pack:
                    if loader.pack["__pillar__"] is self.pillar_data:
                        continue
                    if isinstance(loader.pack["__pillar__"], dict):
                        loader.pack["__pillar__"].clear()
                        loader.pack["__pillar__"].update(self.pillar_data)
                    else:
                        loader.pack["__pillar__"] = self.pillar_data
                else:
                    loader.pack["__pillar__"] = self.pillar_data

        # Also update matchers in context if they exist
        context = {}
        if hasattr(self.functions, "pack"):
            context = self.functions.pack.get("__context__", {})
        if not context and hasattr(self.matchers, "pack"):
            context = self.matchers.pack.get("__context__", {})

        if hasattr(context, "value"):
            context = context.value()

        if isinstance(context, dict) and "matchers" in context:
            m = context["matchers"]
            if hasattr(m, "pack"):
                if "__pillar__" in m.pack:
                    if m.pack["__pillar__"] is not self.pillar_data:
                        if isinstance(m.pack["__pillar__"], dict):
                            m.pack["__pillar__"].clear()
                            m.pack["__pillar__"].update(self.pillar_data)
                        else:
                            m.pack["__pillar__"] = self.pillar_data
                else:
                    m.pack["__pillar__"] = self.pillar_data

        if hasattr(self.rend, "pack"):
            if "__pillar__" in self.rend.pack:
                if self.rend.pack["__pillar__"] is not self.pillar_data:
                    if isinstance(self.rend.pack["__pillar__"], dict):
                        self.rend.pack["__pillar__"].clear()
                        self.rend.pack["__pillar__"].update(self.pillar_data)
                    else:
                        self.rend.pack["__pillar__"] = self.pillar_data
            else:
                self.rend.pack["__pillar__"] = self.pillar_data

    def decrypt_pillar(self, pillar):
        """
        Decrypt the specified pillar dictionary items, if configured to do so
        """
        errors = []
        if self.opts.get("decrypt_pillar"):
            decrypt_pillar = self.opts["decrypt_pillar"]
            if not isinstance(decrypt_pillar, dict):
                decrypt_pillar = salt.utils.data.repack_dictlist(
                    self.opts["decrypt_pillar"]
                )
            if not decrypt_pillar:
                errors.append("decrypt_pillar config option is malformed")
            for key, rend in decrypt_pillar.items():
                ptr = salt.utils.data.traverse_dict(
                    pillar,
                    key,
                    default=None,
                    delimiter=self.opts["decrypt_pillar_delimiter"],
                )
                if ptr is None:
                    log.debug("Pillar key %s not present", key)
                    continue
                try:
                    hash(ptr)
                    immutable = True
                except TypeError:
                    immutable = False
                try:
                    ret = salt.utils.crypt.decrypt(
                        ptr,
                        rend or self.opts["decrypt_pillar_default"],
                        renderers=self.rend,
                        opts=self.opts,
                        valid_rend=self.opts["decrypt_pillar_renderers"],
                    )
                    if immutable:
                        # Since the key pointed to an immutable type, we need
                        # to replace it in the pillar dict. First we will find
                        # the parent, and then we will replace the child key
                        # with the return data from the renderer.
                        parent, _, child = key.rpartition(
                            self.opts["decrypt_pillar_delimiter"]
                        )
                        if not parent:
                            # key is a top-level key, so the pointer to the
                            # parent is the pillar dict itself.
                            ptr = pillar
                        else:
                            ptr = salt.utils.data.traverse_dict(
                                pillar,
                                parent,
                                default=None,
                                delimiter=self.opts["decrypt_pillar_delimiter"],
                            )
                        if ptr is not None:
                            ptr[child] = ret
                except Exception as exc:  # pylint: disable=broad-except
                    msg = f"Failed to decrypt pillar key '{key}': {exc}"
                    errors.append(msg)
                    log.error(msg, exc_info=True)
        return errors

    def destroy(self):
        """
        This method exist in order to be API compatible with RemotePillar
        """
        if self._closing:
            return
        self._closing = True
        if self.client:
            try:
                self.client.destroy()
            except AttributeError:
                pass
        if self.fileclient:
            try:
                self.fileclient.destroy()
            except AttributeError:
                pass

    # pylint: disable=W1701
    def __del__(self):
        self.destroy()

    # pylint: enable=W1701


# TODO: actually migrate from Pillar to AsyncPillar to allow for futures in
# ext_pillar etc.
class AsyncPillar(Pillar):
    async def compile_pillar(
        self, ext=True
    ):  # pylint: disable=invalid-overridden-method
        ret = super().compile_pillar(ext=ext)
        return ret


class PillarCache(Pillar):
    """
    Return a cached pillar if it exists, otherwise cache it.

    Pillar caches are structed in two diminensions: minion_id with a dict of
    saltenvs. Each saltenv contains a pillar dict

    Example data structure:

    ```
    {'minion_1':
        {'base': {'pilar_key_1' 'pillar_val_1'}
    }
    """

    def __init__(
        self,
        *args,
        clean_cache=False,
        pillar_override=None,
        **kwargs,
    ):
        super().__init__(*args, **kwargs)
        self.clean_cache = clean_cache
        self.pillar_override = pillar_override or {}
        self.cache = salt.cache.factory(
            self.opts,
            driver=self.opts["pillar.cache_driver"],
            expires=self.opts["pillar_cache_ttl"],
        )

    @property
    def pillar_key(self):
        if not self.opts["pillarenv"]:
            return self.minion_id
        else:
            return f"{self.minion_id}:{self.opts['pillarenv']}"

    def cached_pillar(self):
        """
        Return the cached pillar if it exists, or None
        """
        return self.cache.fetch("pillar", self.pillar_key)

    def clear_pillar(self):
        """
        Clear the pillar cache, if it exists
        """
        return self.cache.flush("pillar", self.pillar_key)

    def compile_pillar(self, *args, **kwargs):  # Will likely just be pillar_dirs
        # matching to consume the same dataset
        if self.clean_cache:
            self.clear_pillar()
        log.debug(
            "Scanning pillar cache for information about minion %s and pillarenv %s",
            self.minion_id,
            self.opts["pillarenv"],
        )

        # if MDC is on, but not pillar cache, we never read the cache, only write to it
        if self.opts["minion_data_cache"] and not self.opts["pillar_cache"]:
            pillar_data = {}
        else:
            # Check the cache!
            pillar_data = self.cached_pillar()

        if pillar_data:
            log.debug(
                "Pillar cache hit for minion %s and pillarenv %s",
                self.minion_id,
                self.opts["pillarenv"],
            )
        else:
            # We found the minion but not the env. Store it.
            log.debug(
                "Pillar cache miss for pillarenv %s for minion %s",
                self.opts["pillarenv"],
                self.minion_id,
            )
            pillar_data = super().compile_pillar(*args, **kwargs)

            self.cache.store("pillar", self.pillar_key, pillar_data)

        # we dont want the pillar_override baked into the cached compile_pillar from above
        if self.pillar_override:
            pillar_data = salt.utils.dictupdate.merge(
                pillar_data,
                self.pillar_override,
                self.merge_strategy,
                self.opts.get("renderer", "yaml"),
                self.opts.get("pillar_merge_lists", False),
            )

        return pillar_data
