"""Rule Recommender — Recommend security rules based on scan results.

Analyzes scan results, detects patterns, identifies coverage gaps in
CWE/OWASP categories, and recommends missing security rules with
priority scoring based on risk and effort.

Features:
- RuleRecommendation: rule_id, priority, reason, estimated_impact
- Recommender engine: analyze scan results, suggest missing rules
- Pattern detection: if finding X exists, recommend rule Y
- Coverage gap analysis: which CWE/OWASP categories lack rules
- Prioritization by risk x effort scoring
- Export recommendations as Markdown report

Zero external dependencies — pure Python stdlib.

Usage:
    from mcpaudit.rule_recommender import RuleRecommender
    recommender = RuleRecommender()
    recs = recommender.analyze(scan_results)
    print(recommender.format_markdown(recs))

CLI hint:
    mcpaudit recommend scan_results.json
    mcpaudit recommend scan_results.json --format json
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any


# ---------- Constants ----------

class Priority:
    """Priority levels for recommendations."""
    CRITICAL = "CRITICAL"
    HIGH = "HIGH"
    MEDIUM = "MEDIUM"
    LOW = "LOW"

    ALL = ("CRITICAL", "HIGH", "MEDIUM", "LOW")

    @staticmethod
    def weight(priority: str) -> int:
        """Numeric weight for a priority level."""
        return {
            "CRITICAL": 4,
            "HIGH": 3,
            "MEDIUM": 2,
            "LOW": 1,
        }.get(priority, 0)


# ---------- CWE / OWASP Registry ----------

CWE_CATEGORIES: dict[str, str] = {
    "CWE-78": "OS Command Injection",
    "CWE-79": "Cross-site Scripting (XSS)",
    "CWE-89": "SQL Injection",
    "CWE-94": "Code Injection",
    "CWE-200": "Exposure of Sensitive Information",
    "CWE-250": "Execution with Unnecessary Privileges",
    "CWE-276": "Incorrect Default Permissions",
    "CWE-311": "Missing Encryption of Sensitive Data",
    "CWE-327": "Use of Broken Crypto Algorithm",
    "CWE-352": "Cross-Site Request Forgery",
    "CWE-400": "Uncontrolled Resource Consumption",
    "CWE-502": "Deserialization of Untrusted Data",
    "CWE-522": "Insufficiently Protected Credentials",
    "CWE-611": "Improper Restriction of XML External Entity",
    "CWE-732": "Incorrect Permission Assignment",
    "CWE-798": "Use of Hard-coded Credentials",
    "CWE-918": "Server-Side Request Forgery (SSRF)",
    "CWE-943": "Improper Neutralization of Special Elements in Data Query Logic",
}

OWASP_CATEGORIES: dict[str, str] = {
    "A01": "Broken Access Control",
    "A02": "Cryptographic Failures",
    "A03": "Injection",
    "A04": "Insecure Design",
    "A05": "Security Misconfiguration",
    "A06": "Vulnerable and Outdated Components",
    "A07": "Identification and Authentication Failures",
    "A08": "Software and Data Integrity Failures",
    "A09": "Security Logging and Monitoring Failures",
    "A10": "Server-Side Request Forgery",
}


# ---------- Pattern-Based Recommendation Rules ----------

@dataclass
class PatternRule:
    """If finding pattern X exists, recommend rule Y."""

    trigger_rule_id: str  # Rule ID in scan findings that triggers this
    trigger_keywords: list[str] = field(default_factory=list)  # Keywords in finding titles
    recommend_rule_id: str = ""
    recommend_title: str = ""
    reason: str = ""
    priority: str = Priority.MEDIUM
    cwe: str = ""
    owasp: str = ""
    estimated_effort: str = "medium"  # low, medium, high
    estimated_impact: str = "medium"  # low, medium, high


PATTERN_RULES: list[PatternRule] = [
    PatternRule(
        trigger_rule_id="MCP-TOOL-EXEC",
        trigger_keywords=["command", "exec", "shell"],
        recommend_rule_id="MCP-SANDBOX-001",
        recommend_title="Add sandbox isolation for tool execution",
        reason="Tool execution without sandboxing detected; sandbox isolation prevents command injection escalation",
        priority=Priority.CRITICAL,
        cwe="CWE-78",
        owasp="A03",
        estimated_effort="high",
        estimated_impact="high",
    ),
    PatternRule(
        trigger_rule_id="MCP-HARDCODED-SECRET",
        trigger_keywords=["password", "secret", "credential", "api_key", "token"],
        recommend_rule_id="MCP-VAULT-001",
        recommend_title="Implement secrets management / vault integration",
        reason="Hardcoded credentials found; use a secrets manager to avoid exposure",
        priority=Priority.CRITICAL,
        cwe="CWE-798",
        owasp="A07",
        estimated_effort="medium",
        estimated_impact="high",
    ),
    PatternRule(
        trigger_rule_id="MCP-UNSAFE-DESER",
        trigger_keywords=["deserializ", "unsafe load", "untrusted data"],
        recommend_rule_id="MCP-DESER-001",
        recommend_title="Add safe deserialization enforcement",
        reason="Unsafe deserialization patterns detected; enforce safe loaders",
        priority=Priority.HIGH,
        cwe="CWE-502",
        owasp="A08",
        estimated_effort="low",
        estimated_impact="high",
    ),
    PatternRule(
        trigger_rule_id="MCP-SSRF",
        trigger_keywords=["ssrf", "request", "fetch", "url"],
        recommend_rule_id="MCP-SSRF-001",
        recommend_title="Add SSRF protection with URL allowlisting",
        reason="Potential SSRF vectors found; restrict outbound requests to allowlisted hosts",
        priority=Priority.HIGH,
        cwe="CWE-918",
        owasp="A10",
        estimated_effort="medium",
        estimated_impact="high",
    ),
    PatternRule(
        trigger_rule_id="MCP-NO-AUTH",
        trigger_keywords=["auth", "permission", "access control"],
        recommend_rule_id="MCP-AUTH-001",
        recommend_title="Implement authentication and authorization checks",
        reason="Missing or weak authentication detected; add proper auth enforcement",
        priority=Priority.CRITICAL,
        cwe="CWE-276",
        owasp="A01",
        estimated_effort="high",
        estimated_impact="high",
    ),
    PatternRule(
        trigger_rule_id="MCP-INJECTION",
        trigger_keywords=["injection", "sql", "nosql", "query"],
        recommend_rule_id="MCP-INPUT-001",
        recommend_title="Add input validation and parameterized queries",
        reason="Injection vulnerabilities found; enforce parameterized queries and input sanitization",
        priority=Priority.CRITICAL,
        cwe="CWE-89",
        owasp="A03",
        estimated_effort="medium",
        estimated_impact="high",
    ),
    PatternRule(
        trigger_rule_id="MCP-LOGGING",
        trigger_keywords=["log", "audit", "monitor"],
        recommend_rule_id="MCP-AUDIT-001",
        recommend_title="Add comprehensive audit logging",
        reason="Insufficient logging detected; add structured audit logs for security events",
        priority=Priority.MEDIUM,
        cwe="CWE-200",
        owasp="A09",
        estimated_effort="low",
        estimated_impact="medium",
    ),
    PatternRule(
        trigger_rule_id="MCP-CRYPTO",
        trigger_keywords=["crypto", "encrypt", "hash", "md5", "sha1"],
        recommend_rule_id="MCP-CRYPTO-001",
        recommend_title="Upgrade to strong cryptographic algorithms",
        reason="Weak cryptographic algorithms in use; migrate to AES-256 / SHA-256+",
        priority=Priority.HIGH,
        cwe="CWE-327",
        owasp="A02",
        estimated_effort="medium",
        estimated_impact="medium",
    ),
    PatternRule(
        trigger_rule_id="MCP-RESOURCE",
        trigger_keywords=["resource", "memory", "cpu", "timeout", "dos"],
        recommend_rule_id="MCP-RATE-001",
        recommend_title="Add rate limiting and resource quotas",
        reason="Resource exhaustion risks detected; add rate limits and quotas",
        priority=Priority.MEDIUM,
        cwe="CWE-400",
        owasp="A05",
        estimated_effort="medium",
        estimated_impact="medium",
    ),
    PatternRule(
        trigger_rule_id="MCP-PRIV-ESC",
        trigger_keywords=["privilege", "root", "admin", "sudo"],
        recommend_rule_id="MCP-PRIVS-001",
        recommend_title="Apply principle of least privilege",
        reason="Excessive privileges detected; reduce to minimum required permissions",
        priority=Priority.HIGH,
        cwe="CWE-250",
        owasp="A01",
        estimated_effort="medium",
        estimated_impact="high",
    ),
    PatternRule(
        trigger_rule_id="MCP-XSS",
        trigger_keywords=["xss", "script", "html", "render"],
        recommend_rule_id="MCP-XSS-001",
        recommend_title="Add output encoding and CSP headers",
        reason="XSS vectors detected; add output encoding and Content-Security-Policy",
        priority=Priority.HIGH,
        cwe="CWE-79",
        owasp="A03",
        estimated_effort="low",
        estimated_impact="high",
    ),
    PatternRule(
        trigger_rule_id="MCP-CSRF",
        trigger_keywords=["csrf", "forgery", "state"],
        recommend_rule_id="MCP-CSRF-001",
        recommend_title="Add CSRF token validation",
        reason="CSRF vulnerabilities found; implement anti-CSRF tokens",
        priority=Priority.MEDIUM,
        cwe="CWE-352",
        owasp="A01",
        estimated_effort="low",
        estimated_impact="medium",
    ),
]


# ---------- Data Models ----------

@dataclass
class RuleRecommendation:
    """A recommended security rule."""

    rule_id: str
    title: str = ""
    priority: str = Priority.MEDIUM
    reason: str = ""
    estimated_impact: str = "medium"  # low, medium, high
    estimated_effort: str = "medium"  # low, medium, high
    cwe: str = ""
    owasp: str = ""
    triggered_by: list[str] = field(default_factory=list)

    @property
    def risk_effort_score(self) -> float:
        """Score combining risk (priority x impact) and effort.

        Higher score = higher priority to implement.
        Range: 0.0 to 16.0
        """
        impact_w = {"high": 4, "medium": 2, "low": 1}.get(self.estimated_impact, 1)
        effort_w = {"high": 1, "medium": 2, "low": 4}.get(self.estimated_effort, 2)
        priority_w = Priority.weight(self.priority)
        return priority_w * impact_w * (effort_w / 4)

    def to_dict(self) -> dict[str, Any]:
        return {
            "rule_id": self.rule_id,
            "title": self.title,
            "priority": self.priority,
            "reason": self.reason,
            "estimated_impact": self.estimated_impact,
            "estimated_effort": self.estimated_effort,
            "risk_effort_score": self.risk_effort_score,
            "cwe": self.cwe,
            "owasp": self.owasp,
            "triggered_by": self.triggered_by,
        }


@dataclass
class CoverageGap:
    """A coverage gap in CWE or OWASP categories."""

    category_id: str
    category_name: str
    category_type: str = "CWE"  # or "OWASP"
    covered: bool = False
    finding_count: int = 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "category_id": self.category_id,
            "category_name": self.category_name,
            "category_type": self.category_type,
            "covered": self.covered,
            "finding_count": self.finding_count,
        }


@dataclass
class RecommendationReport:
    """Complete recommendation analysis report."""

    recommendations: list[RuleRecommendation] = field(default_factory=list)
    coverage_gaps: list[CoverageGap] = field(default_factory=list)
    total_findings_analyzed: int = 0
    unique_rules_found: int = 0
    coverage_percentage: float = 0.0

    @property
    def total_recommendations(self) -> int:
        return len(self.recommendations)

    @property
    def critical_count(self) -> int:
        return sum(1 for r in self.recommendations if r.priority == Priority.CRITICAL)

    @property
    def high_count(self) -> int:
        return sum(1 for r in self.recommendations if r.priority == Priority.HIGH)

    @property
    def uncovered_gaps(self) -> list[CoverageGap]:
        return [g for g in self.coverage_gaps if not g.covered]

    def to_dict(self) -> dict[str, Any]:
        return {
            "total_recommendations": self.total_recommendations,
            "critical_count": self.critical_count,
            "high_count": self.high_count,
            "total_findings_analyzed": self.total_findings_analyzed,
            "unique_rules_found": self.unique_rules_found,
            "coverage_percentage": round(self.coverage_percentage, 2),
            "recommendations": [r.to_dict() for r in self.recommendations],
            "coverage_gaps": [g.to_dict() for g in self.coverage_gaps],
        }


# ---------- Helpers ----------

def _extract_findings(data: dict[str, Any]) -> list[dict[str, Any]]:
    """Extract findings from scan data in various formats."""
    if "results" in data:
        findings: list[dict[str, Any]] = []
        for r in data["results"]:
            if isinstance(r, dict) and "findings" in r:
                findings.extend(r["findings"])
        return findings

    if "findings" in data and isinstance(data["findings"], list):
        return data["findings"]

    return []


def _matches_pattern(
    finding: dict[str, Any], rule: PatternRule,
) -> bool:
    """Check if a finding matches a pattern rule trigger."""
    rule_id = finding.get("rule_id", "")
    title = finding.get("title", "").lower()
    description = finding.get("description", "").lower()
    combined = f"{title} {description}"

    # Match by rule_id
    if rule.trigger_rule_id and rule.trigger_rule_id == rule_id:
        return True

    # Match by keywords in title/description
    if rule.trigger_keywords:
        for kw in rule.trigger_keywords:
            if kw.lower() in combined:
                return True

    return False


# ---------- RuleRecommender ----------

class RuleRecommender:
    """Analyzes scan results and recommends missing security rules.

    Uses pattern-based detection and coverage gap analysis to generate
    prioritized rule recommendations.
    """

    def __init__(
        self,
        pattern_rules: list[PatternRule] | None = None,
        cwe_categories: dict[str, str] | None = None,
        owasp_categories: dict[str, str] | None = None,
    ) -> None:
        self.pattern_rules = pattern_rules if pattern_rules is not None else list(PATTERN_RULES)
        self.cwe_categories = cwe_categories if cwe_categories is not None else dict(CWE_CATEGORIES)
        self.owasp_categories = owasp_categories if owasp_categories is not None else dict(OWASP_CATEGORIES)

    def analyze(self, scan_data: dict[str, Any]) -> RecommendationReport:
        """Analyze scan results and generate recommendations.

        Args:
            scan_data: Scan results dict (supports results/findings formats).

        Returns:
            RecommendationReport with prioritized recommendations.
        """
        findings = _extract_findings(scan_data)
        report = RecommendationReport(
            total_findings_analyzed=len(findings),
        )

        # Collect unique rule IDs
        found_rule_ids: set[str] = set()
        for f in findings:
            rid = f.get("rule_id", "")
            if rid:
                found_rule_ids.add(rid)
        report.unique_rules_found = len(found_rule_ids)

        # Pattern-based recommendations
        already_recommended: set[str] = set()
        for rule in self.pattern_rules:
            triggers: list[str] = []
            for f in findings:
                if _matches_pattern(f, rule):
                    trigger_id = f.get("rule_id", f.get("title", "unknown"))
                    triggers.append(trigger_id)

            if triggers and rule.recommend_rule_id not in already_recommended:
                report.recommendations.append(RuleRecommendation(
                    rule_id=rule.recommend_rule_id,
                    title=rule.recommend_title,
                    priority=rule.priority,
                    reason=rule.reason,
                    estimated_impact=rule.estimated_impact,
                    estimated_effort=rule.estimated_effort,
                    cwe=rule.cwe,
                    owasp=rule.owasp,
                    triggered_by=list(set(triggers)),
                ))
                already_recommended.add(rule.recommend_rule_id)

        # Sort by risk_effort_score descending
        report.recommendations.sort(key=lambda r: r.risk_effort_score, reverse=True)

        # Coverage gap analysis
        report.coverage_gaps = self._analyze_coverage(findings)
        covered = sum(1 for g in report.coverage_gaps if g.covered)
        total = len(report.coverage_gaps)
        report.coverage_percentage = (covered / total * 100) if total > 0 else 0.0

        return report

    def _analyze_coverage(
        self, findings: list[dict[str, Any]],
    ) -> list[CoverageGap]:
        """Analyze which CWE/OWASP categories are covered by existing rules."""
        gaps: list[CoverageGap] = []

        # Collect CWE/OWASP IDs mentioned in findings
        found_cwes: set[str] = set()
        found_owasp: set[str] = set()

        for f in findings:
            cwe = f.get("cwe", "")
            owasp = f.get("owasp", "")
            if cwe:
                found_cwes.add(cwe)
            if owasp:
                found_owasp.add(owasp)

            # Also check tags
            tags = f.get("tags", [])
            if isinstance(tags, list):
                for t in tags:
                    t_str = str(t).upper()
                    if t_str.startswith("CWE-"):
                        found_cwes.add(t_str)
                    elif t_str.startswith("A") and len(t_str) <= 4:
                        found_owasp.add(t_str)

        # Also count rule IDs from pattern rules as coverage
        all_rule_ids = {f.get("rule_id", "") for f in findings}
        for pr in self.pattern_rules:
            if pr.trigger_rule_id in all_rule_ids:
                if pr.cwe:
                    found_cwes.add(pr.cwe)
                if pr.owasp:
                    found_owasp.add(pr.owasp)

        # CWE gaps
        for cwe_id, cwe_name in sorted(self.cwe_categories.items()):
            count = sum(1 for f in findings if f.get("cwe", "") == cwe_id)
            gaps.append(CoverageGap(
                category_id=cwe_id,
                category_name=cwe_name,
                category_type="CWE",
                covered=cwe_id in found_cwes,
                finding_count=count,
            ))

        # OWASP gaps
        for owasp_id, owasp_name in sorted(self.owasp_categories.items()):
            count = sum(1 for f in findings if f.get("owasp", "") == owasp_id)
            gaps.append(CoverageGap(
                category_id=owasp_id,
                category_name=owasp_name,
                category_type="OWASP",
                covered=owasp_id in found_owasp,
                finding_count=count,
            ))

        return gaps

    def recommend_for_category(
        self, category_id: str,
    ) -> list[RuleRecommendation]:
        """Get recommendations specifically for a CWE/OWASP category.

        Args:
            category_id: A CWE or OWASP category identifier.

        Returns:
            List of recommendations matching that category.
        """
        result: list[RuleRecommendation] = []
        for rule in self.pattern_rules:
            if rule.cwe == category_id or rule.owasp == category_id:
                result.append(RuleRecommendation(
                    rule_id=rule.recommend_rule_id,
                    title=rule.recommend_title,
                    priority=rule.priority,
                    reason=rule.reason,
                    estimated_impact=rule.estimated_impact,
                    estimated_effort=rule.estimated_effort,
                    cwe=rule.cwe,
                    owasp=rule.owasp,
                ))
        return result

    def add_pattern_rule(self, rule: PatternRule) -> None:
        """Add a custom pattern rule to the recommender."""
        self.pattern_rules.append(rule)


# ---------- Formatters ----------

def format_markdown(report: RecommendationReport) -> str:
    """Format a RecommendationReport as Markdown.

    Args:
        report: The RecommendationReport to format.

    Returns:
        Markdown string with recommendations and coverage analysis.
    """
    lines: list[str] = []
    lines.append("# Rule Recommendations Report")
    lines.append("")
    lines.append("## Overview")
    lines.append("")
    lines.append(f"| Metric | Value |")
    lines.append(f"|--------|-------|")
    lines.append(f"| Findings analyzed | {report.total_findings_analyzed} |")
    lines.append(f"| Unique rules found | {report.unique_rules_found} |")
    lines.append(f"| Recommendations | {report.total_recommendations} |")
    lines.append(f"| Critical | {report.critical_count} |")
    lines.append(f"| High | {report.high_count} |")
    lines.append(f"| Coverage | {report.coverage_percentage:.1f}% |")
    lines.append("")

    if report.recommendations:
        lines.append("## Recommendations")
        lines.append("")
        lines.append("| Priority | Rule ID | Title | Impact | Effort | Score |")
        lines.append("|----------|---------|-------|--------|--------|-------|")
        for r in report.recommendations:
            lines.append(
                f"| {r.priority} | {r.rule_id} | {r.title} | "
                f"{r.estimated_impact} | {r.estimated_effort} | "
                f"{r.risk_effort_score:.1f} |"
            )
        lines.append("")

        # Detail section
        lines.append("## Details")
        lines.append("")
        for r in report.recommendations:
            lines.append(f"### {r.rule_id}: {r.title}")
            lines.append("")
            lines.append(f"- **Priority:** {r.priority}")
            lines.append(f"- **Reason:** {r.reason}")
            if r.cwe:
                cwe_name = CWE_CATEGORIES.get(r.cwe, "")
                lines.append(f"- **CWE:** {r.cwe} ({cwe_name})")
            if r.owasp:
                owasp_name = OWASP_CATEGORIES.get(r.owasp, "")
                lines.append(f"- **OWASP:** {r.owasp} ({owasp_name})")
            if r.triggered_by:
                lines.append(f"- **Triggered by:** {', '.join(r.triggered_by)}")
            lines.append("")

    # Coverage gaps
    uncovered = report.uncovered_gaps
    if uncovered:
        lines.append("## Coverage Gaps")
        lines.append("")
        lines.append("| Type | Category | Description |")
        lines.append("|------|----------|-------------|")
        for g in uncovered:
            lines.append(f"| {g.category_type} | {g.category_id} | {g.category_name} |")
        lines.append("")

    return "\n".join(lines)


def format_json(report: RecommendationReport) -> str:
    """Format a RecommendationReport as JSON.

    Args:
        report: The RecommendationReport to format.

    Returns:
        JSON string.
    """
    return json.dumps(report.to_dict(), indent=2, ensure_ascii=False)
