"""AST-based static security analysis of Python source files."""
from __future__ import annotations

import ast
from dataclasses import dataclass

from pyguard.models import Finding, Severity

# Attribute access patterns that are suspicious
SUSPICIOUS_CALLS: dict[tuple[str, str], tuple[str, Severity, str]] = {
    ("requests", "post"): ("Outbound HTTP POST request", Severity.HIGH, "exfiltration"),
    ("requests", "get"): ("Outbound HTTP GET request", Severity.MEDIUM, "exfiltration"),
    ("requests", "put"): ("Outbound HTTP PUT request", Severity.MEDIUM, "exfiltration"),
    ("urllib", "urlopen"): ("urllib.urlopen network call", Severity.MEDIUM, "exfiltration"),
    ("subprocess", "Popen"): ("Subprocess creation (Popen)", Severity.HIGH, "execution"),
    ("subprocess", "run"): ("Subprocess execution (run)", Severity.HIGH, "execution"),
    ("subprocess", "call"): ("Subprocess execution (call)", Severity.HIGH, "execution"),
    ("subprocess", "check_output"): ("Subprocess check_output", Severity.HIGH, "execution"),
    ("os", "system"): ("Shell command execution (os.system)", Severity.HIGH, "execution"),
    ("os", "popen"): ("Shell pipe execution (os.popen)", Severity.HIGH, "execution"),
    ("os", "execvp"): ("Process replacement (os.execvp)", Severity.HIGH, "execution"),
    ("pty", "spawn"): ("PTY spawn", Severity.HIGH, "execution"),
    ("shutil", "rmtree"): ("Recursive directory deletion", Severity.MEDIUM, "destructive"),
}

# Built-in functions to flag when called
SUSPICIOUS_BUILTINS = {"exec", "eval", "compile", "__import__"}

CREDENTIAL_PATTERNS = (
    ".ssh",
    "/.ssh/",
    "id_rsa",
    "id_ed25519",
    ".aws",
    "/.aws/",
    ".kube",
    "kubeconfig",
    ".git-credentials",
    ".gitconfig",
    ".bash_history",
    ".zsh_history",
    ".config/gcloud",
    ".azure",
    ".env",
    "wallet",
    "keystore",
)

# Patterns to detect in string literals (credential exfiltration indicators)
EXFILTRATION_DOMAINS = (
    "ngrok.io",
    "ngrok.app",
    "requestbin",
    "webhook.site",
    "burpcollaborator",
    "interact.sh",
    "oastify.com",
    "pipedream.net",
    "canarytokens",
    "dnslog.cn",
)


@dataclass(slots=True)
class ASTIssue:
    title: str
    description: str
    severity: Severity
    category: str
    line: int
    evidence: dict[str, str]


class SecurityASTVisitor(ast.NodeVisitor):
    def __init__(self) -> None:
        self.issues: list[ASTIssue] = []

    # ------------------------------------------------------------------
    # Function calls
    # ------------------------------------------------------------------
    def visit_Call(self, node: ast.Call) -> None:
        call_name = self._attr_call_name(node.func)
        if call_name and call_name in SUSPICIOUS_CALLS:
            title, severity, category = SUSPICIOUS_CALLS[call_name]
            self.issues.append(
                ASTIssue(
                    title=title,
                    description=f"Call to {call_name[0]}.{call_name[1]} detected.",
                    severity=severity,
                    category=category,
                    line=node.lineno,
                    evidence={"call": f"{call_name[0]}.{call_name[1]}"},
                )
            )

        # Flag plain builtins: exec(), eval(), compile()
        if isinstance(node.func, ast.Name) and node.func.id in SUSPICIOUS_BUILTINS:
            self.issues.append(
                ASTIssue(
                    title=f"Built-in '{node.func.id}' called",
                    description=(
                        f"Direct call to '{node.func.id}' can execute arbitrary code "
                        "and is a common obfuscation technique."
                    ),
                    severity=Severity.HIGH,
                    category="obfuscation",
                    line=node.lineno,
                    evidence={"builtin": node.func.id},
                )
            )

        # Check string arguments for credential paths and exfiltration domains
        for arg in (*node.args, *[kw.value for kw in node.keywords]):
            literal = self._str_literal(arg)
            if not literal:
                continue
            for pattern in CREDENTIAL_PATTERNS:
                if pattern in literal:
                    self.issues.append(
                        ASTIssue(
                            title="Credential path reference in function call",
                            description=f"String literal references sensitive path: '{literal}'",
                            severity=Severity.HIGH,
                            category="credential_access",
                            line=node.lineno,
                            evidence={"path": literal},
                        )
                    )
                    break
            for domain in EXFILTRATION_DOMAINS:
                if domain in literal:
                    self.issues.append(
                        ASTIssue(
                            title="Known exfiltration domain reference",
                            description=(
                                f"Code references known data-collection domain '{domain}' "
                                "in a function call argument."
                            ),
                            severity=Severity.CRITICAL,
                            category="exfiltration",
                            line=node.lineno,
                            evidence={"domain": domain, "literal": literal[:200]},
                        )
                    )
                    break

        self.generic_visit(node)

    # ------------------------------------------------------------------
    # os.environ subscript access
    # ------------------------------------------------------------------
    def visit_Subscript(self, node: ast.Subscript) -> None:
        if (
            isinstance(node.value, ast.Attribute)
            and self._attr_call_name(node.value) == ("os", "environ")
        ):
            self.issues.append(
                ASTIssue(
                    title="Environment variable accessed via os.environ[…]",
                    description=(
                        "Code indexes into os.environ, which may expose API keys or secrets."
                    ),
                    severity=Severity.MEDIUM,
                    category="credential_access",
                    line=node.lineno,
                    evidence={"access": "os.environ[...]"},
                )
            )
        self.generic_visit(node)

    # ------------------------------------------------------------------
    # String assignments that contain credential paths
    # ------------------------------------------------------------------
    def visit_Assign(self, node: ast.Assign) -> None:
        literal = self._str_literal(node.value)
        if literal:
            for pattern in CREDENTIAL_PATTERNS:
                if pattern in literal:
                    self.issues.append(
                        ASTIssue(
                            title="Credential path assigned to variable",
                            description=(
                                "Variable assigned a string referencing "
                                f"sensitive path: '{literal}'"
                            ),
                            severity=Severity.MEDIUM,
                            category="credential_access",
                            line=node.lineno,
                            evidence={"path": literal},
                        )
                    )
                    break
        self.generic_visit(node)

    # ------------------------------------------------------------------
    # Import statements — flag suspicious top-level imports
    # ------------------------------------------------------------------
    _SUSPICIOUS_IMPORT_TOPS = {"ctypes", "cffi", "marshal", "pty"}

    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            top = alias.name.split(".")[0]
            if top in self._SUSPICIOUS_IMPORT_TOPS:
                self.issues.append(
                    ASTIssue(
                        title=f"Suspicious module imported: '{alias.name}'",
                        description=(
                            f"Import of '{alias.name}' is unusual in most packages "
                            "and may indicate low-level or obfuscated code."
                        ),
                        severity=Severity.MEDIUM,
                        category="suspicious_import",
                        line=node.lineno,
                        evidence={"module": alias.name},
                    )
                )
        self.generic_visit(node)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        module = node.module or ""
        top = module.split(".")[0]
        if top in self._SUSPICIOUS_IMPORT_TOPS:
            self.issues.append(
                ASTIssue(
                    title=f"Suspicious module imported: '{module}'",
                    description=(
                        f"Import of '{module}' is unusual in most packages "
                        "and may indicate low-level or obfuscated code."
                    ),
                    severity=Severity.MEDIUM,
                    category="suspicious_import",
                    line=node.lineno,
                    evidence={"module": module},
                )
            )
        self.generic_visit(node)

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------
    def _attr_call_name(self, node: ast.AST) -> tuple[str, str] | None:
        if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
            return (node.value.id, node.attr)
        return None

    def _str_literal(self, node: ast.AST) -> str | None:
        if isinstance(node, ast.Constant) and isinstance(node.value, str):
            return node.value
        return None


def analyze_source(
    package_name: str, version: str, file_path: str, source: str
) -> list[Finding]:
    try:
        tree = ast.parse(source)
    except SyntaxError as exc:
        return [
            Finding(
                severity=Severity.LOW,
                title="Source could not be parsed",
                description=f"AST parsing failed: {exc.msg}",
                layer="static",
                package=package_name,
                version=version,
                evidence={"file": file_path},
                confidence=0.4,
                file=file_path,
                line=exc.lineno,
                category="parse_error",
            )
        ]

    visitor = SecurityASTVisitor()
    visitor.visit(tree)
    findings: list[Finding] = []
    for issue in visitor.issues:
        findings.append(
            Finding(
                severity=issue.severity,
                title=issue.title,
                description=issue.description,
                layer="static",
                package=package_name,
                version=version,
                evidence=issue.evidence,
                confidence=0.75,
                file=file_path,
                line=issue.line,
                category=issue.category,
            )
        )
    return findings
