"""Unit tests for secure token handling (Story 3.8).

Tests that registry tokens are never exposed in logs, repr, or error messages.
"""

import io
import logging
import sys
import pytest
from mca_sdk.config.settings import MCAConfig
from mca_sdk.utils.exceptions import RegistryError, ConfigurationError


class TestTokenMaskingInRepr:
    """Test that tokens are masked in __repr__ output (AC#3)."""

    def test_repr_masks_full_token(self):
        """Token should be masked in repr, showing only last 4 chars."""
        config = MCAConfig(service_name="test-service")
        # Set token via environment (the only valid way)
        config.registry_token = "secret-token-1234567890"

        repr_str = repr(config)

        # Token should be masked
        assert "secret-token-1234567890" not in repr_str
        assert "***7890" in repr_str or "****" in repr_str

    def test_repr_masks_short_token(self):
        """Short tokens (<= 4 chars) should be fully masked."""
        config = MCAConfig(service_name="test-service")
        config.registry_token = "abc"

        repr_str = repr(config)

        # Token should be fully masked (not long enough to show last 4)
        assert "abc" not in repr_str
        assert "****" in repr_str

    def test_repr_handles_none_token(self):
        """None token should not cause errors in repr."""
        config = MCAConfig(service_name="test-service")
        config.registry_token = None

        repr_str = repr(config)

        # Should contain registry_token=None
        assert "registry_token=None" in repr_str

    def test_str_masks_token(self):
        """__str__ should mask tokens consistently with __repr__."""
        config = MCAConfig(service_name="test-service")
        config.registry_token = "secret-token-1234567890"

        str_repr = str(config)

        # Token should be masked in str() output
        assert "secret-token-1234567890" not in str_repr
        assert "***7890" in str_repr or "****" in str_repr

        # str() and repr() should produce the same masked output
        assert str(config) == repr(config)


class TestTokenNotInLogs:
    """Test that tokens are not logged during startup (AC#2)."""

    def test_filter_automatically_applied_on_config_creation(self, caplog):
        """TokenSanitizingFilter should be automatically applied when token is configured."""
        import logging
        from mca_sdk.config.settings import TokenSanitizingFilter

        # Remove stale TokenSanitizingFilter instances from previous tests
        # (the mca_sdk logger is a singleton that persists between tests)
        sdk_logger = logging.getLogger("mca_sdk")
        sdk_logger.filters = [
            f for f in sdk_logger.filters if not isinstance(f, TokenSanitizingFilter)
        ]

        # Create config with token - should automatically apply filter
        config = MCAConfig(service_name="test-service", model_id="test-model")
        config.registry_token = "auto-applied-token-xyz"

        # Verify filter was applied to mca_sdk logger
        filter_found = False
        for f in sdk_logger.filters:
            if isinstance(f, TokenSanitizingFilter):
                filter_found = True
                break

        assert (
            filter_found
        ), "TokenSanitizingFilter should be automatically applied to mca_sdk logger"

        # Verify the filter actually works by logging from SDK logger
        with caplog.at_level(logging.INFO, logger="mca_sdk"):
            sdk_logger.info("Testing with token: auto-applied-token-xyz")

        # Token should be masked in logs
        assert "auto-applied-token-xyz" not in caplog.text
        assert "***-xyz" in caplog.text

    def test_token_not_logged_at_config_creation(self, caplog):
        """Token should not appear in logs when config is created."""
        with caplog.at_level(logging.DEBUG):
            config = MCAConfig(service_name="test-service", model_id="test-model")
            config.registry_token = "secret-token-1234567890"

            # Simulate logging that might occur
            logging.info(f"Config created: {config!r}")

        # Check all log messages
        full_log = caplog.text
        assert "secret-token-1234567890" not in full_log

    def test_token_not_in_debug_logs(self, caplog):
        """Token should not appear even in debug-level logs."""
        with caplog.at_level(logging.DEBUG):
            config = MCAConfig(
                service_name="test-service", registry_url="https://registry.example.com"
            )
            config.registry_token = "debug-secret-token"

            logging.debug(f"Configuration: {config}")

        assert "debug-secret-token" not in caplog.text

    def test_logging_filter_sanitizes_direct_token_logs(self, caplog):
        """Logging filter should protect against direct token logging."""
        from mca_sdk.config.settings import TokenSanitizingFilter

        # Create filter and register token
        filter = TokenSanitizingFilter()
        token = "direct-logged-token-xyz"
        filter.set_token(token)

        # Add filter to logger
        logger = logging.getLogger("test_logger")
        logger.addFilter(filter)

        try:
            with caplog.at_level(logging.INFO, logger="test_logger"):
                # This is the security risk: developer logs token directly
                logger.info(f"Using token: {token}")

            # Token should be sanitized even though it was logged directly
            assert token not in caplog.text
            assert "***-xyz" in caplog.text
        finally:
            logger.removeFilter(filter)

    def test_logging_filter_sanitizes_formatted_logs(self, caplog):
        """Logging filter should sanitize tokens in % formatted logs."""
        from mca_sdk.config.settings import TokenSanitizingFilter

        filter = TokenSanitizingFilter()
        token = "formatted-token-abc"
        filter.set_token(token)

        logger = logging.getLogger("test_logger2")
        logger.addFilter(filter)

        try:
            with caplog.at_level(logging.INFO, logger="test_logger2"):
                # Old-style % formatting
                logger.info("Token is: %s", token)

            assert token not in caplog.text
            assert "***-abc" in caplog.text
        finally:
            logger.removeFilter(filter)


class TestTokenNotInErrorMessages:
    """Test that tokens are sanitized from exception messages (AC#1)."""

    def test_configuration_error_masks_token(self):
        """Configuration errors should not expose token values."""
        # This test will be enhanced after implementing sanitization helper
        config = MCAConfig(service_name="test-service")
        config.registry_token = "error-test-token-xyz"

        # Simulate an error that might include config details
        error_msg = f"Failed to validate config: {config!r}"

        assert "error-test-token-xyz" not in error_msg

    def test_registry_error_does_not_leak_token(self):
        """Registry errors should not contain token values."""
        # Test that will be implemented with registry client enhancements
        token = "registry-error-token-123"

        # Simulate a registry error message
        # This would be expanded once we implement error sanitization
        error = RegistryError("Registry request failed")

        assert token not in str(error)


class TestSensitiveString:
    """Test that SensitiveString prevents token exposure in tracebacks."""

    def test_sensitive_string_repr_masks_value(self):
        """SensitiveString repr should mask the value for safe display."""
        from mca_sdk.config.settings import SensitiveString

        token = SensitiveString("secret-token-xyz123")

        # repr should mask the value
        assert "secret-token-xyz123" not in repr(token)
        assert "***z123" in repr(token) or "SensitiveString" in repr(token)

    def test_sensitive_string_str_returns_masked_value(self):
        """SensitiveString str() should return masked value to prevent exposure."""
        from mca_sdk.config.settings import SensitiveString

        token = SensitiveString("secret-token-xyz123")

        # str() should return masked value (no bypass via str() cast)
        assert str(token) == "***z123"
        assert "secret-token-xyz123" not in str(token)

    def test_sensitive_string_callback_pattern(self):
        """SensitiveString __unsafe_process_value() uses callback pattern for safe access."""
        from mca_sdk.config.settings import SensitiveString

        token = SensitiveString("secret-token-xyz123")

        # Callback pattern: secret passed to processor, never returned to calling frame
        result = token._SensitiveString__unsafe_process_value(
            lambda token_val: f"Processed: {token_val}"
        )
        assert result == "Processed: secret-token-xyz123"

    def test_sensitive_string_callback_bearer_header(self):
        """SensitiveString callback pattern works for building auth headers."""
        from mca_sdk.config.settings import SensitiveString

        token = SensitiveString("test-token-abc123")

        # Use callback to build header - token never in caller's variables
        header = token._SensitiveString__unsafe_process_value(
            lambda token_val: {"Authorization": f"Bearer {token_val}"}
        )
        assert header == {"Authorization": "Bearer test-token-abc123"}
        assert isinstance(header, dict)

    def test_sensitive_string_callback_prevents_traceback_leak(self):
        """Callback pattern ensures token doesn't leak in traceback of CALLING frame."""
        from mca_sdk.config.settings import SensitiveString
        import traceback

        token = SensitiveString("should-not-appear-xyz")

        try:
            # Use callback pattern
            _ = token._SensitiveString__unsafe_process_value(
                lambda t: {"Authorization": f"Bearer {t}"}
            )
            # Token was never assigned to variable in this frame
            # So if we raise here, traceback won't show token
            raise RuntimeError("Test exception in calling frame")
        except RuntimeError as e:
            tb_str = "".join(traceback.format_exception(type(e), e, e.__traceback__))
            # Token should NOT appear in traceback of this frame
            assert "should-not-appear-xyz" not in tb_str

    def test_sensitive_string_in_traceback(self):
        """When SensitiveString appears in traceback, it should be masked."""
        from mca_sdk.config.settings import SensitiveString
        import traceback

        token = SensitiveString("traceback-secret-xyz")

        try:
            # Simulate passing token as argument (common traceback leak)
            def function_with_token_arg(auth_token):
                raise ValueError(f"Auth failed for token: {auth_token!r}")

            function_with_token_arg(token)
        except ValueError as e:
            # Get traceback as string
            tb_str = "".join(traceback.format_exception(type(e), e, e.__traceback__))

            # Token should be masked in traceback
            assert "traceback-secret-xyz" not in tb_str
            assert "***xyz" in tb_str or "SensitiveString" in tb_str

    def test_config_wraps_token_in_sensitive_string(self):
        """MCAConfig should automatically wrap registry_token in SensitiveString."""
        from mca_sdk.config.settings import SensitiveString

        # Test that __setattr__ automatically wraps plain strings
        config = MCAConfig(service_name="test")
        config.registry_token = "plain-token-abc"

        # Verify token is now a SensitiveString
        assert isinstance(config.registry_token, SensitiveString)

        # Verify we can access the actual value via callback pattern
        actual = config.registry_token._SensitiveString__unsafe_process_value(lambda t: t)
        assert actual == "plain-token-abc"

        # Verify str() returns masked value
        assert str(config.registry_token) == "***-abc"

        # Test wrapping on construction
        config2 = MCAConfig(service_name="test", model_id="test")
        config2.registry_token = "another-token"
        assert isinstance(config2.registry_token, SensitiveString)
        actual2 = config2.registry_token._SensitiveString__unsafe_process_value(lambda t: t)
        assert actual2 == "another-token"

    def test_sensitive_string_handles_empty(self):
        """SensitiveString should handle None and empty strings without crashing."""
        from mca_sdk.config.settings import SensitiveString

        # Empty string
        empty = SensitiveString("")
        assert str(empty) == "<empty>"
        assert repr(empty) == "SensitiveString(<empty>)"
        actual = empty._SensitiveString__unsafe_process_value(lambda t: t)
        assert actual == ""

        # None value
        none_val = SensitiveString(None)
        assert str(none_val) == "<empty>"
        assert repr(none_val) == "SensitiveString(<empty>)"

        # Very short string
        short = SensitiveString("ab")
        assert str(short) == "****"
        assert repr(short) == "SensitiveString(****)"
        actual_short = short._SensitiveString__unsafe_process_value(lambda t: t)
        assert actual_short == "ab"

    def test_sensitive_string_prevents_comparison(self):
        """SensitiveString should prevent equality comparison to avoid timing attacks."""
        from mca_sdk.config.settings import SensitiveString
        import pytest

        token1 = SensitiveString("secret-abc")
        token2 = SensitiveString("secret-abc")

        # Test that equality comparison raises TypeError with informative message
        with pytest.raises(TypeError, match="disabled for security reasons"):
            _ = token1 == token2

        with pytest.raises(TypeError, match="disabled for security reasons"):
            _ = token1 != token2

        # Test with plain string also raises
        with pytest.raises(TypeError, match="disabled for security reasons"):
            _ = token1 == "secret-abc"

    def test_sensitive_string_prevents_hashing(self):
        """SensitiveString should not be hashable to prevent fingerprinting."""
        from mca_sdk.config.settings import SensitiveString
        import pytest

        token = SensitiveString("secret-abc")

        # Test that hashing raises TypeError
        with pytest.raises(TypeError, match="unhashable"):
            _ = hash(token)

        # Test that it cannot be used in set/dict
        with pytest.raises(TypeError):
            _ = {token}

        with pytest.raises(TypeError):
            _ = {token: "value"}


class TestTokenSanitizationHelper:
    """Test the token sanitization utility function."""

    def test_sanitize_token_in_message(self):
        """Sanitize function should replace token with masked version."""
        from mca_sdk.config.settings import sanitize_token

        message = "Error connecting with token: secret-token-1234567890"
        sanitized = sanitize_token(message, "secret-token-1234567890")

        assert "secret-token-1234567890" not in sanitized
        assert "***7890" in sanitized

    def test_sanitize_token_handles_none(self):
        """Sanitize should handle None token gracefully."""
        from mca_sdk.config.settings import sanitize_token

        message = "Error occurred"
        sanitized = sanitize_token(message, None)

        assert sanitized == message  # Unchanged

    def test_sanitize_token_not_present(self):
        """Sanitize should not modify message if token not present."""
        from mca_sdk.config.settings import sanitize_token

        message = "Error occurred without token"
        sanitized = sanitize_token(message, "some-token")

        assert sanitized == message  # Unchanged

    def test_sanitize_multiple_occurrences(self):
        """Sanitize should replace all occurrences of token."""
        from mca_sdk.config.settings import sanitize_token

        token = "repeat-token-xyz"
        message = f"Token {token} was used, and {token} failed"
        sanitized = sanitize_token(message, token)

        assert token not in sanitized
        # Token "repeat-token-xyz" (16 chars) has last 4 chars as "-xyz", so masked version is "***-xyz"
        assert sanitized.count("***-xyz") == 2


class TestRegistryClientTokenHandling:
    """Test that RegistryClient properly handles token security."""

    def test_registry_client_auto_wraps_plain_string_token(self):
        """RegistryClient should auto-wrap plain string tokens in SensitiveString."""
        from mca_sdk.registry.client import RegistryClient
        from mca_sdk.config.settings import SensitiveString
        import warnings

        # Capture deprecation warning
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")

            # Pass plain string token (should auto-wrap with warning)
            client = RegistryClient(
                url="https://registry.example.com", token="plain-string-token-xyz"
            )

            # Verify deprecation warning was emitted
            assert len(w) == 1
            assert issubclass(w[0].category, DeprecationWarning)
            assert "plain string tokens" in str(w[0].message).lower()

            # Verify token was wrapped
            assert isinstance(client._token, SensitiveString)

            # Verify wrapped token works correctly
            assert str(client._token) == "***-xyz"

    def test_registry_client_accepts_sensitive_string_token(self):
        """RegistryClient should accept SensitiveString tokens without warning."""
        from mca_sdk.registry.client import RegistryClient
        from mca_sdk.config.settings import SensitiveString
        import warnings

        token = SensitiveString("sensitive-token-abc")

        # Should NOT emit deprecation warning
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")

            client = RegistryClient(url="https://registry.example.com", token=token)

            # No warnings should be emitted
            assert len(w) == 0

            # Verify token was not re-wrapped
            assert client._token is token


class TestSecurityConstraints:
    """Test that security constraints prevent token exposure (existing)."""

    def test_token_not_allowed_in_kwargs(self):
        """registry_token cannot be passed via kwargs (existing security check)."""
        with pytest.raises(ConfigurationError, match="Security violation.*kwargs"):
            MCAConfig.load(service_name="test", registry_token="should-fail")

    def test_token_not_allowed_in_yaml(self, tmp_path):
        """registry_token cannot be loaded from YAML file (existing security check)."""
        yaml_file = tmp_path / "config.yaml"
        yaml_file.write_text("""
service_name: test-service
registry_token: should-fail
""")

        with pytest.raises(ConfigurationError, match="Security violation.*YAML"):
            MCAConfig.from_file(str(yaml_file))

    def test_token_variants_blocked_in_yaml(self, tmp_path):
        """YAML validation should block any sensitive pattern (token, secret, key, etc.)."""
        # Test hyphenated variant
        yaml_file1 = tmp_path / "config1.yaml"
        yaml_file1.write_text("""
service_name: test-service
registry-token: should-fail-hyphenated
""")

        with pytest.raises(ConfigurationError, match="Security violation.*token"):
            MCAConfig.from_file(str(yaml_file1))

        # Test api_key variant
        yaml_file2 = tmp_path / "config2.yaml"
        yaml_file2.write_text("""
service_name: test-service
api_key: should-fail-api-key
""")

        with pytest.raises(ConfigurationError, match="Security violation.*key"):
            MCAConfig.from_file(str(yaml_file2))

        # Test secret variant
        yaml_file3 = tmp_path / "config3.yaml"
        yaml_file3.write_text("""
service_name: test-service
auth_secret: should-fail-secret
""")

        with pytest.raises(ConfigurationError, match="Security violation.*(secret|auth)"):
            MCAConfig.from_file(str(yaml_file3))
