"""Static pattern detection against parsed Python ASTs.

Detects known-bad function calls and constructs that commonly indicate
input-sanitization weaknesses. This module operates without an LLM and
provides useful output in degraded mode.
"""

from __future__ import annotations

import ast
from dataclasses import dataclass
from pathlib import Path
from typing import Literal

from sanicode.scanner._sql_detect import is_formatted_sql

Severity = Literal["critical", "high", "medium", "low", "info"]


@dataclass
class Finding:
    """A single security finding produced by the scanner."""

    file: Path
    line: int
    column: int
    rule_id: str
    message: str
    severity: Severity
    cwe_id: int


# Known-bad patterns: maps rule_id → human-readable description.
# Each entry corresponds to a detectable AST pattern or function call.
KNOWN_BAD_PATTERNS: dict[str, str] = {
    "SC001": "Use of eval() — arbitrary code execution via unsanitized input (CWE-78)",
    "SC002": "Use of exec() — arbitrary code execution via unsanitized input (CWE-78)",
    "SC003": "Use of os.system() — command injection risk (CWE-78)",
    "SC004": "Use of subprocess with shell=True — command injection risk (CWE-78)",
    "SC005": "Use of pickle.loads() — deserialization of untrusted data (CWE-502)",
    "SC006": "String formatting in SQL context — potential SQL injection (CWE-89)",
    "SC007": "Use of __import__() — dynamic import from untrusted input (CWE-94)",
    "SC008": "Use of yaml.load() without Loader — deserialization of untrusted data (CWE-502)",
}


class _PatternVisitor(ast.NodeVisitor):
    """AST visitor that accumulates findings for a single file."""

    def __init__(self, file_path: Path) -> None:
        self.file = file_path
        self.findings: list[Finding] = []

    def _add(
        self, node: ast.AST, rule_id: str, message: str, severity: Severity, cwe_id: int
    ) -> None:
        self.findings.append(
            Finding(
                file=self.file,
                line=getattr(node, "lineno", 0),
                column=getattr(node, "col_offset", 0),
                rule_id=rule_id,
                message=message,
                severity=severity,
                cwe_id=cwe_id,
            )
        )

    def visit_Call(self, node: ast.Call) -> None:  # noqa: N802
        func = node.func

        # eval() / exec() / __import__()
        if isinstance(func, ast.Name):
            if func.id == "eval":
                self._add(node, "SC001", KNOWN_BAD_PATTERNS["SC001"], "critical", 78)
            elif func.id == "exec":
                self._add(node, "SC002", KNOWN_BAD_PATTERNS["SC002"], "high", 78)
            elif func.id == "__import__":
                self._add(node, "SC007", KNOWN_BAD_PATTERNS["SC007"], "medium", 94)

        # Attribute calls: os.system, subprocess.*, pickle.loads, yaml.load
        if isinstance(func, ast.Attribute):
            qual = f"{_attr_name(func.value)}.{func.attr}"

            if qual == "os.system":
                self._add(node, "SC003", KNOWN_BAD_PATTERNS["SC003"], "high", 78)

            elif func.attr in ("call", "run", "Popen", "check_output", "check_call"):
                # Check for shell=True keyword argument
                for kw in node.keywords:
                    if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value:
                        self._add(
                            node, "SC004", KNOWN_BAD_PATTERNS["SC004"], "high", 78
                        )

            elif qual == "pickle.loads":
                self._add(node, "SC005", KNOWN_BAD_PATTERNS["SC005"], "high", 502)

            elif func.attr == "execute" and is_formatted_sql(node):
                self._add(node, "SC006", KNOWN_BAD_PATTERNS["SC006"], "high", 89)

            elif qual == "yaml.load":
                # yaml.load with an explicit Loader= kwarg is safe; flag bare calls.
                has_loader = any(kw.arg == "Loader" for kw in node.keywords)
                if not has_loader:
                    self._add(node, "SC008", KNOWN_BAD_PATTERNS["SC008"], "high", 502)

        self.generic_visit(node)


def _attr_name(node: ast.expr) -> str:
    """Return a dotted name string for a Name or Attribute node, or ''."""
    if isinstance(node, ast.Name):
        return node.id
    if isinstance(node, ast.Attribute):
        return f"{_attr_name(node.value)}.{node.attr}"
    return ""


def check_patterns(tree: ast.Module, file_path: Path = Path("<unknown>")) -> list[Finding]:
    """Run all static pattern checks against a parsed AST.

    Args:
        tree: A parsed ast.Module (e.g., from ast_parser.parse_file).
        file_path: The source file path, used to populate Finding.file.

    Returns:
        List of Finding objects (may be empty).
    """
    visitor = _PatternVisitor(file_path)
    visitor.visit(tree)
    return visitor.findings
