"""Tests for Tru-Guard actions."""

import pytest

from trusys.actions import (
    ActionResolver,
    EncryptAction,
    FailAction,
    FilterAction,
    FixAction,
    IgnoreAction,
    MaskAction,
    get_action_for_type,
)
from trusys.exceptions import ActionConflictError, ScanFailedError
from trusys.types import (
    ActionType,
    ConflictStrategy,
    ScanResult,
    ScanType,
    Severity,
    Violation,
)


class TestFixAction:
    """Tests for FixAction."""

    def test_fix_with_suggested_fix_no_spans(self):
        """Test that suggested_fix is used when no violations have spans."""
        action = FixAction()
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            suggested_fix="fixed content",
            violations=[],  # No violations with spans
        )
        modified, should_continue = action.execute(result, "original content")
        assert modified == "fixed content"
        assert should_continue is True

    def test_fix_uses_suggested_fix(self):
        """Test that suggested_fix is used when present."""
        action = FixAction()
        violation = Violation(
            rule="test_rule",
            severity=Severity.HIGH,
            message="test",
            span=(5, 8),  # "bad" in "test bad content"
        )
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[violation],
            suggested_fix="test [CUSTOM_FIX] content",
        )
        modified, should_continue = action.execute(result, "test bad content")
        assert modified == "test [CUSTOM_FIX] content"
        assert should_continue is True

    def test_fix_returns_none_when_no_suggested_fix(self):
        """Test that fix returns None when no suggested_fix (no modification)."""
        action = FixAction()
        violation = Violation(
            rule="test_rule",
            severity=Severity.HIGH,
            message="test",
            span=(5, 8),  # "bad" in "test bad content"
        )
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[violation],
            # No suggested_fix
        )
        modified, should_continue = action.execute(result, "test bad content")
        assert modified is None
        assert should_continue is True

    def test_fix_no_violations_no_suggested_fix(self):
        """Test fix with no violations and no suggested_fix returns None."""
        action = FixAction()
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[],
        )
        modified, should_continue = action.execute(result, "content")
        assert modified is None
        assert should_continue is True

    def test_fix_no_spans_uses_suggested_fix(self):
        """Test fix with violations without spans uses suggested_fix."""
        action = FixAction()
        violation = Violation(
            rule="test",
            severity=Severity.HIGH,
            message="test",
            # No span
        )
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[violation],
            suggested_fix="manually fixed",
        )
        modified, should_continue = action.execute(result, "content")
        assert modified == "manually fixed"
        assert should_continue is True


class TestMaskAction:
    """Tests for MaskAction."""

    def test_mask_replaces_with_asterisks(self):
        """Test that mask action replaces violations with ****."""
        action = MaskAction()
        violation = Violation(
            rule="test",
            severity=Severity.HIGH,
            message="test",
            span=(5, 8),  # "bad"
        )
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified, should_continue = action.execute(result, "test bad content")
        assert modified == "test **** content"
        assert should_continue is True

    def test_mask_multiple_violations(self):
        """Test mask with multiple violations."""
        action = MaskAction()
        violations = [
            Violation(rule="test", severity=Severity.HIGH, message="t", span=(0, 3)),  # "bad"
            Violation(rule="test", severity=Severity.HIGH, message="t", span=(9, 13)),  # "ugly"
        ]
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=violations,
        )
        modified, should_continue = action.execute(result, "bad word ugly word")
        assert modified == "**** word **** word"
        assert should_continue is True

    def test_mask_no_spans_uses_suggested_fix(self):
        """Test mask with no spans falls back to suggested_fix if present."""
        action = MaskAction()
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[Violation(rule="x", severity=Severity.HIGH, message="x")],
            suggested_fix="masked content",
        )
        modified, _ = action.execute(result, "content")
        assert modified == "masked content"


class TestEncryptAction:
    """Tests for EncryptAction using FF1/FFX format-preserving encryption."""

    def test_encrypt_format_preserving(self):
        """Test that encrypt preserves format (digits, dashes) with PHONE_NUMBER entity."""
        action = EncryptAction()
        violation = Violation(
            rule="test",
            severity=Severity.HIGH,
            message="test",
            span=(0, 12),  # "555-123-4567"
            metadata={"entity_type": "PHONE_NUMBER", "matched_text": "555-123-4567"},
        )
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified, should_continue = action.execute(
            result, "555-123-4567 is my number"
        )
        encrypted_part = modified.split(" ")[0]
        assert len(encrypted_part) == 12
        assert encrypted_part[3] == "-"
        assert encrypted_part[7] == "-"
        assert encrypted_part[:3].isdigit()
        assert encrypted_part[4:7].isdigit()
        assert encrypted_part[8:].isdigit()
        assert should_continue is True

    def test_encrypt_preserves_letter_case(self):
        """Test that encrypt preserves letter case for PERSON entity type."""
        action = EncryptAction()
        violation = Violation(
            rule="test",
            severity=Severity.HIGH,
            message="test",
            span=(0, 10),  # "John Smith"
            metadata={"entity_type": "PERSON", "matched_text": "John Smith"},
        )
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified, _ = action.execute(result, "John Smith is here")
        encrypted_part = modified[:10]
        # Space preserved, letters encrypted
        assert encrypted_part[4] == " "
        assert all(c.isalpha() for c in encrypted_part if c != " ")

    def test_encrypt_consistent(self):
        """Test that encrypt is deterministic (same key gives same output)."""
        action = EncryptAction()
        violation = Violation(
            rule="test",
            severity=Severity.HIGH,
            message="test",
            span=(0, 10),
        )
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified1, _ = action.execute(result, "1234567890 test")
        modified2, _ = action.execute(result, "1234567890 test")
        assert modified1 == modified2

    def test_encrypt_no_spans_uses_suggested_fix(self):
        """Test encrypt with no spans uses suggested_fix if present."""
        action = EncryptAction()
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[Violation(rule="x", severity=Severity.HIGH, message="x")],
            suggested_fix="encrypted content",
        )
        modified, _ = action.execute(result, "content")
        assert modified == "encrypted content"

    def test_encrypt_phone_number_entity_type(self):
        """Test encryption with PHONE_NUMBER entity type uses digit alphabet."""
        action = EncryptAction()
        # "Call me at 212-555-5555 please"
        #  01234567890123456789...
        # "212-555-5555" starts at position 11 (after "Call me at ")
        violation = Violation(
            rule="pii_detected_phone_number",
            severity=Severity.HIGH,
            message="PII detected: PHONE_NUMBER",
            span=(11, 23),  # "212-555-5555"
            metadata={"entity_type": "PHONE_NUMBER", "matched_text": "212-555-5555"},
        )
        result = ScanResult(
            passed=False,
            scanner_name="pii",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified, _ = action.execute(result, "Call me at 212-555-5555 please")
        # Extract encrypted phone number
        encrypted = modified[11:23]
        assert encrypted[3] == "-"
        assert encrypted[7] == "-"
        # All non-dash chars should be digits
        digits_only = encrypted.replace("-", "")
        assert digits_only.isdigit()
        assert len(digits_only) == 10

    def test_encrypt_credit_card_entity_type(self):
        """Test encryption with CREDIT_CARD entity type."""
        action = EncryptAction()
        violation = Violation(
            rule="pii_detected_credit_card",
            severity=Severity.HIGH,
            message="PII detected: CREDIT_CARD",
            span=(0, 19),  # "4111-1111-1111-1111"
            metadata={"entity_type": "CREDIT_CARD", "matched_text": "4111-1111-1111-1111"},
        )
        result = ScanResult(
            passed=False,
            scanner_name="pii",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified, _ = action.execute(result, "4111-1111-1111-1111")
        # Format preserved: dashes at positions 4, 9, 14
        assert modified[4] == "-"
        assert modified[9] == "-"
        assert modified[14] == "-"
        # All digits encrypted but still digits
        digits_only = modified.replace("-", "")
        assert digits_only.isdigit()

    def test_encrypt_email_preserves_structure(self):
        """Test encryption of EMAIL_ADDRESS preserves @ and domain structure."""
        action = EncryptAction()
        violation = Violation(
            rule="pii_detected_email_address",
            severity=Severity.HIGH,
            message="PII detected: EMAIL_ADDRESS",
            span=(0, 16),  # "test@example.com"
            metadata={"entity_type": "EMAIL_ADDRESS", "matched_text": "test@example.com"},
        )
        result = ScanResult(
            passed=False,
            scanner_name="pii",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified, _ = action.execute(result, "test@example.com")
        # @ symbol preserved
        assert "@" in modified
        # Dot in domain preserved
        parts = modified.split("@")
        assert len(parts) == 2
        assert "." in parts[1]

    def test_encrypt_ssn_entity_type(self):
        """Test encryption with US_SSN entity type."""
        action = EncryptAction()
        violation = Violation(
            rule="pii_detected_us_ssn",
            severity=Severity.HIGH,
            message="PII detected: US_SSN",
            span=(0, 11),  # "123-45-6789"
            metadata={"entity_type": "US_SSN", "matched_text": "123-45-6789"},
        )
        result = ScanResult(
            passed=False,
            scanner_name="pii",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified, _ = action.execute(result, "123-45-6789")
        # Dashes preserved at positions 3 and 6
        assert modified[3] == "-"
        assert modified[6] == "-"
        # All non-dash chars are digits
        digits_only = modified.replace("-", "")
        assert digits_only.isdigit()
        assert len(digits_only) == 9


class TestEncryptDecryptRoundTrip:
    """Tests for FF1/FFX encryption reversibility."""

    def test_decrypt_reverses_encrypt(self):
        """Test that decrypt(encrypt(text)) returns original text."""
        from trusys.actions.builtin import _encrypt_text, decrypt_text, _get_fpe_key
        
        key = _get_fpe_key()
        original = "1234567890"
        encrypted = _encrypt_text(original, key=key)
        decrypted = decrypt_text(encrypted, key=key)
        assert decrypted == original

    def test_decrypt_phone_number(self):
        """Test decrypt round-trip for phone number."""
        from trusys.actions.builtin import _encrypt_text, decrypt_text, _get_fpe_key
        
        key = _get_fpe_key()
        original = "212-555-5555"
        encrypted = _encrypt_text(original, key=key, entity_type="PHONE_NUMBER")
        decrypted = decrypt_text(encrypted, key=key, entity_type="PHONE_NUMBER")
        assert decrypted == original

    def test_decrypt_email(self):
        """Test decrypt round-trip for email address."""
        from trusys.actions.builtin import _encrypt_text, decrypt_text, _get_fpe_key
        
        key = _get_fpe_key()
        original = "test@example.com"
        encrypted = _encrypt_text(original, key=key, entity_type="EMAIL_ADDRESS")
        decrypted = decrypt_text(encrypted, key=key, entity_type="EMAIL_ADDRESS")
        assert decrypted == original

    def test_decrypt_ssn(self):
        """Test decrypt round-trip for SSN."""
        from trusys.actions.builtin import _encrypt_text, decrypt_text, _get_fpe_key
        
        key = _get_fpe_key()
        original = "123-45-6789"
        encrypted = _encrypt_text(original, key=key, entity_type="US_SSN")
        decrypted = decrypt_text(encrypted, key=key, entity_type="US_SSN")
        assert decrypted == original

    def test_decrypt_person_name(self):
        """Test decrypt round-trip for person name."""
        from trusys.actions.builtin import _encrypt_text, decrypt_text, _get_fpe_key
        
        key = _get_fpe_key()
        original = "John Smith"
        encrypted = _encrypt_text(original, key=key, entity_type="PERSON")
        decrypted = decrypt_text(encrypted, key=key, entity_type="PERSON")
        assert decrypted == original


class TestFilterAction:
    """Tests for FilterAction."""

    def test_filter_with_span(self):
        action = FilterAction(replacement="[REDACTED]")
        violation = Violation(
            rule="test",
            severity=Severity.HIGH,
            message="test",
            span=(5, 8),
        )
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified, should_continue = action.execute(result, "test bad content")
        assert modified == "test [REDACTED] content"
        assert should_continue is True

    def test_filter_multiple_violations(self):
        action = FilterAction(replacement="***")
        violations = [
            Violation(rule="test", severity=Severity.HIGH, message="t", span=(0, 3)),
            Violation(rule="test", severity=Severity.HIGH, message="t", span=(8, 12)),
        ]
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=violations,
        )
        modified, should_continue = action.execute(result, "bad word here word")
        # Violations sorted in reverse order, so positions stay valid
        assert "***" in modified
        assert should_continue is True

    def test_filter_no_spans(self):
        """Without span or matched_text there is nothing to replace, so no modification."""
        action = FilterAction(replacement="[FILTERED]")
        violation = Violation(
            rule="test",
            severity=Severity.HIGH,
            message="test",
        )
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        modified, should_continue = action.execute(result, "content")
        assert modified is None
        assert should_continue is True

    def test_filter_duplicate_matched_text_uses_spans(self):
        """When the same text appears in multiple spans, each span is replaced by position not by first occurrence."""
        action = FilterAction(replacement="[X]")
        # "32" appears at index 7 and at index 17 - replace by span so both get replaced correctly
        content = "prefix 32 middle 32 suffix"
        violations = [
            Violation(rule="a", severity=Severity.HIGH, message="first", span=(7, 9)),   # first "32"
            Violation(rule="b", severity=Severity.HIGH, message="second", span=(17, 19)),  # second "32"
        ]
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=violations,
        )
        modified, _ = action.execute(result, content)
        assert modified == "prefix [X] middle [X] suffix"

    def test_filter_overlapping_spans_inner_skipped(self):
        """When one span contains another (e.g. URL inside email), only the outer span is replaced."""
        action = FilterAction(replacement="[EMAIL]")
        content = "Contact: john@example.com today"
        # Email 9-25 ("john@example.com"); URL "example.com" at 14-25. Replace by span right-to-left:
        # replace email 9-25 first, then URL 14-25 is inside replaced -> skip
        violations = [
            Violation(rule="email", severity=Severity.HIGH, message="e", span=(9, 25), metadata={"matched_text": "john@example.com"}),
            Violation(rule="url", severity=Severity.HIGH, message="u", span=(14, 25), metadata={"matched_text": "example.com"}),
        ]
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=violations,
        )
        modified, _ = action.execute(result, content)
        assert modified == "Contact: [EMAIL] today"


class TestFailAction:
    """Tests for FailAction."""

    def test_fail_raises_exception(self):
        action = FailAction()
        violation = Violation(
            rule="test",
            severity=Severity.HIGH,
            message="test violation",
        )
        result = ScanResult(
            passed=False,
            scanner_name="test_scanner",
            scan_type=ScanType.INPUT,
            violations=[violation],
        )
        with pytest.raises(ScanFailedError) as exc_info:
            action.execute(result, "content")

        assert exc_info.value.scanner_name == "test_scanner"
        assert len(exc_info.value.violations) == 1


class TestIgnoreAction:
    """Tests for IgnoreAction."""

    def test_ignore_passes_through(self):
        action = IgnoreAction()
        result = ScanResult(
            passed=False,
            scanner_name="test",
            scan_type=ScanType.INPUT,
            violations=[
                Violation(rule="test", severity=Severity.HIGH, message="test")
            ],
        )
        modified, should_continue = action.execute(result, "content")
        assert modified is None
        assert should_continue is True


class TestGetActionForType:
    """Tests for get_action_for_type function."""

    def test_get_fix_action(self):
        action = get_action_for_type(ActionType.FIX)
        assert isinstance(action, FixAction)

    def test_get_filter_action(self):
        action = get_action_for_type(ActionType.FILTER)
        assert isinstance(action, FilterAction)

    def test_get_fail_action(self):
        action = get_action_for_type(ActionType.FAIL)
        assert isinstance(action, FailAction)

    def test_get_ignore_action(self):
        action = get_action_for_type(ActionType.IGNORE)
        assert isinstance(action, IgnoreAction)

    def test_get_mask_action(self):
        action = get_action_for_type(ActionType.MASK)
        assert isinstance(action, MaskAction)

    def test_get_encrypt_action(self):
        action = get_action_for_type(ActionType.ENCRYPT)
        assert isinstance(action, EncryptAction)

    def test_custom_raises_error(self):
        with pytest.raises(ValueError):
            get_action_for_type(ActionType.CUSTOM)


class TestActionResolver:
    """Tests for ActionResolver."""

    def test_resolve_single_action(self):
        resolver = ActionResolver()
        actions = [("scanner1", ActionType.FIX, None)]
        resolved_list = resolver.resolve(actions)
        assert len(resolved_list) == 1
        name, action_type, data = resolved_list[0]
        assert name == "scanner1"
        assert action_type == ActionType.FIX

    def test_resolve_empty_actions(self):
        resolver = ActionResolver()
        resolved_list = resolver.resolve([])
        assert resolved_list == []

    def test_priority_strategy_fail_wins(self):
        resolver = ActionResolver(strategy=ConflictStrategy.PRIORITY)
        actions = [
            ("scanner1", ActionType.FIX, None),
            ("scanner2", ActionType.FAIL, None),  # Higher priority
            ("scanner3", ActionType.IGNORE, None),
        ]
        resolved_list = resolver.resolve(actions)
        assert len(resolved_list) == 1
        assert resolved_list[0][1] == ActionType.FAIL

    def test_priority_strategy_apply_all_when_no_fail(self):
        resolver = ActionResolver(strategy=ConflictStrategy.PRIORITY)
        actions = [
            ("scanner1", ActionType.FILTER, None),
            ("scanner2", ActionType.FIX, None),
        ]
        resolved_list = resolver.resolve(actions)
        # No FAIL: all non-IGNORE actions returned, sorted by priority (FIX then FILTER)
        assert len(resolved_list) == 2
        assert resolved_list[0][1] == ActionType.FIX
        assert resolved_list[1][1] == ActionType.FILTER

    def test_first_wins_strategy(self):
        resolver = ActionResolver(strategy=ConflictStrategy.FIRST_WINS)
        actions = [
            ("scanner1", ActionType.FIX, None),
            ("scanner2", ActionType.FAIL, None),
        ]
        resolved_list = resolver.resolve(actions)
        assert len(resolved_list) == 1
        name, action_type, data = resolved_list[0]
        assert name == "scanner1"
        assert action_type == ActionType.FIX

    def test_raise_error_strategy_with_conflict(self):
        resolver = ActionResolver(strategy=ConflictStrategy.RAISE_ERROR)
        actions = [
            ("scanner1", ActionType.FIX, None),
            ("scanner2", ActionType.FAIL, None),
        ]
        with pytest.raises(ActionConflictError):
            resolver.resolve(actions)

    def test_raise_error_strategy_same_types(self):
        resolver = ActionResolver(strategy=ConflictStrategy.RAISE_ERROR)
        actions = [
            ("scanner1", ActionType.FIX, None),
            ("scanner2", ActionType.FIX, None),
        ]
        # Should not raise - same action types
        resolved_list = resolver.resolve(actions)
        assert len(resolved_list) == 1
        assert resolved_list[0][1] == ActionType.FIX

    def test_ignore_actions_filtered(self):
        resolver = ActionResolver(strategy=ConflictStrategy.PRIORITY)
        actions = [
            ("scanner1", ActionType.IGNORE, None),
            ("scanner2", ActionType.IGNORE, None),
        ]
        resolved_list = resolver.resolve(actions)
        # Returns single IGNORE when all are IGNORE
        assert len(resolved_list) == 1
        assert resolved_list[0][1] == ActionType.IGNORE

    def test_resolve_fixes_chain(self):
        resolver = ActionResolver()
        fixes = [
            ("scanner1", "fix1"),
            ("scanner2", "fix2"),
        ]
        result = resolver.resolve_fixes(fixes, "original", merge_strategy="chain")
        assert result == "fix2"  # Last fix wins in chain

    def test_resolve_fixes_first(self):
        resolver = ActionResolver()
        fixes = [
            ("scanner1", "fix1"),
            ("scanner2", "fix2"),
        ]
        result = resolver.resolve_fixes(fixes, "original", merge_strategy="first")
        assert result == "fix1"
