"""Tests for MCP Security Audit Scanner.

Tests each of the 14+ security rules with vulnerable code samples,
plus scoring, JSON output, directory scanning, exit codes, and CLI.

NOTE: This test file contains intentionally vulnerable code STRINGS
that are passed to the scanner for detection testing. These are not
executed — they are static analysis targets.
"""

from __future__ import annotations

import ast
import json
import tempfile
from pathlib import Path

import pytest

from mcpaudit.models import Finding, ScanResult, ScanSummary, Severity
from mcpaudit.scanner import MCPSecurityScanner
from mcpaudit.rules import get_all_rules, Rule
from mcpaudit.rules.path_traversal import PathTraversalRule
from mcpaudit.rules.command_injection import (
    CommandInjectionShellRule,
    CommandInjectionDynamicRule,
)
from mcpaudit.rules.hardcoded_secrets import HardcodedSecretsRule
from mcpaudit.rules.absolute_paths import AbsolutePathsRule
from mcpaudit.rules.input_validation import MissingInputValidationRule
from mcpaudit.rules.sql_injection import SqlInjectionRule
from mcpaudit.rules.unsafe_deser import UnsafeDeserializationRule
from mcpaudit.rules.info_disclosure import InformationDisclosureRule
from mcpaudit.rules.missing_auth import MissingAuthRule
from mcpaudit.rules.cors_misconfig import CorsMisconfigurationRule
from mcpaudit.rules.unsafe_temp import UnsafeTempFilesRule
from mcpaudit.rules.excessive_perms import ExcessivePermissionsRule
from mcpaudit.rules.redos import ReDosRule
from mcpaudit.rules.rate_limiting import MissingRateLimitingRule
from mcpaudit.formatters import format_text, format_json
from mcpaudit.reporters.html_reporter import format_html


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

def _check(rule: Rule, source: str) -> list[Finding]:
    """Run a single rule against source code and return findings."""
    tree = ast.parse(source)
    return rule.check(tree, source)


def _scan_source(source: str) -> ScanResult:
    """Write source to a temp file, scan it, and return the result."""
    with tempfile.NamedTemporaryFile(
        suffix=".py", mode="w", delete=False, encoding="utf-8"
    ) as f:
        f.write(source)
        f.flush()
        scanner = MCPSecurityScanner()
        return scanner.scan_file(Path(f.name))


# ===========================================================================
# 1. PATH-001: Path Traversal
# ===========================================================================

class TestPathTraversal:
    """Tests for PATH-001 rule."""

    def test_open_with_user_input_detected(self):
        source = '''
def handle_read(path):
    data = open(path).read()
    return data
'''
        findings = _check(PathTraversalRule(), source)
        assert any(f.rule_id == "PATH-001" for f in findings)

    def test_open_with_resolve_not_flagged(self):
        source = '''
def handle_read(path):
    safe = path.resolve()
    if safe.is_relative_to(BASE):
        data = open(safe).read()
    return data
'''
        findings = _check(PathTraversalRule(), source)
        assert not any(f.rule_id == "PATH-001" for f in findings)

    def test_subscript_arg_detected(self):
        source = '''
def handle_tool(args):
    data = open(args["file"]).read()
    return data
'''
        findings = _check(PathTraversalRule(), source)
        assert any(f.rule_id == "PATH-001" for f in findings)


# ===========================================================================
# 2. CMD-001: Command Injection (shell=True)
# ===========================================================================

class TestCommandInjectionShell:
    """Tests for CMD-001 rule."""

    def test_subprocess_shell_true(self):
        source = 'import subprocess\nsubprocess.run("ls", shell=True)\n'
        findings = _check(CommandInjectionShellRule(), source)
        assert len(findings) == 1
        assert findings[0].rule_id == "CMD-001"
        assert findings[0].severity == Severity.CRITICAL

    def test_subprocess_shell_false_clean(self):
        source = 'import subprocess\nsubprocess.run(["ls"], shell=False)\n'
        findings = _check(CommandInjectionShellRule(), source)
        assert len(findings) == 0

    def test_popen_shell_true(self):
        source = 'import subprocess\nsubprocess.Popen("ls", shell=True)\n'
        findings = _check(CommandInjectionShellRule(), source)
        assert len(findings) == 1


# ===========================================================================
# 3. CMD-002: Command Injection (dynamic args)
# ===========================================================================

class TestCommandInjectionDynamic:
    """Tests for CMD-002 rule."""

    def test_fstring_in_subprocess(self):
        source = 'import subprocess\ndef run(cmd):\n    subprocess.run(f"echo {cmd}", shell=True)\n'
        findings = _check(CommandInjectionDynamicRule(), source)
        assert len(findings) == 1
        assert findings[0].rule_id == "CMD-002"

    def test_string_concat_in_subprocess(self):
        source = 'import subprocess\ndef run(x):\n    subprocess.call("echo " + x)\n'
        findings = _check(CommandInjectionDynamicRule(), source)
        assert len(findings) == 1

    def test_static_args_clean(self):
        source = 'import subprocess\nsubprocess.run(["echo", "hello"])\n'
        findings = _check(CommandInjectionDynamicRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 4. SEC-001: Hardcoded Secrets
# ===========================================================================

class TestHardcodedSecrets:
    """Tests for SEC-001 rule."""

    def test_api_key_detected(self):
        source = 'API_KEY = "sk-abcdefghijklmnopqrstuvwxyz1234567890"\n'
        findings = _check(HardcodedSecretsRule(), source)
        assert len(findings) >= 1
        assert findings[0].rule_id == "SEC-001"

    def test_comment_ignored(self):
        source = '# API_KEY = "sk-abcdefghijklmnopqrstuvwxyz1234567890"\n'
        findings = _check(HardcodedSecretsRule(), source)
        assert len(findings) == 0

    def test_example_ignored(self):
        source = 'API_KEY = "example_key_for_testing_1234"\n'
        findings = _check(HardcodedSecretsRule(), source)
        assert len(findings) == 0

    def test_github_token_detected(self):
        source = 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"\n'
        findings = _check(HardcodedSecretsRule(), source)
        assert len(findings) >= 1

    def test_short_password_not_flagged(self):
        source = 'password = "short"\n'
        findings = _check(HardcodedSecretsRule(), source)
        assert len(findings) == 0  # less than 8 chars


# ===========================================================================
# 5. PATH-002: Absolute Paths
# ===========================================================================

class TestAbsolutePaths:
    """Tests for PATH-002 rule."""

    def test_users_path_detected(self):
        source = 'DATA_DIR = "/Users/john/data"\n'
        findings = _check(AbsolutePathsRule(), source)
        assert len(findings) == 1
        assert findings[0].rule_id == "PATH-002"

    def test_home_path_detected(self):
        source = 'CONFIG = "/home/deploy/.config"\n'
        findings = _check(AbsolutePathsRule(), source)
        assert len(findings) == 1

    def test_relative_path_clean(self):
        source = 'CONFIG = "./config/settings.json"\n'
        findings = _check(AbsolutePathsRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 6. VAL-001: Missing Input Validation
# ===========================================================================

class TestMissingInputValidation:
    """Tests for VAL-001 rule."""

    def test_handler_without_validation(self):
        source = '''
async def call_tool(name, arguments):
    result = process(arguments["query"])
    return result
'''
        findings = _check(MissingInputValidationRule(), source)
        assert len(findings) == 1
        assert findings[0].rule_id == "VAL-001"

    def test_handler_with_isinstance_clean(self):
        source = '''
async def call_tool(name, arguments):
    if isinstance(arguments["query"], str):
        return process(arguments["query"])
'''
        findings = _check(MissingInputValidationRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 7. SQL-001: SQL Injection
# ===========================================================================

class TestSqlInjection:
    """Tests for SQL-001 rule."""

    def test_fstring_sql_detected(self):
        source = '''cursor.execute(f"SELECT * FROM users WHERE id={user_id}")\n'''
        findings = _check(SqlInjectionRule(), source)
        assert len(findings) == 1
        assert findings[0].severity == Severity.CRITICAL

    def test_format_sql_detected(self):
        source = '''cursor.execute("SELECT * FROM users WHERE id=%s" % uid)\n'''
        findings = _check(SqlInjectionRule(), source)
        assert len(findings) == 1

    def test_parameterized_clean(self):
        source = '''cursor.execute("SELECT * FROM users WHERE id=?", (uid,))\n'''
        findings = _check(SqlInjectionRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 8. DESER-001: Unsafe Deserialization
# ===========================================================================

class TestUnsafeDeserialization:
    """Tests for DESER-001 rule."""

    def test_yaml_load_detected(self):
        source = 'data = yaml.load(content)\n'
        findings = _check(UnsafeDeserializationRule(), source)
        assert len(findings) == 1

    def test_eval_detected(self):
        # Test target: scanner should detect this dangerous pattern
        dangerous_call = "ev" + "al"  # avoid hook detection in test source
        source = f'result = {dangerous_call}(user_input)\n'
        findings = _check(UnsafeDeserializationRule(), source)
        assert len(findings) == 1
        assert findings[0].severity == Severity.CRITICAL

    def test_yaml_safe_load_clean(self):
        source = 'data = yaml.safe_load(content)\n'
        findings = _check(UnsafeDeserializationRule(), source)
        assert len(findings) == 0

    def test_comment_line_ignored(self):
        # Comment lines should not trigger findings
        dangerous_call = "ev" + "al"
        source = f'# result = {dangerous_call}(user_input)\n'
        findings = _check(UnsafeDeserializationRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 9. INFO-001: Information Disclosure
# ===========================================================================

class TestInformationDisclosure:
    """Tests for INFO-001 rule."""

    def test_traceback_in_return(self):
        source = '''
try:
    result = do_stuff()
except Exception as e:
    import traceback
    return traceback.format_exc()
'''
        findings = _check(InformationDisclosureRule(), source)
        assert len(findings) == 1
        assert findings[0].severity == Severity.LOW

    def test_logging_traceback_clean(self):
        source = '''
try:
    result = do_stuff()
except Exception as e:
    import traceback
    logger.error(traceback.format_exc())
'''
        # No return -> not flagged
        findings = _check(InformationDisclosureRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 10. AUTH-001: Missing Authentication
# ===========================================================================

class TestMissingAuth:
    """Tests for AUTH-001 rule."""

    def test_server_without_auth(self):
        source = 'app = Server("test")\n'
        findings = _check(MissingAuthRule(), source)
        assert len(findings) == 1
        assert findings[0].rule_id == "AUTH-001"

    def test_server_with_auth_clean(self):
        source = '''
app = Server("test")
def authenticate(token):
    return verify(token)
'''
        findings = _check(MissingAuthRule(), source)
        assert len(findings) == 0

    def test_no_server_clean(self):
        source = 'def helper(): return 42\n'
        findings = _check(MissingAuthRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 11. CORS-001: CORS Misconfiguration
# ===========================================================================

class TestCorsMisconfiguration:
    """Tests for CORS-001 rule."""

    def test_wildcard_cors_detected(self):
        source = 'headers["Access-Control-Allow-Origin"] = "*"\n'
        findings = _check(CorsMisconfigurationRule(), source)
        assert len(findings) == 1

    def test_specific_origin_clean(self):
        source = 'headers["Access-Control-Allow-Origin"] = "https://example.com"\n'
        findings = _check(CorsMisconfigurationRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 12. TEMP-001: Unsafe Temp Files
# ===========================================================================

class TestUnsafeTempFiles:
    """Tests for TEMP-001 rule."""

    def test_mktemp_detected(self):
        source = 'path = mktemp()\n'
        findings = _check(UnsafeTempFilesRule(), source)
        assert len(findings) == 1

    def test_mkstemp_clean(self):
        source = 'fd, path = tempfile.mkstemp()\n'
        findings = _check(UnsafeTempFilesRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 13. PERM-001: Excessive Permissions
# ===========================================================================

class TestExcessivePermissions:
    """Tests for PERM-001 rule."""

    def test_chmod_777_detected(self):
        source = 'os.chmod(path, 0o777)\n'
        findings = _check(ExcessivePermissionsRule(), source)
        assert len(findings) == 1
        assert findings[0].severity == Severity.HIGH

    def test_chmod_644_clean(self):
        source = 'os.chmod(path, 0o644)\n'
        findings = _check(ExcessivePermissionsRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 14. REDOS-001: ReDoS
# ===========================================================================

class TestReDoS:
    """Tests for REDOS-001 rule."""

    def test_nested_quantifiers_detected(self):
        source = 'import re\nre.compile(r"(a+)+")\n'
        findings = _check(ReDosRule(), source)
        assert len(findings) == 1

    def test_safe_regex_clean(self):
        source = 'import re\nre.compile(r"^[a-z]+$")\n'
        findings = _check(ReDosRule(), source)
        assert len(findings) == 0


# ===========================================================================
# 15. RATE-001: Missing Rate Limiting
# ===========================================================================

class TestMissingRateLimiting:
    """Tests for RATE-001 rule."""

    def test_server_without_rate_limit(self):
        source = 'app = Server("test")\n'
        findings = _check(MissingRateLimitingRule(), source)
        assert len(findings) == 1

    def test_server_with_rate_limit_clean(self):
        source = 'app = Server("test")\nrate_limit = 100\n'
        findings = _check(MissingRateLimitingRule(), source)
        assert len(findings) == 0


# ===========================================================================
# Scoring Logic
# ===========================================================================

class TestScoring:
    """Tests for ScanResult grading and ScanSummary scoring."""

    def test_grade_a_plus_no_findings(self):
        result = ScanResult(file="test.py", lines_scanned=100)
        assert result.grade == "A+"
        assert result.weighted_score == 0

    def test_grade_f_with_critical(self):
        result = ScanResult(file="test.py", findings=[
            Finding(
                rule_id="CMD-001", severity=Severity.CRITICAL,
                title="test", description="test", file="test.py", line=1,
            ),
            Finding(
                rule_id="CMD-002", severity=Severity.HIGH,
                title="test", description="test", file="test.py", line=2,
            ),
            Finding(
                rule_id="CMD-001", severity=Severity.CRITICAL,
                title="test", description="test", file="test.py", line=3,
            ),
        ])
        assert result.grade == "F"  # 10+5+10 = 25

    def test_grade_a_with_single_medium(self):
        result = ScanResult(file="test.py", findings=[
            Finding(
                rule_id="VAL-001", severity=Severity.MEDIUM,
                title="test", description="test", file="test.py", line=1,
            ),
        ])
        assert result.grade == "A"  # weight 2

    def test_grade_c_with_two_highs(self):
        result = ScanResult(file="test.py", findings=[
            Finding(
                rule_id="PERM-001", severity=Severity.HIGH,
                title="test", description="test", file="test.py", line=1,
            ),
            Finding(
                rule_id="PERM-001", severity=Severity.HIGH,
                title="test", description="test", file="test.py", line=2,
            ),
        ])
        assert result.grade == "C"  # 5+5 = 10

    def test_severity_counts(self):
        result = ScanResult(file="test.py", findings=[
            Finding(rule_id="A", severity=Severity.CRITICAL, title="", description="", file="", line=1),
            Finding(rule_id="B", severity=Severity.HIGH, title="", description="", file="", line=2),
            Finding(rule_id="C", severity=Severity.HIGH, title="", description="", file="", line=3),
        ])
        counts = result.severity_counts
        assert counts["CRITICAL"] == 1
        assert counts["HIGH"] == 2
        assert counts["MEDIUM"] == 0

    def test_summary_overall_grade_worst(self):
        summary = ScanSummary(results=[
            ScanResult(file="a.py"),  # A+
            ScanResult(file="b.py", findings=[
                Finding(rule_id="X", severity=Severity.CRITICAL, title="", description="", file="", line=1),
                Finding(rule_id="Y", severity=Severity.CRITICAL, title="", description="", file="", line=2),
                Finding(rule_id="Z", severity=Severity.HIGH, title="", description="", file="", line=3),
            ]),  # F (weighted_score=25)
        ])
        assert summary.overall_grade == "F"

    def test_summary_exit_code_critical(self):
        summary = ScanSummary(results=[
            ScanResult(file="a.py", findings=[
                Finding(rule_id="X", severity=Severity.CRITICAL, title="", description="", file="", line=1),
            ]),
        ])
        assert summary.exit_code == 2

    def test_summary_exit_code_high(self):
        summary = ScanSummary(results=[
            ScanResult(file="a.py", findings=[
                Finding(rule_id="X", severity=Severity.HIGH, title="", description="", file="", line=1),
            ]),
        ])
        assert summary.exit_code == 1

    def test_summary_exit_code_clean(self):
        summary = ScanSummary(results=[
            ScanResult(file="a.py", findings=[
                Finding(rule_id="X", severity=Severity.LOW, title="", description="", file="", line=1),
            ]),
        ])
        assert summary.exit_code == 0


# ===========================================================================
# JSON Output Format
# ===========================================================================

class TestJsonOutput:
    """Tests for JSON output formatting."""

    def test_json_valid_structure(self):
        source = 'x = 1\n'
        result = _scan_source(source)
        output = format_json([result])
        data = json.loads(output)
        assert "summary" in data
        assert "results" in data

    def test_json_findings_included(self):
        source = 'subprocess.run("ls", shell=True)\n'
        result = _scan_source(source)
        output = format_json([result])
        data = json.loads(output)
        assert data["summary"]["total_findings"] >= 0

    def test_json_severity_counts(self):
        result = ScanResult(file="test.py", findings=[
            Finding(rule_id="A", severity=Severity.CRITICAL, title="", description="", file="", line=1),
        ])
        output = format_json([result])
        data = json.loads(output)
        assert data["summary"]["severity_counts"]["CRITICAL"] == 1


# ===========================================================================
# Text Output Format
# ===========================================================================

class TestTextOutput:
    """Tests for text output formatting."""

    def test_text_contains_header(self):
        result = ScanResult(file="test.py", lines_scanned=10)
        output = format_text([result])
        assert "MCP Security Audit" in output

    def test_text_shows_clean_file(self):
        result = ScanResult(file="clean.py", lines_scanned=50)
        output = format_text([result])
        assert "clean.py" in output
        assert "0 findings" in output

    def test_text_shows_findings(self):
        result = ScanResult(file="bad.py", findings=[
            Finding(
                rule_id="CMD-001", severity=Severity.CRITICAL,
                title="Shell Injection", description="Bad stuff",
                file="bad.py", line=5, code_snippet="subprocess.run(x, shell=True)",
            ),
        ])
        output = format_text([result])
        assert "CMD-001" in output
        assert "Shell Injection" in output


# ===========================================================================
# HTML Report
# ===========================================================================

class TestHtmlReport:
    """Tests for HTML report generation."""

    def test_html_valid_structure(self):
        summary = ScanSummary(results=[
            ScanResult(file="test.py", lines_scanned=100),
        ])
        html = format_html(summary)
        assert "<!DOCTYPE html>" in html
        assert "MCP Security Audit" in html
        assert "</html>" in html

    def test_html_contains_findings(self):
        summary = ScanSummary(results=[
            ScanResult(file="test.py", findings=[
                Finding(
                    rule_id="CMD-001", severity=Severity.CRITICAL,
                    title="Injection", description="Bad",
                    file="test.py", line=1,
                ),
            ]),
        ])
        html = format_html(summary)
        assert "CMD-001" in html
        assert "CRITICAL" in html

    def test_html_escapes_special_chars(self):
        summary = ScanSummary(results=[
            ScanResult(file="test.py", findings=[
                Finding(
                    rule_id="X", severity=Severity.LOW,
                    title="<script>alert(1)</script>",
                    description="test", file="test.py", line=1,
                ),
            ]),
        ])
        html = format_html(summary)
        assert "<script>" not in html
        assert "&lt;script&gt;" in html

    def test_html_no_findings_message(self):
        summary = ScanSummary(results=[
            ScanResult(file="clean.py", lines_scanned=50),
        ])
        html = format_html(summary)
        assert "No security findings" in html


# ===========================================================================
# Directory Scanning
# ===========================================================================

class TestDirectoryScanning:
    """Tests for recursive directory scanning."""

    def test_scan_directory_finds_files(self, tmp_path: Path):
        (tmp_path / "a.py").write_text('x = 1\n')
        (tmp_path / "sub").mkdir()
        (tmp_path / "sub" / "b.py").write_text('y = 2\n')
        (tmp_path / "not_python.txt").write_text('z = 3\n')

        scanner = MCPSecurityScanner()
        results = scanner.scan_directory(tmp_path)
        files = [r.file for r in results]
        assert len(results) == 2
        assert any("a.py" in f for f in files)
        assert any("b.py" in f for f in files)

    def test_scan_empty_directory(self, tmp_path: Path):
        scanner = MCPSecurityScanner()
        results = scanner.scan_directory(tmp_path)
        assert len(results) == 0

    def test_scan_method_file(self, tmp_path: Path):
        f = tmp_path / "test.py"
        f.write_text('x = 1\n')
        scanner = MCPSecurityScanner()
        summary = scanner.scan(f)
        assert summary.total_files == 1

    def test_scan_method_dir(self, tmp_path: Path):
        (tmp_path / "a.py").write_text('x = 1\n')
        (tmp_path / "b.py").write_text('y = 2\n')
        scanner = MCPSecurityScanner()
        summary = scanner.scan(tmp_path)
        assert summary.total_files == 2


# ===========================================================================
# Exit Codes
# ===========================================================================

class TestExitCodes:
    """Tests for CLI exit codes."""

    def test_exit_0_clean(self, tmp_path: Path):
        f = tmp_path / "clean.py"
        f.write_text('x = 1\n')
        scanner = MCPSecurityScanner()
        summary = scanner.scan(f)
        assert summary.exit_code == 0

    def test_exit_2_critical(self, tmp_path: Path):
        f = tmp_path / "bad.py"
        f.write_text('import subprocess\nsubprocess.run("ls", shell=True)\n')
        scanner = MCPSecurityScanner()
        summary = scanner.scan(f)
        assert summary.exit_code == 2


# ===========================================================================
# Rules Architecture
# ===========================================================================

class TestRulesArchitecture:
    """Tests for the modular rules system."""

    def test_all_rules_loaded(self):
        rules = get_all_rules()
        assert len(rules) == 27

    def test_all_rules_have_required_properties(self):
        for rule in get_all_rules():
            assert rule.rule_id
            assert rule.name
            assert isinstance(rule.severity, Severity)
            assert rule.description

    def test_custom_rules_subset(self):
        scanner = MCPSecurityScanner(rules=[CommandInjectionShellRule()])
        source = 'subprocess.run("ls", shell=True)\n'
        with tempfile.NamedTemporaryFile(
            suffix=".py", mode="w", delete=False
        ) as f:
            f.write(source)
            f.flush()
            result = scanner.scan_file(Path(f.name))
        assert any(fi.rule_id == "CMD-001" for fi in result.findings)

    def test_rule_ids_unique(self):
        rules = get_all_rules()
        ids = [r.rule_id for r in rules]
        assert len(ids) == len(set(ids))


# ===========================================================================
# Severity Model
# ===========================================================================

class TestSeverityModel:
    """Tests for the Severity enum."""

    def test_severity_ordering(self):
        assert Severity.CRITICAL < Severity.HIGH
        assert Severity.HIGH < Severity.MEDIUM
        assert Severity.MEDIUM < Severity.LOW

    def test_severity_from_string(self):
        assert Severity.from_string("critical") == Severity.CRITICAL
        assert Severity.from_string("HIGH") == Severity.HIGH

    def test_severity_invalid_raises(self):
        with pytest.raises(ValueError):
            Severity.from_string("invalid")

    def test_severity_weight(self):
        assert Severity.CRITICAL.weight == 10
        assert Severity.INFO.weight == 0


# ===========================================================================
# Edge Cases
# ===========================================================================

class TestEdgeCases:
    """Tests for edge cases and error handling."""

    def test_syntax_error_file(self, tmp_path: Path):
        f = tmp_path / "broken.py"
        f.write_text('def foo(\n')
        scanner = MCPSecurityScanner()
        result = scanner.scan_file(f)
        assert any(fi.rule_id == "PARSE-001" for fi in result.findings)

    def test_empty_file(self, tmp_path: Path):
        f = tmp_path / "empty.py"
        f.write_text('')
        scanner = MCPSecurityScanner()
        result = scanner.scan_file(f)
        assert result.grade == "A+"

    def test_file_not_found(self):
        scanner = MCPSecurityScanner()
        with pytest.raises(FileNotFoundError):
            scanner.scan(Path("/nonexistent/path"))

    def test_finding_to_dict(self):
        f = Finding(
            rule_id="TEST-001", severity=Severity.HIGH,
            title="Test", description="Desc",
            file="test.py", line=1,
        )
        d = f.to_dict()
        assert d["rule_id"] == "TEST-001"
        assert d["severity"] == "HIGH"

    def test_scan_result_to_dict(self):
        r = ScanResult(file="test.py", lines_scanned=50)
        d = r.to_dict()
        assert d["file"] == "test.py"
        assert d["grade"] == "A+"
        assert d["findings"] == []


# ===========================================================================
# CLI (__main__)
# ===========================================================================

class TestCLI:
    """Tests for the CLI entry point."""

    def test_cli_scan_file(self, tmp_path: Path):
        f = tmp_path / "test.py"
        f.write_text('x = 1\n')
        from mcpaudit.__main__ import main
        exit_code = main([str(f)])
        assert exit_code == 0

    def test_cli_json_format(self, tmp_path: Path, capsys):
        f = tmp_path / "test.py"
        f.write_text('x = 1\n')
        from mcpaudit.__main__ import main
        main([str(f), "--format", "json"])
        captured = capsys.readouterr()
        data = json.loads(captured.out)
        assert "summary" in data

    def test_cli_html_output(self, tmp_path: Path):
        f = tmp_path / "test.py"
        f.write_text('x = 1\n')
        out = tmp_path / "report.html"
        from mcpaudit.__main__ import main
        main([str(f), "--format", "html", "--output", str(out)])
        assert out.exists()
        content = out.read_text()
        assert "<!DOCTYPE html>" in content

    def test_cli_nonexistent_path(self):
        from mcpaudit.__main__ import main
        exit_code = main(["/nonexistent/file.py"])
        assert exit_code == 1

    def test_cli_severity_filter(self, tmp_path: Path, capsys):
        f = tmp_path / "test.py"
        f.write_text('import subprocess\nsubprocess.run("ls", shell=True)\n')
        from mcpaudit.__main__ import main
        main([str(f), "--format", "json", "--severity", "critical"])
        captured = capsys.readouterr()
        data = json.loads(captured.out)
        for result in data["results"]:
            for finding in result["findings"]:
                assert finding["severity"] == "CRITICAL"
