"""Tests for Rule Recommender."""

from __future__ import annotations

import json

import pytest

from mcpaudit.rule_recommender import (
    CWE_CATEGORIES,
    OWASP_CATEGORIES,
    PATTERN_RULES,
    CoverageGap,
    PatternRule,
    Priority,
    RecommendationReport,
    RuleRecommendation,
    RuleRecommender,
    _extract_findings,
    _matches_pattern,
    format_json,
    format_markdown,
)


# ---------- Priority ----------

class TestPriority:
    def test_critical(self):
        assert Priority.CRITICAL == "CRITICAL"

    def test_high(self):
        assert Priority.HIGH == "HIGH"

    def test_medium(self):
        assert Priority.MEDIUM == "MEDIUM"

    def test_low(self):
        assert Priority.LOW == "LOW"

    def test_all_has_four(self):
        assert len(Priority.ALL) == 4

    def test_weight_critical(self):
        assert Priority.weight("CRITICAL") == 4

    def test_weight_high(self):
        assert Priority.weight("HIGH") == 3

    def test_weight_medium(self):
        assert Priority.weight("MEDIUM") == 2

    def test_weight_low(self):
        assert Priority.weight("LOW") == 1

    def test_weight_unknown(self):
        assert Priority.weight("UNKNOWN") == 0


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

class TestCategories:
    def test_cwe_not_empty(self):
        assert len(CWE_CATEGORIES) >= 10

    def test_owasp_has_10(self):
        assert len(OWASP_CATEGORIES) == 10

    def test_cwe_keys_format(self):
        for key in CWE_CATEGORIES:
            assert key.startswith("CWE-"), f"Invalid CWE key: {key}"

    def test_owasp_keys_format(self):
        for key in OWASP_CATEGORIES:
            assert key.startswith("A"), f"Invalid OWASP key: {key}"


# ---------- PatternRule ----------

class TestPatternRule:
    def test_creation(self):
        r = PatternRule(trigger_rule_id="MCP-TEST")
        assert r.trigger_rule_id == "MCP-TEST"
        assert r.priority == Priority.MEDIUM

    def test_with_keywords(self):
        r = PatternRule(
            trigger_rule_id="MCP-TEST",
            trigger_keywords=["sql", "injection"],
        )
        assert len(r.trigger_keywords) == 2


# ---------- Built-in Pattern Rules ----------

class TestBuiltinPatternRules:
    def test_at_least_10(self):
        assert len(PATTERN_RULES) >= 10

    def test_all_have_rule_id(self):
        for r in PATTERN_RULES:
            assert r.recommend_rule_id, f"Missing recommend_rule_id: {r.trigger_rule_id}"

    def test_all_have_title(self):
        for r in PATTERN_RULES:
            assert r.recommend_title, f"Missing title: {r.recommend_rule_id}"

    def test_all_have_reason(self):
        for r in PATTERN_RULES:
            assert r.reason, f"Missing reason: {r.recommend_rule_id}"

    def test_all_valid_priority(self):
        valid = set(Priority.ALL)
        for r in PATTERN_RULES:
            assert r.priority in valid, f"Invalid priority in {r.recommend_rule_id}: {r.priority}"


# ---------- RuleRecommendation ----------

class TestRuleRecommendation:
    def test_basic(self):
        r = RuleRecommendation(rule_id="MCP-TEST-001")
        assert r.rule_id == "MCP-TEST-001"
        assert r.priority == Priority.MEDIUM

    def test_risk_effort_score_high_impact_low_effort(self):
        r = RuleRecommendation(
            rule_id="X",
            priority=Priority.CRITICAL,
            estimated_impact="high",
            estimated_effort="low",
        )
        score = r.risk_effort_score
        assert score == 16.0  # 4 * 4 * (4/4)

    def test_risk_effort_score_low_impact_high_effort(self):
        r = RuleRecommendation(
            rule_id="X",
            priority=Priority.LOW,
            estimated_impact="low",
            estimated_effort="high",
        )
        score = r.risk_effort_score
        assert score == 0.25  # 1 * 1 * (1/4)

    def test_risk_effort_score_medium(self):
        r = RuleRecommendation(
            rule_id="X",
            priority=Priority.MEDIUM,
            estimated_impact="medium",
            estimated_effort="medium",
        )
        score = r.risk_effort_score
        assert score == 2.0  # 2 * 2 * (2/4)

    def test_to_dict(self):
        r = RuleRecommendation(
            rule_id="MCP-X",
            title="Test Rule",
            priority=Priority.HIGH,
            reason="Because",
            cwe="CWE-78",
            owasp="A03",
            triggered_by=["R1"],
        )
        d = r.to_dict()
        assert d["rule_id"] == "MCP-X"
        assert d["priority"] == "HIGH"
        assert d["cwe"] == "CWE-78"
        assert "risk_effort_score" in d


# ---------- CoverageGap ----------

class TestCoverageGap:
    def test_creation(self):
        g = CoverageGap(category_id="CWE-78", category_name="OS Command Injection")
        assert g.category_id == "CWE-78"
        assert g.covered is False

    def test_to_dict(self):
        g = CoverageGap(
            category_id="A01",
            category_name="Broken Access Control",
            category_type="OWASP",
            covered=True,
            finding_count=3,
        )
        d = g.to_dict()
        assert d["covered"] is True
        assert d["finding_count"] == 3


# ---------- RecommendationReport ----------

class TestRecommendationReport:
    def test_empty(self):
        r = RecommendationReport()
        assert r.total_recommendations == 0
        assert r.critical_count == 0
        assert r.high_count == 0

    def test_counts(self):
        r = RecommendationReport(recommendations=[
            RuleRecommendation("R1", priority=Priority.CRITICAL),
            RuleRecommendation("R2", priority=Priority.CRITICAL),
            RuleRecommendation("R3", priority=Priority.HIGH),
            RuleRecommendation("R4", priority=Priority.LOW),
        ])
        assert r.total_recommendations == 4
        assert r.critical_count == 2
        assert r.high_count == 1

    def test_uncovered_gaps(self):
        r = RecommendationReport(coverage_gaps=[
            CoverageGap("CWE-78", "Cmd Inj", covered=True),
            CoverageGap("CWE-89", "SQL Inj", covered=False),
            CoverageGap("CWE-79", "XSS", covered=False),
        ])
        assert len(r.uncovered_gaps) == 2

    def test_to_dict(self):
        r = RecommendationReport(
            total_findings_analyzed=10,
            unique_rules_found=5,
            coverage_percentage=40.0,
        )
        d = r.to_dict()
        assert d["total_findings_analyzed"] == 10
        assert d["coverage_percentage"] == 40.0


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

class TestExtractFindings:
    def test_results_format(self):
        data = {"results": [{"findings": [{"rule_id": "R1"}]}]}
        assert len(_extract_findings(data)) == 1

    def test_findings_format(self):
        data = {"findings": [{"rule_id": "R1"}, {"rule_id": "R2"}]}
        assert len(_extract_findings(data)) == 2

    def test_empty(self):
        assert _extract_findings({}) == []


class TestMatchesPattern:
    def test_match_by_rule_id(self):
        finding = {"rule_id": "MCP-TOOL-EXEC"}
        rule = PatternRule(trigger_rule_id="MCP-TOOL-EXEC")
        assert _matches_pattern(finding, rule) is True

    def test_no_match_by_rule_id(self):
        finding = {"rule_id": "MCP-OTHER"}
        rule = PatternRule(trigger_rule_id="MCP-TOOL-EXEC")
        assert _matches_pattern(finding, rule) is False

    def test_match_by_keyword_in_title(self):
        finding = {"rule_id": "X", "title": "SQL injection vulnerability"}
        rule = PatternRule(trigger_rule_id="Y", trigger_keywords=["injection"])
        assert _matches_pattern(finding, rule) is True

    def test_match_by_keyword_in_description(self):
        finding = {"rule_id": "X", "description": "Command exec detected"}
        rule = PatternRule(trigger_rule_id="Y", trigger_keywords=["exec"])
        assert _matches_pattern(finding, rule) is True

    def test_keyword_case_insensitive(self):
        finding = {"rule_id": "X", "title": "SQL INJECTION"}
        rule = PatternRule(trigger_rule_id="Y", trigger_keywords=["sql"])
        assert _matches_pattern(finding, rule) is True

    def test_no_match_empty(self):
        finding = {"rule_id": "X", "title": "Clean code"}
        rule = PatternRule(trigger_rule_id="Y", trigger_keywords=["injection"])
        assert _matches_pattern(finding, rule) is False


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

class TestRuleRecommender:
    def setup_method(self):
        self.recommender = RuleRecommender()

    def test_empty_scan(self):
        report = self.recommender.analyze({"findings": []})
        assert report.total_recommendations == 0
        assert report.total_findings_analyzed == 0

    def test_trigger_by_rule_id(self):
        scan = {"findings": [
            {"rule_id": "MCP-TOOL-EXEC", "severity": "CRITICAL", "title": "Tool exec"},
        ]}
        report = self.recommender.analyze(scan)
        rule_ids = [r.rule_id for r in report.recommendations]
        assert "MCP-SANDBOX-001" in rule_ids

    def test_trigger_by_keyword(self):
        scan = {"findings": [
            {"rule_id": "CUSTOM", "title": "SQL injection in query handler", "severity": "HIGH"},
        ]}
        report = self.recommender.analyze(scan)
        rule_ids = [r.rule_id for r in report.recommendations]
        assert "MCP-INPUT-001" in rule_ids

    def test_multiple_triggers(self):
        scan = {"findings": [
            {"rule_id": "MCP-TOOL-EXEC", "title": "Tool exec", "severity": "CRITICAL"},
            {"rule_id": "MCP-HARDCODED-SECRET", "title": "Secret found", "severity": "HIGH"},
        ]}
        report = self.recommender.analyze(scan)
        assert report.total_recommendations >= 2

    def test_no_duplicate_recommendations(self):
        scan = {"findings": [
            {"rule_id": "X", "title": "SQL injection 1", "severity": "HIGH"},
            {"rule_id": "Y", "title": "SQL injection 2", "severity": "HIGH"},
        ]}
        report = self.recommender.analyze(scan)
        rule_ids = [r.rule_id for r in report.recommendations]
        # Should not recommend MCP-INPUT-001 twice
        assert rule_ids.count("MCP-INPUT-001") <= 1

    def test_sorted_by_score(self):
        scan = {"findings": [
            {"rule_id": "MCP-TOOL-EXEC", "title": "Exec", "severity": "CRITICAL"},
            {"rule_id": "MCP-LOGGING", "title": "Log issue", "severity": "LOW"},
        ]}
        report = self.recommender.analyze(scan)
        if len(report.recommendations) >= 2:
            scores = [r.risk_effort_score for r in report.recommendations]
            assert scores == sorted(scores, reverse=True)

    def test_coverage_analysis_present(self):
        report = self.recommender.analyze({"findings": []})
        assert len(report.coverage_gaps) > 0

    def test_coverage_with_cwe_findings(self):
        scan = {"findings": [
            {"rule_id": "R1", "cwe": "CWE-78", "title": "cmd inj", "severity": "HIGH"},
        ]}
        report = self.recommender.analyze(scan)
        cwe78_gap = [g for g in report.coverage_gaps if g.category_id == "CWE-78"]
        assert len(cwe78_gap) == 1
        assert cwe78_gap[0].covered is True

    def test_coverage_with_owasp_findings(self):
        scan = {"findings": [
            {"rule_id": "R1", "owasp": "A01", "title": "access", "severity": "HIGH"},
        ]}
        report = self.recommender.analyze(scan)
        a01_gap = [g for g in report.coverage_gaps if g.category_id == "A01"]
        assert len(a01_gap) == 1
        assert a01_gap[0].covered is True

    def test_coverage_percentage(self):
        scan = {"findings": [
            {"rule_id": "R1", "cwe": "CWE-78", "title": "t", "severity": "HIGH"},
            {"rule_id": "R2", "owasp": "A01", "title": "t", "severity": "HIGH"},
        ]}
        report = self.recommender.analyze(scan)
        assert report.coverage_percentage > 0

    def test_results_format_input(self):
        scan = {"results": [{"findings": [
            {"rule_id": "MCP-TOOL-EXEC", "title": "Exec", "severity": "CRITICAL"},
        ]}]}
        report = self.recommender.analyze(scan)
        assert report.total_findings_analyzed == 1

    def test_unique_rules_counted(self):
        scan = {"findings": [
            {"rule_id": "R1", "title": "A", "severity": "HIGH"},
            {"rule_id": "R1", "title": "B", "severity": "HIGH"},
            {"rule_id": "R2", "title": "C", "severity": "LOW"},
        ]}
        report = self.recommender.analyze(scan)
        assert report.unique_rules_found == 2

    def test_recommend_for_category_cwe(self):
        recs = self.recommender.recommend_for_category("CWE-78")
        assert len(recs) >= 1
        assert all(r.cwe == "CWE-78" for r in recs)

    def test_recommend_for_category_owasp(self):
        recs = self.recommender.recommend_for_category("A03")
        assert len(recs) >= 1
        assert all(r.owasp == "A03" for r in recs)

    def test_recommend_for_category_unknown(self):
        recs = self.recommender.recommend_for_category("UNKNOWN")
        assert len(recs) == 0

    def test_add_custom_pattern_rule(self):
        self.recommender.add_pattern_rule(PatternRule(
            trigger_rule_id="CUSTOM-TRIGGER",
            recommend_rule_id="CUSTOM-REC-001",
            recommend_title="Custom recommendation",
            reason="Custom reason",
            priority=Priority.HIGH,
        ))
        scan = {"findings": [
            {"rule_id": "CUSTOM-TRIGGER", "title": "Custom", "severity": "HIGH"},
        ]}
        report = self.recommender.analyze(scan)
        rule_ids = [r.rule_id for r in report.recommendations]
        assert "CUSTOM-REC-001" in rule_ids

    def test_custom_categories(self):
        recommender = RuleRecommender(
            cwe_categories={"CWE-999": "Custom"},
            owasp_categories={"A99": "Custom OWASP"},
        )
        report = recommender.analyze({"findings": []})
        ids = [g.category_id for g in report.coverage_gaps]
        assert "CWE-999" in ids
        assert "A99" in ids

    def test_tags_recognized_as_coverage(self):
        scan = {"findings": [
            {"rule_id": "R1", "title": "t", "severity": "HIGH", "tags": ["CWE-89"]},
        ]}
        report = self.recommender.analyze(scan)
        cwe89_gap = [g for g in report.coverage_gaps if g.category_id == "CWE-89"]
        assert len(cwe89_gap) == 1
        assert cwe89_gap[0].covered is True


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

class TestFormatMarkdown:
    def test_basic_output(self):
        report = RecommendationReport(
            recommendations=[
                RuleRecommendation("R1", title="Test", priority=Priority.CRITICAL),
            ],
            total_findings_analyzed=5,
        )
        md = format_markdown(report)
        assert "# Rule Recommendations Report" in md
        assert "CRITICAL" in md
        assert "R1" in md

    def test_coverage_gaps_shown(self):
        report = RecommendationReport(
            coverage_gaps=[
                CoverageGap("CWE-78", "Cmd Inj", covered=False),
            ],
        )
        md = format_markdown(report)
        assert "Coverage Gaps" in md
        assert "CWE-78" in md

    def test_empty_report(self):
        md = format_markdown(RecommendationReport())
        assert "# Rule Recommendations Report" in md

    def test_details_section(self):
        report = RecommendationReport(recommendations=[
            RuleRecommendation(
                "R1", title="Test", reason="Because",
                cwe="CWE-78", owasp="A03",
            ),
        ])
        md = format_markdown(report)
        assert "## Details" in md
        assert "CWE-78" in md
        assert "A03" in md


class TestFormatJson:
    def test_valid_json(self):
        report = RecommendationReport(
            recommendations=[RuleRecommendation("R1")],
            total_findings_analyzed=3,
        )
        data = json.loads(format_json(report))
        assert data["total_recommendations"] == 1
        assert data["total_findings_analyzed"] == 3

    def test_empty(self):
        data = json.loads(format_json(RecommendationReport()))
        assert data["total_recommendations"] == 0

    def test_roundtrip_scores(self):
        report = RecommendationReport(recommendations=[
            RuleRecommendation(
                "R1", priority=Priority.CRITICAL,
                estimated_impact="high", estimated_effort="low",
            ),
        ])
        data = json.loads(format_json(report))
        assert data["recommendations"][0]["risk_effort_score"] == 16.0
