"""Command Analysis.

Functions for analyzing shell commands for security evaluation.
Parses commands, resolves executables, and evaluates against allowlists.
"""

from __future__ import annotations

import os
import re
import shutil
import stat
import sys
from collections.abc import Callable
from pathlib import Path

from elizaos_plugin_shell.approvals.types import (
    DEFAULT_SAFE_BINS,
    CommandResolution,
    ExecAllowlistAnalysis,
    ExecAllowlistEntry,
    ExecAllowlistEvaluation,
    ExecAsk,
    ExecCommandAnalysis,
    ExecCommandSegment,
    ExecSecurity,
    RiskLevel,
    CommandClassification,
)
from elizaos_plugin_shell.approvals.allowlist import match_allowlist

# Disallowed tokens in pipeline commands
DISALLOWED_PIPELINE_TOKENS = frozenset({">", "<", "`", "\n", "\r", "(", ")"})

# Escape characters inside double quotes
DOUBLE_QUOTE_ESCAPES = frozenset({"\\", '"', "$", "`", "\n", "\r"})

# Patterns that indicate high-risk commands
HIGH_RISK_PATTERNS: list[re.Pattern[str]] = [
    re.compile(r"\brm\s+(-rf?|--recursive)\b", re.IGNORECASE),
    re.compile(r"\bdd\s+if=", re.IGNORECASE),
    re.compile(r"\bmkfs\b", re.IGNORECASE),
    re.compile(r"\bformat\b", re.IGNORECASE),
    re.compile(r"\bfdisk\b", re.IGNORECASE),
    re.compile(r"\bshred\b", re.IGNORECASE),
    re.compile(r":\(\)\{.*\};:", re.IGNORECASE),
    re.compile(r"\bshutdown\b", re.IGNORECASE),
    re.compile(r"\breboot\b", re.IGNORECASE),
    re.compile(r"\bchmod\s+777\b", re.IGNORECASE),
]

# Read-only commands
READ_ONLY_COMMANDS = frozenset({
    "cat", "head", "tail", "less", "more", "grep", "find", "ls", "dir",
    "pwd", "whoami", "uname", "hostname", "date", "wc", "sort", "uniq",
    "cut", "tr", "awk", "sed", "file", "stat", "du", "df", "which",
    "whereis", "type", "echo", "printf", "env", "printenv", "id",
})

# Network commands
NETWORK_COMMANDS = frozenset({
    "curl", "wget", "ssh", "scp", "rsync", "ftp", "sftp", "nc",
    "netcat", "nmap", "ping", "traceroute", "dig", "nslookup", "host",
})

# System commands
SYSTEM_COMMANDS = frozenset({
    "systemctl", "service", "journalctl", "dmesg", "mount", "umount",
    "iptables", "firewall-cmd", "ufw", "crontab", "at",
})

# Destructive commands
DESTRUCTIVE_COMMANDS = frozenset({
    "rm", "rmdir", "shred", "dd", "mkfs", "fdisk", "format",
    "shutdown", "reboot", "halt", "poweroff",
})

# Type alias for iterator actions
IteratorAction = str | dict[str, str]  # "split" | "skip" | "include" | {"reject": reason}


def _is_executable_file(file_path: str) -> bool:
    """Check if a file is executable."""
    try:
        st = os.stat(file_path)
        if not stat.S_ISREG(st.st_mode):
            return False
        if sys.platform != "win32":
            return os.access(file_path, os.X_OK)
        return True
    except OSError:
        return False


def _parse_first_token(command: str) -> str | None:
    """Parse the first token from a command string."""
    trimmed = command.strip()
    if not trimmed:
        return None

    first = trimmed[0]
    if first in ('"', "'"):
        end = trimmed.find(first, 1)
        if end > 1:
            return trimmed[1:end]
        return trimmed[1:]

    match = re.match(r"^[^\s]+", trimmed)
    return match.group(0) if match else None


def _resolve_executable_path(
    raw_executable: str,
    cwd: str | None = None,
    env: dict[str, str] | None = None,
) -> str | None:
    """Resolve executable path from PATH."""
    expanded = os.path.expanduser(raw_executable) if raw_executable.startswith("~") else raw_executable

    if "/" in expanded or "\\" in expanded:
        if os.path.isabs(expanded):
            return expanded if _is_executable_file(expanded) else None
        base = (cwd or "").strip() or os.getcwd()
        candidate = os.path.normpath(os.path.join(base, expanded))
        return candidate if _is_executable_file(candidate) else None

    # Use shutil.which for PATH-based resolution
    resolved = shutil.which(expanded)
    if resolved:
        return resolved

    # Manual PATH search
    env_path = (env or {}).get("PATH") or os.environ.get("PATH", "")
    entries = [e for e in env_path.split(os.pathsep) if e]

    for entry in entries:
        candidate = os.path.join(entry, expanded)
        if _is_executable_file(candidate):
            return candidate

    return None


def resolve_command_resolution(
    command: str,
    cwd: str | None = None,
    env: dict[str, str] | None = None,
) -> CommandResolution | None:
    """Resolve command to executable info."""
    raw_executable = _parse_first_token(command)
    if not raw_executable:
        return None

    resolved_path = _resolve_executable_path(raw_executable, cwd, env)
    executable_name = os.path.basename(resolved_path) if resolved_path else raw_executable

    return CommandResolution(
        raw_executable=raw_executable,
        resolved_path=resolved_path,
        executable_name=executable_name,
    )


def resolve_command_from_argv(
    argv: list[str],
    cwd: str | None = None,
    env: dict[str, str] | None = None,
) -> CommandResolution | None:
    """Resolve command from argv."""
    if not argv:
        return None
    raw_executable = argv[0].strip()
    if not raw_executable:
        return None

    resolved_path = _resolve_executable_path(raw_executable, cwd, env)
    executable_name = os.path.basename(resolved_path) if resolved_path else raw_executable

    return CommandResolution(
        raw_executable=raw_executable,
        resolved_path=resolved_path,
        executable_name=executable_name,
    )


def _is_double_quote_escape(ch: str | None) -> bool:
    """Check if next char is a double-quote escape."""
    return ch is not None and ch in DOUBLE_QUOTE_ESCAPES


def _iterate_quote_aware(
    command: str,
    on_char: Callable[[str, str | None, int], IteratorAction],
) -> dict[str, bool | list[str] | str | None]:
    """Iterate through command while respecting shell quoting.

    Returns a dict with:
      - ok: bool
      - parts: list[str]  (if ok)
      - has_split: bool    (if ok)
      - reason: str        (if not ok)
    """
    parts: list[str] = []
    buf: list[str] = []
    in_single = False
    in_double = False
    escaped = False
    has_split = False

    def push_part() -> None:
        nonlocal buf
        trimmed = "".join(buf).strip()
        if trimmed:
            parts.append(trimmed)
        buf = []

    i = 0
    while i < len(command):
        ch = command[i]
        nxt = command[i + 1] if i + 1 < len(command) else None

        if escaped:
            buf.append(ch)
            escaped = False
            i += 1
            continue

        if not in_single and not in_double and ch == "\\":
            escaped = True
            buf.append(ch)
            i += 1
            continue

        if in_single:
            if ch == "'":
                in_single = False
            buf.append(ch)
            i += 1
            continue

        if in_double:
            if ch == "\\" and _is_double_quote_escape(nxt):
                buf.append(ch)
                buf.append(nxt)  # type: ignore[arg-type]
                i += 2
                continue
            if ch == "$" and nxt == "(":
                return {"ok": False, "reason": "unsupported shell token: $()"}
            if ch == "`":
                return {"ok": False, "reason": "unsupported shell token: `"}
            if ch in ("\n", "\r"):
                return {"ok": False, "reason": "unsupported shell token: newline"}
            if ch == '"':
                in_double = False
            buf.append(ch)
            i += 1
            continue

        if ch == "'":
            in_single = True
            buf.append(ch)
            i += 1
            continue

        if ch == '"':
            in_double = True
            buf.append(ch)
            i += 1
            continue

        action = on_char(ch, nxt, i)
        if isinstance(action, dict) and "reject" in action:
            return {"ok": False, "reason": action["reject"]}
        if action == "split":
            push_part()
            has_split = True
            i += 1
            continue
        if action == "skip":
            i += 1
            continue
        buf.append(ch)
        i += 1

    if escaped or in_single or in_double:
        return {"ok": False, "reason": "unterminated shell quote/escape"}

    push_part()
    return {"ok": True, "parts": parts, "has_split": has_split}


def _split_shell_pipeline(
    command: str,
) -> dict[str, bool | str | None | list[str]]:
    """Split command by pipeline operators."""
    empty_segment = False

    def on_char(ch: str, nxt: str | None, idx: int) -> IteratorAction:
        nonlocal empty_segment
        if ch == "|" and nxt == "|":
            return {"reject": "unsupported shell token: ||"}
        if ch == "|" and nxt == "&":
            return {"reject": "unsupported shell token: |&"}
        if ch == "|":
            empty_segment = True
            return "split"
        if ch in ("&", ";"):
            return {"reject": f"unsupported shell token: {ch}"}
        if ch in DISALLOWED_PIPELINE_TOKENS:
            return {"reject": f"unsupported shell token: {ch}"}
        if ch == "$" and nxt == "(":
            return {"reject": "unsupported shell token: $()"}
        empty_segment = False
        return "include"

    result = _iterate_quote_aware(command, on_char)
    if not result.get("ok"):
        return {"ok": False, "reason": result.get("reason"), "segments": []}

    parts = result.get("parts", [])
    if empty_segment or not parts:
        reason = "empty command" if not parts else "empty pipeline segment"
        return {"ok": False, "reason": reason, "segments": []}

    return {"ok": True, "segments": parts}


def _tokenize_shell_segment(segment: str) -> list[str] | None:
    """Tokenize shell segment into argv."""
    tokens: list[str] = []
    buf: list[str] = []
    in_single = False
    in_double = False
    escaped = False

    def push_token() -> None:
        nonlocal buf
        if buf:
            tokens.append("".join(buf))
            buf = []

    for i, ch in enumerate(segment):
        if escaped:
            buf.append(ch)
            escaped = False
            continue

        if not in_single and not in_double and ch == "\\":
            escaped = True
            continue

        if in_single:
            if ch == "'":
                in_single = False
            else:
                buf.append(ch)
            continue

        if in_double:
            nxt = segment[i + 1] if i + 1 < len(segment) else None
            if ch == "\\" and _is_double_quote_escape(nxt):
                buf.append(nxt)  # type: ignore[arg-type]
                continue
            if ch == '"':
                in_double = False
            else:
                buf.append(ch)
            continue

        if ch == "'":
            in_single = True
            continue
        if ch == '"':
            in_double = True
            continue
        if ch.isspace():
            push_token()
            continue

        buf.append(ch)

    if escaped or in_single or in_double:
        return None
    push_token()
    return tokens


def _parse_segments_from_parts(
    parts: list[str],
    cwd: str | None = None,
    env: dict[str, str] | None = None,
) -> list[ExecCommandSegment] | None:
    """Parse segments from parts."""
    segments = []
    for raw in parts:
        argv = _tokenize_shell_segment(raw)
        if argv is None or not argv:
            return None
        segments.append(
            ExecCommandSegment(
                raw=raw,
                argv=argv,
                resolution=resolve_command_from_argv(argv, cwd, env),
            )
        )
    return segments


def _split_command_chain(command: str) -> list[str] | None:
    """Split command by chain operators (&&, ||, ;)."""
    parts: list[str] = []
    buf: list[str] = []
    in_single = False
    in_double = False
    escaped = False
    found_chain = False
    invalid_chain = False

    def push_part() -> bool:
        nonlocal buf
        trimmed = "".join(buf).strip()
        buf = []
        if trimmed:
            parts.append(trimmed)
            return True
        return False

    i = 0
    while i < len(command):
        ch = command[i]
        nxt = command[i + 1] if i + 1 < len(command) else None

        if escaped:
            buf.append(ch)
            escaped = False
            i += 1
            continue

        if not in_single and not in_double and ch == "\\":
            escaped = True
            buf.append(ch)
            i += 1
            continue

        if in_single:
            if ch == "'":
                in_single = False
            buf.append(ch)
            i += 1
            continue

        if in_double:
            if ch == "\\" and _is_double_quote_escape(nxt):
                buf.append(ch)
                buf.append(nxt)  # type: ignore[arg-type]
                i += 2
                continue
            if ch == '"':
                in_double = False
            buf.append(ch)
            i += 1
            continue

        if ch == "'":
            in_single = True
            buf.append(ch)
            i += 1
            continue

        if ch == '"':
            in_double = True
            buf.append(ch)
            i += 1
            continue

        if ch == "&" and nxt == "&":
            if not push_part():
                invalid_chain = True
            i += 2
            found_chain = True
            continue

        if ch == "|" and nxt == "|":
            if not push_part():
                invalid_chain = True
            i += 2
            found_chain = True
            continue

        if ch == ";":
            if not push_part():
                invalid_chain = True
            found_chain = True
            i += 1
            continue

        buf.append(ch)
        i += 1

    pushed_final = push_part()
    if not found_chain:
        return None
    if invalid_chain or not pushed_final:
        return None
    return parts if parts else None


def analyze_shell_command(
    command: str,
    cwd: str | None = None,
    env: dict[str, str] | None = None,
    platform: str | None = None,
) -> ExecCommandAnalysis:
    """Analyze a shell command."""
    # Try splitting by chain operators
    chain_parts = _split_command_chain(command)
    if chain_parts:
        chains: list[list[ExecCommandSegment]] = []
        all_segments: list[ExecCommandSegment] = []

        for part in chain_parts:
            pipeline_split = _split_shell_pipeline(part)
            if not pipeline_split.get("ok"):
                return ExecCommandAnalysis(
                    ok=False, reason=str(pipeline_split.get("reason")), segments=[]
                )

            segments = _parse_segments_from_parts(
                pipeline_split.get("segments", []), cwd, env  # type: ignore[arg-type]
            )
            if segments is None:
                return ExecCommandAnalysis(
                    ok=False, reason="unable to parse shell segment", segments=[]
                )

            chains.append(segments)
            all_segments.extend(segments)

        return ExecCommandAnalysis(ok=True, segments=all_segments, chains=chains)

    # No chain operators, parse as simple pipeline
    split = _split_shell_pipeline(command)
    if not split.get("ok"):
        return ExecCommandAnalysis(
            ok=False, reason=str(split.get("reason")), segments=[]
        )

    segments = _parse_segments_from_parts(
        split.get("segments", []), cwd, env  # type: ignore[arg-type]
    )
    if segments is None:
        return ExecCommandAnalysis(
            ok=False, reason="unable to parse shell segment", segments=[]
        )

    return ExecCommandAnalysis(ok=True, segments=segments)


def normalize_safe_bins(entries: list[str] | None = None) -> set[str]:
    """Normalize safe bins set."""
    if not entries:
        return set()
    return {e.strip().lower() for e in entries if e.strip()}


def resolve_safe_bins(entries: list[str] | None = None) -> set[str]:
    """Resolve safe bins with defaults."""
    if entries is None:
        return normalize_safe_bins(list(DEFAULT_SAFE_BINS))
    return normalize_safe_bins(entries)


def _is_path_like_token(value: str) -> bool:
    """Check if path-like token."""
    trimmed = value.strip()
    if not trimmed or trimmed == "-":
        return False
    if trimmed.startswith(("./", "../", "~")):
        return True
    if trimmed.startswith("/"):
        return True
    return bool(re.match(r"^[A-Za-z]:[/\\]", trimmed))


def _default_file_exists(file_path: str) -> bool:
    """Default file existence check."""
    return os.path.exists(file_path)


def is_safe_bin_usage(
    argv: list[str],
    resolution: CommandResolution | None,
    safe_bins: set[str],
    cwd: str | None = None,
    file_exists: Callable[[str], bool] | None = None,
) -> bool:
    """Check if command is safe bin usage (no file args)."""
    if not safe_bins or not resolution:
        return False

    exec_name = (resolution.executable_name or "").lower()
    if not exec_name or exec_name not in safe_bins or not resolution.resolved_path:
        return False

    working_dir = cwd or os.getcwd()
    exists = file_exists or _default_file_exists
    args = argv[1:]

    for token in args:
        if not token or token == "-":
            continue

        if token.startswith("-"):
            eq_index = token.find("=")
            if eq_index > 0:
                value = token[eq_index + 1:]
                if value and (
                    _is_path_like_token(value)
                    or exists(os.path.normpath(os.path.join(working_dir, value)))
                ):
                    return False
            continue

        if _is_path_like_token(token):
            return False
        if exists(os.path.normpath(os.path.join(working_dir, token))):
            return False

    return True


def _resolve_allowlist_candidate_path(
    resolution: CommandResolution | None,
    cwd: str | None = None,
) -> str | None:
    """Resolve allowlist candidate path."""
    if not resolution:
        return None
    if resolution.resolved_path:
        return resolution.resolved_path

    raw = (resolution.raw_executable or "").strip()
    if not raw:
        return None

    expanded = os.path.expanduser(raw) if raw.startswith("~") else raw
    if "/" not in expanded and "\\" not in expanded:
        return None

    if os.path.isabs(expanded):
        return expanded

    base = (cwd or "").strip() or os.getcwd()
    return os.path.normpath(os.path.join(base, expanded))


def _evaluate_segments(
    segments: list[ExecCommandSegment],
    allowlist: list[ExecAllowlistEntry],
    safe_bins: set[str],
    cwd: str | None = None,
    skill_bins: set[str] | None = None,
    auto_allow_skills: bool = False,
) -> tuple[bool, list[ExecAllowlistEntry]]:
    """Evaluate segments against allowlist.

    Returns:
        Tuple of (satisfied, matches).
    """
    matches: list[ExecAllowlistEntry] = []
    allow_skills = auto_allow_skills and bool(skill_bins)

    satisfied = True
    for segment in segments:
        candidate_path = _resolve_allowlist_candidate_path(segment.resolution, cwd)
        candidate_resolution = (
            segment.resolution.model_copy(update={"resolved_path": candidate_path})
            if candidate_path and segment.resolution
            else segment.resolution
        )

        match = match_allowlist(allowlist, candidate_resolution)
        if match:
            matches.append(match)

        safe = is_safe_bin_usage(
            argv=segment.argv,
            resolution=segment.resolution,
            safe_bins=safe_bins,
            cwd=cwd,
        )

        skill_allow = (
            allow_skills
            and segment.resolution is not None
            and segment.resolution.executable_name is not None
            and skill_bins is not None
            and segment.resolution.executable_name in skill_bins
        )

        if not (match or safe or skill_allow):
            satisfied = False

    return satisfied, matches


def evaluate_exec_allowlist(
    analysis: ExecCommandAnalysis,
    allowlist: list[ExecAllowlistEntry],
    safe_bins: set[str],
    cwd: str | None = None,
    skill_bins: set[str] | None = None,
    auto_allow_skills: bool = False,
) -> ExecAllowlistEvaluation:
    """Evaluate command against allowlist."""
    if not analysis.ok or not analysis.segments:
        return ExecAllowlistEvaluation(
            allowlist_satisfied=False, allowlist_matches=[]
        )

    # If analysis contains chains, evaluate each separately
    if analysis.chains:
        all_matches: list[ExecAllowlistEntry] = []
        for chain_segments in analysis.chains:
            satisfied, matches = _evaluate_segments(
                chain_segments, allowlist, safe_bins, cwd, skill_bins, auto_allow_skills
            )
            if not satisfied:
                return ExecAllowlistEvaluation(
                    allowlist_satisfied=False, allowlist_matches=[]
                )
            all_matches.extend(matches)
        return ExecAllowlistEvaluation(
            allowlist_satisfied=True, allowlist_matches=all_matches
        )

    # No chains, evaluate all segments together
    satisfied, matches = _evaluate_segments(
        analysis.segments, allowlist, safe_bins, cwd, skill_bins, auto_allow_skills
    )
    return ExecAllowlistEvaluation(
        allowlist_satisfied=satisfied,
        allowlist_matches=matches,
    )


def evaluate_shell_allowlist(
    command: str,
    allowlist: list[ExecAllowlistEntry],
    safe_bins: set[str],
    cwd: str | None = None,
    env: dict[str, str] | None = None,
    skill_bins: set[str] | None = None,
    auto_allow_skills: bool = False,
    platform: str | None = None,
) -> ExecAllowlistAnalysis:
    """Evaluate shell command for allowlist (combined analysis + evaluation)."""
    chain_parts = _split_command_chain(command)

    if not chain_parts:
        analysis = analyze_shell_command(command, cwd, env, platform)
        if not analysis.ok:
            return ExecAllowlistAnalysis(
                analysis_ok=False,
                allowlist_satisfied=False,
                allowlist_matches=[],
                segments=[],
            )

        evaluation = evaluate_exec_allowlist(
            analysis, allowlist, safe_bins, cwd, skill_bins, auto_allow_skills
        )
        return ExecAllowlistAnalysis(
            analysis_ok=True,
            allowlist_satisfied=evaluation.allowlist_satisfied,
            allowlist_matches=evaluation.allowlist_matches,
            segments=analysis.segments,
        )

    all_matches: list[ExecAllowlistEntry] = []
    all_segments: list[ExecCommandSegment] = []

    for part in chain_parts:
        analysis = analyze_shell_command(part, cwd, env, platform)
        if not analysis.ok:
            return ExecAllowlistAnalysis(
                analysis_ok=False,
                allowlist_satisfied=False,
                allowlist_matches=[],
                segments=[],
            )

        all_segments.extend(analysis.segments)

        evaluation = evaluate_exec_allowlist(
            analysis, allowlist, safe_bins, cwd, skill_bins, auto_allow_skills
        )
        all_matches.extend(evaluation.allowlist_matches)

        if not evaluation.allowlist_satisfied:
            return ExecAllowlistAnalysis(
                analysis_ok=True,
                allowlist_satisfied=False,
                allowlist_matches=all_matches,
                segments=all_segments,
            )

    return ExecAllowlistAnalysis(
        analysis_ok=True,
        allowlist_satisfied=True,
        allowlist_matches=all_matches,
        segments=all_segments,
    )


def requires_exec_approval(
    ask: ExecAsk,
    security: ExecSecurity,
    analysis_ok: bool,
    allowlist_satisfied: bool,
) -> bool:
    """Check if approval is required."""
    return ask == ExecAsk.ALWAYS or (
        ask == ExecAsk.ON_MISS
        and security == ExecSecurity.ALLOWLIST
        and (not analysis_ok or not allowlist_satisfied)
    )


def get_risk_level(command: str) -> RiskLevel:
    """Get risk level for a command."""
    for pattern in HIGH_RISK_PATTERNS:
        if pattern.search(command):
            return RiskLevel.CRITICAL

    first_token = _parse_first_token(command)
    if not first_token:
        return RiskLevel.MEDIUM

    base_cmd = os.path.basename(first_token).lower()

    if base_cmd in DESTRUCTIVE_COMMANDS:
        return RiskLevel.HIGH
    if base_cmd in SYSTEM_COMMANDS:
        return RiskLevel.HIGH
    if base_cmd in NETWORK_COMMANDS:
        return RiskLevel.MEDIUM
    if base_cmd in READ_ONLY_COMMANDS:
        return RiskLevel.LOW

    # Check for sudo prefix
    if base_cmd == "sudo":
        return RiskLevel.HIGH

    return RiskLevel.MEDIUM


def classify_command_type(command: str) -> CommandClassification:
    """Classify the type of a command."""
    first_token = _parse_first_token(command)
    if not first_token:
        return CommandClassification.UNKNOWN

    base_cmd = os.path.basename(first_token).lower()

    if base_cmd in DESTRUCTIVE_COMMANDS:
        return CommandClassification.DESTRUCTIVE
    if base_cmd in SYSTEM_COMMANDS:
        return CommandClassification.SYSTEM
    if base_cmd in NETWORK_COMMANDS:
        return CommandClassification.NETWORK

    # Check for write indicators before read-only classification
    # since redirects like > override a read-only base command (e.g. echo hi > file)
    if any(token in command for token in (">", ">>", "tee")):
        return CommandClassification.WRITE

    # Commands that modify files
    write_cmds = {"cp", "mv", "mkdir", "touch", "chmod", "chown", "ln", "install"}
    if base_cmd in write_cmds:
        return CommandClassification.WRITE

    if base_cmd in READ_ONLY_COMMANDS:
        return CommandClassification.READ_ONLY

    return CommandClassification.UNKNOWN


def detect_patterns(command: str) -> list[str]:
    """Detect notable patterns in a command."""
    patterns_found: list[str] = []

    if "|" in command:
        patterns_found.append("pipeline")
    if "&&" in command or "||" in command:
        patterns_found.append("chain")
    if ";" in command:
        patterns_found.append("semicolon_chain")
    if "$(" in command or "`" in command:
        patterns_found.append("command_substitution")
    if ">" in command or ">>" in command:
        patterns_found.append("output_redirect")
    if "<" in command:
        patterns_found.append("input_redirect")
    if command.strip().startswith("sudo"):
        patterns_found.append("sudo")
    if "2>&1" in command or "2>" in command:
        patterns_found.append("stderr_redirect")
    if "&" in command and "&&" not in command:
        patterns_found.append("background")
    if any(p.search(command) for p in HIGH_RISK_PATTERNS):
        patterns_found.append("high_risk")

    return patterns_found
