"""Security-focused unit tests for MCA SDK.

Tests verify security requirements:
- Credential handling doesn't expose secrets in errors
- Certificate validation fails for invalid certs
- mTLS rejects unsigned certificates
- HTTPS enforced for non-localhost registry URLs
- Attribute length limits prevent overflow attacks
"""

import pytest
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
import ssl
import requests
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
import datetime

# Import SDK components
import sys

sys.path.insert(0, str(Path(__file__).parent.parent.parent))

from mca_sdk.config.settings import MCAConfig
from mca_sdk.registry.client import RegistryClient
from mca_sdk.utils.exceptions import ConfigurationError, RegistryConnectionError
from mca_sdk.validation.attributes import validate_attribute_length


class TestCredentialHandling:
    """Test that credentials are never exposed in error messages."""

    def test_registry_token_not_in_error_message(self):
        """Verify registry token is not exposed in connection errors."""
        client = RegistryClient(
            url="https://fake-registry.example.com", token="super_secret_token_12345"
        )

        with patch("requests.get") as mock_get:
            mock_get.side_effect = requests.RequestException("Connection failed")

            with pytest.raises(RegistryConnectionError) as exc_info:
                client.fetch_model_config("model-123")

            error_message = str(exc_info.value)
            # Token should NOT appear in error message
            assert "super_secret_token_12345" not in error_message
            assert "secret" not in error_message.lower() or "redacted" in error_message.lower()

    def test_registry_token_not_in_repr(self):
        """Verify registry token is not exposed in object representation."""
        config = MCAConfig(
            service_name="test-service",
            model_id="test-model",
            team_name="test-team",
            registry_token="super_secret_token_12345",
        )

        config_repr = repr(config)
        config_str = str(config)

        # Token should NOT appear in repr/str
        assert "super_secret_token_12345" not in config_repr
        assert "super_secret_token_12345" not in config_str

    def test_config_validation_error_hides_credentials(self):
        """Verify validation errors don't leak credentials."""
        with pytest.raises(ConfigurationError) as exc_info:
            config = MCAConfig(
                service_name="test-service",
                model_id="test-model",
                team_name="test-team",
                registry_url="http://insecure.com",  # Will fail HTTPS check
                registry_token="super_secret_token_12345",
            )

        error_message = str(exc_info.value)
        # Token should NOT appear in validation error
        assert "super_secret_token_12345" not in error_message

    def test_auth_header_not_logged(self, caplog):
        """Verify authorization headers are not logged."""
        client = RegistryClient(
            url="https://registry.example.com", token="super_secret_token_12345"
        )

        with patch("requests.get") as mock_get:
            mock_get.return_value.status_code = 200
            mock_get.return_value.json.return_value = {"model_id": "test"}

            try:
                client.fetch_model_config("model-123")
            except Exception:
                pass

        # Check logs don't contain token
        for record in caplog.records:
            assert "super_secret_token_12345" not in record.message


class TestCertificateValidation:
    """Test certificate validation for mTLS."""

    def test_invalid_certificate_rejected(self):
        """Verify invalid certificates are rejected during validation."""
        from mca_sdk.security.certificate import validate_certificate_format

        # Nonexistent certificate should raise error
        with pytest.raises((FileNotFoundError, ConfigurationError)):
            validate_certificate_format("/nonexistent/cert.pem", is_key=False)

    def test_certificate_must_be_valid_pem(self, tmp_path):
        """Verify certificate must be valid PEM format."""
        from mca_sdk.security.certificate import validate_certificate_format

        invalid_cert = tmp_path / "invalid_cert.pem"
        invalid_cert.write_text("This is not a valid certificate")

        invalid_key = tmp_path / "invalid_key.pem"
        invalid_key.write_text("This is not a valid key")

        # Should fail to parse invalid certificate
        with pytest.raises(ConfigurationError):
            validate_certificate_format(str(invalid_cert), is_key=False)

        # Should fail to parse invalid key
        with pytest.raises(ConfigurationError):
            validate_certificate_format(str(invalid_key), is_key=True)

    def test_mtls_requires_both_cert_and_key(self):
        """Verify mTLS requires both certificate and private key."""
        # Missing key
        with pytest.raises(ConfigurationError):
            config = MCAConfig(
                service_name="test-service",
                model_id="test-model",
                team_name="test-team",
                mtls_enabled=True,
                client_cert_path="/path/to/cert.pem",
                ca_cert_path="/path/to/ca.pem",
                # Missing client_key_path
            )

        # Missing cert
        with pytest.raises(ConfigurationError):
            config = MCAConfig(
                service_name="test-service",
                model_id="test-model",
                team_name="test-team",
                mtls_enabled=True,
                client_key_path="/path/to/key.pem",
                ca_cert_path="/path/to/ca.pem",
                # Missing client_cert_path
            )

        # Missing CA cert
        with pytest.raises(ConfigurationError):
            config = MCAConfig(
                service_name="test-service",
                model_id="test-model",
                team_name="test-team",
                mtls_enabled=True,
                client_cert_path="/path/to/cert.pem",
                client_key_path="/path/to/key.pem",
                # Missing ca_cert_path
            )

    def test_expired_certificate_detected(self, tmp_path):
        """Verify expired certificates are detected and rejected."""
        # Generate an expired self-signed certificate
        private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

        subject = issuer = x509.Name(
            [
                x509.NameAttribute(NameOID.COMMON_NAME, "test-expired.local"),
            ]
        )

        # Create cert that expired 1 year ago
        cert = (
            x509.CertificateBuilder()
            .subject_name(subject)
            .issuer_name(issuer)
            .public_key(private_key.public_key())
            .serial_number(x509.random_serial_number())
            .not_valid_before(datetime.datetime.utcnow() - datetime.timedelta(days=730))
            .not_valid_after(datetime.datetime.utcnow() - datetime.timedelta(days=365))
            .sign(private_key, hashes.SHA256())
        )

        cert_path = tmp_path / "expired_cert.pem"
        cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))

        key_path = tmp_path / "expired_key.pem"
        key_path.write_bytes(
            private_key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.PKCS8,
                encryption_algorithm=serialization.NoEncryption(),
            )
        )

        # Certificate validation should detect expiration
        from mca_sdk.security.certificate import validate_certificate_expiry

        with pytest.raises(Exception) as exc_info:
            validate_certificate_expiry(str(cert_path))

        assert "expired" in str(exc_info.value).lower()

    @patch("requests.Session.get")
    def test_mtls_connection_refused_ssl_error(self, mock_get):
        """Verify connection refused with SSLError for invalid cert (AC 4)."""
        # Simulate an SSLError from the requests library (e.g. expired client cert)
        ssl_error = requests.exceptions.SSLError(
            "certificate verify failed: certificate has expired"
        )
        mock_get.side_effect = ssl_error

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

        # The client wraps SSLError in RegistryConnectionError, but preserves original via __cause__
        with pytest.raises(RegistryConnectionError) as exc_info:
            client.fetch_model_config("model-123")

        # Check that the original SSLError is preserved in the exception chain
        assert exc_info.value.__cause__ is not None
        assert isinstance(exc_info.value.__cause__, requests.exceptions.SSLError)

        # Verify the original SSL error message is accessible
        original_error = str(exc_info.value.__cause__).lower()
        assert "certificate" in original_error or "ssl" in original_error

        # Verify no cert data leaked in any part of the exception chain
        full_exception_str = str(exc_info.value) + str(exc_info.value.__cause__ or "")
        assert "-----BEGIN CERTIFICATE-----" not in full_exception_str

    @patch("requests.Session.get")
    def test_mtls_connection_refused_certificate_error(self, mock_get):
        """Verify connection refused with CertificateError for invalid cert (AC 4)."""
        # Simulate a certificate verification failure
        ssl_error = requests.exceptions.SSLError(
            "HTTPSConnectionPool(host='registry.example.com', port=443): "
            "Max retries exceeded with url: /api/v1/models/test "
            "(Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate (_ssl.c:1131)')))"
        )
        mock_get.side_effect = ssl_error

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

        with pytest.raises(RegistryConnectionError) as exc_info:
            client.fetch_model_config("model-123")

        # Check that the original SSLError is preserved in the exception chain
        assert exc_info.value.__cause__ is not None
        assert isinstance(exc_info.value.__cause__, requests.exceptions.SSLError)

        # Verify the original SSL error message is accessible
        original_error = str(exc_info.value.__cause__).lower()
        assert "certificate" in original_error or "ssl" in original_error

        # Verify no sensitive data leaked
        full_exception_str = str(exc_info.value) + str(exc_info.value.__cause__ or "")
        assert "-----BEGIN CERTIFICATE-----" not in full_exception_str
        assert "-----BEGIN PRIVATE KEY-----" not in full_exception_str

    @patch("requests.Session.get")
    def test_mtls_missing_certificate_connection_error(self, mock_get):
        """Verify connection fails when client certificate is missing (AC 4)."""
        # Simulate missing client certificate error
        ssl_error = requests.exceptions.SSLError(
            "HTTPSConnectionPool(host='registry.example.com', port=443): "
            "Max retries exceeded with url: /api/v1/models/test "
            "(Caused by SSLError(SSLError('PEM lib')))"
        )
        mock_get.side_effect = ssl_error

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

        with pytest.raises(RegistryConnectionError) as exc_info:
            client.fetch_model_config("model-123")

        # Check that the original SSLError is preserved in the exception chain
        assert exc_info.value.__cause__ is not None
        assert isinstance(exc_info.value.__cause__, requests.exceptions.SSLError)

        # Verify the original SSL error message is accessible
        original_error = str(exc_info.value.__cause__).lower()
        assert "ssl" in original_error or "pem" in original_error

        # Verify no sensitive data leaked
        full_exception_str = str(exc_info.value) + str(exc_info.value.__cause__ or "")
        assert "-----BEGIN" not in full_exception_str

    @patch("requests.Session.get")
    def test_mtls_missing_key_connection_error(self, mock_get):
        """Verify connection fails when client private key is missing (AC 4)."""
        # Simulate missing private key error
        ssl_error = requests.exceptions.SSLError(
            "HTTPSConnectionPool(host='registry.example.com', port=443): "
            "Max retries exceeded with url: /api/v1/models/test "
            "(Caused by SSLError('Cannot load private key file'))"
        )
        mock_get.side_effect = ssl_error

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

        with pytest.raises(RegistryConnectionError) as exc_info:
            client.fetch_model_config("model-123")

        # Check that the original SSLError is preserved in the exception chain
        assert exc_info.value.__cause__ is not None
        assert isinstance(exc_info.value.__cause__, requests.exceptions.SSLError)

        # Verify the original SSL error message is accessible
        original_error = str(exc_info.value.__cause__).lower()
        assert "ssl" in original_error or "key" in original_error or "private" in original_error

        # Verify no sensitive data leaked
        full_exception_str = str(exc_info.value) + str(exc_info.value.__cause__ or "")
        assert "-----BEGIN" not in full_exception_str
        assert "-----END" not in full_exception_str


class TestHTTPSEnforcement:
    """Test HTTPS enforcement for external endpoints."""

    def test_http_registry_url_rejected(self):
        """Verify HTTP URLs are rejected for registry (non-localhost)."""
        with pytest.raises(ConfigurationError) as exc_info:
            config = MCAConfig(
                service_name="test-service",
                model_id="test-model",
                team_name="test-team",
                registry_url="http://external-registry.example.com",
            )

        assert "https" in str(exc_info.value).lower()

    def test_localhost_http_allowed(self):
        """Verify localhost can use HTTP for development."""
        # These should NOT raise errors
        config1 = MCAConfig(
            service_name="test-service",
            model_id="test-model",
            team_name="test-team",
            registry_url="http://localhost:8080",
        )
        assert config1.registry_url == "http://localhost:8080"

        config2 = MCAConfig(
            service_name="test-service",
            model_id="test-model",
            team_name="test-team",
            registry_url="http://127.0.0.1:8080",
        )
        assert config2.registry_url == "http://127.0.0.1:8080"

    def test_https_required_for_external_endpoints(self):
        """Verify HTTPS is required for external endpoints."""
        valid_config = MCAConfig(
            service_name="test-service",
            model_id="test-model",
            team_name="test-team",
            registry_url="https://registry.example.com",
        )
        assert valid_config.registry_url.startswith("https://")

    def test_collector_endpoint_https_enforcement(self):
        """Verify HTTPS enforcement for OTel endpoints (non-localhost)."""
        # External endpoint must use HTTPS
        with pytest.raises(ConfigurationError):
            config = MCAConfig(
                service_name="test-service",
                model_id="test-model",
                team_name="test-team",
                collector_endpoint="http://external-collector.example.com:4318",
            )

        # Localhost can use HTTP
        config = MCAConfig(
            service_name="test-service",
            model_id="test-model",
            team_name="test-team",
            collector_endpoint="http://localhost:4318",
        )
        assert config.collector_endpoint == "http://localhost:4318"


class TestAttributeLengthLimits:
    """Test attribute length limits prevent overflow attacks."""

    def test_attribute_value_length_limit(self):
        """Verify attribute values are limited to prevent overflow."""
        from mca_sdk.utils.exceptions import ValidationError

        MAX_ATTRIBUTE_LENGTH = 1024

        # Short attribute should pass
        short_value = "x" * 100
        result = validate_attribute_length("test_key", short_value)
        assert result is True

        # Attribute at limit should pass
        max_value = "x" * MAX_ATTRIBUTE_LENGTH
        result = validate_attribute_length("test_key", max_value)
        assert result is True

        # Oversized attribute should be rejected
        oversized_value = "x" * (MAX_ATTRIBUTE_LENGTH + 1000)
        with pytest.raises(ValidationError) as exc_info:
            validate_attribute_length("test_key", oversized_value)
        assert "exceeds maximum length" in str(exc_info.value).lower()

    def test_attribute_count_limit(self):
        """Verify attribute count is limited per span/metric."""
        from mca_sdk.utils.exceptions import ValidationError
        from mca_sdk.validation.attributes import validate_attribute_count

        MAX_ATTRIBUTE_COUNT = 128

        # Small attribute dict should pass
        small_attrs = {f"key_{i}": f"value_{i}" for i in range(50)}
        assert validate_attribute_count(small_attrs) is True

        # Attribute dict at limit should pass
        max_attrs = {f"key_{i}": f"value_{i}" for i in range(MAX_ATTRIBUTE_COUNT)}
        assert validate_attribute_count(max_attrs) is True

        # Oversized attribute dict should be rejected
        oversized_attrs = {f"key_{i}": f"value_{i}" for i in range(MAX_ATTRIBUTE_COUNT + 50)}
        with pytest.raises(ValidationError) as exc_info:
            validate_attribute_count(oversized_attrs)
        assert "exceeds maximum" in str(exc_info.value).lower()

    def test_attribute_key_length_limit(self):
        """Verify attribute keys have length limits."""
        from mca_sdk.utils.exceptions import ValidationError

        MAX_KEY_LENGTH = 128

        # Short key should pass
        short_key = "a" * 50
        result = validate_attribute_length(short_key, "value")
        assert result is True

        # Oversized key should be rejected
        oversized_key = "a" * (MAX_KEY_LENGTH + 100)
        with pytest.raises(ValidationError) as exc_info:
            validate_attribute_length(oversized_key, "value")
        assert "exceeds maximum length" in str(exc_info.value).lower()

    def test_nested_attribute_depth_limit(self):
        """Verify nested attributes have depth limits to prevent stack overflow."""
        from mca_sdk.utils.exceptions import ValidationError
        from mca_sdk.validation.attributes import validate_attribute_structure

        # Shallow nesting should pass
        shallow = {"level1": {"level2": {"level3": "value"}}}
        assert validate_attribute_structure(shallow) is True

        # Deep nesting should be rejected
        deep = {"level1": shallow}
        for i in range(20):
            deep = {f"level_{i}": deep}

        with pytest.raises(ValidationError) as exc_info:
            validate_attribute_structure(deep)
        assert "depth exceeds maximum" in str(exc_info.value).lower()


class TestInputValidation:
    """Test input validation prevents injection attacks."""

    def test_model_id_sanitization(self):
        """Verify model_id is sanitized to prevent injection."""
        from mca_sdk.validation.schema import validate_model_id
        from mca_sdk.utils.exceptions import ValidationError

        # Valid model IDs should pass
        assert validate_model_id("mdl-001") is True
        assert validate_model_id("model_123") is True
        assert validate_model_id("readmission-v2") is True

        # Malicious inputs should be rejected
        malicious_inputs = [
            "../../../etc/passwd",
            "model; rm -rf /",
            "model' OR '1'='1",
            "model<script>alert('xss')</script>",
        ]

        for malicious in malicious_inputs:
            with pytest.raises(ValidationError):
                validate_model_id(malicious)

    def test_service_name_sanitization(self):
        """Verify service_name is sanitized."""
        from mca_sdk.validation.schema import validate_service_name
        from mca_sdk.utils.exceptions import ValidationError

        # Valid service names
        assert validate_service_name("my-service") is True
        assert validate_service_name("service_123") is True

        # Malicious inputs should be rejected
        with pytest.raises(ValidationError):
            validate_service_name("service'; DROP TABLE users;--")

        with pytest.raises(ValidationError):
            validate_service_name("service/../../../etc/passwd")


class TestSecureDefaults:
    """Test that SDK has secure defaults."""

    def test_mtls_disabled_by_default(self):
        """Verify mTLS is explicitly configured, not on by default."""
        config = MCAConfig(
            service_name="test-service", model_id="test-model", team_name="test-team"
        )

        # mTLS should be explicitly enabled, not on by default
        assert config.mtls_enabled is False

    def test_debug_mode_off_by_default(self):
        """Verify debug mode (which may log sensitive data) is off by default."""
        config = MCAConfig(
            service_name="test-service", model_id="test-model", team_name="test-team"
        )

        # Debug should be off by default
        assert config.debug_mode is False

    def test_telemetry_buffering_disabled_by_default(self):
        """Verify telemetry buffering is disabled by default for backward compatibility.

        Note: Buffering is disabled by default but can be enabled explicitly
        for improved resilience in production environments.
        """
        config = MCAConfig(
            service_name="test-service", model_id="test-model", team_name="test-team"
        )

        # Buffering is disabled by default for backward compatibility
        assert config.buffering_enabled is False

        # Can be explicitly enabled
        config_with_buffering = MCAConfig(
            service_name="test-service",
            model_id="test-model",
            team_name="test-team",
            buffering_enabled=True,
        )
        assert config_with_buffering.buffering_enabled is True

    def test_https_enforced_by_default(self):
        """Verify HTTPS enforcement is on by default for non-localhost."""
        # External endpoint should require HTTPS
        with pytest.raises(ConfigurationError):
            config = MCAConfig(
                service_name="test-service",
                model_id="test-model",
                team_name="test-team",
                registry_url="http://external.example.com",
            )

        # HTTPS should work
        config = MCAConfig(
            service_name="test-service",
            model_id="test-model",
            team_name="test-team",
            registry_url="https://external.example.com",
        )
        assert config.registry_url.startswith("https://")
