"""Security validation tests for MCAConfig.

This module tests security scenarios and attack vectors to ensure
the configuration system is hardened against malicious input.

Tests cover:
- Path traversal attacks in persist_path
- YAML injection attacks (!!python/object)
- Resource exhaustion (huge config files)
- Sensitive data leakage in logs
"""

import logging
import os
import pytest

from mca_sdk.config.settings import MCAConfig, SensitiveString
from mca_sdk.utils.exceptions import ConfigurationError


class TestPathTraversalPrevention:
    """Test protection against path traversal attacks in persist_path."""

    def test_persist_path_rejects_path_traversal(self):
        """Test persist_path rejects path traversal attacks.

        Security Rule: Paths resolving outside whitelisted directories are rejected
        to prevent path traversal attacks that could expose sensitive files like /etc/passwd.

        Story 3.15 Fix: Path traversal validation now uses comprehensive canonicalization
        with os.path.commonpath() checks. Paths like "/tmp/../etc/passwd" resolve to
        "/etc/passwd" which is caught by either sensitive directory checks or whitelist validation.
        """
        with pytest.raises(ConfigurationError, match="allowed directories"):
            MCAConfig(
                service_name="test",
                persist_queue=True,
                persist_path="/tmp/../etc/passwd",
            )

    def test_persist_path_with_user_home_directory(self):
        """Test persist_path with ~ expands correctly."""
        config = MCAConfig(
            service_name="test", persist_queue=True, persist_path="~/.mca_sdk/queue.pkl"
        )
        # Config stores path as-is; expansion happens at use time
        assert "~" in config.persist_path

    def test_persist_path_absolute_vs_relative(self):
        """Test persist_path validation with absolute and relative paths.

        Story 3.15: Enhanced security now validates that paths resolve to
        whitelisted directories, regardless of whether they're absolute or relative.
        """
        # Absolute path in whitelisted directory should work
        config_abs = MCAConfig(
            service_name="test",
            persist_queue=True,
            persist_path="/var/lib/mca/queue.pkl",
        )
        assert os.path.isabs(config_abs.persist_path)

        # Relative path that resolves outside whitelisted directories should be rejected
        with pytest.raises(ConfigurationError, match="must be within allowed directories"):
            MCAConfig(service_name="test", persist_queue=True, persist_path="./queue.pkl")

    def test_vendor_bridge_state_file_path_traversal_rejected(self):
        """Test vendor_bridge_state_file rejects path traversal."""
        # Path resolves to /etc/passwd which is caught by either:
        # - Unauthorized (not in whitelist) OR
        # - Sensitive directory check
        with pytest.raises(ConfigurationError, match="allowed directories|sensitive"):
            MCAConfig(service_name="test", vendor_bridge_state_file="/tmp/../etc/passwd")

    def test_vendor_bridge_state_file_safe_path_accepted(self):
        """Test vendor_bridge_state_file accepts safe paths."""
        config = MCAConfig(
            service_name="test", vendor_bridge_state_file="~/.mca_sdk/vendor_state.json"
        )
        assert "~" in config.vendor_bridge_state_file


class TestYAMLInjectionPrevention:
    """Test protection against YAML injection attacks."""

    def test_yaml_python_object_injection_blocked(self, tmp_path):
        """Test that !!python/object YAML tag is blocked by safe_load."""
        config_file = tmp_path / "malicious.yaml"
        config_file.write_text("""
service_name: test
malicious: !!python/object/apply:os.system
  args: ['echo hacked']
""")

        # yaml.safe_load should reject !!python/object tags
        with pytest.raises(ConfigurationError, match="Invalid YAML"):
            MCAConfig.from_file(str(config_file))

    def test_yaml_python_module_injection_blocked(self, tmp_path):
        """Test that !!python/module YAML tag is blocked."""
        config_file = tmp_path / "malicious.yaml"
        config_file.write_text("""
service_name: test
malicious: !!python/module:os
""")

        with pytest.raises(ConfigurationError, match="Invalid YAML"):
            MCAConfig.from_file(str(config_file))

    def test_yaml_arbitrary_code_execution_blocked(self, tmp_path):
        """Test that arbitrary code execution via YAML is blocked."""
        config_file = tmp_path / "malicious.yaml"
        config_file.write_text("""
service_name: test
code: !!python/object/new:os.system [echo pwned]
""")

        with pytest.raises(ConfigurationError, match="Invalid YAML"):
            MCAConfig.from_file(str(config_file))

    def test_yaml_safe_tags_are_allowed(self, tmp_path):
        """Test that safe YAML tags like !!str and !!int work correctly."""
        config_file = tmp_path / "safe.yaml"
        config_file.write_text("""
service_name: !!str test-service
max_queue_size: !!int 1000
""")

        config = MCAConfig.from_file(str(config_file))
        assert config.service_name == "test-service"
        assert config.max_queue_size == 1000

    def test_yaml_anchors_and_aliases_work(self, tmp_path):
        """Test that YAML anchors and aliases work correctly.

        YAML anchors (&) and aliases (*) allow referencing values, which is
        safe with yaml.safe_load() as it only duplicates data, not code.
        """
        config_file = tmp_path / "anchors.yaml"
        config_file.write_text("""
service_name: &service test-service
model_id: *service
team_name: ml-team
""")

        config = MCAConfig.from_file(str(config_file))
        assert config.service_name == "test-service"
        assert config.model_id == "test-service"  # Aliased from service_name
        assert config.team_name == "ml-team"


class TestResourceExhaustion:
    """Test protection against resource exhaustion attacks."""

    def test_large_yaml_file_loads_successfully(self, tmp_path):
        """Test that reasonably large YAML files load successfully.

        Note: This is NOT a DoS protection test (that's handled at infrastructure layer).
        This tests that legitimate large configs work correctly.
        """
        config_file = tmp_path / "large.yaml"
        content = "service_name: test\n"
        # Add many config fields (but still reasonable)
        for i in range(100):
            content += f"# Comment {i}: This is metadata\n"
        content += "model_id: mdl-large-config\n"
        config_file.write_text(content)

        config = MCAConfig.from_file(str(config_file))
        assert config.service_name == "test"
        assert config.model_id == "mdl-large-config"

    def test_extremely_long_service_name(self):
        """Test that extremely long service names are accepted (no arbitrary length limit).

        Why: Service names should not have arbitrary length limits in config.
        Telemetry attribute limits are enforced at export time, not config time.
        """
        long_name = "x" * 10000
        config = MCAConfig(service_name=long_name)
        assert config.service_name == long_name

    def test_very_large_yaml_file_with_memory_limits(self, tmp_path):
        """Test that very large YAML files load successfully without memory errors.

        This tests handling of legitimate large configs (1MB+) that might be
        generated programmatically or include extensive metadata.

        Note: This is NOT DoS protection (handled at infrastructure layer).
        This validates SDK can handle legitimate large configs in production.
        """
        config_file = tmp_path / "very_large.yaml"
        content = "service_name: test\n"

        # Generate ~1MB of config data with many fields
        # Use unique field names to test parser memory handling
        for i in range(5000):
            content += f"# Metadata entry {i}: {'x' * 100}\n"

        # Add valid config fields
        content += "model_id: mdl-very-large\n"
        content += "team_name: large-config-team\n"
        content += "max_queue_size: 5000\n"

        config_file.write_text(content)

        # Should load without memory errors
        config = MCAConfig.from_file(str(config_file))
        assert config.service_name == "test"
        assert config.model_id == "mdl-very-large"
        assert config.max_queue_size == 5000

    def test_many_configuration_fields(self):
        """Test that config with many fields loads correctly.

        Why: Validates no arbitrary limits on number of fields in config dict.
        """
        config_dict = {
            "service_name": "test",
            "model_id": "mdl-001",
            "model_version": "1.0.0",
            "team_name": "ml-team",
            "model_type": "regression",
            "model_category": "internal",
            "collector_endpoint": "http://localhost:4318",
            "collector_timeout": 10,
            "buffering_enabled": True,
            "max_queue_size": 1000,
            "retry_attempts": 3,
            "base_delay": 1.0,
            "max_delay": 30.0,
            "backoff_multiplier": 2.0,
        }
        config = MCAConfig.from_dict(config_dict)
        assert config.service_name == "test"


class TestSensitiveDataLeakage:
    """Test protection against sensitive data leakage in logs and errors."""

    def test_registry_token_not_in_repr(self):
        """Test that registry_token does not appear in repr() output."""
        config = MCAConfig(service_name="test")
        config.registry_token = "super-secret-token-12345"
        repr_output = repr(config)
        assert "super-secret-token-12345" not in repr_output
        assert "***" in repr_output  # Should show masked version

    def test_registry_token_not_in_str(self):
        """Test that registry_token does not appear in str() output."""
        config = MCAConfig(service_name="test")
        config.registry_token = "super-secret-token-12345"
        str_output = str(config)
        assert "super-secret-token-12345" not in str_output

    def test_registry_token_not_in_exception_traceback(self):
        """Test that registry_token does not leak in exception messages."""
        try:
            # Force validation error with token set
            config = MCAConfig(service_name="test")
            config.registry_token = "secret-token-xyz"
            # Trigger a different validation error
            config.model_category = "invalid"
            config._validate_taxonomy()
        except ConfigurationError as e:
            assert "secret-token-xyz" not in str(e)

    def test_sensitive_string_not_in_logs(self, caplog):
        """Test that SensitiveString values don't leak in log messages."""
        with caplog.at_level(logging.DEBUG):
            config = MCAConfig(service_name="test")
            config.registry_token = "my-secret-token"
            # Log the config (common debugging scenario)
            logging.getLogger("test").info(f"Config: {config}")

        # Check that raw token doesn't appear in any log record
        for record in caplog.records:
            assert "my-secret-token" not in record.message

    def test_token_not_leaked_via_vars(self):
        """Test that vars() doesn't expose raw token."""
        config = MCAConfig(service_name="test")
        config.registry_token = "secret-value"
        config_vars = vars(config)
        # Token should be SensitiveString, not plain string
        assert isinstance(config_vars["registry_token"], SensitiveString)
        # String representation should be masked
        token_str = str(config_vars["registry_token"])
        assert "secret-value" not in token_str


class TestConfigurationValidationFailFast:
    """Test that configuration validation fails fast with actionable errors."""

    def test_invalid_url_provides_actionable_error(self):
        """Test that invalid URL errors are actionable."""
        with pytest.raises(ConfigurationError, match="must start with http"):
            MCAConfig(service_name="test", collector_endpoint="not-a-url")

    def test_missing_required_field_provides_actionable_error(self):
        """Test that missing required fields have clear error messages."""
        with pytest.raises(ConfigurationError, match="service_name is required"):
            MCAConfig(service_name="")

    def test_invalid_type_provides_actionable_error(self):
        """Test that type errors have clear messages."""
        with pytest.raises(ConfigurationError, match="must be a boolean"):
            MCAConfig(service_name="test", buffering_enabled="not-a-bool")

    def test_validation_happens_at_init(self):
        """Test that validation happens at __post_init__, not lazily."""
        # Should raise immediately, not later
        with pytest.raises(ConfigurationError):
            MCAConfig(service_name="test", model_category="invalid")

    def test_multiple_validation_errors_reported(self):
        """Test that first validation error is reported immediately."""
        # Fail-fast means we see first error, not all errors
        with pytest.raises(ConfigurationError, match="service_name"):
            MCAConfig(service_name="", model_category="invalid", model_type="invalid")


class TestPersistPathConfigValidation:
    """Test enhanced config-layer validation (Story 3.15)."""

    def test_config_validates_canonical_path(self):
        """Config layer should validate canonical paths (Finding #1)."""
        with pytest.raises(ConfigurationError):
            MCAConfig(service_name="test", persist_queue=True, persist_path="../etc/passwd")

    def test_config_suggests_valid_directories(self):
        """Config errors should suggest valid directories (Finding #4)."""
        with pytest.raises(ConfigurationError) as exc_info:
            MCAConfig(
                service_name="test",
                persist_queue=True,
                persist_path="/invalid/location/queue.json",
            )

        error_msg = str(exc_info.value)
        assert "try one of:" in error_msg.lower() or "suggested" in error_msg.lower()

    def test_config_accepts_custom_allowed_dirs(self, tmp_path):
        """Config should accept custom allowed directories (Finding #2)."""
        custom_dir = str(tmp_path / "custom")
        os.makedirs(custom_dir, exist_ok=True)
        os.chmod(custom_dir, 0o700)

        config = MCAConfig(
            service_name="test",
            persist_queue=True,
            persist_path=os.path.join(custom_dir, "queue.json"),
            persist_allowed_dirs=custom_dir,
        )

        assert config.persist_allowed_dirs == custom_dir

    def test_config_warns_tmp_in_production(self, tmp_path, caplog, monkeypatch):
        """Config should warn about /tmp usage in production (Finding #7)."""
        monkeypatch.setenv("ENVIRONMENT", "production")

        import logging

        with caplog.at_level(logging.WARNING):
            tmp_dir = tmp_path / ".mca_sdk"
            tmp_dir.mkdir()
            os.chmod(tmp_dir, 0o700)

            MCAConfig(
                service_name="test",
                persist_queue=True,
                persist_path=str(tmp_dir / "queue.json"),
            )

            tmp_warnings = [
                r
                for r in caplog.records
                if "/tmp" in r.message.lower() and "production" in r.message.lower()
            ]
            assert len(tmp_warnings) > 0


class TestSecurityConstraints:
    """Test security constraints in load() method."""

    def test_registry_token_via_kwargs_blocked(self):
        """Test that registry_token in kwargs raises security error."""
        with pytest.raises(ConfigurationError, match="Security violation"):
            MCAConfig.load(service_name="test", registry_token="token")

    def test_registry_token_via_yaml_blocked(self, tmp_path):
        """Test that registry_token in YAML file raises security error."""
        config_file = tmp_path / "bad_token.yaml"
        config_file.write_text("service_name: test\nregistry_token: secret123\n")

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

    def test_registry_token_via_env_allowed(self, monkeypatch):
        """Test that registry_token via environment variable is allowed."""
        monkeypatch.setenv("MCA_SERVICE_NAME", "test")
        monkeypatch.setenv("MCA_REGISTRY_TOKEN", "env-token-12345")
        config = MCAConfig.from_env()
        # Token should be wrapped in SensitiveString
        assert isinstance(config.registry_token, SensitiveString)

    def test_secret_url_pattern_rejected_in_yaml(self, tmp_path):
        """Test that secret-like keys in YAML are rejected.

        Why: Prevents accidental exposure of secrets in version control.
        """
        config_file = tmp_path / "secret_key.yaml"
        config_file.write_text("service_name: test\napi_secret: bad\n")

        with pytest.raises(ConfigurationError, match="Security violation.*sensitive data"):
            MCAConfig.from_file(str(config_file))
