"""Tests for the deobfuscation layer (mcpaudit.deobfuscate).

Tests zero-width character stripping, Unicode tag character detection
(ASCII smuggling), variation selector removal, BiDi control character
stripping, HTML comment removal in strings, and Base64 block detection.

NOTE: These tests contain intentional Unicode control characters and
intentionally vulnerable code STRINGS that are passed to the scanner
for detection testing. These are NOT executed — they are static
analysis targets only.
"""

from __future__ import annotations

import base64
import tempfile
from pathlib import Path

import pytest

from mcpaudit.deobfuscate import (
    DeobfuscationFinding,
    deobfuscate,
    _decode_tag_chars,
    _is_suspicious_char,
    _strip_invisible_chars,
    _strip_html_comments_in_strings,
    _detect_base64_blocks,
)
from mcpaudit.scanner import MCPSecurityScanner
from mcpaudit.models import ScanResult


# ===========================================================================
# 1. Zero-Width Character Stripping
# ===========================================================================

class TestZeroWidthChars:
    """Tests for zero-width character detection and stripping."""

    def test_zwsp_stripped(self):
        """Zero-width space U+200B is stripped."""
        source = "print\u200B('hello')"
        cleaned, findings = deobfuscate(source)
        assert "\u200B" not in cleaned
        assert cleaned == "print('hello')"
        assert len(findings) >= 1
        assert any(f.category == "zero_width" for f in findings)

    def test_zwnj_stripped(self):
        """Zero-width non-joiner U+200C is stripped."""
        source = "x\u200C = 1"
        cleaned, findings = deobfuscate(source)
        assert "\u200C" not in cleaned
        assert any(f.category == "zero_width" for f in findings)

    def test_zwj_stripped(self):
        """Zero-width joiner U+200D is stripped."""
        source = "func\u200Dtion()"
        cleaned, findings = deobfuscate(source)
        assert "\u200D" not in cleaned

    def test_bom_stripped(self):
        """BOM character U+FEFF is stripped."""
        source = "\uFEFFimport os"
        cleaned, findings = deobfuscate(source)
        assert "\uFEFF" not in cleaned
        assert cleaned == "import os"

    def test_soft_hyphen_stripped(self):
        """Soft hyphen U+00AD is stripped."""
        source = "sub\u00ADprocess"
        cleaned, findings = deobfuscate(source)
        assert "\u00AD" not in cleaned
        assert cleaned == "subprocess"

    def test_word_joiner_stripped(self):
        """Word joiner U+2060 is stripped."""
        source = "run\u2060(x)"
        cleaned, _ = deobfuscate(source)
        assert cleaned == "run(x)"

    def test_multiple_zwchars_same_line(self):
        """Multiple zero-width chars on one line produce one finding per category."""
        source = "a\u200B\u200B\u200Bb = 1"
        cleaned, findings = deobfuscate(source)
        assert cleaned == "ab = 1"
        zw = [f for f in findings if f.category == "zero_width"]
        assert len(zw) == 1
        assert "3" in zw[0].description

    def test_clean_source_no_findings(self):
        """Clean source produces no findings."""
        source = "x = 1\ny = 2\n"
        cleaned, findings = deobfuscate(source)
        assert cleaned == source
        assert len(findings) == 0


# ===========================================================================
# 2. Unicode Tag Characters (ASCII Smuggling)
# ===========================================================================

class TestUnicodeTagChars:
    """Tests for Unicode tag character stripping (U+E0001-U+E007F)."""

    def test_tag_chars_stripped(self):
        """Tag characters are stripped from source."""
        smuggled = "\U000E0068\U000E0069"
        source = f"x = 1{smuggled}"
        cleaned, findings = deobfuscate(source)
        assert "\U000E0068" not in cleaned
        assert cleaned == "x = 1"
        tag_findings = [f for f in findings if f.category == "unicode_tag"]
        assert len(tag_findings) == 1

    def test_tag_chars_decoded_in_description(self):
        """Decoded ASCII content appears in the finding description."""
        smuggled = "\U000E0074\U000E0065\U000E0073\U000E0074"  # 'test'
        source = f"safe = True{smuggled}"
        _, findings = deobfuscate(source)
        tag_findings = [f for f in findings if f.category == "unicode_tag"]
        assert len(tag_findings) == 1
        assert "test" in tag_findings[0].description

    def test_decode_tag_chars_helper(self):
        """_decode_tag_chars correctly maps to ASCII."""
        chars = ["\U000E0041", "\U000E0042"]  # 'AB'
        assert _decode_tag_chars(chars) == "AB"

    def test_tag_boundary_chars(self):
        """Tag chars at boundaries U+E0001 and U+E007F are detected."""
        source = f"x\U000E0001\U000E007F= 1"
        cleaned, findings = deobfuscate(source)
        assert "\U000E0001" not in cleaned
        tag_findings = [f for f in findings if f.category == "unicode_tag"]
        assert len(tag_findings) == 1


# ===========================================================================
# 3. Variation Selectors
# ===========================================================================

class TestVariationSelectors:
    """Tests for variation selector stripping (U+FE00-U+FE0F)."""

    def test_variation_selector_stripped(self):
        """Variation selectors are stripped."""
        source = "print\uFE01('x')"
        cleaned, findings = deobfuscate(source)
        assert "\uFE01" not in cleaned
        assert cleaned == "print('x')"
        vs_findings = [f for f in findings if f.category == "variation_selector"]
        assert len(vs_findings) == 1

    def test_multiple_variation_selectors(self):
        """Multiple variation selectors on the same line."""
        source = "a\uFE00\uFE0F = 1"
        cleaned, findings = deobfuscate(source)
        assert cleaned == "a = 1"
        vs_findings = [f for f in findings if f.category == "variation_selector"]
        assert len(vs_findings) == 1
        assert "2" in vs_findings[0].description


# ===========================================================================
# 4. BiDi Control Characters
# ===========================================================================

class TestBiDiChars:
    """Tests for BiDi control character stripping."""

    def test_ltr_mark_stripped(self):
        """Left-to-right mark U+200E is stripped."""
        source = "user\u200E = 'admin'"
        cleaned, findings = deobfuscate(source)
        assert "\u200E" not in cleaned
        bidi_findings = [f for f in findings if f.category == "bidi"]
        assert len(bidi_findings) == 1

    def test_rtl_override_stripped(self):
        """Right-to-left override U+202E is stripped."""
        source = "access\u202E = 'deny'"
        cleaned, findings = deobfuscate(source)
        assert "\u202E" not in cleaned

    def test_rli_stripped(self):
        """Right-to-left isolate U+2067 is stripped."""
        source = "x = 1\u2067"
        cleaned, _ = deobfuscate(source)
        assert cleaned == "x = 1"

    def test_multiple_bidi_chars(self):
        """Multiple BiDi chars produce finding with count."""
        source = "a\u200E\u200F\u202A = 1"
        cleaned, findings = deobfuscate(source)
        assert cleaned == "a = 1"
        bidi_findings = [f for f in findings if f.category == "bidi"]
        assert len(bidi_findings) == 1
        assert "3" in bidi_findings[0].description


# ===========================================================================
# 5. HTML Comment Stripping
# ===========================================================================

class TestHtmlCommentStripping:
    """Tests for HTML comment stripping in strings."""

    def test_html_comment_stripped(self):
        """HTML comments are stripped from source."""
        source = 'x = "hello <!-- inject --> world"'
        cleaned, findings = deobfuscate(source)
        assert "<!-- inject -->" not in cleaned
        html_findings = [f for f in findings if f.category == "html_comment"]
        assert len(html_findings) == 1

    def test_multiline_preserved(self):
        """HTML comment within a single line is stripped; other lines ok."""
        source = "a = 1\nb = '<!-- hidden -->'\nc = 3"
        cleaned, findings = deobfuscate(source)
        assert "<!-- hidden -->" not in cleaned
        assert "a = 1" in cleaned
        assert "c = 3" in cleaned

    def test_no_html_comment_clean(self):
        """Source without HTML comments produces no html_comment findings."""
        source = 'x = "normal string"'
        _, findings = deobfuscate(source)
        assert not any(f.category == "html_comment" for f in findings)


# ===========================================================================
# 6. Base64 Block Detection
# ===========================================================================

class TestBase64Detection:
    """Tests for Base64 block detection in strings."""

    def test_base64_payload_detected(self):
        """A valid Base64-encoded payload in a string is flagged."""
        # Encode a long enough benign string to trigger detection
        payload = base64.b64encode(
            b"This is a long test payload for base64 detection testing only"
        ).decode()
        source = f'data = "{payload}"'
        _, findings = deobfuscate(source)
        b64_findings = [f for f in findings if f.category == "base64"]
        assert len(b64_findings) == 1
        assert "decodes to" in b64_findings[0].description

    def test_short_base64_not_flagged(self):
        """Short Base64 strings (< 40 chars) are not flagged."""
        source = 'x = "SGVsbG8="'  # "Hello" — only 8 chars
        _, findings = deobfuscate(source)
        assert not any(f.category == "base64" for f in findings)

    def test_invalid_base64_not_flagged(self):
        """Invalid Base64 content does not cause a crash."""
        source = 'x = "' + "A" * 50 + '"'
        _, findings = deobfuscate(source)
        # Just verify no exception is raised

    def test_binary_base64_detected(self):
        """Base64 that decodes to binary is flagged."""
        payload = base64.b64encode(bytes(range(256))).decode()
        source = f'blob = "{payload}"'
        _, findings = deobfuscate(source)
        b64_findings = [f for f in findings if f.category == "base64"]
        assert len(b64_findings) >= 1


# ===========================================================================
# 7. Full Pipeline Integration
# ===========================================================================

class TestDeobfuscatePipeline:
    """Integration tests for the full deobfuscation pipeline."""

    def test_combined_obfuscation(self):
        """Multiple obfuscation types in one source are all detected."""
        source = (
            "x\u200B = 1\n"               # zero-width
            "y\u200E = 2\n"               # bidi
            'z = "<!-- inject -->"\n'      # html comment
        )
        cleaned, findings = deobfuscate(source)
        categories = {f.category for f in findings}
        assert "zero_width" in categories
        assert "bidi" in categories
        assert "html_comment" in categories

    def test_deobfuscated_source_parseable(self):
        """Cleaned source can be parsed by Python AST."""
        import ast
        source = "def\u200B foo\u200C():\n    return\u200D 1\n"
        cleaned, _ = deobfuscate(source)
        tree = ast.parse(cleaned)
        assert tree is not None

    def test_empty_source(self):
        """Empty source returns empty with no findings."""
        cleaned, findings = deobfuscate("")
        assert cleaned == ""
        assert findings == []

    def test_line_numbers_accurate(self):
        """Finding line numbers match the actual line in source."""
        source = "line1\nline2\u200B\nline3\n"
        _, findings = deobfuscate(source)
        assert len(findings) == 1
        assert findings[0].line == 2

    def test_finding_to_dict(self):
        """DeobfuscationFinding.to_dict() returns expected structure."""
        f = DeobfuscationFinding(
            line=5,
            description="test",
            original_bytes=b"\xe2\x80\x8b",
            category="zero_width",
        )
        d = f.to_dict()
        assert d["line"] == 5
        assert d["category"] == "zero_width"
        assert "original_bytes" in d


# ===========================================================================
# 8. Scanner Integration (deobfuscation + scanning)
# ===========================================================================

class TestScannerDeobfuscation:
    """Tests for deobfuscation integrated with the scanner."""

    def test_scanner_deobfuscates_by_default(self, tmp_path: Path):
        """Scanner with default settings runs deobfuscation."""
        f = tmp_path / "obfuscated.py"
        f.write_text("x\u200B = 1\n", encoding="utf-8")
        scanner = MCPSecurityScanner()
        result = scanner.scan_file(f)
        deob = [fi for fi in result.findings if fi.rule_id.startswith("DEOB-")]
        assert len(deob) >= 1

    def test_scanner_deobfuscate_disabled(self, tmp_path: Path):
        """Scanner with deobfuscate_enabled=False skips deobfuscation."""
        f = tmp_path / "obfuscated.py"
        f.write_text("x\u200B = 1\n", encoding="utf-8")
        scanner = MCPSecurityScanner(deobfuscate_enabled=False)
        result = scanner.scan_file(f)
        deob = [fi for fi in result.findings if fi.rule_id.startswith("DEOB-")]
        assert len(deob) == 0

    def test_deob_findings_have_correct_file(self, tmp_path: Path):
        """Deobfuscation findings have the correct file path."""
        f = tmp_path / "test.py"
        f.write_text("y\u200E = 2\n", encoding="utf-8")
        scanner = MCPSecurityScanner()
        result = scanner.scan_file(f)
        for fi in result.findings:
            if fi.rule_id.startswith("DEOB-"):
                assert str(f) in fi.file

    def test_deob_bidi_is_high_severity(self, tmp_path: Path):
        """BiDi findings are HIGH severity."""
        f = tmp_path / "bidi.py"
        f.write_text("x\u202E = 1\n", encoding="utf-8")
        scanner = MCPSecurityScanner()
        result = scanner.scan_file(f)
        bidi_findings = [
            fi for fi in result.findings if fi.rule_id.startswith("DEOB-")
        ]
        assert len(bidi_findings) >= 1
        from mcpaudit.models import Severity
        assert bidi_findings[0].severity == Severity.HIGH

    def test_deob_zero_width_is_medium_severity(self, tmp_path: Path):
        """Zero-width findings are MEDIUM severity."""
        f = tmp_path / "zw.py"
        f.write_text("x\u200B = 1\n", encoding="utf-8")
        scanner = MCPSecurityScanner()
        result = scanner.scan_file(f)
        zw_findings = [
            fi for fi in result.findings if fi.rule_id.startswith("DEOB-")
        ]
        assert len(zw_findings) >= 1
        from mcpaudit.models import Severity
        assert zw_findings[0].severity == Severity.MEDIUM


# ===========================================================================
# 9. Helper Functions
# ===========================================================================

class TestHelperFunctions:
    """Tests for internal helper functions."""

    def test_is_suspicious_char_zero_width(self):
        assert _is_suspicious_char(0x200B) == "zero_width"

    def test_is_suspicious_char_tag(self):
        assert _is_suspicious_char(0xE0041) == "unicode_tag"

    def test_is_suspicious_char_variation(self):
        assert _is_suspicious_char(0xFE00) == "variation_selector"

    def test_is_suspicious_char_bidi(self):
        assert _is_suspicious_char(0x200E) == "bidi"

    def test_is_suspicious_char_clean(self):
        assert _is_suspicious_char(ord("A")) is None

    def test_decode_tag_chars_empty(self):
        assert _decode_tag_chars([]) == ""
