"""Risk Scoring Engine for MCP Security Audit.

Multi-dimensional risk scoring that integrates data from scanner findings,
trust score, supply chain analysis, and config guard to produce a composite
risk profile with per-dimension scores and historical trend analysis.

Zero external dependencies -- pure Python stdlib.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Sequence


# ---------------------------------------------------------------------------
# Risk dimensions
# ---------------------------------------------------------------------------

class RiskDimension(Enum):
    """Risk assessment dimensions."""

    CODE_QUALITY = "code_quality"
    AUTH_SECURITY = "auth_security"
    DATA_HANDLING = "data_handling"
    DEPENDENCY_HEALTH = "dependency_health"
    CONFIG_SAFETY = "config_safety"


# Default equal weights (each dimension contributes 20%)
DEFAULT_WEIGHTS: dict[str, float] = {
    RiskDimension.CODE_QUALITY.value: 0.20,
    RiskDimension.AUTH_SECURITY.value: 0.20,
    RiskDimension.DATA_HANDLING.value: 0.20,
    RiskDimension.DEPENDENCY_HEALTH.value: 0.20,
    RiskDimension.CONFIG_SAFETY.value: 0.20,
}

# Rule prefix -> dimension mapping
_RULE_DIMENSION_MAP: dict[str, RiskDimension] = {
    # Auth-related rules
    "AUTH": RiskDimension.AUTH_SECURITY,
    "RATE": RiskDimension.AUTH_SECURITY,
    "PRIV": RiskDimension.AUTH_SECURITY,
    "OAUTH": RiskDimension.AUTH_SECURITY,
    "SESSION": RiskDimension.AUTH_SECURITY,
    # Data handling rules
    "DATA": RiskDimension.DATA_HANDLING,
    "LEAK": RiskDimension.DATA_HANDLING,
    "LOG": RiskDimension.DATA_HANDLING,
    "SENS": RiskDimension.DATA_HANDLING,
    "PII": RiskDimension.DATA_HANDLING,
    "SECRET": RiskDimension.DATA_HANDLING,
    # Config safety
    "GUARD": RiskDimension.CONFIG_SAFETY,
    "CONF": RiskDimension.CONFIG_SAFETY,
    "ENV": RiskDimension.CONFIG_SAFETY,
    "PERM": RiskDimension.CONFIG_SAFETY,
    # Dependency health
    "DEP": RiskDimension.DEPENDENCY_HEALTH,
    "SUPPLY": RiskDimension.DEPENDENCY_HEALTH,
    "SBOM": RiskDimension.DEPENDENCY_HEALTH,
    "LICENSE": RiskDimension.DEPENDENCY_HEALTH,
    # Code quality (default)
    "CMD": RiskDimension.CODE_QUALITY,
    "PATH": RiskDimension.CODE_QUALITY,
    "INJ": RiskDimension.CODE_QUALITY,
    "DESER": RiskDimension.CODE_QUALITY,
    "FLOW": RiskDimension.CODE_QUALITY,
    "PROMPT": RiskDimension.CODE_QUALITY,
    "MCP": RiskDimension.CODE_QUALITY,
    "OWASP": RiskDimension.CODE_QUALITY,
}

# Severity weights for dimension scoring
_SEVERITY_WEIGHTS: dict[str, int] = {
    "CRITICAL": 25,
    "HIGH": 15,
    "MEDIUM": 8,
    "LOW": 3,
    "INFO": 1,
}


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------

@dataclass
class DimensionScore:
    """Score for a single risk dimension."""

    dimension: str
    score: int  # 0-100, higher is better (lower risk)
    finding_count: int = 0
    weighted_impact: int = 0
    weight: float = 0.20
    top_rules: list[str] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        return {
            "dimension": self.dimension,
            "score": self.score,
            "finding_count": self.finding_count,
            "weighted_impact": self.weighted_impact,
            "weight": self.weight,
            "top_rules": self.top_rules,
        }


@dataclass
class RiskTrend:
    """Risk trend compared to historical scores."""

    direction: str  # "improving", "degrading", "stable", "new"
    delta: float = 0.0
    previous_score: float = 0.0
    current_score: float = 0.0
    data_points: int = 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "direction": self.direction,
            "delta": round(self.delta, 2),
            "previous_score": round(self.previous_score, 2),
            "current_score": round(self.current_score, 2),
            "data_points": self.data_points,
        }


@dataclass
class RiskProfile:
    """Complete risk profile with per-dimension and composite scores."""

    composite_score: int  # 0-100, higher is better
    dimensions: dict[str, DimensionScore] = field(default_factory=dict)
    trend: RiskTrend | None = None
    label: str = ""
    total_findings: int = 0
    trust_score: int | None = None
    supply_chain_issues: int = 0
    config_issues: int = 0

    def to_dict(self) -> dict[str, Any]:
        result: dict[str, Any] = {
            "composite_score": self.composite_score,
            "label": self.label,
            "total_findings": self.total_findings,
            "dimensions": {
                k: v.to_dict() for k, v in self.dimensions.items()
            },
        }
        if self.trend is not None:
            result["trend"] = self.trend.to_dict()
        if self.trust_score is not None:
            result["trust_score"] = self.trust_score
        if self.supply_chain_issues > 0:
            result["supply_chain_issues"] = self.supply_chain_issues
        if self.config_issues > 0:
            result["config_issues"] = self.config_issues
        return result


# ---------------------------------------------------------------------------
# Dimension classifier
# ---------------------------------------------------------------------------

def classify_finding_dimension(rule_id: str) -> RiskDimension:
    """Map a rule_id to its risk dimension.

    Uses prefix matching against the rule-dimension map.
    Falls back to CODE_QUALITY for unknown prefixes.

    Args:
        rule_id: Rule identifier (e.g. "AUTH-001", "CMD-002").

    Returns:
        The matching RiskDimension.
    """
    prefix = rule_id.split("-")[0] if "-" in rule_id else rule_id
    return _RULE_DIMENSION_MAP.get(prefix, RiskDimension.CODE_QUALITY)


# ---------------------------------------------------------------------------
# Weight management
# ---------------------------------------------------------------------------

def load_weights(path: Path | str | None = None) -> dict[str, float]:
    """Load dimension weights from a JSON file.

    Expected format: {"code_quality": 0.25, "auth_security": 0.25, ...}
    Values are normalized so they sum to 1.0.

    Args:
        path: Path to weights JSON file, or None for defaults.

    Returns:
        Dictionary mapping dimension names to weights (sum to 1.0).
    """
    if path is None:
        return dict(DEFAULT_WEIGHTS)

    path = Path(path)
    if not path.exists():
        return dict(DEFAULT_WEIGHTS)

    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return dict(DEFAULT_WEIGHTS)

    weights: dict[str, float] = {}
    valid_dims = {d.value for d in RiskDimension}
    for k, v in raw.items():
        if k in valid_dims:
            try:
                weights[k] = float(v)
            except (ValueError, TypeError):
                weights[k] = DEFAULT_WEIGHTS.get(k, 0.20)

    # Fill missing dimensions
    for dim in valid_dims:
        if dim not in weights:
            weights[dim] = DEFAULT_WEIGHTS.get(dim, 0.20)

    # Normalize
    total = sum(weights.values())
    if total > 0:
        weights = {k: v / total for k, v in weights.items()}
    else:
        weights = dict(DEFAULT_WEIGHTS)

    return weights


def validate_weights(weights: dict[str, float]) -> list[str]:
    """Validate a weights dictionary.

    Args:
        weights: Dimension name -> weight mapping.

    Returns:
        List of validation errors (empty if valid).
    """
    errors: list[str] = []
    valid_dims = {d.value for d in RiskDimension}

    for k in weights:
        if k not in valid_dims:
            errors.append(f"Unknown dimension: {k}")

    for dim in valid_dims:
        if dim not in weights:
            errors.append(f"Missing dimension: {dim}")

    for k, v in weights.items():
        if not isinstance(v, (int, float)) or v < 0:
            errors.append(f"Invalid weight for {k}: {v}")

    total = sum(v for v in weights.values() if isinstance(v, (int, float)))
    if abs(total - 1.0) > 0.05:
        errors.append(
            f"Weights sum to {total:.3f}, expected ~1.0"
        )

    return errors


# ---------------------------------------------------------------------------
# Scoring helpers
# ---------------------------------------------------------------------------

def _score_label(score: int) -> str:
    """Map a risk score to a label.

    Args:
        score: Composite score 0-100.

    Returns:
        Human-readable label.
    """
    if score >= 90:
        return "EXCELLENT"
    if score >= 75:
        return "GOOD"
    if score >= 50:
        return "MODERATE"
    if score >= 25:
        return "POOR"
    return "CRITICAL"


def _compute_dimension_score(
    finding_severities: list[str],
    rule_ids: list[str],
) -> tuple[int, int, list[str]]:
    """Compute the score for a single dimension.

    Starts at 100 and deducts per finding severity.
    Capped at 0 minimum.

    Args:
        finding_severities: List of severity strings for this dimension.
        rule_ids: List of rule IDs for this dimension.

    Returns:
        Tuple of (score, weighted_impact, top_rules).
    """
    base = 100
    impact = 0
    rule_counts: dict[str, int] = {}

    for sev in finding_severities:
        deduction = _SEVERITY_WEIGHTS.get(sev, 1)
        impact += deduction

    for rid in rule_ids:
        rule_counts[rid] = rule_counts.get(rid, 0) + 1

    # Top rules by frequency (up to 5)
    sorted_rules = sorted(rule_counts.items(), key=lambda x: x[1], reverse=True)
    top_rules = [r[0] for r in sorted_rules[:5]]

    score = max(0, min(100, base - impact))
    return score, impact, top_rules


# ---------------------------------------------------------------------------
# Core scoring
# ---------------------------------------------------------------------------

def compute_risk_profile(
    findings: list[dict[str, Any]],
    weights: dict[str, float] | None = None,
    trust_score: int | None = None,
    supply_chain_findings: list[dict[str, Any]] | None = None,
    config_findings: list[dict[str, Any]] | None = None,
    history: list[dict[str, Any]] | None = None,
) -> RiskProfile:
    """Compute a multi-dimensional risk profile.

    Integrates findings from scanner, trust score, supply chain, and
    config guard into a composite risk score.

    Args:
        findings: List of finding dicts with rule_id, severity.
        weights: Optional custom weights (dimension -> float).
        trust_score: Optional trust score 0-100 from trust_score module.
        supply_chain_findings: Optional supply chain findings.
        config_findings: Optional config guard findings.
        history: Optional list of previous risk profile dicts for trend.

    Returns:
        RiskProfile with per-dimension and composite scores.
    """
    if weights is None:
        weights = dict(DEFAULT_WEIGHTS)

    # Classify findings by dimension
    dim_severities: dict[str, list[str]] = {d.value: [] for d in RiskDimension}
    dim_rules: dict[str, list[str]] = {d.value: [] for d in RiskDimension}

    for f in findings:
        rule_id = f.get("rule_id", "")
        severity = f.get("severity", "INFO")
        dim = classify_finding_dimension(rule_id)
        dim_severities[dim.value].append(severity)
        dim_rules[dim.value].append(rule_id)

    # Integrate supply chain findings
    sc_count = 0
    if supply_chain_findings:
        sc_count = len(supply_chain_findings)
        for scf in supply_chain_findings:
            sev = scf.get("severity", "MEDIUM")
            dim_severities[RiskDimension.DEPENDENCY_HEALTH.value].append(sev)

    # Integrate config guard findings
    cfg_count = 0
    if config_findings:
        cfg_count = len(config_findings)
        for cf in config_findings:
            sev = cf.get("severity", "MEDIUM")
            dim_severities[RiskDimension.CONFIG_SAFETY.value].append(sev)

    # Compute per-dimension scores
    dimensions: dict[str, DimensionScore] = {}
    for dim in RiskDimension:
        score, impact, top_rules = _compute_dimension_score(
            dim_severities[dim.value],
            dim_rules[dim.value],
        )
        dimensions[dim.value] = DimensionScore(
            dimension=dim.value,
            score=score,
            finding_count=len(dim_severities[dim.value]),
            weighted_impact=impact,
            weight=weights.get(dim.value, 0.20),
            top_rules=top_rules,
        )

    # Composite score = weighted average of dimension scores
    composite = 0.0
    for dim_name, dim_score in dimensions.items():
        w = weights.get(dim_name, 0.20)
        composite += dim_score.score * w

    # Integrate trust score if available (blend 80% composite + 20% trust)
    if trust_score is not None:
        composite = composite * 0.8 + trust_score * 0.2

    composite_int = max(0, min(100, round(composite)))

    # Trend analysis
    trend = _compute_trend(composite_int, history)

    total_findings = sum(len(sl) for sl in dim_severities.values())

    return RiskProfile(
        composite_score=composite_int,
        dimensions=dimensions,
        trend=trend,
        label=_score_label(composite_int),
        total_findings=total_findings,
        trust_score=trust_score,
        supply_chain_issues=sc_count,
        config_issues=cfg_count,
    )


def compute_risk_from_scan(
    scan_data: dict[str, Any],
    weights: dict[str, float] | None = None,
    trust_score: int | None = None,
    supply_chain_data: dict[str, Any] | None = None,
    config_data: dict[str, Any] | None = None,
    history: list[dict[str, Any]] | None = None,
) -> RiskProfile:
    """Compute risk profile from raw scan JSON data.

    Extracts findings from the scan data structure produced by
    MCPSecurityScanner.

    Args:
        scan_data: Scan result dict (with "results" list).
        weights: Optional custom weights.
        trust_score: Optional trust score.
        supply_chain_data: Optional supply chain scan result dict.
        config_data: Optional config guard result dict.
        history: Optional historical profiles.

    Returns:
        RiskProfile.
    """
    findings: list[dict[str, Any]] = []
    results = scan_data.get("results", [])
    for r in results:
        for f in r.get("findings", []):
            findings.append(f)

    sc_findings: list[dict[str, Any]] | None = None
    if supply_chain_data:
        sc_findings = supply_chain_data.get("findings", [])

    cfg_findings: list[dict[str, Any]] | None = None
    if config_data:
        cfg_findings = config_data.get("findings", [])

    return compute_risk_profile(
        findings=findings,
        weights=weights,
        trust_score=trust_score,
        supply_chain_findings=sc_findings,
        config_findings=cfg_findings,
        history=history,
    )


# ---------------------------------------------------------------------------
# Trend analysis
# ---------------------------------------------------------------------------

def _compute_trend(
    current_score: int,
    history: list[dict[str, Any]] | None,
) -> RiskTrend:
    """Compute risk trend from historical data.

    Args:
        current_score: Current composite score.
        history: List of previous risk profile dicts with "composite_score".

    Returns:
        RiskTrend describing the direction and delta.
    """
    if not history:
        return RiskTrend(
            direction="new",
            delta=0.0,
            previous_score=0.0,
            current_score=float(current_score),
            data_points=0,
        )

    scores = []
    for h in history:
        cs = h.get("composite_score")
        if cs is not None:
            try:
                scores.append(float(cs))
            except (ValueError, TypeError):
                pass

    if not scores:
        return RiskTrend(
            direction="new",
            delta=0.0,
            previous_score=0.0,
            current_score=float(current_score),
            data_points=0,
        )

    prev = scores[-1]
    delta = float(current_score) - prev

    if abs(delta) < 2.0:
        direction = "stable"
    elif delta > 0:
        direction = "improving"
    else:
        direction = "degrading"

    return RiskTrend(
        direction=direction,
        delta=delta,
        previous_score=prev,
        current_score=float(current_score),
        data_points=len(scores),
    )


def load_history(path: Path | str) -> list[dict[str, Any]]:
    """Load risk score history from a JSON file.

    Args:
        path: Path to history JSON file.

    Returns:
        List of historical risk profile dicts.
    """
    path = Path(path)
    if not path.exists():
        return []
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        if isinstance(data, list):
            return data
        return []
    except (json.JSONDecodeError, OSError):
        return []


def save_history(
    path: Path | str,
    profile: RiskProfile,
    max_entries: int = 100,
) -> None:
    """Save a risk profile to the history file.

    Appends the profile to existing history, capping at max_entries.

    Args:
        path: Path to history JSON file.
        profile: RiskProfile to save.
        max_entries: Maximum history entries to keep.
    """
    path = Path(path)
    existing = load_history(path)
    existing.append(profile.to_dict())
    if len(existing) > max_entries:
        existing = existing[-max_entries:]
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(existing, indent=2), encoding="utf-8")


# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------

def format_risk_text(profile: RiskProfile) -> str:
    """Format a risk profile as human-readable text.

    Args:
        profile: RiskProfile to format.

    Returns:
        Formatted text string.
    """
    lines: list[str] = []
    lines.append(f"Risk Score: {profile.composite_score}/100 ({profile.label})")
    lines.append(f"Total Findings: {profile.total_findings}")
    lines.append("")
    lines.append("Dimensions:")

    for dim in RiskDimension:
        ds = profile.dimensions.get(dim.value)
        if ds:
            bar = _score_bar(ds.score)
            lines.append(
                f"  {dim.value:<22s} {ds.score:3d}/100 {bar} "
                f"({ds.finding_count} findings, weight: {ds.weight:.0%})"
            )
            if ds.top_rules:
                lines.append(f"    Top rules: {', '.join(ds.top_rules)}")

    if profile.trend:
        lines.append("")
        lines.append(f"Trend: {profile.trend.direction}")
        if profile.trend.direction != "new":
            sign = "+" if profile.trend.delta >= 0 else ""
            lines.append(
                f"  Delta: {sign}{profile.trend.delta:.1f} "
                f"(prev: {profile.trend.previous_score:.0f})"
            )

    if profile.trust_score is not None:
        lines.append(f"\nIntegrated Trust Score: {profile.trust_score}/100")
    if profile.supply_chain_issues > 0:
        lines.append(f"Supply Chain Issues: {profile.supply_chain_issues}")
    if profile.config_issues > 0:
        lines.append(f"Config Issues: {profile.config_issues}")

    return "\n".join(lines)


def format_risk_json(profile: RiskProfile) -> str:
    """Format a risk profile as JSON.

    Args:
        profile: RiskProfile to format.

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


def format_risk_html(profile: RiskProfile) -> str:
    """Format a risk profile as a self-contained HTML report.

    Args:
        profile: RiskProfile to format.

    Returns:
        HTML string.
    """
    rows = ""
    for dim in RiskDimension:
        ds = profile.dimensions.get(dim.value)
        if ds:
            color = _score_color(ds.score)
            rows += (
                f"<tr><td>{_html_escape(dim.value)}</td>"
                f"<td style='color:{color};font-weight:bold'>{ds.score}/100</td>"
                f"<td>{ds.finding_count}</td>"
                f"<td>{ds.weight:.0%}</td>"
                f"<td>{_html_escape(', '.join(ds.top_rules))}</td></tr>\n"
            )

    trend_html = ""
    if profile.trend and profile.trend.direction != "new":
        sign = "+" if profile.trend.delta >= 0 else ""
        trend_html = (
            f"<p>Trend: <strong>{profile.trend.direction}</strong> "
            f"({sign}{profile.trend.delta:.1f})</p>"
        )

    color = _score_color(profile.composite_score)
    return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Risk Score Report</title>
<style>
body {{ font-family: sans-serif; max-width: 900px; margin: 2em auto; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background: #f4f4f4; }}
.score {{ font-size: 2em; color: {color}; font-weight: bold; }}
</style></head><body>
<h1>MCP Risk Score Report</h1>
<p class="score">{profile.composite_score}/100 ({_html_escape(profile.label)})</p>
<p>Total Findings: {profile.total_findings}</p>
{trend_html}
<h2>Dimension Breakdown</h2>
<table><tr><th>Dimension</th><th>Score</th><th>Findings</th><th>Weight</th><th>Top Rules</th></tr>
{rows}</table>
</body></html>"""


def _score_bar(score: int, width: int = 20) -> str:
    """Create a text progress bar for a score.

    Args:
        score: Score 0-100.
        width: Bar width in characters.

    Returns:
        Progress bar string like [#########...........].
    """
    filled = round(score / 100 * width)
    empty = width - filled
    return f"[{'#' * filled}{'.' * empty}]"


def _score_color(score: int) -> str:
    """Return an HTML color for a score.

    Args:
        score: Score 0-100.

    Returns:
        CSS color string.
    """
    if score >= 80:
        return "#2d9e2d"
    if score >= 60:
        return "#6aa84f"
    if score >= 40:
        return "#e69138"
    if score >= 20:
        return "#cc4125"
    return "#990000"


def _html_escape(text: str) -> str:
    """Escape HTML special characters.

    Args:
        text: Raw text.

    Returns:
        HTML-safe text.
    """
    return (
        text.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
    )
