"""Tests for Rule Authoring SDK.

These tests verify the security rule SDK's ability to detect
dangerous patterns. Test strings contain intentional security
anti-patterns as they test the scanner's detection capabilities.
"""

from __future__ import annotations

import ast
import json
from pathlib import Path
from typing import Any

import pytest

from mcpaudit.rule_sdk import (
    EXAMPLE_RULES,
    Rule,
    RuleFinding,
    RuleMetadata,
    RuleRegistry,
    VALID_ID_PATTERN,
    VALID_OWASP_CATEGORIES,
    VALID_SEVERITIES,
    create_ast_rule,
    create_regex_rule,
    create_rule,
    detect_os_system,
    detect_unsafe_deserialize,
    detect_weak_hash,
    format_findings_json,
    format_findings_text,
    format_registry_text,
    get_example_registry,
    rule,
    validate_rule_metadata,
)


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

def _noop_check(code: str, filename: str) -> list[dict[str, Any]]:
    return []


def _simple_check(code: str, filename: str) -> list[dict[str, Any]]:
    results = []
    for i, line in enumerate(code.split("\n"), 1):
        if "dangerous" in line:
            results.append({"line": i, "snippet": line.strip()})
    return results


# Test samples that contain known vulnerable patterns (for scanner testing)
_SAMPLE_OS_SYSTEM = "import os\nos.system('ls')\nprint('done')"
_SAMPLE_DESERIALIZE = "import yaml\ndata = yaml.load(raw)"
_SAMPLE_WEAK_HASH = "import hashlib\nhash_val = hashlib.md5(data)"
_SAMPLE_SHA1 = "h = hashlib.sha1(data)"
_SAMPLE_COMBINED = (
    "import os\nos.system('cmd')\nhash_val = hashlib.md5(b'x')"
)
_SAMPLE_MIXED = (
    "import os\nos.system('ls')\ndangerous_fn('code')"
)


# ---------------------------------------------------------------------------
# RuleMetadata
# ---------------------------------------------------------------------------

class TestRuleMetadata:
    def test_valid_metadata(self):
        m = RuleMetadata(id="CUSTOM-001", severity="HIGH", cwe="CWE-78")
        assert m.validate() == []

    def test_empty_id(self):
        m = RuleMetadata(id="", severity="HIGH")
        errors = m.validate()
        assert len(errors) == 1
        assert "required" in errors[0]

    def test_invalid_id_format(self):
        m = RuleMetadata(id="bad", severity="HIGH")
        errors = m.validate()
        assert len(errors) == 1
        assert "must match" in errors[0]

    def test_invalid_id_lowercase(self):
        m = RuleMetadata(id="custom-001", severity="HIGH")
        assert len(m.validate()) > 0

    def test_invalid_severity(self):
        m = RuleMetadata(id="CUSTOM-001", severity="EXTREME")
        errors = m.validate()
        assert any("severity" in e.lower() for e in errors)

    def test_invalid_cwe_format(self):
        m = RuleMetadata(id="CUSTOM-001", severity="HIGH", cwe="78")
        errors = m.validate()
        assert any("CWE" in e for e in errors)

    def test_invalid_cwe_zero(self):
        m = RuleMetadata(id="CUSTOM-001", severity="HIGH", cwe="CWE-0")
        assert len(m.validate()) > 0

    def test_invalid_cwe_nonnumeric(self):
        m = RuleMetadata(id="CUSTOM-001", severity="HIGH", cwe="CWE-abc")
        assert len(m.validate()) > 0

    def test_valid_cwe(self):
        m = RuleMetadata(id="CUSTOM-001", severity="HIGH", cwe="CWE-78")
        assert m.validate() == []

    def test_invalid_owasp(self):
        m = RuleMetadata(
            id="CUSTOM-001", severity="HIGH", owasp_category="X99",
        )
        errors = m.validate()
        assert any("OWASP" in e for e in errors)

    def test_valid_owasp(self):
        for cat in VALID_OWASP_CATEGORIES:
            m = RuleMetadata(
                id="CUSTOM-001", severity="HIGH", owasp_category=cat,
            )
            assert m.validate() == []

    def test_to_dict(self):
        m = RuleMetadata(
            id="CUSTOM-001", severity="HIGH",
            cwe="CWE-78", description="Test",
            tags=["shell"], enabled=True,
        )
        d = m.to_dict()
        assert d["id"] == "CUSTOM-001"
        assert d["severity"] == "HIGH"
        assert d["cwe"] == "CWE-78"
        assert d["tags"] == ["shell"]

    def test_defaults(self):
        m = RuleMetadata(id="CUSTOM-001")
        assert m.severity == "MEDIUM"
        assert m.cwe == ""
        assert m.tags == []
        assert m.enabled is True

    def test_all_valid_severities(self):
        for sev in VALID_SEVERITIES:
            m = RuleMetadata(id="CUSTOM-001", severity=sev)
            assert m.validate() == []

    def test_multiple_errors(self):
        m = RuleMetadata(id="", severity="WRONG", cwe="bad")
        errors = m.validate()
        assert len(errors) >= 2


# ---------------------------------------------------------------------------
# RuleFinding
# ---------------------------------------------------------------------------

class TestRuleFinding:
    def test_to_dict_minimal(self):
        f = RuleFinding(
            rule_id="X-001", severity="HIGH",
            title="Test", description="Desc",
            file="a.py", line=10,
        )
        d = f.to_dict()
        assert d["rule_id"] == "X-001"
        assert d["line"] == 10
        assert "code_snippet" not in d

    def test_to_dict_full(self):
        f = RuleFinding(
            rule_id="X-001", severity="HIGH",
            title="Test", description="Desc",
            file="a.py", line=10,
            code_snippet="x = 1",
            cwe="CWE-78",
            owasp_category="A03",
            recommendation="Fix it",
        )
        d = f.to_dict()
        assert d["code_snippet"] == "x = 1"
        assert d["cwe"] == "CWE-78"
        assert d["owasp_category"] == "A03"
        assert d["recommendation"] == "Fix it"


# ---------------------------------------------------------------------------
# Rule
# ---------------------------------------------------------------------------

class TestRule:
    def test_properties(self):
        r = create_rule("CUSTOM-001", "HIGH", _noop_check)
        assert r.id == "CUSTOM-001"
        assert r.severity == "HIGH"
        assert r.enabled is True

    def test_check_noop(self):
        r = create_rule("CUSTOM-001", "HIGH", _noop_check)
        findings = r.check("some code", "test.py")
        assert findings == []

    def test_check_with_findings(self):
        r = create_rule(
            "CUSTOM-001", "HIGH", _simple_check,
            description="Finds dangerous",
        )
        findings = r.check("safe\ndangerous\nok", "test.py")
        assert len(findings) == 1
        assert findings[0].line == 2
        assert findings[0].file == "test.py"

    def test_disabled_rule(self):
        r = create_rule(
            "CUSTOM-001", "HIGH", _simple_check,
            enabled=False,
        )
        findings = r.check("dangerous", "test.py")
        assert findings == []

    def test_to_dict(self):
        r = create_rule("CUSTOM-001", "HIGH", _noop_check)
        d = r.to_dict()
        assert d["metadata"]["id"] == "CUSTOM-001"
        assert d["function"] == "_noop_check"

    def test_invalid_return_ignored(self):
        def bad_check(code, filename):
            return [42, "not a dict", {"line": 1, "snippet": "ok"}]
        r = create_rule("CUSTOM-001", "HIGH", bad_check)
        findings = r.check("code", "test.py")
        assert len(findings) == 1


# ---------------------------------------------------------------------------
# create_rule (factory)
# ---------------------------------------------------------------------------

class TestCreateRule:
    def test_valid(self):
        r = create_rule("CUSTOM-001", "HIGH", _noop_check)
        assert isinstance(r, Rule)

    def test_invalid_id_raises(self):
        with pytest.raises(ValueError, match="metadata"):
            create_rule("bad", "HIGH", _noop_check)

    def test_invalid_severity_raises(self):
        with pytest.raises(ValueError, match="severity"):
            create_rule("CUSTOM-001", "EXTREME", _noop_check)

    def test_with_all_params(self):
        r = create_rule(
            "CUSTOM-001", "CRITICAL", _noop_check,
            cwe="CWE-89", description="SQL injection",
            owasp_category="A03", tags=["sql"],
        )
        assert r.metadata.cwe == "CWE-89"
        assert r.metadata.tags == ["sql"]


# ---------------------------------------------------------------------------
# @rule decorator
# ---------------------------------------------------------------------------

class TestRuleDecorator:
    def test_basic_decorator(self):
        @rule(id="TEST-001", severity="LOW")
        def my_rule(code, filename):
            return []

        assert hasattr(my_rule, "rule")
        assert hasattr(my_rule, "metadata")
        assert my_rule.rule.id == "TEST-001"
        assert my_rule.metadata.severity == "LOW"

    def test_decorator_with_all_params(self):
        @rule(
            id="TEST-002", severity="HIGH",
            cwe="CWE-78", description="Desc",
            owasp_category="A03", tags=["test"],
            enabled=False,
        )
        def my_rule(code, filename):
            return []

        assert my_rule.rule.metadata.cwe == "CWE-78"
        assert my_rule.rule.metadata.owasp_category == "A03"
        assert my_rule.rule.enabled is False

    def test_decorated_fn_still_callable(self):
        @rule(id="TEST-003", severity="INFO")
        def my_rule(code, filename):
            return [{"line": 1}]

        result = my_rule("code", "file.py")
        assert len(result) == 1

    def test_rule_check_via_decorator(self):
        @rule(id="TEST-004", severity="MEDIUM", description="Find X")
        def find_x(code, filename):
            results = []
            for i, line in enumerate(code.split("\n"), 1):
                if "X" in line:
                    results.append({"line": i, "snippet": line.strip()})
            return results

        findings = find_x.rule.check("no match\nX here\nend", "test.py")
        assert len(findings) == 1
        assert findings[0].line == 2


# ---------------------------------------------------------------------------
# create_regex_rule
# ---------------------------------------------------------------------------

class TestCreateRegexRule:
    def test_basic_regex(self):
        r = create_regex_rule(
            "REGEX-001", "HIGH", r"dangerous_fn\(",
            description="Dangerous function usage",
        )
        findings = r.check("safe\ndangerous_fn('x')\nok", "test.py")
        assert len(findings) == 1
        assert findings[0].line == 2

    def test_regex_no_match(self):
        r = create_regex_rule(
            "REGEX-002", "LOW", r"xxxx_never_match",
        )
        findings = r.check("some code", "test.py")
        assert findings == []

    def test_regex_multiple_matches(self):
        r = create_regex_rule("REGEX-003", "MEDIUM", r"TODO")
        findings = r.check("TODO: fix\nok\nTODO: refactor", "test.py")
        assert len(findings) == 2

    def test_invalid_regex_raises(self):
        with pytest.raises(ValueError, match="regex"):
            create_regex_rule("REGEX-004", "HIGH", r"[invalid")

    def test_recommendation_propagated(self):
        r = create_regex_rule(
            "REGEX-005", "HIGH", r"unsafe_call\(",
            recommendation="Remove unsafe_call",
        )
        findings = r.check("unsafe_call('code')", "test.py")
        assert len(findings) == 1
        assert findings[0].recommendation == "Remove unsafe_call"


# ---------------------------------------------------------------------------
# create_ast_rule
# ---------------------------------------------------------------------------

class TestCreateAstRule:
    def test_find_imports(self):
        r = create_ast_rule(
            "AST-001", "LOW", "Import",
            description="Detect imports",
        )
        code = "import os\nimport sys\nx = 1"
        findings = r.check(code, "test.py")
        assert len(findings) == 2

    def test_find_calls_with_filter(self):
        def is_target_call(node):
            return (
                isinstance(node, ast.Call)
                and isinstance(node.func, ast.Name)
                and node.func.id == "dangerous_fn"
            )

        r = create_ast_rule(
            "AST-002", "CRITICAL", "Call",
            attr_check=is_target_call,
            description="Detect dangerous_fn() calls",
        )
        code = "x = dangerous_fn('1+1')\ny = print('ok')"
        findings = r.check(code, "test.py")
        assert len(findings) == 1

    def test_syntax_error_returns_empty(self):
        r = create_ast_rule("AST-003", "LOW", "Import")
        findings = r.check("def <<<invalid>>>", "test.py")
        assert findings == []

    def test_unknown_node_type(self):
        r = create_ast_rule("AST-004", "LOW", "NonexistentNode")
        findings = r.check("import os", "test.py")
        assert findings == []

    def test_non_python_code(self):
        r = create_ast_rule("AST-005", "LOW", "Import")
        findings = r.check("const x = 1;", "test.ts")
        assert findings == []


# ---------------------------------------------------------------------------
# RuleRegistry
# ---------------------------------------------------------------------------

class TestRuleRegistry:
    def test_empty_registry(self):
        reg = RuleRegistry()
        assert reg.count == 0
        assert reg.list_rules() == []
        assert reg.list_ids() == []

    def test_register_rule(self):
        reg = RuleRegistry()
        r = create_rule("CUSTOM-001", "HIGH", _noop_check)
        errors = reg.register(r)
        assert errors == []
        assert reg.count == 1

    def test_register_decorated_fn(self):
        reg = RuleRegistry()
        errors = reg.register(detect_os_system)
        assert errors == []
        assert reg.count == 1

    def test_register_duplicate(self):
        reg = RuleRegistry()
        r = create_rule("CUSTOM-001", "HIGH", _noop_check)
        reg.register(r)
        errors = reg.register(r)
        assert len(errors) == 1
        assert "already" in errors[0]

    def test_register_invalid(self):
        reg = RuleRegistry()
        errors = reg.register("not a rule")  # type: ignore
        assert len(errors) == 1

    def test_unregister(self):
        reg = RuleRegistry()
        r = create_rule("CUSTOM-001", "HIGH", _noop_check)
        reg.register(r)
        assert reg.unregister("CUSTOM-001") is True
        assert reg.count == 0

    def test_unregister_missing(self):
        reg = RuleRegistry()
        assert reg.unregister("MISSING-001") is False

    def test_get_rule(self):
        reg = RuleRegistry()
        r = create_rule("CUSTOM-001", "HIGH", _noop_check)
        reg.register(r)
        got = reg.get("CUSTOM-001")
        assert got is not None
        assert got.id == "CUSTOM-001"

    def test_get_missing(self):
        reg = RuleRegistry()
        assert reg.get("MISSING-001") is None

    def test_list_ids_sorted(self):
        reg = RuleRegistry()
        reg.register(create_rule("CUSTOM-002", "LOW", _noop_check))
        reg.register(create_rule("CUSTOM-001", "HIGH", _noop_check))
        assert reg.list_ids() == ["CUSTOM-001", "CUSTOM-002"]

    def test_filter_by_severity(self):
        reg = RuleRegistry()
        reg.register(create_rule("A-001", "HIGH", _noop_check))
        reg.register(create_rule("B-001", "LOW", _noop_check))
        reg.register(create_rule("C-001", "HIGH", _noop_check))
        high_rules = reg.filter_by_severity("HIGH")
        assert len(high_rules) == 2

    def test_filter_by_tag(self):
        reg = RuleRegistry()
        reg.register(create_rule(
            "A-001", "HIGH", _noop_check, tags=["shell"],
        ))
        reg.register(create_rule(
            "B-001", "LOW", _noop_check, tags=["crypto"],
        ))
        shell_rules = reg.filter_by_tag("shell")
        assert len(shell_rules) == 1
        assert shell_rules[0].id == "A-001"

    def test_scan(self):
        reg = RuleRegistry()
        reg.register(create_rule(
            "CUSTOM-001", "HIGH", _simple_check,
            description="Finds dangerous",
        ))
        findings = reg.scan("safe\ndangerous\nsafe", "test.py")
        assert len(findings) == 1
        assert findings[0].rule_id == "CUSTOM-001"

    def test_scan_multiple_rules(self):
        def find_a(code, filename):
            return [
                {"line": i, "snippet": line.strip()}
                for i, line in enumerate(code.split("\n"), 1)
                if "AAA" in line
            ]

        def find_b(code, filename):
            return [
                {"line": i, "snippet": line.strip()}
                for i, line in enumerate(code.split("\n"), 1)
                if "BBB" in line
            ]

        reg = RuleRegistry()
        reg.register(create_rule("A-001", "HIGH", find_a))
        reg.register(create_rule("B-001", "LOW", find_b))
        findings = reg.scan("AAA\nBBB\nAAA", "test.py")
        assert len(findings) == 3

    def test_scan_sorted_by_line(self):
        def check(code, filename):
            return [
                {"line": 3, "snippet": "c"},
                {"line": 1, "snippet": "a"},
            ]
        reg = RuleRegistry()
        reg.register(create_rule("A-001", "HIGH", check))
        findings = reg.scan("a\nb\nc", "test.py")
        assert findings[0].line == 1
        assert findings[1].line == 3

    def test_clear(self):
        reg = RuleRegistry()
        reg.register(create_rule("A-001", "HIGH", _noop_check))
        reg.clear()
        assert reg.count == 0

    def test_to_dict(self):
        reg = RuleRegistry()
        reg.register(create_rule("A-001", "HIGH", _noop_check))
        d = reg.to_dict()
        assert d["count"] == 1
        assert len(d["rules"]) == 1

    def test_scan_file(self, tmp_path):
        code = "line1\ndangerous line\nline3"
        f = tmp_path / "test.py"
        f.write_text(code, encoding="utf-8")

        reg = RuleRegistry()
        reg.register(create_rule(
            "CUSTOM-001", "HIGH", _simple_check,
        ))
        findings = reg.scan_file(str(f))
        assert len(findings) == 1

    def test_scan_file_missing(self):
        reg = RuleRegistry()
        reg.register(create_rule("A-001", "HIGH", _noop_check))
        findings = reg.scan_file("/nonexistent/file.py")
        assert findings == []


# ---------------------------------------------------------------------------
# Example rules
# ---------------------------------------------------------------------------

class TestExampleRules:
    def test_detect_os_system(self):
        findings = detect_os_system.rule.check(_SAMPLE_OS_SYSTEM, "test.py")
        assert len(findings) == 1
        assert findings[0].rule_id == "SDK-001"
        assert findings[0].severity == "HIGH"
        assert findings[0].cwe == "CWE-78"

    def test_detect_os_system_no_match(self):
        code = "import subprocess\nsubprocess.run(['ls'])"
        findings = detect_os_system.rule.check(code, "test.py")
        assert findings == []

    def test_detect_unsafe_deserialize(self):
        findings = detect_unsafe_deserialize.rule.check(
            _SAMPLE_DESERIALIZE, "test.py",
        )
        assert len(findings) == 1
        assert findings[0].rule_id == "SDK-002"
        assert findings[0].severity == "CRITICAL"

    def test_detect_unsafe_deserialize_no_match(self):
        code = "import json\ndata = json.loads(raw)"
        findings = detect_unsafe_deserialize.rule.check(code, "test.py")
        assert findings == []

    def test_detect_weak_hash(self):
        findings = detect_weak_hash.rule.check(_SAMPLE_WEAK_HASH, "test.py")
        assert len(findings) == 1
        assert findings[0].cwe == "CWE-327"

    def test_detect_weak_hash_sha1(self):
        findings = detect_weak_hash.rule.check(_SAMPLE_SHA1, "test.py")
        assert len(findings) == 1

    def test_detect_weak_hash_no_match(self):
        code = "h = hashlib.sha256(data)"
        findings = detect_weak_hash.rule.check(code, "test.py")
        assert findings == []

    def test_example_rules_count(self):
        assert len(EXAMPLE_RULES) == 3

    def test_get_example_registry(self):
        reg = get_example_registry()
        assert reg.count == 3
        assert "SDK-001" in reg.list_ids()
        assert "SDK-002" in reg.list_ids()
        assert "SDK-003" in reg.list_ids()


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

class TestFormatting:
    def test_format_findings_text_empty(self):
        text = format_findings_text([])
        assert "No findings" in text

    def test_format_findings_text(self):
        reg = get_example_registry()
        findings = reg.scan(_SAMPLE_OS_SYSTEM, "test.py")
        text = format_findings_text(findings)
        assert "SDK-001" in text
        assert "test.py" in text

    def test_format_findings_json(self):
        reg = get_example_registry()
        findings = reg.scan(_SAMPLE_OS_SYSTEM, "test.py")
        j = format_findings_json(findings)
        data = json.loads(j)
        assert data["total"] >= 1
        assert len(data["findings"]) >= 1

    def test_format_registry_text_empty(self):
        reg = RuleRegistry()
        text = format_registry_text(reg)
        assert "No custom rules" in text

    def test_format_registry_text(self):
        reg = get_example_registry()
        text = format_registry_text(reg)
        assert "SDK-001" in text
        assert "SDK-002" in text
        assert "SDK-003" in text

    def test_format_findings_json_empty(self):
        j = format_findings_json([])
        data = json.loads(j)
        assert data["total"] == 0
        assert data["findings"] == []


# ---------------------------------------------------------------------------
# validate_rule_metadata
# ---------------------------------------------------------------------------

class TestValidateRuleMetadata:
    def test_valid(self):
        m = RuleMetadata(id="TEST-001", severity="LOW")
        assert validate_rule_metadata(m) == []

    def test_invalid(self):
        m = RuleMetadata(id="bad", severity="WRONG")
        errors = validate_rule_metadata(m)
        assert len(errors) >= 2


# ---------------------------------------------------------------------------
# VALID_ID_PATTERN
# ---------------------------------------------------------------------------

class TestValidIdPattern:
    def test_valid_patterns(self):
        valid = [
            "CUSTOM-001", "CMD-123", "A-001", "XX-9999",
            "SDK-001", "ABC-000100",
        ]
        for pid in valid:
            assert VALID_ID_PATTERN.match(pid), f"{pid} should be valid"

    def test_invalid_patterns(self):
        invalid = [
            "custom-001", "001-CUSTOM", "CUSTOM", "CUSTOM-",
            "CUSTOM-01", "-001", "",
        ]
        for pid in invalid:
            assert not VALID_ID_PATTERN.match(pid), f"{pid} should be invalid"


# ---------------------------------------------------------------------------
# Integration
# ---------------------------------------------------------------------------

class TestIntegration:
    def test_full_workflow(self, tmp_path):
        f = tmp_path / "server.py"
        f.write_text(_SAMPLE_COMBINED, encoding="utf-8")

        reg = get_example_registry()
        findings = reg.scan_file(str(f))
        assert len(findings) >= 2

        severities = {f.severity for f in findings}
        assert "HIGH" in severities or "MEDIUM" in severities

    def test_mixed_rule_types(self):
        reg = RuleRegistry()
        r1 = create_regex_rule(
            "REGEX-001", "HIGH", r"dangerous_fn\(",
            description="Dangerous function usage",
        )
        reg.register(r1)
        r2 = create_ast_rule(
            "AST-001", "LOW", "Import",
            description="Detect imports",
        )
        reg.register(r2)
        reg.register(detect_os_system)

        findings = reg.scan(_SAMPLE_MIXED, "test.py")
        rule_ids = {f.rule_id for f in findings}
        assert "REGEX-001" in rule_ids
        assert "AST-001" in rule_ids
        assert "SDK-001" in rule_ids
