"""
This module contains routines used to verify the matcher against the minions
expected to return
"""

import fnmatch
import logging
import re

import salt.cache
import salt.payload
import salt.roster
import salt.transport
import salt.utils.data
import salt.utils.files
import salt.utils.network
import salt.utils.resource_registry
import salt.utils.resources
import salt.utils.stringutils
import salt.utils.versions
from salt._compat import ipaddress
from salt.defaults import DEFAULT_TARGET_DELIM
from salt.exceptions import CommandExecutionError, SaltCacheError

HAS_RANGE = False
try:
    import seco.range  # pylint: disable=import-error

    HAS_RANGE = True
except ImportError:
    pass

log = logging.getLogger(__name__)

TARGET_REX = re.compile(
    r"""(?x)
        (
            (?P<engine>G|P|I|J|L|N|S|E|R|T|M)  # Possible target engines
            (?P<delimiter>(?<=G|P|I|J).)?        # Optional delimiter (G/P/I/J only)
        @)?                                      # Engine+delimiter separated by '@', optional
        (?P<pattern>.+)$"""  # The pattern passed to the target engine
)


def _nodegroup_regex(nodegroup, words, opers):
    opers_set = set(opers)
    ret = words
    if (set(ret) - opers_set) == set(ret):
        # No compound operators found in nodegroup definition. Check for
        # group type specifiers
        group_type_re = re.compile("^[A-Z]@")
        regex_chars = ["(", "[", "{", "\\", "?", "}", "]", ")"]
        if not [x for x in ret if "*" in x or group_type_re.match(x)]:
            # No group type specifiers and no wildcards.
            # Treat this as an expression.
            if [x for x in ret if x in [x for y in regex_chars if y in x]]:
                joined = "E@" + ",".join(ret)
                log.debug(
                    "Nodegroup '%s' (%s) detected as an expression. "
                    "Assuming compound matching syntax of '%s'",
                    nodegroup,
                    ret,
                    joined,
                )
            else:
                # Treat this as a list of nodenames.
                joined = "L@" + ",".join(ret)
                log.debug(
                    "Nodegroup '%s' (%s) detected as list of nodenames. "
                    "Assuming compound matching syntax of '%s'",
                    nodegroup,
                    ret,
                    joined,
                )
            # Return data must be a list of compound matching components
            # to be fed into compound matcher. Enclose return data in list.
            return [joined]


def parse_target(target_expression):
    """Parse `target_expressing` splitting it into `engine`, `delimiter`,
    `pattern` - returns a dict"""

    match = TARGET_REX.match(target_expression)
    if not match:
        log.warning('Unable to parse target "%s"', target_expression)
        ret = {
            "engine": None,
            "delimiter": None,
            "pattern": target_expression,
        }
    else:
        ret = match.groupdict()
    return ret


def get_minion_data(minion, opts):
    """
    Get the grains/pillar for a specific minion.  If minion is None, it
    will return the grains/pillar for the first minion it finds.

    Return value is a tuple of the minion ID, grains, and pillar
    """
    grains = None
    pillar = None
    if opts.get("minion_data_cache", False):
        cache = salt.cache.factory(opts)
        if minion is None:
            for id_ in cache.list("grains"):
                grains = cache.fetch("grains", id_)
                pillar = cache.fetch("pillar", id_)
                if grains:
                    break
        else:
            grains = cache.fetch("grains", minion)
            pillar = cache.fetch("pillar", minion)
    return minion if minion else None, grains, pillar


def nodegroup_comp(nodegroup, nodegroups, skip=None, first_call=True):
    """
    Recursively expand ``nodegroup`` from ``nodegroups``; ignore nodegroups in ``skip``

    If a top-level (non-recursive) call finds no nodegroups, return the original
    nodegroup definition (for backwards compatibility). Keep track of recursive
    calls via `first_call` argument
    """
    expanded_nodegroup = False
    if skip is None:
        skip = set()
    elif nodegroup in skip:
        log.error(
            'Failed nodegroup expansion: illegal nested nodegroup "%s"', nodegroup
        )
        return ""

    if nodegroup not in nodegroups:
        log.error('Failed nodegroup expansion: unknown nodegroup "%s"', nodegroup)
        return ""

    nglookup = nodegroups[nodegroup]
    if isinstance(nglookup, str):
        words = nglookup.split()
    elif isinstance(nglookup, (list, tuple)):
        words = nglookup
    else:
        log.error(
            "Nodegroup '%s' (%s) is neither a string, list nor tuple",
            nodegroup,
            nglookup,
        )
        return ""

    skip.add(nodegroup)
    ret = []
    opers = ["and", "or", "not", "(", ")"]
    for word in words:
        if not isinstance(word, str):
            word = str(word)
        if word in opers:
            ret.append(word)
        elif len(word) >= 3 and word.startswith("N@"):
            expanded_nodegroup = True
            ret.extend(
                nodegroup_comp(word[2:], nodegroups, skip=skip, first_call=False)
            )
        else:
            ret.append(word)

    if ret:
        ret.insert(0, "(")
        ret.append(")")

    skip.remove(nodegroup)

    log.debug("nodegroup_comp(%s) => %s", nodegroup, ret)
    # Only return list form if a nodegroup was expanded. Otherwise return
    # the original string to conserve backwards compat
    if expanded_nodegroup or not first_call:
        if not first_call:
            joined = _nodegroup_regex(nodegroup, words, opers)
            if joined:
                return joined
        return ret
    else:
        ret = words
        joined = _nodegroup_regex(nodegroup, ret, opers)
        if joined:
            return joined

        log.debug(
            "No nested nodegroups detected. Using original nodegroup definition: %s",
            nodegroups[nodegroup],
        )
        return ret


# ---------------------------------------------------------------------------
# Resource-index call-path
# ---------------------------------------------------------------------------
#
# The authoritative store is :class:`salt.utils.resource_registry.ResourceRegistry`
# (mmap-backed primary on disk, in-process derived by_type / by_minion views).
# This module only exposes thin helpers over the registry:
#
# * :func:`update_resource_index` — called by the master's
#   ``_register_resources`` handler when a minion reports its resource set.
# * :meth:`CkMinions._check_resource_minions` and
#   :meth:`CkMinions._augment_with_resources` — hot targeting paths.
#
# The legacy ``schema_version`` / ``_build_resource_index`` / ``_coerce_*``
# helpers below are preserved as pure utilities because they're still used for
# constructing test fixtures and for one-shot migration scripts; they are not
# on any production code path.
#
# Single-writer invariant: only the master's AESFuncs process mutates the
# registry. All other master workers (and any runner) are readers.

# Legacy bank name retained for any on-disk artefact still shipped as a blob
# (e.g. the ``resources`` bank referenced by :class:`ResourceRegistry` for
# topology lookups — distinct from the mmap primary).
_RESOURCE_INDEX_BANK = "resource_index"
_RESOURCE_INDEX_KEY = "index"

# Persisted resource index schema: v2 uses composite SRN keys in ``by_id``
# (``"<type>:<id>"``) so the same bare ``id`` may exist under multiple types.
RESOURCE_INDEX_SCHEMA_VERSION = 2


def resource_index_srn_key(resource_type, resource_id):
    """
    Canonical cache / ``by_id`` key for a Salt resource (SRN without ``T@``).

    :param str resource_type: Resource type (e.g. ``"ssh"``).
    :param str resource_id: Bare resource id (e.g. ``"web-01"``).
    :rtype: str
    """
    return salt.utils.resource_registry.resource_index_srn_key(
        resource_type, resource_id
    )


def _empty_resource_index():
    return {
        "schema_version": RESOURCE_INDEX_SCHEMA_VERSION,
        "by_id": {},
        "by_type": {},
        "by_minion": {},
    }


def _coerce_resource_index_schema(index):
    """
    Normalize a loaded index dict to :data:`RESOURCE_INDEX_SCHEMA_VERSION`.

    Older indexes used bare resource ids as ``by_id`` keys, which cannot
    represent the same id under multiple types. Those are rebuilt from
    ``by_minion``, which remains authoritative.

    Preserved as a pure utility for test fixtures and one-shot migration
    from legacy on-disk blobs. Not on any production read path.
    """
    if not isinstance(index, dict):
        return _empty_resource_index()
    if index.get("schema_version") == RESOURCE_INDEX_SCHEMA_VERSION:
        return {
            "schema_version": RESOURCE_INDEX_SCHEMA_VERSION,
            "by_id": dict(index.get("by_id") or {}),
            "by_type": dict(index.get("by_type") or {}),
            "by_minion": dict(index.get("by_minion") or {}),
        }
    by_minion = dict(index.get("by_minion") or {})
    by_type = dict(index.get("by_type") or {})
    by_id = {}
    for minion_id, resources in by_minion.items():
        if not isinstance(resources, dict):
            continue
        for rtype, rids in resources.items():
            if not isinstance(rids, list):
                continue
            for rid in rids:
                by_id[resource_index_srn_key(rtype, rid)] = {
                    "minion": minion_id,
                    "type": rtype,
                }
    return {
        "schema_version": RESOURCE_INDEX_SCHEMA_VERSION,
        "by_id": by_id,
        "by_type": by_type,
        "by_minion": by_minion,
    }


# Functions where resources run inline and their results are merged into the
# managing minion's own response. The operator sees ONE combined block + ONE
# Summary section instead of separate blocks per resource.
_MERGE_RESOURCE_FUNS = frozenset(
    {
        "state.apply",
        "state.highstate",
        "state.sls",
        "state.sls_id",
        "state.single",
    }
)


def _build_resource_index(by_minion):
    """
    Build the three-way flat index from a ``{minion_id: {rtype: [rid, ...]}}``
    mapping. Pure utility; used by test fixtures and migration scripts.

    Returns::

        {
            "schema_version": RESOURCE_INDEX_SCHEMA_VERSION,
            "by_id":     {"<type>:<id>": {"minion": minion_id, "type": rtype}, ...},
            "by_type":   {rtype: [rid, ...], ...},
            "by_minion": {minion_id: {rtype: [rid, ...]}, ...},
        }
    """
    by_id = {}
    by_type = {}
    for minion_id, resources in by_minion.items():
        for rtype, rids in resources.items():
            if rtype not in by_type:
                by_type[rtype] = []
            for rid in rids:
                by_id[resource_index_srn_key(rtype, rid)] = {
                    "minion": minion_id,
                    "type": rtype,
                }
                if rid not in by_type[rtype]:
                    by_type[rtype].append(rid)
    return {
        "schema_version": RESOURCE_INDEX_SCHEMA_VERSION,
        "by_id": by_id,
        "by_type": by_type,
        "by_minion": dict(by_minion),
    }


def update_resource_index(opts, minion_id, resources):
    """
    Register or update the full set of resources managed by ``minion_id``.

    Thin wrapper around :meth:`ResourceRegistry.register_minion` that wires
    the per-process singleton. Called by the master's ``_register_resources``
    handler.

    :param dict opts: Salt opts (needs ``cachedir``).
    :param str minion_id: The reporting minion.
    :param dict resources: ``{resource_type: [resource_id, ...]}``.
    :returns: ``(n_put, n_deleted)`` for logging.
    """
    try:
        registry = salt.utils.resource_registry.get_registry(opts)
    except Exception:  # pylint: disable=broad-except
        log.error(
            "Failed to open resource registry while registering %s; "
            "resource targeting for this minion will be unavailable.",
            minion_id,
            exc_info=True,
        )
        return (0, 0)
    try:
        return registry.register_minion(minion_id, resources or {})
    except Exception:  # pylint: disable=broad-except
        log.error(
            "Failed to register resources for minion '%s'",
            minion_id,
            exc_info=True,
        )
        return (0, 0)


class CkMinions:
    """
    Used to check what minions should respond from a target

    Note: This is a best-effort set of the minions that would match a target.
    Depending on master configuration (grains caching, etc.) and topology (syndics)
    the list may be a subset-- but we err on the side of too-many minions in this
    class.
    """

    def __init__(self, opts):
        import salt.key  # noqa: PLC0415 — module-level import pulls ``salt.key`` → ``masterapi``

        # → ``minion`` before ``salt.config`` finishes loading (mixed trees).

        self.opts = opts
        self.cache = salt.cache.factory(opts)
        self.key = salt.key.get_key(opts)
        # ``self.registry`` is a lazy property — see :py:meth:`registry`.
        # Eager instantiation forced :class:`MmapCache` (and thus ``xxhash``)
        # to load at every ``CkMinions(opts)`` site, including paths that
        # never target resources (e.g. master startup on a minion-only Salt
        # install where ``xxhash`` is not bundled in the same site-packages
        # the daemon resolves at runtime).
        # TODO: this is actually an *auth* check
        if self.opts.get("transport", "zeromq") in salt.transport.TRANSPORTS:
            self.acc = "minions"
        else:
            self.acc = "accepted"

    @property
    def registry(self):
        """
        Process-wide singleton :class:`ResourceRegistry`, materialised on
        first access. Shared with every other ``CkMinions`` /
        ``AESFuncs`` collaborator in this process, so the derived-view
        cache and the mmap handle are reused.
        """
        return salt.utils.resource_registry.get_registry(self.opts)

    def _check_nodegroup_minions(self, expr, greedy):  # pylint: disable=unused-argument
        """
        Return minions found by looking at nodegroups
        """
        return self._check_compound_minions(
            nodegroup_comp(expr, self.opts["nodegroups"]), DEFAULT_TARGET_DELIM, greedy
        )

    def _check_glob_minions(
        self, expr, greedy, minions=None
    ):  # pylint: disable=unused-argument
        """
        Return the minions found by looking via globs
        """
        if minions:
            result_minions = fnmatch.filter(minions, expr)
        else:
            if hasattr(self.key, "glob_match"):
                result_minions = list(self.key.glob_match(expr).get(self.key.ACC, []))
            else:
                # Salt 3007 ``Key`` API — ``name_match`` / legacy layouts without ``glob_match``.
                result_minions = fnmatch.filter(list(self._pki_minions()), expr)

        if (
            isinstance(expr, str)
            and expr
            and not any(c in expr for c in ("*", "?", "["))
        ):
            try:
                if expr not in result_minions and (
                    self.registry.resolve_bare_resource_id(expr)
                    or salt.utils.resources.bare_resource_id_in_minion_data_cache(
                        self.opts, expr, cache=self.cache
                    )
                ):
                    result_minions = list(result_minions) + [expr]
            except Exception:  # pylint: disable=broad-except
                log.debug(
                    "Glob match: bare resource id resolution failed for %r",
                    expr,
                    exc_info=True,
                )

        return {"minions": result_minions, "missing": []}

    def _check_list_minions(
        self, expr, greedy, ignore_missing=False, minions=None
    ):  # pylint: disable=unused-argument
        """
        Return the minions found by looking via a list

        Tokens that are not accepted minion keys but match a registered bare
        resource id (see :meth:`ResourceRegistry.resolve_bare_resource_id`) are
        appended so ``salt -L <resource-id>`` resolves like ``T@type:<id>``.
        """
        if isinstance(expr, str):
            expr = [m for m in expr.split(",") if m]

        if minions:
            matched = [x for x in expr if x in minions]
            missing = [] if ignore_missing else [x for x in expr if x not in minions]
        else:
            if hasattr(self.key, "list_match"):
                found = self.key.list_match(expr)
                matched = found.get(self.key.ACC, [])
                missing = (
                    []
                    if ignore_missing
                    else [x for x in expr if x not in found.get(self.key.ACC, [])]
                )
            else:
                pk = self._pki_minions()
                matched = [x for x in expr if x in pk]
                missing = [] if ignore_missing else [x for x in expr if x not in pk]

        acc = set(matched)
        extra = []
        try:
            for token in expr:
                if token in acc:
                    continue
                if self.registry.resolve_bare_resource_id(
                    token
                ) or salt.utils.resources.bare_resource_id_in_minion_data_cache(
                    self.opts, token, cache=self.cache
                ):
                    extra.append(token)
                    acc.add(token)
                    if not ignore_missing and token in missing:
                        missing.remove(token)
        except Exception:  # pylint: disable=broad-except
            log.debug(
                "List match: bare resource id resolution failed; continuing without extras",
                exc_info=True,
            )

        return {"minions": matched + extra, "missing": missing}

    def _check_pcre_minions(
        self, expr, greedy, minions=None
    ):  # pylint: disable=unused-argument
        """
        Return the minions found by looking via regular expressions
        """
        reg = re.compile(expr)

        if not minions:
            minions = self._pki_minions()

        return {
            "minions": [m for m in minions if reg.match(m)],
            "missing": [],
        }

    def _pki_minions(self):
        """
        Retrieve complete minion list from PKI dir.
        Respects cache if configured
        """

        minions = set()

        try:
            accepted = self.key.list_status("accepted").get("minions")
            if accepted:
                minions = minions | set(accepted)
        except OSError as exc:
            log.error(
                "Encountered OSError while evaluating minions in PKI dir: %s", exc
            )
        except Exception as exc:  # pylint: disable=broad-except
            log.error("Encountered Exception while evaluating pki minions: %s", exc)

        return minions

    def _check_cache_minions(
        self,
        expr,
        delimiter,
        greedy,
        search_type,
        regex_match=False,
        exact_match=False,
        minions=None,
    ):
        """
        Helper function to search for minions in master caches.

        Return only minions whose cached ``search_type`` data exists and
        matches the expression. Minions with no cache entry are excluded
        from the result regardless of ``greedy`` — otherwise grain/pillar
        targeting silently matches any offline minion whose cache directory
        has been pruned (#68976).

        ``greedy`` still controls which minions are *considered*: when
        greedy, every accepted minion key is considered (and any without
        cached data is dropped); when not greedy, only minions that already
        have a cache entry are considered.
        """
        cache_enabled = self.opts.get("minion_data_cache", False)

        def list_cached_minions():
            # we use grains as a equivalent for minion list
            return self.cache.list("grains")

        if greedy:
            if not minions:
                minions = self._pki_minions()
        elif cache_enabled:
            minions = list_cached_minions()
        else:
            return {"minions": [], "missing": []}

        if cache_enabled:
            if greedy:
                cminions = list_cached_minions()
            else:
                cminions = minions
            if not cminions:
                return {"minions": [], "missing": []}
            minions = set(minions)
            # Track which accepted minions have cache data we can evaluate.
            # Any accepted minion not present in ``cminions`` has no cache
            # entry and must be excluded; otherwise grain/pillar targeting
            # would match every minion whose cache dir is missing (#68976).
            evaluated = set()
            for id_ in cminions:
                if id_ not in minions:
                    continue
                evaluated.add(id_)
                mdata = self.cache.fetch(search_type, id_)
                if mdata is None:
                    minions.discard(id_)
                    continue
                if not salt.utils.data.subdict_match(
                    mdata,
                    expr,
                    delimiter=delimiter,
                    regex_match=regex_match,
                    exact_match=exact_match,
                ):
                    minions.discard(id_)
            # Drop minions that were never evaluated (no cache entry at all).
            minions &= evaluated
            minions = list(minions)
        return {"minions": minions, "missing": []}

    def _check_grain_minions(self, expr, delimiter, greedy, minions=None):
        """
        Return the minions found by looking via grains
        """
        result = self._check_cache_minions(
            expr, delimiter, greedy, "grains", minions=minions
        )
        self._augment_grain_match_with_resource_grains(
            result, expr, delimiter, regex_match=False
        )
        return result

    def _check_grain_pcre_minions(self, expr, delimiter, greedy, minions=None):
        """
        Return the minions found by looking via grains with PCRE
        """
        result = self._check_cache_minions(
            expr, delimiter, greedy, "grains", regex_match=True, minions=minions
        )
        self._augment_grain_match_with_resource_grains(
            result, expr, delimiter, regex_match=True
        )
        return result

    def _augment_grain_match_with_resource_grains(
        self, result, expr, delimiter, regex_match
    ):
        """
        Append matching resource IDs to ``result["minions"]`` for grain
        targeting.

        Per-resource grain dicts live in the ``resource_grains`` cache bank
        (populated by the master's ``_register_resources`` handler from the
        minion-side ``Minion._collect_resource_grains``). We walk the bank
        in-place rather than going through :meth:`_check_cache_minions`,
        because the SRN composite key (``"<type>:<id>"``) needs to be split
        back to a bare resource ID for the response wait-set.

        :meth:`_check_cache_minions` may return ``result["minions"]`` as
        either a list or a ``set`` depending on which early-return branch
        fired (e.g. an empty ``grains`` cache returns the upstream
        ``set`` from ``_pki_minions``). We normalise to a list before
        appending so the downstream consumers in
        :meth:`_check_compound_minions` get the same shape regardless of
        which branch was hit.
        """
        if not self.opts.get("minion_data_cache", False):
            return
        bank = salt.utils.resource_registry.RESOURCE_GRAINS_BANK
        try:
            srns = list(self.cache.list(bank) or [])
        except Exception:  # pylint: disable=broad-except
            return
        if not srns:
            return
        existing = result.get("minions", [])
        seen = set(existing)
        # Normalise to list; preserve any existing order.
        if not isinstance(existing, list):
            result["minions"] = list(existing)
        for srn in srns:
            try:
                gdict = self.cache.fetch(bank, srn)
            except Exception:  # pylint: disable=broad-except
                continue
            if not isinstance(gdict, dict):
                continue
            try:
                if not salt.utils.data.subdict_match(
                    gdict, expr, delimiter=delimiter, regex_match=regex_match
                ):
                    continue
            except Exception:  # pylint: disable=broad-except
                continue
            _rtype, _, rid = srn.partition(":")
            if rid and rid not in seen:
                result["minions"].append(rid)
                seen.add(rid)

    def _check_pillar_minions(self, expr, delimiter, greedy, minions=None):
        """
        Return the minions found by looking via pillar
        """
        return self._check_cache_minions(
            expr, delimiter, greedy, "pillar", minions=minions
        )

    def _check_pillar_pcre_minions(self, expr, delimiter, greedy, minions=None):
        """
        Return the minions found by looking via pillar with PCRE
        """
        return self._check_cache_minions(
            expr, delimiter, greedy, "pillar", regex_match=True, minions=minions
        )

    def _check_pillar_exact_minions(self, expr, delimiter, greedy, minions=None):
        """
        Return the minions found by looking via pillar
        """
        return self._check_cache_minions(
            expr, delimiter, greedy, "pillar", exact_match=True, minions=minions
        )

    def _check_ipcidr_minions(self, expr, greedy, minions=None):
        """
        Return the minions found by looking via ipcidr
        """
        cache_enabled = self.opts.get("minion_data_cache", False)

        if greedy:
            if not minions:
                minions = self._pki_minions()
        elif cache_enabled:
            minions = self.cache.list("grains")
        else:
            return {"minions": [], "missing": []}

        if cache_enabled:
            if greedy:
                cminions = self.cache.list("grains")
            else:
                cminions = minions
            if cminions is None:
                return {"minions": minions, "missing": []}

            tgt = expr
            try:
                # Target is an address?
                tgt = ipaddress.ip_address(tgt)
            except Exception:  # pylint: disable=broad-except
                try:
                    # Target is a network?
                    tgt = ipaddress.ip_network(tgt)
                except Exception:  # pylint: disable=broad-except
                    log.error("Invalid IP/CIDR target: %s", tgt)
                    return {"minions": [], "missing": []}
            proto = f"ipv{tgt.version}"

            minions = set(minions)
            for id_ in cminions:
                grains = self.cache.fetch("grains", id_)
                if grains is None:
                    if not greedy:
                        minions.remove(id_)
                    continue
                if grains is None or proto not in grains:
                    match = False
                elif isinstance(tgt, (ipaddress.IPv4Address, ipaddress.IPv6Address)):
                    match = str(tgt) in grains[proto]
                else:
                    match = salt.utils.network.in_subnet(tgt, grains[proto])

                if not match and id_ in minions:
                    minions.remove(id_)

        return {"minions": list(minions), "missing": []}

    def _check_range_minions(self, expr, greedy):
        """
        Return the minions found by looking via range expression
        """
        if not HAS_RANGE:
            raise CommandExecutionError(
                "Range matcher unavailable (unable to import seco.range, "
                "module most likely not installed)"
            )
        if not hasattr(self, "_range"):
            self._range = seco.range.Range(self.opts["range_server"])
        try:
            return self._range.expand(expr)
        except seco.range.RangeException as exc:
            log.error("Range exception in compound match: %s", exc)
            cache_enabled = self.opts.get("minion_data_cache", False)

            if greedy:
                mlist = []
                for fn_ in self._pki_minions():
                    if not fn_.startswith("."):
                        mlist.append(fn_)
                return {"minions": mlist, "missing": []}
            elif cache_enabled:
                return {"minions": self.cache.list("grains"), "missing": []}
            else:
                return {"minions": [], "missing": []}

    def _check_compound_pillar_exact_minions(
        self, expr, delimiter, greedy, minions=None, fun=None
    ):
        """
        Return the minions found by looking via compound matcher

        Disable pillar glob matching
        """
        return self._check_compound_minions(
            expr, delimiter, greedy, pillar_exact=True, minions=minions, fun=fun
        )

    def _check_compound_minions(
        self, expr, delimiter, greedy, pillar_exact=False, minions=None, fun=None
    ):  # pylint: disable=unused-argument
        """
        Return the minions found by looking via compound matcher
        """
        if not isinstance(expr, str) and not isinstance(expr, (list, tuple)):
            log.error("Compound target that is neither string, list nor tuple")
            return {"minions": [], "missing": []}

        # we wrap this in a dict so we can assign to it from within the eval at
        # the end of this method
        _deferred_minions_scope = {"minions": minions}

        def _deferred_minions():
            if not _deferred_minions_scope["minions"]:
                _deferred_minions_scope["minions"] = self._pki_minions()

            return _deferred_minions_scope["minions"]

        log.debug("expr: %s, delimiter: %s, minions: %s", expr, delimiter, minions)

        nodegroups = self.opts.get("nodegroups", {})

        if self.opts.get("minion_data_cache", False):
            ref = {
                "G": self._check_grain_minions,
                "P": self._check_grain_pcre_minions,
                "I": self._check_pillar_minions,
                "J": self._check_pillar_pcre_minions,
                "L": self._check_list_minions,
                "N": None,  # nodegroups should already be expanded
                "S": self._check_ipcidr_minions,
                "E": self._check_pcre_minions,
                "R": self._all_minions,
                "T": self._check_resource_minions,
                "M": self._check_managing_minion_minions,
            }
            if pillar_exact:
                ref["I"] = self._check_pillar_exact_minions
                ref["J"] = self._check_pillar_exact_minions

            results = []
            unmatched = []
            opers = ["and", "or", "not", "(", ")"]
            missing = []

            if isinstance(expr, str):
                words = expr.split()
            else:
                # we make a shallow copy in order to not affect the passed in arg
                words = expr[:]

            while words:
                word = words.pop(0)
                target_info = parse_target(word)

                # Easy check first
                if word in opers:
                    if results:
                        if results[-1] == "(" and word in ("and", "or"):
                            log.error('Invalid beginning operator after "(": %s', word)
                            return {"minions": [], "missing": []}
                        if word == "not":
                            if not results[-1] in ("&", "|", "("):
                                results.append("&")
                            results.append("(")
                            results.append("set(all_minions())")
                            results.append("-")
                            unmatched.append("-")
                        elif word == "and":
                            results.append("&")
                        elif word == "or":
                            results.append("|")
                        elif word == "(":
                            results.append(word)
                            unmatched.append(word)
                        elif word == ")":
                            if not unmatched or unmatched[-1] != "(":
                                log.error(
                                    "Invalid compound expr (unexpected "
                                    "right parenthesis): %s",
                                    expr,
                                )
                                return {"minions": [], "missing": []}
                            results.append(word)
                            unmatched.pop()
                            if unmatched and unmatched[-1] == "-":
                                results.append(")")
                                unmatched.pop()
                        else:  # Won't get here, unless oper is added
                            log.error("Unhandled oper in compound expr: %s", expr)
                            return {"minions": [], "missing": []}
                    else:
                        # seq start with oper, fail
                        if word == "not":
                            results.append("(")
                            results.append("set(all_minions())")
                            results.append("-")
                            unmatched.append("-")
                        elif word == "(":
                            results.append(word)
                            unmatched.append(word)
                        else:
                            log.error(
                                "Expression may begin with binary operator: %s", word
                            )
                            return {"minions": [], "missing": []}

                elif target_info and target_info["engine"]:
                    if "N" == target_info["engine"]:
                        # if we encounter a node group, just evaluate it in-place
                        decomposed = nodegroup_comp(target_info["pattern"], nodegroups)
                        if decomposed:
                            words = decomposed + words
                        continue

                    engine = ref.get(target_info["engine"])
                    if not engine:
                        # If an unknown engine is called at any time, fail out
                        log.error(
                            'Unrecognized target engine "%s" for'
                            ' target expression "%s"',
                            target_info["engine"],
                            word,
                        )
                        return {"minions": [], "missing": []}

                    engine_args = [target_info["pattern"]]
                    if target_info["engine"] in ("G", "P", "I", "J"):
                        engine_args.append(target_info["delimiter"] or ":")
                    engine_args.append(greedy)

                    # ignore missing minions for lists if we exclude them with
                    # a 'not'
                    if "L" == target_info["engine"]:
                        engine_args.append(results and results[-1] == "-")
                    # Resource-engine matchers need the function name so that
                    # merge-mode functions (state.apply etc.) wait for the
                    # managing minion's combined response instead of the
                    # individual resource ids that never produce a separate
                    # return.
                    engine_kwargs = {"minions": minions}
                    if target_info["engine"] == "T":
                        engine_kwargs["fun"] = fun
                    _results = engine(*engine_args, **engine_kwargs)
                    results.append(str(set(_results["minions"])))
                    missing.extend(_results["missing"])
                    if unmatched and unmatched[-1] == "-":
                        results.append(")")
                        unmatched.pop()

                else:
                    _results = self._check_glob_minions(word, True, minions=minions)
                    results.append(str(set(_results["minions"])))
                    if unmatched and unmatched[-1] == "-":
                        results.append(")")
                        unmatched.pop()

            # Add a closing ')' for each item left in unmatched
            results.extend([")" for item in unmatched])

            results = " ".join(results)
            log.debug("Evaluating final compound matching expr: %s", results)
            try:
                minions = list(
                    eval(  # pylint: disable=eval-used
                        results,
                        {
                            "all_minions": _deferred_minions,
                            "_deferred_minions_scope": _deferred_minions_scope,
                        },
                    )
                )  # pylint: disable=W0123
                return {"minions": minions, "missing": missing}
            except Exception:  # pylint: disable=broad-except
                log.error("Invalid compound target: %s", expr)
                return {"minions": [], "missing": []}

        return {"minions": list(minions), "missing": []}

    def connected_ids(self, subset=None, show_ip=False):
        """
        Return a set of all connected minion ids, optionally within a subset
        """
        minions = set()
        if self.opts.get("minion_data_cache", False):
            search = self.cache.list("grains")
            if search is None:
                return minions
            addrs = salt.utils.network.local_port_tcp(int(self.opts["publish_port"]))
            if self.opts.get("detect_remote_minions", False):
                addrs = addrs.union(
                    salt.utils.network.remote_port_tcp(self.opts["remote_minions_port"])
                )
            if "127.0.0.1" in addrs:
                # Add in the address of a possible locally-connected minion.
                addrs.discard("127.0.0.1")
                addrs.update(set(salt.utils.network.ip_addrs(include_loopback=False)))
            if "::1" in addrs:
                # Add in the address of a possible locally-connected minion.
                addrs.discard("::1")
                addrs.update(set(salt.utils.network.ip_addrs6(include_loopback=False)))
            if subset:
                search = subset
            for id_ in search:
                try:
                    grains = self.cache.fetch("grains", id_)
                except SaltCacheError:
                    # If a SaltCacheError is explicitly raised during the fetch operation,
                    # permission was denied to open the cached data.p file. Continue on as
                    # in the releases <= 2016.3. (An explicit error raise was added in PR
                    # #35388. See issue #36867 for more information.
                    continue
                if grains is None:
                    continue
                for ipv4 in grains.get("ipv4", []):
                    if ipv4 in addrs:
                        if show_ip:
                            minions.add((id_, ipv4))
                        else:
                            minions.add(id_)
                        break
                for ipv6 in grains.get("ipv6", []):
                    if ipv6 in addrs:
                        if show_ip:
                            minions.add((id_, ipv6))
                        else:
                            minions.add(id_)
                        break
        return minions

    def _all_minions(self, expr=None, minions=None):
        """
        Return a list of all minions that have auth'd
        """
        if not minions:
            minions = self._pki_minions()

        return {"minions": minions, "missing": []}

    def _check_resource_minions(self, expr, greedy, minions=None, fun=None):
        """
        Return the IDs that match the ``T@`` pattern ``expr``.

        ``expr`` is either a bare resource type (``dummy``) or a full SRN
        (``dummy:dummy-01``).

        Normally (and historically) the returned IDs are **resource IDs**:
        the CLI uses this list to know which return IDs to expect, and
        resource returns are keyed by resource ID (remapped by the master's
        ``_return`` handler after the transport security check passes).

        For merge-mode functions (``state.apply``, ``state.highstate``,
        ``state.sls``, ``state.sls_id``, ``state.single``) the managing
        minion runs every targeted resource inline and returns ONE combined
        response under its own minion id — no per-resource ``_return`` is
        ever emitted, so waiting on resource ids would time out with
        ``ERROR: Minion did not return``. When ``fun`` is in
        :data:`_MERGE_RESOURCE_FUNS` we therefore remap the wait list to
        the managing minion id(s) of the targeted resources.

        Job delivery is handled separately: the job is published with the
        original ``T@`` target expression and ``tgt_type=compound``; managing
        minions receive it via broadcast and filter locally with
        ``resource_match.match()``.

        Backed by :class:`ResourceRegistry`: full-SRN lookups are O(1) on
        the mmap primary; bare-type lookups are O(k) on the derived
        ``by_type`` view. Reads during compaction are consistent because
        the registry detects atomic swaps via inode (see
        :data:`STALENESS_CHECK_INTERVAL`).
        """
        parsed = salt.utils.resource_registry.parse_srn(expr)
        resource_type = parsed["type"]
        resource_id = parsed["id"]

        if resource_type is None:
            return {"minions": [], "missing": []}

        if resource_id is not None:
            # Full SRN: echo the resource ID back. The managing minion will
            # filter locally via ``resource_match.match()``. We log if the
            # SRN is not registered so operators can detect typos without
            # blocking the job (minion might not have registered yet).
            #
            # For both merge-mode and non-merge functions the wait list
            # is the resource id: merge-mode now emits one per-resource
            # return on the minion side (each with ``resource_id`` set),
            # so the CLI's wait set is the same shape as ``test.ping``.
            try:
                known = self.registry.has_srn(resource_type, resource_id)
            except Exception:  # pylint: disable=broad-except
                log.error(
                    "Resource registry lookup failed for T@%s", expr, exc_info=True
                )
                known = False
            if not known:
                log.debug(
                    "T@%s not in resource registry; using resource ID from expression",
                    expr,
                )
            return {"minions": [resource_id], "missing": []}

        # Bare type: return every registered resource id of this type.
        # Merge-mode now emits one per-resource return — same wait-set
        # shape as ``salt -C 'T@dummy' test.ping``.
        try:
            rids = self.registry.get_resource_ids_by_type(resource_type)
        except Exception:  # pylint: disable=broad-except
            log.error("Resource registry lookup failed for T@%s", expr, exc_info=True)
            rids = []
        if rids:
            return {"minions": list(rids), "missing": []}

        log.warning(
            "T@%s: resource registry has no entries of this type. "
            "Restart or sync_all the managing minion to populate the registry.",
            expr,
        )
        return {"minions": [], "missing": []}

    def _augment_with_resources(self, minion_ids):
        """
        Append the resource IDs managed by each matched minion to the list.

        Called by :meth:`check_minions` for wildcard glob targets so that
        ``salt '*' test.ping`` also includes returns from managed resources.

        Per-minion lookups hit the in-process ``by_minion`` derived view
        (O(1) dict access). If the registry is unavailable the method logs
        an error and returns ``minion_ids`` unchanged so ordinary targeting
        is never disrupted by a registry failure.
        """
        result = list(minion_ids)
        if not result:
            return result
        seen = set(result)
        for minion_id in minion_ids:
            try:
                resources = self.registry.get_resources_for_minion(minion_id)
            except Exception:  # pylint: disable=broad-except
                log.error(
                    "Failed to load resources for minion %s; resource IDs will "
                    "not be included in this target expansion.",
                    minion_id,
                    exc_info=True,
                )
                return list(minion_ids)
            for rids in resources.values():
                for rid in rids:
                    if rid not in seen:
                        result.append(rid)
                        seen.add(rid)
        return result

    def _check_managing_minion_minions(self, expr, greedy, minions=None):
        """
        Return the minion set for a ``M@`` managing-minion expression.

        ``expr`` is a minion ID glob.  Returns any accepted minion whose ID
        matches ``expr``.
        """
        return self._check_glob_minions(expr, greedy, minions=minions)

    def check_minions(
        self,
        expr,
        tgt_type="glob",
        delimiter=DEFAULT_TARGET_DELIM,
        greedy=True,
        fun=None,
    ):
        """
        Check the passed regex against the available minions' public keys
        stored for authentication. This should return a set of ids which
        match the regex, this will then be used to parse the returns to
        make sure everyone has checked back in.
        """
        try:
            if expr is None:
                expr = ""
            check_func = getattr(self, f"_check_{tgt_type}_minions", None)
            if tgt_type in (
                "grain",
                "grain_pcre",
                "pillar",
                "pillar_pcre",
                "pillar_exact",
                "compound",
                "compound_pillar_exact",
            ):
                # pylint: disable=not-callable
                if tgt_type in ("compound", "compound_pillar_exact"):
                    _res = check_func(expr, delimiter, greedy, fun=fun)
                else:
                    _res = check_func(expr, delimiter, greedy)
                # pylint: enable=not-callable
            else:
                _res = check_func(expr, greedy)  # pylint: disable=not-callable
            # For wildcard glob targets (e.g. ``salt '*'``), include resource
            # IDs managed by matched minions so that the master keeps its
            # response window open long enough to receive resource results.
            # Specific name targets (e.g. ``salt 'minion'``) are intentionally
            # NOT augmented — targeting a minion by name should not implicitly
            # include its resources.
            # Compound targets handle resource matching explicitly via T@/M@.
            # Merge-mode functions (state.apply etc.) run resources inline on
            # the managing minion and return ONE combined response, so the
            # master must NOT add separate resource IDs to its wait-list.
            if (
                tgt_type == "glob"
                and isinstance(expr, str)
                and any(c in expr for c in ("*", "?", "["))
                and not (isinstance(fun, str) and fun in _MERGE_RESOURCE_FUNS)
            ):
                _res["minions"] = self._augment_with_resources(_res["minions"])
            _res["ssh_minions"] = False
            if self.opts.get("enable_ssh_minions", False) is True and isinstance(
                "tgt", str
            ):
                roster = salt.roster.Roster(self.opts, self.opts.get("roster", "flat"))
                ssh_minions = roster.targets(expr, tgt_type)
                if ssh_minions:
                    _res["minions"].extend(ssh_minions)
                    _res["ssh_minions"] = True
                roster.destroy()
        except Exception:  # pylint: disable=broad-except
            log.exception(
                "Failed matching available minions with %s pattern: %s", tgt_type, expr
            )
            _res = {"minions": [], "missing": []}
        return _res

    def validate_tgt(self, valid, expr, tgt_type, minions=None, expr_form=None):
        """
        Validate the target minions against the possible valid minions.

        If ``minions`` is provided, they will be compared against the valid
        minions. Otherwise, ``expr`` and ``tgt_type`` will be used to expand
        to a list of target minions.

        Return True if all of the requested minions are valid minions,
        otherwise return False.
        """

        # save some cpu cycles, also avoid a masterminion no-cache bug
        if valid == "*":
            return True

        # No minions can ever match a @runner,@master,etc
        if valid.startswith("@"):
            return False

        v_minions = set(self.check_minions(valid, "compound").get("minions", []))
        if minions is None:
            _res = self.check_minions(expr, tgt_type)
            minions = set(_res["minions"])
        else:
            minions = set(minions)
        d_bool = not bool(minions.difference(v_minions))
        if len(v_minions) == len(minions) and d_bool:
            return True
        return d_bool

    def match_check(self, regex, fun):
        """
        Validate a single regex to function comparison, the function argument
        can be a list of functions. It is all or nothing for a list of
        functions
        """
        vals = []
        if isinstance(fun, str):
            fun = [fun]
        for func in fun:
            try:
                if re.match(regex, func):
                    vals.append(True)
                else:
                    vals.append(False)
            except Exception:  # pylint: disable=broad-except
                log.error("Invalid regular expression: %s", regex)
        return vals and all(vals)

    def auth_check_expanded(
        self,
        auth_list,
        funs,
        args,
        tgt,
        tgt_type="glob",
        groups=None,
        publish_validate=False,
    ):

        # Here's my thinking
        # 1. Retrieve anticipated targeted minions
        # 2. Iterate through each entry in the auth_list
        # 3. If it is a minion_id, check to see if any targeted minions match.
        #    If there is a match, check to make sure funs are permitted
        #    (if it's not a match we don't care about this auth entry and can
        #     move on)
        #    a. If funs are permitted, Add this minion_id to a new set of allowed minion_ids
        #       If funs are NOT permitted, can short-circuit and return FALSE
        #    b. At the end of the auth_list loop, make sure all targeted IDs
        #       are in the set of allowed minion_ids.  If not, return FALSE
        # 4. If it is a target (glob, pillar, etc), retrieve matching minions
        #    and make sure that ALL targeted minions are in the set.
        #    then check to see if the funs are permitted
        #    a. If ALL targeted minions are not in the set, then return FALSE
        #    b. If the desired fun doesn't mass the auth check with any
        #       auth_entry's fun, then return FALSE

        # NOTE we are not going to try to allow functions to run on partial
        # sets of minions.  If a user targets a group of minions and does not
        # have access to run a job on ALL of these minions then the job will
        # fail with 'Eauth Failed'.

        # The recommended workflow in that case will be for the user to narrow
        # his target.

        # This should cover adding the AD LDAP lookup functionality while
        # preserving the existing auth behavior.

        # Recommend we config-get this behind an entry called
        # auth.enable_expanded_auth_matching
        # and default to False
        v_tgt_type = tgt_type
        if tgt_type.lower() in ("pillar", "pillar_pcre"):
            v_tgt_type = "pillar_exact"
        elif tgt_type.lower() == "compound":
            v_tgt_type = "compound_pillar_exact"
        _res = self.check_minions(tgt, v_tgt_type)
        v_minions = set(_res["minions"])

        _res = self.check_minions(tgt, tgt_type)
        minions = set(_res["minions"])

        mismatch = bool(minions.difference(v_minions))
        # If the non-exact match gets more minions than the exact match
        # then pillar globbing or PCRE is being used, and we have a
        # problem
        if publish_validate:
            if mismatch:
                return False
        # compound commands will come in a list so treat everything as a list
        if not isinstance(funs, list):
            funs = [funs]
            args = [args]

        # Take the auth list and get all the minion names inside it
        allowed_minions = set()

        auth_dictionary = {}

        # Make a set, so we are guaranteed to have only one of each minion
        # Also iterate through the entire auth_list and create a dictionary
        # so it's easy to look up what functions are permitted
        for auth_list_entry in auth_list:
            if isinstance(auth_list_entry, str):
                for fun in funs:
                    # represents toplevel auth entry is a function.
                    # so this fn is permitted by all minions
                    if self.match_check(auth_list_entry, fun):
                        return True
                continue
            if isinstance(auth_list_entry, dict):
                if len(auth_list_entry) != 1:
                    log.info("Malformed ACL: %s", auth_list_entry)
                    continue
            allowed_minions.update(set(auth_list_entry.keys()))
            for key in auth_list_entry:
                for match in set(self.check_minions(key, "compound")):
                    if match in auth_dictionary:
                        auth_dictionary[match].extend(auth_list_entry[key])
                    else:
                        auth_dictionary[match] = auth_list_entry[key]

        allowed_minions_from_auth_list = set()
        for next_entry in allowed_minions:
            allowed_minions_from_auth_list.update(
                set(self.check_minions(next_entry, "compound"))
            )
        # 'minions' here are all the names of minions matched by the target
        # if we take out all the allowed minions, and there are any left, then
        # the target includes minions that are not allowed by eauth
        # so we can give up here.
        if minions - allowed_minions_from_auth_list:
            return False

        try:
            for minion in minions:
                results = []
                for num, fun in enumerate(auth_dictionary[minion]):
                    results.append(self.match_check(fun, funs))
                if not any(results):
                    return False
            return True

        except TypeError:
            return False
        return False

    def auth_check(
        self,
        auth_list,
        funs,
        args,
        tgt,
        tgt_type="glob",
        groups=None,
        publish_validate=False,
        minions=None,
        whitelist=None,
    ):
        """
        Returns a bool which defines if the requested function is authorized.
        Used to evaluate the standard structure under external master
        authentication interfaces, like eauth, peer, peer_run, etc.
        """
        if self.opts.get("auth.enable_expanded_auth_matching", False):
            return self.auth_check_expanded(
                auth_list, funs, args, tgt, tgt_type, groups, publish_validate
            )
        if publish_validate:
            v_tgt_type = tgt_type
            if tgt_type.lower() in ("pillar", "pillar_pcre"):
                v_tgt_type = "pillar_exact"
            elif tgt_type.lower() == "compound":
                v_tgt_type = "compound_pillar_exact"
            _res = self.check_minions(tgt, v_tgt_type)
            v_minions = set(_res["minions"])

            _res = self.check_minions(tgt, tgt_type)
            minions = set(_res["minions"])

            mismatch = bool(minions.difference(v_minions))
            # If the non-exact match gets more minions than the exact match
            # then pillar globbing or PCRE is being used, and we have a
            # problem
            if mismatch:
                return False
        # compound commands will come in a list so treat everything as a list
        if not isinstance(funs, list):
            funs = [funs]
            args = [args]
        try:
            for num, fun in enumerate(funs):
                if whitelist and fun in whitelist:
                    return True
                for ind in auth_list:
                    if isinstance(ind, str):
                        # Allowed for all minions
                        if self.match_check(ind, fun):
                            return True
                    elif isinstance(ind, dict):
                        if len(ind) != 1:
                            # Invalid argument
                            continue
                        valid = next(iter(ind.keys()))
                        # Check if minions are allowed
                        if self.validate_tgt(valid, tgt, tgt_type, minions=minions):
                            # Minions are allowed, verify function in allowed list
                            fun_args = args[num]
                            fun_kwargs = fun_args[-1] if fun_args else None
                            if (
                                isinstance(fun_kwargs, dict)
                                and "__kwarg__" in fun_kwargs
                            ):
                                fun_args = list(fun_args)  # copy on modify
                                del fun_args[-1]
                            else:
                                fun_kwargs = None
                            if self.__fun_check(ind[valid], fun, fun_args, fun_kwargs):
                                return True
        except TypeError:
            return False
        return False

    def fill_auth_list_from_groups(self, auth_provider, user_groups, auth_list):
        """
        Returns a list of authorisation matchers that a user is eligible for.
        This list is a combination of the provided personal matchers plus the
        matchers of any group the user is in.
        """
        group_names = [item for item in auth_provider if item.endswith("%")]
        if group_names:
            for group_name in group_names:
                if group_name.rstrip("%") in user_groups:
                    for matcher in auth_provider[group_name]:
                        auth_list.append(matcher)
        return auth_list

    def fill_auth_list(
        self, auth_provider, name, groups, auth_list=None, permissive=None
    ):
        """
        Returns a list of authorisation matchers that a user is eligible for.
        This list is a combination of the provided personal matchers plus the
        matchers of any group the user is in.
        """
        if auth_list is None:
            auth_list = []
        if permissive is None:
            permissive = self.opts.get("permissive_acl")
        name_matched = False
        for match in auth_provider:
            if match == "*" and not permissive:
                continue
            if match.endswith("%"):
                if match.rstrip("%") in groups:
                    auth_list.extend(auth_provider[match])
            else:
                if salt.utils.stringutils.expr_match(match, name):
                    name_matched = True
                    auth_list.extend(auth_provider[match])
        if not permissive and not name_matched and "*" in auth_provider:
            auth_list.extend(auth_provider["*"])
        return auth_list

    def wheel_check(self, auth_list, fun, args):
        """
        Check special API permissions
        """
        return self.spec_check(auth_list, fun, args, "wheel")

    def runner_check(self, auth_list, fun, args):
        """
        Check special API permissions
        """
        return self.spec_check(auth_list, fun, args, "runner")

    def spec_check(self, auth_list, fun, args, form):
        """
        Check special API permissions
        """
        if not auth_list:
            return False
        if form != "cloud":
            comps = fun.split(".")
            if len(comps) != 2:
                # Hint at a syntax error when command is passed improperly,
                # rather than returning an authentication error of some kind.
                # See Issue #21969 for more information.
                return {
                    "error": {
                        "name": "SaltInvocationError",
                        "message": "A command invocation error occurred: Check syntax.",
                    }
                }
            mod_name = comps[0]
            fun_name = comps[1]
        else:
            fun_name = mod_name = fun
        for ind in auth_list:
            if isinstance(ind, str):
                if ind[0] == "@":
                    if ind[1:] == mod_name or ind[1:] == form or ind == f"@{form}s":
                        return True
            elif isinstance(ind, dict):
                if len(ind) != 1:
                    continue
                valid = next(iter(ind.keys()))
                if valid[0] == "@":
                    if valid[1:] == mod_name:
                        if self.__fun_check(
                            ind[valid], fun_name, args.get("arg"), args.get("kwarg")
                        ):
                            return True
                    if valid[1:] == form or valid == f"@{form}s":
                        if self.__fun_check(
                            ind[valid], fun, args.get("arg"), args.get("kwarg")
                        ):
                            return True
        return False

    def __fun_check(self, valid, fun, args=None, kwargs=None):
        """
        Check the given function name (fun) and its arguments (args) against the list of conditions.
        """
        if not isinstance(valid, list):
            valid = [valid]
        for cond in valid:
            # Function name match
            if isinstance(cond, str):
                if self.match_check(cond, fun):
                    return True
            # Function and args match
            elif isinstance(cond, dict):
                if len(cond) != 1:
                    # Invalid argument
                    continue
                fname_cond = next(iter(cond.keys()))
                if self.match_check(
                    fname_cond, fun
                ):  # check key that is function name match
                    if self.__args_check(cond[fname_cond], args, kwargs):
                        return True
        return False

    def __args_check(self, valid, args=None, kwargs=None):
        """
        valid is a dicts: {'args': [...], 'kwargs': {...}} or a list of such dicts.
        """
        if not isinstance(valid, list):
            valid = [valid]
        for cond in valid:
            if not isinstance(cond, dict):
                # Invalid argument
                continue
            # whitelist args, kwargs
            cond_args = cond.get("args", [])
            good = True
            for i, cond_arg in enumerate(cond_args):
                if args is None or len(args) <= i:
                    good = False
                    break
                if cond_arg is None:  # None == '.*' i.e. allow any
                    continue
                if not self.match_check(cond_arg, str(args[i])):
                    good = False
                    break
            if not good:
                continue
            # Check kwargs
            cond_kwargs = cond.get("kwargs", {})
            for k, v in cond_kwargs.items():
                if kwargs is None or k not in kwargs:
                    good = False
                    break
                if v is None:  # None == '.*' i.e. allow any
                    continue
                if not self.match_check(v, str(kwargs[k])):
                    good = False
                    break
            if good:
                return True
        return False
