"""Tests for the Risk Scoring Engine (mcpaudit.risk_scoring).

Tests multi-dimensional risk scoring with configurable weights,
dimension classification, trend analysis, history persistence,
and output formatting.
"""

from __future__ import annotations

import json
import tempfile
from pathlib import Path

import pytest

from mcpaudit.risk_scoring import (
    DEFAULT_WEIGHTS,
    DimensionScore,
    RiskDimension,
    RiskProfile,
    RiskTrend,
    classify_finding_dimension,
    compute_risk_from_scan,
    compute_risk_profile,
    format_risk_html,
    format_risk_json,
    format_risk_text,
    load_history,
    load_weights,
    save_history,
    validate_weights,
    _compute_dimension_score,
    _compute_trend,
    _score_bar,
    _score_color,
    _score_label,
)


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

def _make_finding(
    rule_id: str = "CMD-001",
    severity: str = "MEDIUM",
    file: str = "server.py",
    line: int = 10,
) -> dict:
    return {
        "rule_id": rule_id,
        "severity": severity,
        "title": f"Test {rule_id}",
        "description": "test finding",
        "file": file,
        "line": line,
    }


def _make_findings(
    critical: int = 0,
    high: int = 0,
    medium: int = 0,
    low: int = 0,
    info: int = 0,
    prefix: str = "CMD",
) -> list[dict]:
    findings = []
    idx = 0
    for sev, count in [
        ("CRITICAL", critical),
        ("HIGH", high),
        ("MEDIUM", medium),
        ("LOW", low),
        ("INFO", info),
    ]:
        for i in range(count):
            findings.append(_make_finding(
                rule_id=f"{prefix}-{idx:03d}",
                severity=sev,
                line=idx + 1,
            ))
            idx += 1
    return findings


# ---------------------------------------------------------------------------
# RiskDimension enum
# ---------------------------------------------------------------------------

class TestRiskDimension:
    def test_all_dimensions_exist(self):
        assert len(RiskDimension) == 5

    def test_dimension_values(self):
        assert RiskDimension.CODE_QUALITY.value == "code_quality"
        assert RiskDimension.AUTH_SECURITY.value == "auth_security"
        assert RiskDimension.DATA_HANDLING.value == "data_handling"
        assert RiskDimension.DEPENDENCY_HEALTH.value == "dependency_health"
        assert RiskDimension.CONFIG_SAFETY.value == "config_safety"

    def test_dimension_iteration(self):
        dims = list(RiskDimension)
        assert len(dims) == 5


# ---------------------------------------------------------------------------
# classify_finding_dimension
# ---------------------------------------------------------------------------

class TestClassifyDimension:
    def test_auth_rules(self):
        assert classify_finding_dimension("AUTH-001") == RiskDimension.AUTH_SECURITY
        assert classify_finding_dimension("RATE-002") == RiskDimension.AUTH_SECURITY

    def test_data_rules(self):
        assert classify_finding_dimension("DATA-001") == RiskDimension.DATA_HANDLING
        assert classify_finding_dimension("LEAK-001") == RiskDimension.DATA_HANDLING
        assert classify_finding_dimension("SECRET-001") == RiskDimension.DATA_HANDLING

    def test_config_rules(self):
        assert classify_finding_dimension("GUARD-001") == RiskDimension.CONFIG_SAFETY
        assert classify_finding_dimension("CONF-001") == RiskDimension.CONFIG_SAFETY
        assert classify_finding_dimension("ENV-001") == RiskDimension.CONFIG_SAFETY

    def test_dependency_rules(self):
        assert classify_finding_dimension("DEP-001") == RiskDimension.DEPENDENCY_HEALTH
        assert classify_finding_dimension("SUPPLY-001") == RiskDimension.DEPENDENCY_HEALTH

    def test_code_quality_rules(self):
        assert classify_finding_dimension("CMD-001") == RiskDimension.CODE_QUALITY
        assert classify_finding_dimension("PATH-001") == RiskDimension.CODE_QUALITY
        assert classify_finding_dimension("INJ-001") == RiskDimension.CODE_QUALITY

    def test_unknown_prefix_defaults_to_code_quality(self):
        assert classify_finding_dimension("UNKNOWN-001") == RiskDimension.CODE_QUALITY
        assert classify_finding_dimension("FOOBAR-999") == RiskDimension.CODE_QUALITY

    def test_no_dash_in_rule_id(self):
        result = classify_finding_dimension("CMD")
        assert result == RiskDimension.CODE_QUALITY

    def test_prompt_dimension(self):
        assert classify_finding_dimension("PROMPT-001") == RiskDimension.CODE_QUALITY

    def test_mcp_dimension(self):
        assert classify_finding_dimension("MCP-001") == RiskDimension.CODE_QUALITY


# ---------------------------------------------------------------------------
# Default weights
# ---------------------------------------------------------------------------

class TestDefaultWeights:
    def test_all_dimensions_covered(self):
        for dim in RiskDimension:
            assert dim.value in DEFAULT_WEIGHTS

    def test_weights_sum_to_one(self):
        assert abs(sum(DEFAULT_WEIGHTS.values()) - 1.0) < 0.001

    def test_equal_weights(self):
        for v in DEFAULT_WEIGHTS.values():
            assert abs(v - 0.20) < 0.001


# ---------------------------------------------------------------------------
# load_weights
# ---------------------------------------------------------------------------

class TestLoadWeights:
    def test_none_returns_defaults(self):
        weights = load_weights(None)
        assert weights == DEFAULT_WEIGHTS

    def test_nonexistent_file_returns_defaults(self):
        weights = load_weights("/tmp/nonexistent_risk_weights.json")
        assert weights == DEFAULT_WEIGHTS

    def test_valid_custom_weights(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({
                "code_quality": 0.40,
                "auth_security": 0.30,
                "data_handling": 0.15,
                "dependency_health": 0.10,
                "config_safety": 0.05,
            }, f)
            f.flush()
            weights = load_weights(f.name)
        assert abs(sum(weights.values()) - 1.0) < 0.001
        # code_quality should be highest
        assert weights["code_quality"] > weights["config_safety"]

    def test_partial_weights_filled(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"code_quality": 0.50}, f)
            f.flush()
            weights = load_weights(f.name)
        assert "auth_security" in weights
        assert abs(sum(weights.values()) - 1.0) < 0.001

    def test_invalid_json_returns_defaults(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            f.write("not json")
            f.flush()
            weights = load_weights(f.name)
        assert weights == DEFAULT_WEIGHTS

    def test_zero_weights_normalized(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({d.value: 0 for d in RiskDimension}, f)
            f.flush()
            weights = load_weights(f.name)
        # Should fall back to defaults when all zero
        assert weights == DEFAULT_WEIGHTS


# ---------------------------------------------------------------------------
# validate_weights
# ---------------------------------------------------------------------------

class TestValidateWeights:
    def test_valid_weights(self):
        errors = validate_weights(DEFAULT_WEIGHTS)
        assert errors == []

    def test_missing_dimension(self):
        w = {d.value: 0.25 for d in RiskDimension}
        del w["code_quality"]
        errors = validate_weights(w)
        assert any("Missing" in e for e in errors)

    def test_unknown_dimension(self):
        w = dict(DEFAULT_WEIGHTS)
        w["bogus"] = 0.1
        errors = validate_weights(w)
        assert any("Unknown" in e for e in errors)

    def test_negative_weight(self):
        w = dict(DEFAULT_WEIGHTS)
        w["code_quality"] = -0.1
        errors = validate_weights(w)
        assert any("Invalid" in e for e in errors)

    def test_weights_not_summing_to_one(self):
        w = {d.value: 0.50 for d in RiskDimension}
        errors = validate_weights(w)
        assert any("sum" in e.lower() for e in errors)


# ---------------------------------------------------------------------------
# _score_label
# ---------------------------------------------------------------------------

class TestScoreLabel:
    def test_excellent(self):
        assert _score_label(95) == "EXCELLENT"
        assert _score_label(90) == "EXCELLENT"

    def test_good(self):
        assert _score_label(80) == "GOOD"
        assert _score_label(75) == "GOOD"

    def test_moderate(self):
        assert _score_label(60) == "MODERATE"
        assert _score_label(50) == "MODERATE"

    def test_poor(self):
        assert _score_label(30) == "POOR"
        assert _score_label(25) == "POOR"

    def test_critical(self):
        assert _score_label(10) == "CRITICAL"
        assert _score_label(0) == "CRITICAL"


# ---------------------------------------------------------------------------
# _compute_dimension_score
# ---------------------------------------------------------------------------

class TestComputeDimensionScore:
    def test_no_findings_perfect_score(self):
        score, impact, rules = _compute_dimension_score([], [])
        assert score == 100
        assert impact == 0
        assert rules == []

    def test_critical_findings_heavy_deduction(self):
        score, impact, rules = _compute_dimension_score(
            ["CRITICAL", "CRITICAL"],
            ["AUTH-001", "AUTH-001"],
        )
        assert score == 50
        assert impact == 50

    def test_mixed_severities(self):
        score, impact, rules = _compute_dimension_score(
            ["HIGH", "MEDIUM", "LOW"],
            ["A-001", "B-001", "C-001"],
        )
        assert score == 100 - (15 + 8 + 3)
        assert impact == 26

    def test_score_capped_at_zero(self):
        score, impact, rules = _compute_dimension_score(
            ["CRITICAL"] * 10,
            ["X-001"] * 10,
        )
        assert score == 0
        assert impact == 250

    def test_top_rules_limited_to_five(self):
        rule_ids = [f"RULE-{i:03d}" for i in range(20)]
        score, impact, rules = _compute_dimension_score(
            ["LOW"] * 20, rule_ids,
        )
        assert len(rules) <= 5


# ---------------------------------------------------------------------------
# compute_risk_profile — basic
# ---------------------------------------------------------------------------

class TestComputeRiskProfile:
    def test_no_findings_perfect_score(self):
        profile = compute_risk_profile([])
        assert profile.composite_score == 100
        assert profile.label == "EXCELLENT"
        assert profile.total_findings == 0

    def test_single_critical_finding(self):
        findings = [_make_finding(rule_id="CMD-001", severity="CRITICAL")]
        profile = compute_risk_profile(findings)
        assert profile.composite_score < 100
        assert profile.total_findings == 1

    def test_multiple_dimensions(self):
        findings = [
            _make_finding(rule_id="AUTH-001", severity="HIGH"),
            _make_finding(rule_id="CMD-001", severity="MEDIUM"),
            _make_finding(rule_id="DEP-001", severity="LOW"),
        ]
        profile = compute_risk_profile(findings)
        assert profile.dimensions["auth_security"].finding_count == 1
        assert profile.dimensions["code_quality"].finding_count == 1
        assert profile.dimensions["dependency_health"].finding_count == 1

    def test_dimension_scores_vary(self):
        findings = [
            _make_finding(rule_id="AUTH-001", severity="CRITICAL"),
            _make_finding(rule_id="AUTH-002", severity="CRITICAL"),
        ]
        profile = compute_risk_profile(findings)
        auth = profile.dimensions["auth_security"]
        code = profile.dimensions["code_quality"]
        assert auth.score < code.score

    def test_all_dimensions_present(self):
        profile = compute_risk_profile([])
        for dim in RiskDimension:
            assert dim.value in profile.dimensions

    def test_custom_weights(self):
        findings = _make_findings(high=3, prefix="AUTH")
        w1 = dict(DEFAULT_WEIGHTS)
        w2 = dict(DEFAULT_WEIGHTS)
        w2["auth_security"] = 0.80
        w2["code_quality"] = 0.05
        w2["data_handling"] = 0.05
        w2["dependency_health"] = 0.05
        w2["config_safety"] = 0.05
        p1 = compute_risk_profile(findings, weights=w1)
        p2 = compute_risk_profile(findings, weights=w2)
        # Higher weight on auth means lower composite when auth is bad
        assert p2.composite_score < p1.composite_score


# ---------------------------------------------------------------------------
# Trust score integration
# ---------------------------------------------------------------------------

class TestTrustScoreIntegration:
    def test_trust_score_blended(self):
        profile = compute_risk_profile([], trust_score=50)
        # 100 * 0.8 + 50 * 0.2 = 90
        assert profile.composite_score == 90
        assert profile.trust_score == 50

    def test_low_trust_score_reduces_composite(self):
        p1 = compute_risk_profile([])
        p2 = compute_risk_profile([], trust_score=20)
        assert p2.composite_score < p1.composite_score

    def test_trust_score_none_no_blend(self):
        profile = compute_risk_profile([])
        assert profile.trust_score is None
        assert profile.composite_score == 100


# ---------------------------------------------------------------------------
# Supply chain & config integration
# ---------------------------------------------------------------------------

class TestExternalIntegration:
    def test_supply_chain_findings(self):
        sc = [
            {"severity": "HIGH", "package": "evil-pkg"},
            {"severity": "MEDIUM", "package": "risky-pkg"},
        ]
        profile = compute_risk_profile([], supply_chain_findings=sc)
        dep = profile.dimensions["dependency_health"]
        assert dep.finding_count == 2
        assert profile.supply_chain_issues == 2

    def test_config_findings(self):
        cfg = [
            {"severity": "CRITICAL", "rule_id": "GUARD-001"},
        ]
        profile = compute_risk_profile([], config_findings=cfg)
        conf = profile.dimensions["config_safety"]
        assert conf.finding_count == 1
        assert profile.config_issues == 1

    def test_combined_external(self):
        findings = [_make_finding(rule_id="CMD-001", severity="LOW")]
        sc = [{"severity": "HIGH"}]
        cfg = [{"severity": "MEDIUM"}]
        profile = compute_risk_profile(
            findings,
            supply_chain_findings=sc,
            config_findings=cfg,
        )
        assert profile.total_findings == 3
        assert profile.supply_chain_issues == 1
        assert profile.config_issues == 1


# ---------------------------------------------------------------------------
# compute_risk_from_scan
# ---------------------------------------------------------------------------

class TestComputeRiskFromScan:
    def test_empty_scan(self):
        profile = compute_risk_from_scan({})
        assert profile.composite_score == 100

    def test_scan_with_findings(self):
        scan = {
            "results": [
                {"findings": [_make_finding(severity="HIGH")]},
            ]
        }
        profile = compute_risk_from_scan(scan)
        assert profile.total_findings == 1
        assert profile.composite_score < 100

    def test_scan_with_supply_chain(self):
        scan = {"results": []}
        sc = {"findings": [{"severity": "MEDIUM"}]}
        profile = compute_risk_from_scan(scan, supply_chain_data=sc)
        assert profile.supply_chain_issues == 1


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

class TestTrendAnalysis:
    def test_no_history_is_new(self):
        trend = _compute_trend(80, None)
        assert trend.direction == "new"
        assert trend.data_points == 0

    def test_empty_history_is_new(self):
        trend = _compute_trend(80, [])
        assert trend.direction == "new"

    def test_improving_trend(self):
        history = [{"composite_score": 60}, {"composite_score": 70}]
        trend = _compute_trend(80, history)
        assert trend.direction == "improving"
        assert trend.delta == 10.0

    def test_degrading_trend(self):
        history = [{"composite_score": 90}]
        trend = _compute_trend(70, history)
        assert trend.direction == "degrading"
        assert trend.delta == -20.0

    def test_stable_trend(self):
        history = [{"composite_score": 80}]
        trend = _compute_trend(81, history)
        assert trend.direction == "stable"

    def test_trend_to_dict(self):
        trend = RiskTrend("improving", 5.0, 75.0, 80.0, 3)
        d = trend.to_dict()
        assert d["direction"] == "improving"
        assert d["delta"] == 5.0
        assert d["data_points"] == 3

    def test_profile_includes_trend(self):
        history = [{"composite_score": 50}]
        profile = compute_risk_profile([], history=history)
        assert profile.trend is not None
        assert profile.trend.direction == "improving"


# ---------------------------------------------------------------------------
# History persistence
# ---------------------------------------------------------------------------

class TestHistory:
    def test_load_nonexistent(self):
        result = load_history("/tmp/nonexistent_risk_history.json")
        assert result == []

    def test_save_and_load(self):
        with tempfile.TemporaryDirectory() as td:
            path = Path(td) / "history.json"
            profile = compute_risk_profile([])
            save_history(path, profile)
            loaded = load_history(path)
            assert len(loaded) == 1
            assert loaded[0]["composite_score"] == 100

    def test_save_appends(self):
        with tempfile.TemporaryDirectory() as td:
            path = Path(td) / "history.json"
            for _ in range(3):
                save_history(path, compute_risk_profile([]))
            loaded = load_history(path)
            assert len(loaded) == 3

    def test_save_caps_entries(self):
        with tempfile.TemporaryDirectory() as td:
            path = Path(td) / "history.json"
            for _ in range(10):
                save_history(path, compute_risk_profile([]), max_entries=5)
            loaded = load_history(path)
            assert len(loaded) == 5

    def test_load_invalid_json(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            f.write("not json at all")
            f.flush()
            result = load_history(f.name)
        assert result == []


# ---------------------------------------------------------------------------
# DimensionScore
# ---------------------------------------------------------------------------

class TestDimensionScore:
    def test_to_dict(self):
        ds = DimensionScore(
            dimension="code_quality",
            score=85,
            finding_count=3,
            weighted_impact=24,
            weight=0.25,
            top_rules=["CMD-001"],
        )
        d = ds.to_dict()
        assert d["dimension"] == "code_quality"
        assert d["score"] == 85
        assert d["top_rules"] == ["CMD-001"]


# ---------------------------------------------------------------------------
# RiskProfile
# ---------------------------------------------------------------------------

class TestRiskProfile:
    def test_to_dict_basic(self):
        profile = compute_risk_profile([])
        d = profile.to_dict()
        assert "composite_score" in d
        assert "dimensions" in d
        assert "label" in d

    def test_to_dict_with_trust_score(self):
        profile = compute_risk_profile([], trust_score=75)
        d = profile.to_dict()
        assert d["trust_score"] == 75

    def test_to_dict_with_externals(self):
        profile = compute_risk_profile(
            [],
            supply_chain_findings=[{"severity": "HIGH"}],
            config_findings=[{"severity": "LOW"}],
        )
        d = profile.to_dict()
        assert d["supply_chain_issues"] == 1
        assert d["config_issues"] == 1

    def test_score_clamped_0_to_100(self):
        # Lots of findings
        findings = _make_findings(critical=10, high=10, prefix="CMD")
        profile = compute_risk_profile(findings)
        assert 0 <= profile.composite_score <= 100


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

class TestFormatting:
    def test_format_text_basic(self):
        profile = compute_risk_profile([])
        text = format_risk_text(profile)
        assert "Risk Score:" in text
        assert "100/100" in text
        assert "EXCELLENT" in text

    def test_format_text_with_findings(self):
        findings = _make_findings(high=2, prefix="AUTH")
        profile = compute_risk_profile(findings)
        text = format_risk_text(profile)
        assert "Dimensions:" in text
        assert "auth_security" in text

    def test_format_text_with_trend(self):
        history = [{"composite_score": 50}]
        profile = compute_risk_profile([], history=history)
        text = format_risk_text(profile)
        assert "Trend:" in text
        assert "improving" in text

    def test_format_json_valid(self):
        profile = compute_risk_profile([])
        j = format_risk_json(profile)
        parsed = json.loads(j)
        assert parsed["composite_score"] == 100

    def test_format_json_roundtrip(self):
        findings = _make_findings(critical=1, medium=3, prefix="CMD")
        profile = compute_risk_profile(findings, trust_score=70)
        j = format_risk_json(profile)
        parsed = json.loads(j)
        assert parsed["trust_score"] == 70
        assert "dimensions" in parsed

    def test_format_html_contains_elements(self):
        profile = compute_risk_profile([])
        html = format_risk_html(profile)
        assert "<html>" in html
        assert "Risk Score" in html
        assert "100/100" in html

    def test_format_html_with_trend(self):
        history = [{"composite_score": 50}]
        profile = compute_risk_profile([], history=history)
        html = format_risk_html(profile)
        assert "improving" in html


# ---------------------------------------------------------------------------
# _score_bar and _score_color
# ---------------------------------------------------------------------------

class TestHelpers:
    def test_score_bar_full(self):
        bar = _score_bar(100)
        assert bar == "[" + "#" * 20 + "]"

    def test_score_bar_empty(self):
        bar = _score_bar(0)
        assert bar == "[" + "." * 20 + "]"

    def test_score_bar_half(self):
        bar = _score_bar(50)
        assert "#" in bar
        assert "." in bar

    def test_score_color_high(self):
        assert _score_color(90) == "#2d9e2d"

    def test_score_color_low(self):
        assert _score_color(10) == "#990000"

    def test_score_color_medium(self):
        color = _score_color(50)
        assert color.startswith("#")
