"""
Default worker-pool configuration and validation for the Salt master.

Worker pools partition the master's MWorkers into named groups and route
specific commands to specific groups, so a slow workload cannot starve
time-critical traffic (for example ``_auth``).  See the
:ref:`tunable worker pools <tunable-worker-pools>` topic guide for the
user-facing overview.

This module contains three things:

* :data:`DEFAULT_WORKER_POOLS`, the configuration used when the operator
  provides no explicit ``worker_pools`` stanza and no ``worker_threads``
  override.
* :func:`validate_worker_pools_config`, called from master configuration
  processing to enforce structural and security invariants before the master
  is allowed to start.
* :func:`get_worker_pools_config`, which resolves the effective pool layout
  from the master opts, handling backward compatibility with
  ``worker_threads`` and the ``worker_pools_enabled=False`` legacy switch.

The pool dictionary shape is::

    {
        "<pool-name>": {
            "worker_count": <int >= 1>,
            "commands": ["<cmd>", ..., "*"?],
        },
        ...
    }

``commands`` entries are either exact command names (for example ``_auth``)
or the catchall marker ``"*"``.  Exactly one pool must use ``"*"``, and no
command may be claimed by more than one pool.
"""

# Default worker pool routing configuration.
#
# Two pools: a single-worker ``auth`` pool that handles minion authentication
# (``_auth``) and a four-worker ``default`` pool that catches everything
# else.  Total worker count matches the long-standing ``worker_threads``
# default of 5, so existing deployments see the same number of MWorker
# processes — the only change is that one of those workers is dedicated to
# auth, so a slow workload (e.g. a slow ext_pillar) cannot starve out
# minion authentication.
#
# The auth pool is sized to one worker on purpose: ``salt.cache.Cache``
# stores minion key state under ``keys/<id>`` and ``denied_keys/<id>``, and
# concurrent auth workers writing the same minion id could race on the
# pending → accepted → denied transitions.  Operators that have audited
# the cache backend for atomic store semantics can raise this.
#
# The master falls back to this value only when the operator sets neither
# ``worker_pools`` nor ``worker_threads``.
DEFAULT_WORKER_POOLS = {
    "auth": {
        "worker_count": 1,
        "commands": ["_auth"],
    },
    "default": {
        "worker_count": 4,
        "commands": ["*"],
    },
}


def validate_worker_pools_config(opts):
    """
    Validate the effective worker-pool configuration at master startup.

    Called during master configuration processing.  Returns ``True`` when
    the configuration is acceptable; raises :class:`ValueError` with a
    consolidated multi-line message listing every problem the validator
    found.  The accumulated reporting style lets operators fix their config
    in a single pass instead of discovering errors one at a time.

    The following invariants are enforced:

    * ``worker_pools`` is a non-empty dictionary.
    * Pool names are non-empty strings, contain no path separators
      (``/`` or ``\\``), do not begin with ``..``, and contain no null
      byte.  These rules exist purely to prevent pool names from being
      abused to steer IPC sockets or logs out of the master's runtime
      directories.
    * Each pool value is a dictionary containing an integer
      ``worker_count >= 1`` and a non-empty list of string ``commands``.
    * No command string is claimed by more than one pool.
    * Exactly one pool uses the ``"*"`` catchall entry so that any
      command not listed explicitly has a well-defined destination.

    When ``worker_pools_enabled`` is ``False`` validation is skipped; the
    master runs in the legacy single-queue MWorker mode where pool routing
    does not apply.

    :param dict opts: The master configuration dictionary.
    :returns: ``True`` when the configuration is valid.
    :raises ValueError: If the configuration is invalid.  The exception
        message lists every detected error.
    """
    if not opts.get("worker_pools_enabled", True):
        # Legacy mode, no validation needed
        return True

    # Get the effective worker pools (handles defaults and backward compat)
    worker_pools = get_worker_pools_config(opts)

    # If pools are disabled, no validation needed
    if worker_pools is None:
        return True

    errors = []

    # 1. Validate pool structure
    if not isinstance(worker_pools, dict):
        errors.append("worker_pools must be a dictionary")
        raise ValueError("\n".join(errors))

    if not worker_pools:
        errors.append("worker_pools cannot be empty")
        raise ValueError("\n".join(errors))

    # 2. Validate each pool
    cmd_to_pool = {}
    catchall_pool = None

    for pool_name, pool_config in worker_pools.items():
        # Validate pool name format (security-focused: block path traversal only)
        if not isinstance(pool_name, str):
            errors.append(f"Pool name must be a string, got {type(pool_name).__name__}")
            continue

        if not pool_name:
            errors.append("Pool name cannot be empty")
            continue

        # Security: block path traversal attempts
        if "/" in pool_name or "\\" in pool_name:
            errors.append(
                f"Pool name '{pool_name}' is invalid. Pool names cannot contain "
                "path separators (/ or \\) to prevent path traversal attacks."
            )
            continue

        # Security: block relative path components
        if (
            pool_name == ".."
            or pool_name.startswith("../")
            or pool_name.startswith("..\\")
        ):
            errors.append(
                f"Pool name '{pool_name}' is invalid. Pool names cannot be or start with "
                "'../' to prevent path traversal attacks."
            )
            continue

        # Security: block null bytes
        if "\x00" in pool_name:
            errors.append("Pool name contains null byte, which is not allowed.")
            continue

        if not isinstance(pool_config, dict):
            errors.append(f"Pool '{pool_name}': configuration must be a dictionary")
            continue

        # Check worker_count
        worker_count = pool_config.get("worker_count")
        if not isinstance(worker_count, int) or worker_count < 1:
            errors.append(
                f"Pool '{pool_name}': worker_count must be integer >= 1, "
                f"got {worker_count}"
            )

        # Check commands list
        commands = pool_config.get("commands", [])
        if not isinstance(commands, list):
            errors.append(f"Pool '{pool_name}': commands must be a list")
            continue

        if not commands:
            errors.append(f"Pool '{pool_name}': commands list cannot be empty")
            continue

        # Check for duplicate command mappings and catchall
        for cmd in commands:
            if not isinstance(cmd, str):
                errors.append(f"Pool '{pool_name}': command '{cmd}' must be a string")
                continue

            if cmd == "*":
                # Found catchall pool
                if catchall_pool is not None:
                    errors.append(
                        f"Multiple pools have catchall ('*'): "
                        f"'{catchall_pool}' and '{pool_name}'. "
                        "Only one pool can use catchall."
                    )
                catchall_pool = pool_name
                continue

            if cmd in cmd_to_pool:
                errors.append(
                    f"Command '{cmd}' mapped to multiple pools: "
                    f"'{cmd_to_pool[cmd]}' and '{pool_name}'"
                )
            else:
                cmd_to_pool[cmd] = pool_name

    # 3. Require exactly one catchall pool
    if catchall_pool is None:
        errors.append(
            "No catchall pool ('*') found. One pool must include '*' in its "
            "commands so every command has a routing destination."
        )

    if errors:
        raise ValueError(
            "Worker pools configuration validation failed:\n  - "
            + "\n  - ".join(errors)
        )

    return True


def get_worker_pools_config(opts):
    """
    Resolve the effective worker-pool configuration from master opts.

    Resolution order, first match wins:

    1. ``worker_pools_enabled`` is ``False`` — returns ``None`` to signal
       the legacy non-pooled code path.
    2. ``worker_pools`` is set and non-empty — returned verbatim.  The
       operator is fully in charge of pool layout.
    3. ``worker_threads`` is set — returns a synthesized single-pool
       configuration whose ``worker_count`` matches ``worker_threads`` and
       whose ``commands`` is the catchall ``["*"]``.  This is the upgrade
       path that keeps pre-3008.0 configurations byte-for-byte compatible.
    4. Neither is set — returns :data:`DEFAULT_WORKER_POOLS`.

    :param dict opts: The master configuration dictionary.
    :returns: The resolved pool layout, or ``None`` when pooling is
        explicitly disabled.
    :rtype: dict or None
    """
    # If pools explicitly disabled, return None (legacy mode)
    if not opts.get("worker_pools_enabled", True):
        return None

    # Check if worker_pools is explicitly configured AND not empty
    if "worker_pools" in opts and opts["worker_pools"]:
        return opts["worker_pools"]

    # Backward compatibility: convert worker_threads to single catchall pool
    if "worker_threads" in opts:
        worker_count = opts["worker_threads"]
        return {
            "default": {
                "worker_count": worker_count,
                "commands": ["*"],
            }
        }

    # Use default configuration
    return DEFAULT_WORKER_POOLS
