"""Specialist: shell injection via f-strings/format in subprocess (without shell=True).

Catches subprocess.run(f"cmd {user_input}") where the first arg is a string
instead of a list — even without shell=True, this is a bug because the command
won't be split correctly and can cause unexpected behavior.
"""

from __future__ import annotations

import ast
from autodebug.agent.specialists.base import ASTData, Finding, Specialist


_SUBPROCESS_FUNCS = {"run", "Popen", "call", "check_output", "check_call"}
_OS_EXEC_FUNCS = {"system", "popen"}


class UnsafeShellSpecialist(Specialist):
    name = "unsafe_shell"
    trigger_features = ["has_subprocess"]

    async def run(self, source: str, ast_data: ASTData) -> list[Finding]:
        findings: list[Finding] = []

        for node in ast.walk(ast_data.tree):
            if not isinstance(node, ast.Call):
                continue

            func_name = None
            is_os = False

            # subprocess.run/Popen/etc.
            if (isinstance(node.func, ast.Attribute)
                    and node.func.attr in _SUBPROCESS_FUNCS
                    and isinstance(node.func.value, ast.Name)
                    and node.func.value.id == "subprocess"):
                func_name = f"subprocess.{node.func.attr}"

            # os.system / os.popen
            elif (isinstance(node.func, ast.Attribute)
                  and node.func.attr in _OS_EXEC_FUNCS
                  and isinstance(node.func.value, ast.Name)
                  and node.func.value.id == "os"):
                func_name = f"os.{node.func.attr}"
                is_os = True

            if not func_name or not node.args:
                continue

            first_arg = node.args[0]

            if is_os:
                # os.system() always runs through shell — any dynamic input is injection
                if _is_dynamic_string(first_arg):
                    findings.append(Finding(
                        line=node.lineno,
                        severity="HIGH",
                        message=f"`{func_name}()` with dynamic string — shell injection. Use `subprocess.run()` with a list instead",
                        specialist=self.name,
                        confidence="DEFINITE",
                    ))
                continue

            # For subprocess: check if first arg is a dynamic string (not a list)
            if isinstance(first_arg, (ast.List, ast.Tuple)):
                # List args — check individual elements for f-strings
                for elt in first_arg.elts:
                    if isinstance(elt, ast.JoinedStr):
                        findings.append(Finding(
                            line=node.lineno,
                            severity="MEDIUM",
                            message=f"`{func_name}()` list arg contains f-string — user input may not be properly escaped",
                            specialist=self.name,
                            confidence="LIKELY",
                        ))
                        break
                continue

            # String arg to subprocess (not a list)
            if _is_dynamic_string(first_arg):
                # Check for shell=True
                has_shell = any(
                    kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True
                    for kw in node.keywords
                )
                if has_shell:
                    findings.append(Finding(
                        line=node.lineno,
                        severity="HIGH",
                        message=f"`{func_name}(shell=True)` with dynamic string — command injection",
                        specialist=self.name,
                        confidence="DEFINITE",
                    ))
                else:
                    findings.append(Finding(
                        line=node.lineno,
                        severity="MEDIUM",
                        message=f"`{func_name}()` called with string instead of list — command won't split correctly and may inject",
                        specialist=self.name,
                        confidence="LIKELY",
                    ))

        return findings


def _is_dynamic_string(node: ast.expr) -> bool:
    """Check if an AST node is a dynamic string (f-string, format, concatenation)."""
    if isinstance(node, ast.JoinedStr):
        return True
    if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
        return True
    if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
            and node.func.attr == "format"):
        return True
    return False
