"""Static scanner for ungoverned AI agent tool calls.

Walks Python files, uses ``ast`` to detect patterns that indicate
tool/function calls without Aegis governance wrappers.
"""

from __future__ import annotations

import ast
import json
import sys
from dataclasses import asdict, dataclass
from pathlib import Path


@dataclass(frozen=True, slots=True)
class Finding:
    """A single ungoverned tool-call detected by the scanner."""

    file: str
    line: int
    category: str
    detail: str
    owasp_risk: str = ""
    fix: str = ""


# ---------------------------------------------------------------------------
# OWASP Agentic Top 10 mapping
# ---------------------------------------------------------------------------

# Maps finding categories to OWASP Agentic Top 10 risks (Dec 2025)
_OWASP_MAP: dict[str, tuple[str, str]] = {
    "OpenAI": ("ASI02", "Tool Misuse & Exploitation"),
    "Anthropic": ("ASI02", "Tool Misuse & Exploitation"),
    "LangChain": ("ASI02", "Tool Misuse & Exploitation"),
    "MCP": ("ASI04", "Supply Chain Vulnerabilities"),
    "subprocess": ("ASI08", "Uncontrolled Code Execution"),
    "HTTP": ("ASI07", "Data Leakage & Exfiltration"),
    "CrewAI": ("ASI02", "Tool Misuse & Exploitation"),
    "LlamaIndex": ("ASI02", "Tool Misuse & Exploitation"),
    "LiteLLM": ("ASI02", "Tool Misuse & Exploitation"),
    "PydanticAI": ("ASI02", "Tool Misuse & Exploitation"),
    "OpenAI Agents": ("ASI02", "Tool Misuse & Exploitation"),
    "Instructor": ("ASI02", "Tool Misuse & Exploitation"),
    "Google GenAI": ("ASI02", "Tool Misuse & Exploitation"),
    "DSPy": ("ASI02", "Tool Misuse & Exploitation"),
    "Google ADK": ("ASI02", "Tool Misuse & Exploitation"),
}

# ---------------------------------------------------------------------------
# Quickfix suggestions per category
# ---------------------------------------------------------------------------

_FIX_MAP: dict[str, str] = {
    "OpenAI": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "Anthropic": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "LangChain": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "MCP": "Add aegis MCP middleware: from aegis.mcp_proxy import aegis_mcp_middleware",
    "subprocess": "Use aegis sandbox policy to govern shell execution",
    "HTTP": "Route through aegis-governed HTTP client or add policy rule",
    "CrewAI": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "LlamaIndex": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "LiteLLM": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "PydanticAI": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "OpenAI Agents": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "Instructor": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "Google GenAI": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "DSPy": "Wrap with aegis: import aegis; aegis.auto_instrument()",
    "Google ADK": "Wrap with aegis: import aegis; aegis.auto_instrument()",
}


# ---------------------------------------------------------------------------
# Grade thresholds
# ---------------------------------------------------------------------------

_GRADE_THRESHOLDS: list[tuple[int, str]] = [
    (0, "A"),
    (1, "B"),
    (3, "C"),
    (6, "D"),
    # anything above 6 -> F
]

_GRADE_ORDER = ["A", "B", "C", "D", "F"]


def _grade(count: int) -> str:
    """Return a letter grade for *count* ungoverned calls."""
    result = "F"
    for threshold, letter in _GRADE_THRESHOLDS:
        if count <= threshold:
            return letter
        result = letter
    return "F" if count > _GRADE_THRESHOLDS[-1][0] else result


def _grade_meets_threshold(grade: str, threshold: str) -> bool:
    """Return True if *grade* meets or exceeds *threshold*."""
    try:
        return _GRADE_ORDER.index(grade) <= _GRADE_ORDER.index(threshold)
    except ValueError:
        return False


# ---------------------------------------------------------------------------
# .aegisscanignore support
# ---------------------------------------------------------------------------


def _load_ignore_patterns(directory: Path) -> list[str]:
    """Load ignore patterns from .aegisscanignore file."""
    ignore_file = directory / ".aegisscanignore"
    if not ignore_file.exists():
        return []
    patterns: list[str] = []
    for line in ignore_file.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if line and not line.startswith("#"):
            patterns.append(line)
    return patterns


def _is_ignored(filepath: Path, directory: Path, patterns: list[str]) -> bool:
    """Check if *filepath* matches any ignore pattern."""
    try:
        rel = filepath.relative_to(directory)
    except ValueError:
        return False
    rel_str = str(rel)
    for pattern in patterns:
        # Support both glob-style and prefix matching
        if rel.match(pattern):
            return True
        # Also check if any parent directory matches
        for part in rel.parts:
            if Path(part).match(pattern):
                return True
        # Prefix match (e.g., "tests/" or "vendor/")
        if pattern.endswith("/") and rel_str.startswith(pattern):
            return True
        if not pattern.endswith("/") and rel_str.startswith(pattern + "/"):
            return True
    return False


# ---------------------------------------------------------------------------
# Inline pragma support: # aegis: ignore
# ---------------------------------------------------------------------------


def _read_source_lines(filepath: Path) -> dict[int, str]:
    """Read source and return {line_number: line_text} for pragma checking."""
    try:
        text = filepath.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return {}
    return {i + 1: line for i, line in enumerate(text.splitlines())}


def _has_ignore_pragma(source_lines: dict[int, str], line: int) -> bool:
    """Check if the given line has an ``# aegis: ignore`` pragma."""
    text = source_lines.get(line, "")
    return "# aegis: ignore" in text or "# aegis:ignore" in text


# ---------------------------------------------------------------------------
# AST visitors
# ---------------------------------------------------------------------------


class _ToolCallVisitor(ast.NodeVisitor):
    """Walk an AST and collect :class:`Finding` instances."""

    def __init__(self, filepath: str) -> None:
        self.filepath = filepath
        self.findings: list[Finding] = []
        # Track imports so we can resolve names later.
        self._imports: dict[str, str] = {}  # alias -> dotted module

    # -- helpers ------------------------------------------------------------

    def _add(self, node: ast.AST, category: str, detail: str) -> None:
        owasp = _OWASP_MAP.get(category)
        owasp_risk = f"{owasp[0]}: {owasp[1]}" if owasp else ""
        fix = _FIX_MAP.get(category, "")
        self.findings.append(
            Finding(
                file=self.filepath,
                line=getattr(node, "lineno", 0),
                category=category,
                detail=detail,
                owasp_risk=owasp_risk,
                fix=fix,
            )
        )

    @staticmethod
    def _dotted_name(node: ast.expr) -> str:
        """Best-effort dotted name for an attribute chain."""
        parts: list[str] = []
        cur = node
        while isinstance(cur, ast.Attribute):
            parts.append(cur.attr)
            cur = cur.value
        if isinstance(cur, ast.Name):
            parts.append(cur.id)
        parts.reverse()
        return ".".join(parts)

    # -- import tracking ----------------------------------------------------

    def visit_Import(self, node: ast.Import) -> None:  # noqa: N802
        for alias in node.names:
            name = alias.asname or alias.name
            self._imports[name] = alias.name
        self.generic_visit(node)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:  # noqa: N802
        module = node.module or ""
        for alias in node.names:
            name = alias.asname or alias.name
            self._imports[name] = f"{module}.{alias.name}" if module else alias.name
        self.generic_visit(node)

    # -- decorator detection ------------------------------------------------

    def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:  # noqa: N802
        self._check_decorators(node)
        self.generic_visit(node)

    visit_AsyncFunctionDef = visit_FunctionDef  # noqa: N815

    def _check_decorators(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
        for dec in node.decorator_list:
            # @tool  (LangChain or OpenAI Agents SDK)
            if isinstance(dec, ast.Name) and dec.id == "tool":
                resolved = self._imports.get("tool", "")
                if "openai" in resolved.lower() or "agents" in resolved.lower():
                    self._add(
                        dec,
                        "OpenAI Agents",
                        f'@tool "{node.name}" \u2014 no policy check',
                    )
                else:
                    self._add(
                        dec,
                        "LangChain",
                        f'@tool "{node.name}" \u2014 no policy check',
                    )
            # @agent.tool  (Pydantic AI)
            if isinstance(dec, ast.Attribute) and dec.attr == "tool":
                root = self._dotted_name(dec.value) if isinstance(dec.value, ast.Name) else ""
                resolved = self._imports.get(root, "")
                if "pydantic_ai" in resolved.lower() or root == "agent":
                    self._add(
                        dec,
                        "PydanticAI",
                        f'@agent.tool "{node.name}" \u2014 no policy check',
                    )
            # @mcp.tool()  (MCP without aegis middleware)
            if isinstance(dec, ast.Call):
                dotted = self._dotted_name(dec.func) if isinstance(dec.func, ast.Attribute) else ""
                if dotted.endswith(".tool"):
                    # Check that the receiver looks like an mcp server
                    root = dotted.split(".")[0]
                    resolved = self._imports.get(root, root)
                    if "mcp" in resolved.lower() or root in ("mcp", "server", "app"):
                        self._add(
                            dec,
                            "MCP",
                            f'@mcp.tool() "{node.name}" \u2014 no aegis middleware',
                        )

    # -- class detection ----------------------------------------------------

    def visit_ClassDef(self, node: ast.ClassDef) -> None:  # noqa: N802
        for base in node.bases:
            base_name = (
                self._dotted_name(base) if isinstance(base, ast.Attribute | ast.Name) else ""
            )
            # LangChain BaseTool subclass
            if base_name in ("BaseTool", "langchain_core.tools.BaseTool"):
                all_bases = {
                    self._dotted_name(b) if isinstance(b, ast.Attribute | ast.Name) else ""
                    for b in node.bases
                }
                if "GovernedTool" not in all_bases:
                    self._add(
                        node,
                        "LangChain",
                        f'BaseTool subclass "{node.name}" \u2014 not governed',
                    )
            # DSPy Module subclass
            if base_name in ("dspy.Module", "Module"):
                resolved = self._imports.get("Module", "")
                if base_name == "dspy.Module" or "dspy" in resolved:
                    self._add(
                        node,
                        "DSPy",
                        f'dspy.Module subclass "{node.name}" \u2014 not governed',
                    )
        self.generic_visit(node)

    # -- call detection -----------------------------------------------------

    def visit_Call(self, node: ast.Call) -> None:  # noqa: N802
        if isinstance(node.func, ast.Attribute):
            dotted = self._dotted_name(node.func)
            self._check_openai(node, dotted)
            self._check_anthropic(node, dotted)
            self._check_subprocess(node, dotted)
            self._check_http(node, dotted)
            self._check_litellm(node, dotted)
            self._check_llamaindex(node, dotted)
            self._check_google_genai(node, dotted)
            self._check_pydantic_ai_run(node, dotted)
            self._check_openai_agents_runner(node, dotted)
        elif isinstance(node.func, ast.Name):
            self._check_subprocess_name(node, node.func.id)
            self._check_bare_call(node, node.func.id)
        self.generic_visit(node)

    # -- individual pattern matchers ----------------------------------------

    def _check_openai(self, node: ast.Call, dotted: str) -> None:
        # openai.chat.completions.create  or  client.chat.completions.create
        if (
            dotted.endswith("chat.completions.create") or dotted.endswith("completions.create")
        ) and self._has_keyword(node, "tools"):
            self._add(node, "OpenAI", "function call with tools= \u2014 no governance wrapper")

    def _check_anthropic(self, node: ast.Call, dotted: str) -> None:
        # anthropic.messages.create  or  client.messages.create
        if dotted.endswith("messages.create"):
            root = dotted.split(".")[0]
            resolved = self._imports.get(root, root)
            if ("anthropic" in resolved.lower() or root == "client") and self._has_keyword(
                node, "tools"
            ):
                self._add(
                    node,
                    "Anthropic",
                    "messages.create with tools= \u2014 no governance wrapper",
                )

    def _check_subprocess(self, node: ast.Call, dotted: str) -> None:
        dangerous = {
            "subprocess.run",
            "subprocess.Popen",
            "subprocess.call",
            "subprocess.check_call",
            "subprocess.check_output",
            "os.system",
            "os.popen",
        }
        if dotted in dangerous:
            self._add(node, "subprocess", f"{dotted} \u2014 direct shell execution")
        # os.exec* family
        parts = dotted.split(".")
        if len(parts) == 2 and parts[0] == "os" and parts[1].startswith("exec"):
            self._add(node, "subprocess", f"{dotted} \u2014 direct process exec")

    def _check_subprocess_name(self, node: ast.Call, name: str) -> None:
        """Handle bare ``from subprocess import run; run(...)``."""
        resolved = self._imports.get(name, "")
        bare_dangerous = {
            "subprocess.run",
            "subprocess.Popen",
            "subprocess.call",
            "subprocess.check_call",
            "subprocess.check_output",
            "os.system",
            "os.popen",
        }
        if resolved in bare_dangerous:
            self._add(node, "subprocess", f"{resolved} \u2014 direct shell execution")
        if resolved.startswith("os.exec"):
            self._add(node, "subprocess", f"{resolved} \u2014 direct process exec")

    def _check_http(self, node: ast.Call, dotted: str) -> None:
        http_posts = {"requests.post", "httpx.post"}
        if dotted in http_posts:
            self._add(node, "HTTP", f"{dotted} \u2014 raw HTTP in agent code")
        # Also match aliased imports: e.g.  import httpx as h; h.post(...)
        parts = dotted.split(".")
        if len(parts) == 2:
            resolved_root = self._imports.get(parts[0], parts[0])
            resolved = f"{resolved_root}.{parts[1]}"
            if resolved in http_posts and dotted not in http_posts:
                self._add(node, "HTTP", f"{resolved} \u2014 raw HTTP in agent code")

    # -- CrewAI -------------------------------------------------------------

    def _check_crewai(self, node: ast.Call, name: str) -> None:
        """Detect Crew(...) instantiation from crewai."""
        if name == "Crew":
            resolved = self._imports.get("Crew", "")
            if "crewai" in resolved:
                self._add(node, "CrewAI", "Crew() \u2014 no governance wrapper")

    # -- LiteLLM -----------------------------------------------------------

    def _check_litellm(self, node: ast.Call, dotted: str) -> None:
        if dotted in ("litellm.completion", "litellm.acompletion"):
            self._add(node, "LiteLLM", f"{dotted}() \u2014 no governance wrapper")

    def _check_litellm_bare(self, node: ast.Call, name: str) -> None:
        if name in ("completion", "acompletion"):
            resolved = self._imports.get(name, "")
            if "litellm" in resolved:
                self._add(node, "LiteLLM", f"litellm.{name}() \u2014 no governance wrapper")

    # -- LlamaIndex --------------------------------------------------------

    def _check_llamaindex(self, node: ast.Call, dotted: str) -> None:
        llama_methods = (".chat", ".achat", ".complete", ".acomplete", ".query", ".aquery")
        for method in llama_methods:
            if dotted.endswith(method):
                root = dotted.split(".")[0]
                resolved = self._imports.get(root, root)
                if "llama_index" in resolved or "llamaindex" in resolved.lower():
                    self._add(
                        node,
                        "LlamaIndex",
                        f"{dotted}() \u2014 no governance wrapper",
                    )
                    return

    # -- Google GenAI / Gemini ---------------------------------------------

    def _check_google_genai(self, node: ast.Call, dotted: str) -> None:
        if dotted.endswith(".generate_content"):
            root = dotted.split(".")[0]
            resolved = self._imports.get(root, root)
            if "google" in resolved.lower() or "genai" in resolved.lower():
                self._add(
                    node,
                    "Google GenAI",
                    f"{dotted}() \u2014 no governance wrapper",
                )

    # -- Pydantic AI -------------------------------------------------------

    def _check_pydantic_ai_run(self, node: ast.Call, dotted: str) -> None:
        if dotted.endswith((".run", ".run_sync")):
            root = dotted.split(".")[0]
            resolved = self._imports.get(root, "")
            if "pydantic_ai" in resolved:
                self._add(
                    node,
                    "PydanticAI",
                    f"{dotted}() \u2014 no governance wrapper",
                )

    # -- OpenAI Agents SDK -------------------------------------------------

    def _check_openai_agents_runner(self, node: ast.Call, dotted: str) -> None:
        if dotted.endswith((".run", ".run_sync")) and "Runner" in dotted:
            self._add(
                node,
                "OpenAI Agents",
                f"{dotted}() \u2014 no governance wrapper",
            )

    # -- Instructor --------------------------------------------------------

    def _check_instructor_bare(self, node: ast.Call, name: str) -> None:
        if name in ("from_openai", "from_anthropic", "from_vertexai"):
            resolved = self._imports.get(name, "")
            if "instructor" in resolved:
                self._add(
                    node,
                    "Instructor",
                    f"instructor.{name}() \u2014 no governance wrapper",
                )

    # -- Bare call dispatcher (for `from X import Y; Y(...)`) ---------------

    def _check_bare_call(self, node: ast.Call, name: str) -> None:
        """Dispatch bare function/class calls via import resolution."""
        self._check_litellm_bare(node, name)
        self._check_instructor_bare(node, name)
        self._check_crewai(node, name)
        self._check_google_genai_bare(node, name)
        self._check_pydantic_ai_agent(node, name)
        self._check_openai_agents_agent(node, name)

    def _check_google_genai_bare(self, node: ast.Call, name: str) -> None:
        if name == "GenerativeModel":
            resolved = self._imports.get("GenerativeModel", "")
            if "google" in resolved.lower() or "generativeai" in resolved.lower():
                self._add(
                    node,
                    "Google GenAI",
                    "GenerativeModel() \u2014 no governance wrapper",
                )

    def _check_pydantic_ai_agent(self, node: ast.Call, name: str) -> None:
        if name == "Agent":
            resolved = self._imports.get("Agent", "")
            if "pydantic_ai" in resolved:
                self._add(
                    node,
                    "PydanticAI",
                    "Agent() \u2014 no governance wrapper",
                )

    def _check_openai_agents_agent(self, node: ast.Call, name: str) -> None:
        if name == "Agent":
            resolved = self._imports.get("Agent", "")
            if "openai.agents" in resolved or "openai_agents" in resolved:
                self._add(
                    node,
                    "OpenAI Agents",
                    "Agent() \u2014 no governance wrapper",
                )

    # -- utils --------------------------------------------------------------

    @staticmethod
    def _has_keyword(node: ast.Call, name: str) -> bool:
        return any(kw.arg == name for kw in node.keywords)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def scan_file(filepath: str | Path) -> list[Finding]:
    """Parse a single Python file and return findings."""
    filepath = Path(filepath)
    try:
        source = filepath.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return []
    try:
        tree = ast.parse(source, filename=str(filepath))
    except SyntaxError:
        return []
    visitor = _ToolCallVisitor(str(filepath))
    visitor.visit(tree)
    return visitor.findings


def scan_directory(
    directory: str | Path,
    *,
    ignore_patterns: list[str] | None = None,
) -> tuple[int, list[Finding]]:
    """Recursively scan *directory* for Python files.

    Returns ``(file_count, findings)``.
    """
    directory = Path(directory).resolve()
    if ignore_patterns is None:
        ignore_patterns = _load_ignore_patterns(directory)
    findings: list[Finding] = []
    file_count = 0

    # Load source lines for pragma checking (lazy per file)
    for py_file in sorted(directory.rglob("*.py")):
        # Skip hidden dirs, venvs, __pycache__, .git
        parts = py_file.relative_to(directory).parts
        if any(p.startswith(".") or p in ("__pycache__", "node_modules") for p in parts):
            continue
        # Skip files matching .aegisscanignore patterns
        if ignore_patterns and _is_ignored(py_file, directory, ignore_patterns):
            continue
        file_count += 1
        file_findings = scan_file(py_file)

        # Filter out findings with # aegis: ignore pragma
        if file_findings:
            source_lines = _read_source_lines(py_file)
            file_findings = [
                f for f in file_findings if not _has_ignore_pragma(source_lines, f.line)
            ]

        findings.extend(file_findings)
    return file_count, findings


# ---------------------------------------------------------------------------
# Output formatters
# ---------------------------------------------------------------------------


def format_report(
    file_count: int,
    findings: list[Finding],
    *,
    directory: str = ".",
    show_fixes: bool = True,
) -> str:
    """Build the human-readable scan report."""
    lines: list[str] = []
    lines.append("Aegis Governance Scan")
    lines.append("=" * 21)
    lines.append(f"Scanned: {file_count} files in {directory}")
    lines.append("")

    if findings:
        lines.append(f"Found {len(findings)} ungoverned tool call(s):")
        for f in findings:
            # Make path relative for readability
            try:
                rel = str(Path(f.file).relative_to(Path(directory).resolve()))
            except ValueError:
                rel = f.file
            owasp_tag = f"  [{f.owasp_risk}]" if f.owasp_risk else ""
            lines.append(f"  {rel}:{f.line:<8}{f.category:<14}{f.detail}{owasp_tag}")
            if show_fixes and f.fix:
                lines.append(f"    \u2192 {f.fix}")
        lines.append("")

        # OWASP Agentic Top 10 summary
        owasp_counts: dict[str, int] = {}
        for f in findings:
            if f.owasp_risk:
                owasp_counts[f.owasp_risk] = owasp_counts.get(f.owasp_risk, 0) + 1
        if owasp_counts:
            lines.append("OWASP Agentic Top 10 Risks:")
            for risk, count in sorted(owasp_counts.items()):
                lines.append(f"  {risk}: {count} finding(s)")
            lines.append("")

        grade = _grade(len(findings))
        lines.append(f"Governance Score: {grade} ({len(findings)} ungoverned call(s))")
    else:
        lines.append("No ungoverned tool calls found.")
        lines.append("")
        lines.append("Governance Score: A (clean)")

    lines.append("")
    lines.append("Fix: pip install agent-aegis")
    lines.append("Docs: https://acacian.github.io/aegis/")
    return "\n".join(lines)


def format_json(
    file_count: int,
    findings: list[Finding],
    *,
    directory: str = ".",
) -> str:
    """Build a JSON scan report."""
    grade = _grade(len(findings))

    # OWASP summary
    owasp_counts: dict[str, int] = {}
    for f in findings:
        if f.owasp_risk:
            owasp_counts[f.owasp_risk] = owasp_counts.get(f.owasp_risk, 0) + 1

    result = {
        "tool": "aegis-scan",
        "version": "1.0",
        "directory": directory,
        "files_scanned": file_count,
        "findings_count": len(findings),
        "grade": grade,
        "findings": [asdict(f) for f in findings],
        "owasp_summary": owasp_counts,
    }
    return json.dumps(result, indent=2, ensure_ascii=False)


def format_sarif(
    file_count: int,
    findings: list[Finding],
    *,
    directory: str = ".",
) -> str:
    """Build a SARIF v2.1.0 report for GitHub Code Scanning integration."""
    rules: list[dict[str, object]] = []
    rule_ids_seen: set[str] = set()
    results: list[dict[str, object]] = []

    for f in findings:
        # Create rule ID from category + owasp
        owasp_code = ""
        if f.owasp_risk:
            owasp_code = f.owasp_risk.split(":")[0].strip()
        rule_id = f"aegis/{owasp_code or f.category.lower()}"

        if rule_id not in rule_ids_seen:
            rule_ids_seen.add(rule_id)
            owasp_info = _OWASP_MAP.get(f.category)
            rule_desc = (
                f"{owasp_info[1]} ({owasp_info[0]})"
                if owasp_info
                else f"Ungoverned {f.category} call"
            )
            rule_entry: dict[str, object] = {
                "id": rule_id,
                "name": f"Ungoverned{f.category}Call",
                "shortDescription": {"text": f"Ungoverned {f.category} call detected"},
                "fullDescription": {"text": rule_desc},
                "defaultConfiguration": {"level": "warning"},
                "helpUri": "https://acacian.github.io/aegis/",
            }
            if f.fix:
                rule_entry["help"] = {"text": f.fix, "markdown": f"**Fix:** {f.fix}"}
            rules.append(rule_entry)

        # Make path relative
        try:
            rel_path = str(Path(f.file).relative_to(Path(directory).resolve()))
        except ValueError:
            rel_path = f.file

        result_entry: dict[str, object] = {
            "ruleId": rule_id,
            "level": "warning",
            "message": {"text": f"{f.detail} [{f.owasp_risk}]" if f.owasp_risk else f.detail},
            "locations": [
                {
                    "physicalLocation": {
                        "artifactLocation": {"uri": rel_path, "uriBaseId": "%SRCROOT%"},
                        "region": {"startLine": f.line, "startColumn": 1},
                    }
                }
            ],
        }
        if f.fix:
            result_entry["fixes"] = [
                {
                    "description": {"text": f.fix},
                }
            ]
        results.append(result_entry)

    sarif = {
        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
        "version": "2.1.0",
        "runs": [
            {
                "tool": {
                    "driver": {
                        "name": "aegis-scan",
                        "informationUri": "https://github.com/Acacian/aegis",
                        "version": "0.9",
                        "rules": rules,
                    }
                },
                "results": results,
                "invocations": [
                    {
                        "executionSuccessful": True,
                        "toolExecutionNotifications": [],
                    }
                ],
            }
        ],
    }
    return json.dumps(sarif, indent=2, ensure_ascii=False)


# ---------------------------------------------------------------------------
# Suggest rules
# ---------------------------------------------------------------------------


def suggest_rules(findings: list[Finding]) -> str:
    """Generate YAML policy rules based on scan findings."""
    if not findings:
        return "# No findings — no rules needed. Your code is clean!"

    lines: list[str] = [
        "# Auto-generated policy rules based on aegis scan findings",
        "# Review and adjust risk_level/approval as needed.",
        "",
        'version: "1"',
        "",
        "defaults:",
        "  risk_level: medium",
        "  approval: approve",
        "",
        "rules:",
    ]

    seen_categories: set[str] = set()
    for f in findings:
        if f.category in seen_categories:
            continue
        seen_categories.add(f.category)

        _RULE_TEMPLATES: dict[str, tuple[str, str, str, str]] = {
            # category -> (rule_name, type, risk_level, approval)
            "LangChain": ("langchain_tool_governance", "tool_call", "medium", "approve"),
            "OpenAI": ("openai_function_call_governance", "function_call", "medium", "approve"),
            "Anthropic": ("anthropic_tool_use_governance", "tool_use", "medium", "approve"),
            "MCP": ("mcp_tool_governance", "mcp_tool", "high", "approve"),
            "subprocess": ("block_shell_execution", "shell_exec", "critical", "block"),
            "HTTP": ("http_request_governance", "http_request", "high", "approve"),
            "CrewAI": ("crewai_governance", "tool_call", "medium", "approve"),
            "LlamaIndex": ("llamaindex_governance", "tool_call", "medium", "approve"),
            "LiteLLM": ("litellm_governance", "function_call", "medium", "approve"),
            "PydanticAI": ("pydanticai_governance", "tool_call", "medium", "approve"),
            "OpenAI Agents": ("openai_agents_governance", "tool_call", "medium", "approve"),
            "Instructor": ("instructor_governance", "function_call", "medium", "approve"),
            "Google GenAI": ("google_genai_governance", "function_call", "medium", "approve"),
            "DSPy": ("dspy_governance", "tool_call", "medium", "approve"),
            "Google ADK": ("google_adk_governance", "tool_call", "medium", "approve"),
        }
        tmpl = _RULE_TEMPLATES.get(f.category)
        if tmpl:
            name, typ, risk, approval = tmpl
            lines.extend(
                [
                    f"  - name: {name}",
                    f'    match: {{ type: "{typ}", target: "*" }}',
                    f"    risk_level: {risk}",
                    f"    approval: {approval}",
                    "",
                ]
            )

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Public API — run_scan
# ---------------------------------------------------------------------------


def run_scan(
    directory: str = ".",
    *,
    fmt: str = "text",
    threshold: str | None = None,
    show_fixes: bool = True,
) -> int:
    """Execute the scan and print the report. Returns exit code."""
    target = Path(directory).resolve()
    if not target.is_dir():
        print(f"Error: not a directory: {target}", file=sys.stderr)
        return 1

    file_count, findings = scan_directory(target)
    grade = _grade(len(findings))

    if fmt == "json":
        output = format_json(file_count, findings, directory=str(target))
    elif fmt == "sarif":
        output = format_sarif(file_count, findings, directory=str(target))
    elif fmt == "suggest":
        output = suggest_rules(findings)
    else:
        output = format_report(file_count, findings, directory=str(target), show_fixes=show_fixes)

    print(output)

    # Exit code logic
    if threshold:
        if not _grade_meets_threshold(grade, threshold.upper()):
            if fmt == "text":
                print(
                    f"\nFailed: grade {grade} does not meet threshold {threshold.upper()}",
                    file=sys.stderr,
                )
            return 1
        return 0

    return 1 if findings else 0
