"""Static analysis tool for discovering governance decision points.

This is a STATIC ANALYSIS tool. It does NOT execute code.
It does NOT read data. It does NOT access any customer content.

It walks the codebase and identifies governance decision points via AST parsing.
"""

from __future__ import annotations

import ast
import json
from dataclasses import dataclass, field
from pathlib import Path

from primust_discover.patterns import (
    PATTERNS,
    CALL_PATTERNS,
    Pattern,
)


@dataclass
class DecisionPoint:
    """A discovered governance decision point."""

    file: str
    line: int
    function_name: str
    pattern: Pattern
    suggested_manifest_id: str


@dataclass
class DiscoveryReport:
    """Result of analyzing a codebase."""

    root_path: str
    files_analyzed: int
    lines_analyzed: int
    decision_points: list[DecisionPoint] = field(default_factory=list)
    manifests_generated: list[str] = field(default_factory=list)

    @property
    def count(self) -> int:
        return len(self.decision_points)

    def by_proof_level(self) -> dict[str, list[DecisionPoint]]:
        result: dict[str, list[DecisionPoint]] = {
            "mathematical": [],
            "execution": [],
            "witnessed": [],
            "attestation": [],
        }
        for dp in self.decision_points:
            level = dp.pattern.proof_level
            if level == "verifiable_inference":
                level = "execution"
            result.setdefault(level, []).append(dp)
        return result


class _Visitor(ast.NodeVisitor):
    """AST visitor that finds governance decision points."""

    def __init__(self, file_path: str, imported_modules: set[str]) -> None:
        self.file_path = file_path
        self.imported_modules = imported_modules
        self.points: list[DecisionPoint] = []
        self._manifest_counter = 0

    def visit_Call(self, node: ast.Call) -> None:
        call_str = self._get_call_string(node)

        # Check against call patterns
        for pattern_str, pattern in CALL_PATTERNS.items():
            if self._matches_call(call_str, pattern_str):
                self._add_point(node, call_str, pattern)
                break

        # Check function name for human review patterns
        if isinstance(node.func, ast.Name):
            for p in PATTERNS:
                if p.category == "human_review" and node.func.id in p.call_patterns:
                    self._add_point(node, node.func.id, p)
                    break

        self.generic_visit(node)

    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            self.imported_modules.add(alias.name.split(".")[0])
        self.generic_visit(node)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        if node.module:
            self.imported_modules.add(node.module.split(".")[0])
        self.generic_visit(node)

    def _get_call_string(self, node: ast.Call) -> str:
        """Reconstruct a dotted call string from an AST Call node."""
        parts: list[str] = []
        current = node.func
        while isinstance(current, ast.Attribute):
            parts.append(current.attr)
            current = current.value
        if isinstance(current, ast.Name):
            parts.append(current.id)
        parts.reverse()
        return ".".join(parts)

    def _matches_call(self, call_str: str, pattern_str: str) -> bool:
        """Check if a call string matches a pattern (suffix match)."""
        return call_str == pattern_str or call_str.endswith("." + pattern_str)

    def _add_point(self, node: ast.Call, call_str: str, pattern: Pattern) -> None:
        self._manifest_counter += 1
        manifest_id = f"{pattern.suggested_manifest_prefix}v{self._manifest_counter}"
        self.points.append(
            DecisionPoint(
                file=self.file_path,
                line=node.lineno,
                function_name=call_str,
                pattern=pattern,
                suggested_manifest_id=manifest_id,
            )
        )


def discover(
    root_path: str,
    output_dir: str | None = None,
) -> DiscoveryReport:
    """Analyze a codebase for governance decision points.

    Static analysis only — does NOT execute any code.
    Zero API calls — fully offline.

    Args:
        root_path: Path to the codebase root.
        output_dir: Optional directory for generated manifest stubs.

    Returns:
        DiscoveryReport with all found decision points.
    """
    root = Path(root_path)
    if not root.exists():
        raise FileNotFoundError(f"Path not found: {root_path}")

    files_analyzed = 0
    lines_analyzed = 0
    all_points: list[DecisionPoint] = []

    for py_file in root.rglob("*.py"):
        # Skip __pycache__, .venv, node_modules
        parts = py_file.parts
        if any(
            p.startswith(".") or p in ("__pycache__", "node_modules", ".venv")
            for p in parts
        ):
            continue

        try:
            source = py_file.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue

        files_analyzed += 1
        lines_analyzed += source.count("\n") + 1

        try:
            tree = ast.parse(source, filename=str(py_file))
        except SyntaxError:
            continue

        imported_modules: set[str] = set()
        visitor = _Visitor(str(py_file.relative_to(root)), imported_modules)
        visitor.visit(tree)
        all_points.extend(visitor.points)

    report = DiscoveryReport(
        root_path=root_path,
        files_analyzed=files_analyzed,
        lines_analyzed=lines_analyzed,
        decision_points=all_points,
    )

    # Generate manifest stubs if output_dir specified
    if output_dir:
        out = Path(output_dir)
        out.mkdir(parents=True, exist_ok=True)
        for dp in all_points:
            manifest = {
                "manifest_id": dp.suggested_manifest_id,
                "name": dp.function_name,
                "proof_level": dp.pattern.proof_level,
                "category": dp.pattern.category,
                "source_file": dp.file,
                "source_line": dp.line,
            }
            manifest_path = out / f"{dp.suggested_manifest_id}.json"
            manifest_path.write_text(json.dumps(manifest, indent=2))
            report.manifests_generated.append(str(manifest_path))

    return report


def format_report(report: DiscoveryReport) -> str:
    """Format a full 10-section discovery report for CLI output.

    Sections per onboarding spec:
      1. Framework gap surface
      2. Provable surface projection
      3. Instrumentation quality
      4. Check candidates
      5. Configuration hygiene
      6. Jurisdiction surface
      7. Model deprecation
      8. Pipeline structure integrity
      9. Governance laundering surface
      10. Multi-framework efficiency
    """
    lines: list[str] = []
    by_level = report.by_proof_level()

    # Detect frameworks from patterns
    framework_points = [
        dp for dp in report.decision_points
        if dp.pattern.category in ("ml_model", "llm_call")
    ]
    detected_frameworks: set[str] = set()
    for dp in report.decision_points:
        detected_frameworks.add(dp.pattern.name)

    # ── Header ──
    lines.append("=== Primust Discovery Report ===")
    lines.append(
        f"Analyzed: {report.root_path} ({report.files_analyzed} files, "
        f"{report.lines_analyzed:,} lines)"
    )
    lines.append(f"Governance decision points found: {report.count}")
    lines.append("")

    # ── S1 Framework Gap Surface ──
    lines.append("1. FRAMEWORK GAP SURFACE")
    lines.append("   What framework you're running, what's ungoverned.")
    lines.append("")
    if detected_frameworks:
        lines.append(f"   Detected: {', '.join(sorted(detected_frameworks))}")
    else:
        lines.append("   No recognized frameworks detected.")

    ungoverned = report.lines_analyzed - sum(
        1 for _ in report.decision_points
    )
    lines.append(
        f"   Decision points: {report.count} | "
        f"Files without decision points: "
        f"{report.files_analyzed - len({dp.file for dp in report.decision_points})}"
    )
    lines.append("")

    # ── S2 Provable Surface Projection ──
    lines.append("2. PROVABLE SURFACE PROJECTION")
    lines.append("   Estimated provable_surface if you instrument.")
    lines.append("")

    total = max(report.count, 1)
    for level_name, level_points in by_level.items():
        if level_points:
            pct = len(level_points) / total * 100
            lines.append(f"   {level_name}: {len(level_points)} ({pct:.0f}%)")

    if report.count > 0:
        math_pct = len(by_level.get("mathematical", [])) / total
        exec_pct = len(by_level.get("execution", [])) / total
        projected = math_pct + exec_pct * 0.9 + (1 - math_pct - exec_pct) * 0.5
        lines.append(f"   Projected provable_surface: {projected:.2f}")
    else:
        lines.append("   Projected provable_surface: 0.00 (no decision points found)")
    lines.append("")

    # ── S3 Instrumentation Quality ──
    lines.append("3. INSTRUMENTATION QUALITY")
    lines.append("   What's instrumented now, what's missing.")
    lines.append("")

    # Check for existing primust imports
    has_primust_import = any(
        "primust" in dp.function_name for dp in report.decision_points
    )
    if has_primust_import:
        lines.append("   Primust instrumentation detected in codebase.")
    else:
        lines.append("   No Primust instrumentation detected.")
        lines.append("   Add: from primust.adapters import autoinstrument")
    lines.append("")

    # ── S4 Check Candidates ──
    lines.append("4. CHECK CANDIDATES")
    lines.append("   Specific checks to add — advisory only.")
    lines.append("   These do NOT appear in VPEC gaps. Advisory signals only.")
    lines.append("")

    candidates: list[str] = []
    has_llm = any(dp.pattern.category == "llm_call" for dp in report.decision_points)
    has_external = any(
        dp.pattern.category == "external_api" for dp in report.decision_points
    )
    has_human = any(
        dp.pattern.category == "human_review" for dp in report.decision_points
    )

    if has_llm:
        candidates.append("pii_regex — LLM output may contain PII")
        candidates.append("cost_bounds — LLM calls incur token costs")
    if has_external:
        candidates.append("secrets_scanner — External API calls may expose credentials")
    if not has_human and has_llm:
        candidates.append(
            "human_review — No human approval pattern detected for LLM outputs"
        )
    candidates.append("schema_validation — Validate agent output structure")

    for c in candidates:
        lines.append(f"   + {c}")
    lines.append("")

    # ── S5 Configuration Hygiene ──
    lines.append("5. CONFIGURATION HYGIENE")
    lines.append("   Policy bundle mismatches, deprecated fields.")
    lines.append("")
    lines.append("   No configuration files analyzed (static analysis only).")
    lines.append("   Run with API key to enable runtime config checks.")
    lines.append("")

    # ── S6 Jurisdiction Surface ──
    lines.append("6. JURISDICTION SURFACE")
    lines.append("   EU AI Act, HIPAA, SOC 2 exposure.")
    lines.append("")

    if has_llm:
        lines.append("   LLM calls detected — potential EU AI Act Article 12 exposure.")
        lines.append("   Consider: policy='eu_ai_act_art12_v1'")
    has_pii_check = any(dp.pattern.name == "regex_check" for dp in report.decision_points)
    if has_pii_check:
        lines.append("   PII patterns detected — potential HIPAA exposure.")
        lines.append("   Consider: policy='hipaa_v1'")
    if not has_llm and not has_pii_check:
        lines.append("   No jurisdiction-sensitive patterns detected.")
    lines.append("")

    # ── S7 Model Deprecation ──
    lines.append("7. MODEL DEPRECATION")
    lines.append("   Model versions affecting proof ceiling.")
    lines.append("")
    lines.append("   Static analysis cannot determine model versions.")
    lines.append("   Use autoinstrument(mode='discover') for runtime model detection.")
    lines.append("")

    # ── S8 Pipeline Structure Integrity ──
    lines.append("8. PIPELINE STRUCTURE INTEGRITY")
    lines.append("   Missing p.close(), open runs.")
    lines.append("")
    lines.append("   Static analysis cannot verify pipeline lifecycle.")
    lines.append("   Use autoinstrument(mode='discover') for runtime structure checks.")
    lines.append("")

    # ── S9 Governance Laundering Surface ──
    lines.append("9. GOVERNANCE LAUNDERING SURFACE")
    lines.append("   Structural risk patterns — MEDIUM severity, advisory.")
    lines.append("")

    laundering_risks: list[str] = []
    # Check for LLM calls without any governance patterns nearby
    llm_files = {dp.file for dp in report.decision_points if dp.pattern.category == "llm_call"}
    governed_files = {
        dp.file
        for dp in report.decision_points
        if dp.pattern.category in ("deterministic", "human_review")
    }
    ungoverned_llm_files = llm_files - governed_files
    if ungoverned_llm_files:
        for f in sorted(ungoverned_llm_files):
            laundering_risks.append(
                f"MEDIUM: {f} — LLM call without co-located governance check"
            )

    if laundering_risks:
        for risk in laundering_risks:
            lines.append(f"   {risk}")
    else:
        lines.append("   No structural risk patterns detected.")
    lines.append("")

    # ── S10 Multi-Framework Efficiency ──
    lines.append("10. MULTI-FRAMEWORK EFFICIENCY")
    lines.append("    Redundant coverage, consolidation opportunities.")
    lines.append("")

    if len(detected_frameworks) > 3:
        lines.append(
            f"    {len(detected_frameworks)} pattern types detected. "
            "Consider consolidating via OTEL for unified observability."
        )
    elif len(detected_frameworks) > 1:
        lines.append(
            f"    {len(detected_frameworks)} pattern types detected. "
            "autoinstrument() will handle all automatically."
        )
    else:
        lines.append("    Single framework detected. No consolidation needed.")
    lines.append("")

    # ── Decision Points Detail ──
    lines.append("--- DECISION POINTS ---")
    lines.append("")

    level_labels = {
        "mathematical": "Mathematical (deterministic, ZK-provable)",
        "execution": "Execution (ML model, hash-bindable)",
        "witnessed": "Witnessed (human review patterns)",
        "attestation": "Attestation (LLM calls, opaque)",
    }

    for level, label in level_labels.items():
        points = by_level.get(level, [])
        if points:
            lines.append(f"{label}:")
            for dp in points:
                lines.append(
                    f"  {dp.file}:{dp.line}  — {dp.function_name}() "
                    f"— suggest: manifest {dp.suggested_manifest_id}"
                )
            lines.append("")

    if report.manifests_generated:
        lines.append(
            f"Manifest stubs generated: {len(report.manifests_generated)} files"
        )

    lines.append("=== End Report ===")
    return "\n".join(lines)
