"""Secure command validation for subprocess execution.

This module provides security-focused validation of commands before they
are executed via subprocess. It uses a whitelist approach to prevent
arbitrary command execution and shell injection attacks.

Security Principles:
1. Only allow known, safe executables
2. Never use shell=True
3. Detect and reject shell injection patterns
4. Validate all arguments for dangerous content
"""

import re
from typing import FrozenSet, List, Optional, Set

# Windows wrapper suffixes that tool resolution commonly returns.
# ``find_tool()`` and friends hand back paths like ``Scripts\black.exe`` or
# ``node_modules\.bin\eslint.cmd``.  The whitelist stores bare names, so we
# strip these at comparison time.  We deliberately do *not* use
# ``Path.stem`` here: that would turn ``python3.11`` into ``python3``.
_WINDOWS_EXE_SUFFIXES = (".exe", ".cmd", ".bat")


def _normalize_executable_name(raw: str) -> str:
    """Return *raw* executable name with Windows wrapper suffixes removed.

    Handles both POSIX and Windows path separators regardless of the
    host platform, so a Windows-style path resolved on a mounted share
    (or simply passed through from config) is still recognized when
    validated on POSIX — and vice-versa.

    Only strips the last suffix when it exactly matches a known wrapper
    extension.  Case-insensitive on the suffix, preserves case on the
    base name (so ``Python.EXE`` → ``Python``, then callers lower-case
    for set membership).
    """
    # Last component across either separator — ``pathlib`` alone can't do
    # this because ``PurePosixPath`` treats ``\`` as a literal character.
    name = raw.replace("\\", "/").rsplit("/", 1)[-1]
    lowered = name.lower()
    for suffix in _WINDOWS_EXE_SUFFIXES:
        if lowered.endswith(suffix):
            return name[: -len(suffix)]
    return name


class SecurityError(Exception):
    """Raised when a command fails security validation."""


class CommandValidator:
    """Validates commands before subprocess execution.

    This class implements a whitelist-based security model for subprocess
    execution. Only explicitly allowed executables can be run, and all
    arguments are scanned for shell injection patterns.

    Attributes:
        ALLOWED_EXECUTABLES: Set of executable names that can be run
    """

    # Whitelist of allowed executables
    # These are common development tools that are safe to run
    ALLOWED_EXECUTABLES: FrozenSet[str] = frozenset(
        {
            # Python ecosystem
            "python",
            "python3",
            "python3.9",
            "python3.10",
            "python3.11",
            "python3.12",
            "python3.13",
            "python3.14",
            "pip",
            "pip3",
            "black",
            "isort",
            "flake8",
            "pylint",
            "mypy",
            "pytest",
            "coverage",
            "radon",
            "vulture",
            "xenon",
            "bandit",
            "detect-secrets",
            "pip-audit",
            "semgrep",
            "safety",
            "diff-cover",
            "autoflake",
            "pyright",
            "ruff",
            "find-duplicate-strings",
            # JavaScript/Node ecosystem
            "node",
            "npm",
            "npx",
            "yarn",
            "pnpm",
            "eslint",
            "prettier",
            "jest",
            "tsc",
            "dart",
            "flutter",
            # Version control
            "git",
            "gh",
            # Build tools
            "make",
            "cmake",
            # General utilities (some can mutate files - use with caution)
            "timeout",
            "find",
            "wc",
            "which",
            "ls",
            "cat",
            "head",
            "tail",
            "grep",
            "sort",
            "uniq",
            "tee",
            # Note: touch, mkdir, cp, mv can mutate filesystem but are
            # sometimes needed by build tools. They're allowed but logged.
            "touch",
            "mkdir",
            "cp",
            "mv",
            # Shells (for running scripts with explicit paths)
            "bash",
            "sh",
            "zsh",
        }
    )

    # Patterns that indicate potential shell injection
    DANGEROUS_PATTERNS: FrozenSet[str] = frozenset(
        {
            ";",  # Command separator
            "&&",  # Logical AND
            "||",  # Logical OR
            "`",  # Command substitution (backtick)
            "$(",  # Command substitution
            "${",  # Variable expansion
        }
    )

    # Additional patterns for more sophisticated attacks
    DANGEROUS_REGEX_PATTERNS: List[re.Pattern[str]] = [
        re.compile(r"\$\{.*\}"),  # Variable expansion ${...}
        re.compile(r"\$\(.*\)"),  # Command substitution $(...)
        re.compile(r"`.*`"),  # Command substitution `...`
        re.compile(r";\s*\w"),  # Semicolon followed by command
        # Shell operators only when used as operators (space/token delimited).
        # This avoids false positives on legitimate regex args like /(a|b)/.
        re.compile(r"(^|\s)(\||>>?|2>|&>|<)(\s|$)"),
        # Operators at arg start without surrounding spaces (e.g. "|cat", "2>/tmp").
        re.compile(r"^(\||>>?|2>|&>|<)\S"),
        # Operators at arg end without surrounding spaces (e.g. "foo|", "bar>>").
        re.compile(r"\S(\||>>?|2>|&>|<)$"),
    ]

    def __init__(self, additional_allowed: Optional[Set[str]] = None):
        """Initialize validator with optional additional allowed executables.

        Args:
            additional_allowed: Extra executables to add to the whitelist
        """
        # Lookup (``validate``/``is_allowed``) lower-cases the executable
        # name, so the set must also be lower-cased or mixed-case inserts
        # via ``additional_allowed``/``add_allowed`` fail the lookup.
        self._allowed = {
            _normalize_executable_name(x).lower() for x in self.ALLOWED_EXECUTABLES
        }
        if additional_allowed:
            for executable in additional_allowed:
                self.add_allowed(executable)

    def validate(self, command: List[str]) -> bool:
        """Validate that a command is safe to execute.

        Args:
            command: Command as list of strings (first element is executable)

        Returns:
            True if command is safe to execute

        Raises:
            SecurityError: If command fails validation
        """
        if not command:
            raise SecurityError("Empty command")

        if not isinstance(command, list):
            raise SecurityError("Command must be a list of strings")

        for i, arg in enumerate(command):
            if not isinstance(arg, str):
                raise SecurityError(f"Argument {i} is not a string: {type(arg)}")

        # Extract executable name (handle full paths and Windows suffixes)
        executable = _normalize_executable_name(command[0]).lower()

        # Check if executable is in whitelist
        if executable not in self._allowed:
            raise SecurityError(
                f"Executable not in whitelist: {executable}\n"
                f"Allowed executables: {', '.join(sorted(self._allowed))}"
            )

        # Check all arguments for dangerous patterns
        for i, arg in enumerate(command[1:], start=1):
            self._validate_argument(arg, i)

        return True

    def _validate_argument(self, arg: str, position: int) -> None:
        """Validate a single command argument.

        Args:
            arg: The argument to validate
            position: Position in command (for error messages)

        Raises:
            SecurityError: If argument contains dangerous patterns
        """
        # Check for ALL dangerous patterns - any pattern in DANGEROUS_PATTERNS
        # is considered unsafe and will raise an error
        for pattern in self.DANGEROUS_PATTERNS:
            if pattern in arg:
                raise SecurityError(
                    f"Dangerous shell pattern in argument {position}: '{pattern}'\n"
                    f"Full argument: {arg}\n"
                    f"This pattern could enable shell injection attacks."
                )

        # Check regex patterns for more complex attacks
        for regex in self.DANGEROUS_REGEX_PATTERNS:
            if regex.search(arg):
                raise SecurityError(
                    f"Dangerous pattern in argument {position}: {regex.pattern}\n"
                    f"Full argument: {arg}"
                )

    def add_allowed(self, executable: str) -> None:
        """Add an executable to the whitelist.

        Args:
            executable: Name of executable to allow
        """
        self._allowed.add(_normalize_executable_name(executable).lower())

    def is_allowed(self, executable: str) -> bool:
        """Check if an executable is in the whitelist.

        Args:
            executable: Name of executable to check

        Returns:
            True if executable is allowed
        """
        return _normalize_executable_name(executable).lower() in self._allowed


# Module-level singleton for convenience
_default_validator: Optional[CommandValidator] = None


def get_validator() -> CommandValidator:
    """Get the default command validator singleton."""
    global _default_validator
    if _default_validator is None:
        _default_validator = CommandValidator()
    return _default_validator


def validate_command(command: List[str]) -> bool:
    """Validate a command using the default validator.

    Args:
        command: Command to validate

    Returns:
        True if command is safe

    Raises:
        SecurityError: If command fails validation
    """
    return get_validator().validate(command)
