"""
Tests for under-covered src/core/shared/security modules.

Constitutional Hash: 608508a9bd224290

Covers:
- PII detection patterns and classification
- GDPR erasure handler workflow
- CCPA consumer rights handling
- Sandbox config and code validation
- SPIFFE SAN parsing and identity validation
- PQC data classes, exceptions, algorithm normalization
- PQC crypto runtime stubs
- Tenant context validation and middleware
- Auth token creation/verification
- Key loader security
- Error sanitizer credential scrubbing
- Error handler middleware
- CORS configuration and validation
- CSRF token generation/verification
- Input validation and injection detection
"""

import os
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi import HTTPException

from src.core.shared.security.ccpa_handler import (
    CCPABusinessPurpose,
    CCPADataSource,
    CCPAHandler,
    CCPAPersonalInfoCategory,
    CCPARequestStatus,
    CCPARequestType,
    ConsumerDataReport,
    ConsumerInfoRecord,
    reset_ccpa_handler,
)
from src.core.shared.security.cors_config import (
    DEFAULT_ORIGINS,
    CORSConfig,
    CORSEnvironment,
    detect_environment,
    get_cors_config,
    get_origins_from_env,
    get_strict_cors_config,
    validate_origin,
)
from src.core.shared.security.csrf import (
    CSRFConfig,
    _generate_token,
    _verify_token,
)
from src.core.shared.security.data_classification import (
    DataClassificationTier,
    PIICategory,
)
from src.core.shared.security.error_handler_middleware import ErrorHandlerMiddleware
from src.core.shared.security.error_sanitizer import (
    safe_error_detail,
    safe_error_message,
    sanitize_error,
)
from src.core.shared.security.gdpr_erasure import (
    ErasureScope,
    ErasureStatus,
    GDPRErasureHandler,
    reset_gdpr_erasure_handler,
)
from src.core.shared.security.input_validator import (
    InputValidator,
    _contains_injection,
)
from src.core.shared.security.key_loader import load_key_material

# ---------------------------------------------------------------------------
# Module imports
# ---------------------------------------------------------------------------
from src.core.shared.security.pii_detector import (
    PIIDetector,
    classify_data,
    detect_pii,
    get_pii_detector,
    reset_pii_detector,
)
from src.core.shared.security.pqc import (
    APPROVED_CLASSICAL,
    APPROVED_PQC,
    NIST_ALGORITHM_ALIASES,
    ClassicalKeyRejectedError,
    ConstitutionalHashMismatchError,
    KEMResult,
    MigrationRequiredError,
    PQCConfigurationError,
    PQCDecapsulationError,
    PQCEncapsulationError,
    PQCError,
    PQCKeyGenerationError,
    PQCKeyPair,
    PQCKeyRequiredError,
    PQCSignature,
    PQCSignatureError,
    PQCVerificationError,
    SignatureSubstitutionError,
    UnsupportedAlgorithmError,
    UnsupportedPQCAlgorithmError,
    normalize_to_nist,
)
from src.core.shared.security.pqc_crypto import (
    PQC_CRYPTO_AVAILABLE,
    HybridSignature,
    PQCConfig,
    PQCMetadata,
    ValidationResult,
)
from src.core.shared.security.sandbox import (
    MAX_OUTPUT_BYTES,
    ProcessSandbox,
    Sandbox,
    SandboxBackend,
    SandboxConfig,
    SandboxPolicy,
    SandboxResult,
)
from src.core.shared.security.spiffe_san import (
    DEFAULT_TRUST_DOMAINS,
    parse_spiffe_id,
    validate_spiffe_trust_domain,
)
from src.core.shared.security.tenant_context import (
    TENANT_ID_MAX_LENGTH,
    TenantContextConfig,
    TenantValidationError,
    get_current_tenant_id,
    require_tenant_scope,
    sanitize_tenant_id,
    validate_tenant_id,
)

# ===========================================================================
# PII Detector
# ===========================================================================


class TestPIIDetector:
    """Tests for PIIDetector covering detection, classification, batch, stats."""

    def setup_method(self):
        reset_pii_detector()
        self.detector = PIIDetector(min_confidence=0.5)

    # -- String detection ---------------------------------------------------

    def test_detect_email(self):
        detections = self.detector.detect("Contact user@example.com for details")
        assert any(d.matched_pattern == "email" for d in detections)

    def test_detect_us_ssn(self):
        detections = self.detector.detect("SSN: 123-45-6789")
        assert any(d.matched_pattern == "ssn_us" for d in detections)

    def test_detect_us_phone(self):
        detections = self.detector.detect("Call 555-123-4567 now")
        assert any(d.matched_pattern == "phone_us" for d in detections)

    def test_detect_ipv4(self):
        detections = self.detector.detect("Server at 192.168.1.1")
        assert any(d.matched_pattern == "ip_address_v4" for d in detections)

    def test_detect_credit_card_formatted(self):
        detections = self.detector.detect("Card: 4111-1111-1111-1111")
        matches = [d for d in detections if d.category == PIICategory.FINANCIAL]
        assert len(matches) > 0

    def test_detect_mac_address(self):
        detections = self.detector.detect("MAC: 00:1A:2B:3C:4D:5E")
        assert any(d.matched_pattern == "mac_address" for d in detections)

    def test_detect_uuid_pattern(self):
        detections = self.detector.detect("ID: 550e8400-e29b-41d4-a716-446655440000")
        assert any(d.matched_pattern == "uuid" for d in detections)

    def test_detect_session_cookie(self):
        detections = self.detector.detect("session_id=abcdef1234567890abcdef1234567890")
        assert any(d.matched_pattern == "cookie_session" for d in detections)

    def test_detect_no_pii_in_clean_text(self):
        detections = self.detector.detect("Hello world, this is a test")
        # May have low-confidence matches; filter to high confidence
        high_conf = [d for d in detections if d.confidence >= 0.8]
        assert len(high_conf) == 0

    def test_detect_empty_string(self):
        detections = self.detector.detect("")
        assert detections == []

    # -- Dict / list detection ---------------------------------------------

    def test_detect_in_dict(self):
        data = {"email": "admin@test.com", "name": "Alice"}
        detections = self.detector.detect(data)
        assert any(d.matched_pattern == "email" for d in detections)

    def test_detect_in_nested_dict(self):
        data = {"user": {"contact": {"phone": "555-123-4567"}}}
        detections = self.detector.detect(data)
        phone_det = [d for d in detections if d.matched_pattern == "phone_us"]
        assert len(phone_det) > 0

    def test_detect_in_list(self):
        data = ["user@test.com", "123-45-6789"]
        detections = self.detector.detect(data)
        assert len(detections) >= 2

    def test_detect_non_string_value_ignored(self):
        """Non-string, non-dict, non-list values should not crash."""
        detections = self.detector.detect({"count": 42})
        # count=42 is int, should be skipped gracefully
        assert isinstance(detections, list)

    # -- Field name analysis -----------------------------------------------

    def test_field_name_detection_enabled(self):
        data = {"ssn": "hidden"}
        detections = self.detector.detect(data)
        field_detections = [d for d in detections if "field_name:" in d.matched_pattern]
        assert len(field_detections) > 0

    def test_field_name_detection_disabled(self):
        detector = PIIDetector(enable_field_analysis=False)
        data = {"ssn": "hidden"}
        detections = detector.detect(data)
        field_detections = [d for d in detections if "field_name:" in d.matched_pattern]
        assert len(field_detections) == 0

    # -- Context boosting --------------------------------------------------

    def test_context_boosting_raises_confidence(self):
        """SSN pattern in a field named 'ssn' should get boosted confidence."""
        data = {"ssn": "123-45-6789"}
        detections = self.detector.detect(data)
        ssn_det = [d for d in detections if d.matched_pattern == "ssn_us"]
        if ssn_det:
            assert ssn_det[0].confidence > 0.9

    def test_context_boosting_disabled(self):
        detector = PIIDetector(enable_context_boosting=False)
        detections = detector.detect({"ssn": "123-45-6789"})
        ssn_det = [d for d in detections if d.matched_pattern == "ssn_us"]
        # Without boosting, confidence should be base_confidence (0.95) exactly
        if ssn_det:
            assert ssn_det[0].confidence <= 1.0

    # -- Short text penalty ------------------------------------------------

    def test_short_text_penalty(self):
        """Very short strings get confidence penalized."""
        detections_short = self.detector._detect_in_string("1.2.3.4", "$")
        detections_long = self.detector._detect_in_string(
            "The server is at 1.2.3.4 which handles traffic", "$"
        )
        # Both may match ip_address_v4; short one should have lower confidence
        short_ip = [d for d in detections_short if d.matched_pattern == "ip_address_v4"]
        long_ip = [d for d in detections_long if d.matched_pattern == "ip_address_v4"]
        if short_ip and long_ip:
            assert short_ip[0].confidence <= long_ip[0].confidence

    # -- Deduplication -----------------------------------------------------

    def test_deduplication_keeps_highest_confidence(self):
        from src.core.shared.security.data_classification import PIIDetection

        d1 = PIIDetection(
            category=PIICategory.CONTACT_INFO,
            confidence=0.7,
            field_path="$.email",
            matched_pattern="email",
        )
        d2 = PIIDetection(
            category=PIICategory.CONTACT_INFO,
            confidence=0.9,
            field_path="$.email",
            matched_pattern="email",
        )
        result = self.detector._deduplicate_detections([d1, d2])
        assert len(result) == 1
        assert result[0].confidence == 0.9

    # -- Classification ----------------------------------------------------

    def test_classify_no_pii(self):
        result = self.detector.classify("Hello world")
        assert result.tier == DataClassificationTier.INTERNAL
        assert result.overall_confidence == 0.0

    def test_classify_with_pii(self):
        result = self.detector.classify("SSN: 123-45-6789, email: a@b.com")
        assert result.tier != DataClassificationTier.PUBLIC
        assert len(result.pii_detections) > 0
        assert result.overall_confidence > 0

    def test_classify_returns_frameworks(self):
        result = self.detector.classify("SSN: 123-45-6789")
        assert isinstance(result.applicable_frameworks, list)

    def test_classify_encryption_flag(self):
        result = self.detector.classify({"ssn": "123-45-6789"})
        # Restricted tier should require encryption
        assert isinstance(result.requires_encryption, bool)

    # -- Batch processing --------------------------------------------------

    def test_detect_batch(self):
        items = [
            {"id": "item1", "email": "a@b.com"},
            {"id": "item2", "name": "Clean"},
        ]
        results = self.detector.detect_batch(items)
        assert "item1" in results
        assert "item2" in results

    def test_detect_batch_missing_id_field(self):
        """Items without the id field get a generated UUID."""
        items = [{"data": "123-45-6789"}]
        results = self.detector.detect_batch(items)
        assert len(results) == 1

    # -- Scan generator ----------------------------------------------------

    def test_scan_generator(self):
        items = iter([{"email": "x@y.com"}, {"name": "bob"}])
        results = list(self.detector.scan_generator(items))
        assert len(results) == 2
        # tier may be a string due to use_enum_values in Pydantic model
        assert results[0][1].tier in (
            DataClassificationTier.CONFIDENTIAL,
            "confidential",
            DataClassificationTier.INTERNAL,
            "internal",
            DataClassificationTier.RESTRICTED,
            "restricted",
        )

    # -- Statistics --------------------------------------------------------

    def test_get_statistics(self):
        r1 = self.detector.classify("SSN: 123-45-6789")
        r2 = self.detector.classify("Hello world")
        stats = self.detector.get_statistics([r1, r2])
        assert stats["total_items"] == 2
        assert "total_detections" in stats
        assert "average_confidence" in stats
        assert stats["constitutional_hash"] == "608508a9bd224290"

    def test_get_statistics_empty(self):
        stats = self.detector.get_statistics([])
        assert stats["total_items"] == 0
        assert stats["average_confidence"] == 0.0

    # -- Singleton ---------------------------------------------------------

    def test_singleton_lifecycle(self):
        reset_pii_detector()
        d1 = get_pii_detector()
        d2 = get_pii_detector()
        assert d1 is d2
        reset_pii_detector()
        d3 = get_pii_detector()
        assert d3 is not d1

    # -- Convenience functions ---------------------------------------------

    def test_detect_pii_convenience(self):
        reset_pii_detector()
        results = detect_pii("user@example.com")
        assert any(d.matched_pattern == "email" for d in results)

    def test_classify_data_convenience(self):
        reset_pii_detector()
        result = classify_data({"ssn": "123-45-6789"})
        assert result.tier != DataClassificationTier.PUBLIC


# ===========================================================================
# GDPR Erasure Handler
# ===========================================================================


class TestGDPRErasureHandler:
    """Tests for GDPRErasureHandler async workflow."""

    def setup_method(self):
        reset_gdpr_erasure_handler()
        self.handler = GDPRErasureHandler()

    @pytest.mark.asyncio
    async def test_request_erasure_creates_request(self):
        request = await self.handler.request_erasure("subject-1")
        assert request.data_subject_id == "subject-1"
        assert request.status == ErasureStatus.PENDING
        assert len(request.audit_notes) == 1

    @pytest.mark.asyncio
    async def test_validate_request_identity_verified(self):
        req = await self.handler.request_erasure("subject-2")
        updated = await self.handler.validate_request(
            req.request_id, identity_verified=True, verification_method="email"
        )
        assert updated.identity_verified is True
        assert updated.status == ErasureStatus.VALIDATING

    @pytest.mark.asyncio
    async def test_validate_request_not_found(self):
        from src.core.shared.errors.exceptions import ResourceNotFoundError

        with pytest.raises(ResourceNotFoundError):
            await self.handler.validate_request("nonexistent")

    @pytest.mark.asyncio
    async def test_check_exemptions(self):
        req = await self.handler.request_erasure("subject-3")
        updated = await self.handler.check_exemptions(req.request_id)
        assert updated.exemptions_checked is True
        assert isinstance(updated.applicable_exemptions, list)

    @pytest.mark.asyncio
    async def test_discover_data_locations_all_data(self):
        req = await self.handler.request_erasure("subject-4")
        updated = await self.handler.discover_data_locations(req.request_id)
        assert len(updated.data_locations) > 0

    @pytest.mark.asyncio
    async def test_discover_specific_systems(self):
        req = await self.handler.request_erasure(
            "subject-5",
            scope=ErasureScope.SPECIFIC_SYSTEMS,
            specific_systems=["user_database"],
        )
        updated = await self.handler.discover_data_locations(req.request_id)
        assert all(loc.system_name == "user_database" for loc in updated.data_locations)

    @pytest.mark.asyncio
    async def test_discover_specific_categories(self):
        req = await self.handler.request_erasure(
            "subject-6",
            scope=ErasureScope.SPECIFIC_CATEGORIES,
            specific_categories=[PIICategory.BEHAVIORAL],
        )
        updated = await self.handler.discover_data_locations(req.request_id)
        assert len(updated.data_locations) >= 1

    @pytest.mark.asyncio
    async def test_process_erasure_rejected_without_verification(self):
        req = await self.handler.request_erasure("subject-7")
        await self.handler.discover_data_locations(req.request_id)
        result = await self.handler.process_erasure(req.request_id)
        assert result.status == ErasureStatus.REJECTED
        assert result.rejection_reason == "Identity not verified"

    @pytest.mark.asyncio
    async def test_process_erasure_full_workflow(self):
        req = await self.handler.request_erasure("subject-8")
        await self.handler.validate_request(req.request_id, identity_verified=True)
        await self.handler.check_exemptions(req.request_id)
        await self.handler.discover_data_locations(req.request_id)
        result = await self.handler.process_erasure(req.request_id, processed_by="admin")
        # Some systems can't be erased (audit_logs), so partially_completed
        assert result.status in (ErasureStatus.COMPLETED, ErasureStatus.PARTIALLY_COMPLETED)
        assert result.total_records_erased > 0

    @pytest.mark.asyncio
    async def test_generate_certificate(self):
        req = await self.handler.request_erasure("subject-9")
        await self.handler.validate_request(req.request_id, identity_verified=True)
        await self.handler.discover_data_locations(req.request_id)
        await self.handler.process_erasure(req.request_id)
        cert = await self.handler.generate_erasure_certificate(req.request_id)
        assert cert.data_subject_id == "subject-9"
        assert cert.certificate_hash != ""
        assert cert.systems_processed > 0

    @pytest.mark.asyncio
    async def test_generate_certificate_not_completed_raises(self):
        from src.core.shared.errors.exceptions import ValidationError as ACGSValidationError

        req = await self.handler.request_erasure("subject-10")
        with pytest.raises(ACGSValidationError):
            await self.handler.generate_erasure_certificate(req.request_id)

    @pytest.mark.asyncio
    async def test_verify_certificate(self):
        req = await self.handler.request_erasure("subject-11")
        await self.handler.validate_request(req.request_id, identity_verified=True)
        await self.handler.discover_data_locations(req.request_id)
        await self.handler.process_erasure(req.request_id)
        cert = await self.handler.generate_erasure_certificate(req.request_id)
        assert await self.handler.verify_certificate(cert.certificate_id) is True

    @pytest.mark.asyncio
    async def test_verify_certificate_nonexistent(self):
        assert await self.handler.verify_certificate("nonexistent") is False

    @pytest.mark.asyncio
    async def test_get_request(self):
        req = await self.handler.request_erasure("subject-12")
        found = await self.handler.get_request(req.request_id)
        assert found is not None
        assert found.data_subject_id == "subject-12"

    @pytest.mark.asyncio
    async def test_get_pending_requests(self):
        await self.handler.request_erasure("subject-a", tenant_id="t1")
        await self.handler.request_erasure("subject-b", tenant_id="t2")
        pending = await self.handler.get_pending_requests()
        assert len(pending) >= 2
        pending_t1 = await self.handler.get_pending_requests(tenant_id="t1")
        assert all(r.tenant_id == "t1" for r in pending_t1)

    @pytest.mark.asyncio
    async def test_get_overdue_requests(self):
        handler = GDPRErasureHandler(default_deadline_days=-1)
        await handler.request_erasure("overdue-subject")
        overdue = await handler.get_overdue_requests()
        assert len(overdue) >= 1


# ===========================================================================
# CCPA Handler
# ===========================================================================


class TestCCPAHandler:
    """Tests for CCPAHandler async consumer rights workflow."""

    def setup_method(self):
        reset_ccpa_handler()
        self.handler = CCPAHandler()

    @pytest.mark.asyncio
    async def test_right_to_know(self):
        report = await self.handler.handle_right_to_know("consumer-1")
        assert report.consumer_id == "consumer-1"
        assert len(report.categories_collected) > 0
        assert report.report_hash != ""

    @pytest.mark.asyncio
    async def test_right_to_know_with_custom_data(self):
        self.handler._consumer_data["consumer-2"] = [
            ConsumerInfoRecord(
                category=CCPAPersonalInfoCategory.IDENTIFIERS,
                data_collected=["Name"],
                sources=[CCPADataSource.DIRECT_COLLECTION],
                business_purposes=[CCPABusinessPurpose.SECURITY],
                sold_or_shared=True,
            ),
        ]
        report = await self.handler.handle_right_to_know("consumer-2")
        assert CCPAPersonalInfoCategory.IDENTIFIERS in report.categories_sold

    @pytest.mark.asyncio
    async def test_right_to_delete(self):
        request = await self.handler.handle_right_to_delete("consumer-3")
        assert request.request_type == CCPARequestType.RIGHT_TO_DELETE
        assert request.status == CCPARequestStatus.IDENTITY_PENDING

    @pytest.mark.asyncio
    async def test_process_deletion_verified(self):
        req = await self.handler.handle_right_to_delete("consumer-4")
        result = await self.handler.process_deletion(
            req.request_id, identity_verified=True, verification_method="email"
        )
        assert result.status == CCPARequestStatus.COMPLETED
        assert result.identity_verified is True

    @pytest.mark.asyncio
    async def test_process_deletion_denied(self):
        req = await self.handler.handle_right_to_delete("consumer-5")
        result = await self.handler.process_deletion(req.request_id, identity_verified=False)
        assert result.status == CCPARequestStatus.DENIED

    @pytest.mark.asyncio
    async def test_process_deletion_not_found(self):
        from src.core.shared.errors.exceptions import ResourceNotFoundError

        with pytest.raises(ResourceNotFoundError):
            await self.handler.process_deletion("nonexistent", identity_verified=True)

    @pytest.mark.asyncio
    async def test_handle_opt_out(self):
        conf = await self.handler.handle_opt_out("consumer-6")
        assert conf.consumer_id == "consumer-6"
        assert conf.is_active is True
        assert conf.confirmation_sent is True

    @pytest.mark.asyncio
    async def test_handle_opt_in(self):
        await self.handler.handle_opt_out("consumer-7")
        conf = await self.handler.handle_opt_in("consumer-7")
        assert conf.is_active is False
        assert conf.revoked_at is not None

    @pytest.mark.asyncio
    async def test_handle_opt_in_no_prior_opt_out(self):
        from src.core.shared.errors.exceptions import ResourceNotFoundError

        with pytest.raises(ResourceNotFoundError):
            await self.handler.handle_opt_in("consumer-unknown")

    @pytest.mark.asyncio
    async def test_check_opt_out_status(self):
        await self.handler.handle_opt_out("consumer-8")
        status = await self.handler.check_opt_out_status("consumer-8")
        assert status is not None
        assert status.is_active is True

    @pytest.mark.asyncio
    async def test_check_opt_out_status_revoked(self):
        await self.handler.handle_opt_out("consumer-9")
        await self.handler.handle_opt_in("consumer-9")
        status = await self.handler.check_opt_out_status("consumer-9")
        assert status is None

    @pytest.mark.asyncio
    async def test_check_opt_out_status_none(self):
        status = await self.handler.check_opt_out_status("consumer-nonexistent")
        assert status is None

    @pytest.mark.asyncio
    async def test_generate_privacy_notice(self):
        notice = await self.handler.generate_privacy_notice("biz-1")
        assert notice.business_id == "biz-1"
        assert len(notice.categories_collected) > 0
        assert len(notice.consumer_rights) > 0
        assert notice.opt_out_link == "/privacy/opt-out"

    @pytest.mark.asyncio
    async def test_get_request_status(self):
        req = await self.handler.handle_right_to_delete("consumer-10")
        found = await self.handler.get_request_status(req.request_id)
        assert found is not None
        assert found.consumer_id == "consumer-10"

    @pytest.mark.asyncio
    async def test_get_request_status_not_found(self):
        result = await self.handler.get_request_status("nonexistent")
        assert result is None

    @pytest.mark.asyncio
    async def test_extend_deadline(self):
        req = await self.handler.handle_right_to_delete("consumer-11")
        updated = await self.handler.extend_deadline(req.request_id, "Complex request")
        assert updated.extended is True
        assert updated.extended_deadline is not None
        assert updated.status == CCPARequestStatus.EXTENDED

    @pytest.mark.asyncio
    async def test_extend_deadline_twice_fails(self):
        from src.core.shared.errors.exceptions import ValidationError as ACGSValidationError

        req = await self.handler.handle_right_to_delete("consumer-12")
        await self.handler.extend_deadline(req.request_id, "First extension")
        with pytest.raises(ACGSValidationError):
            await self.handler.extend_deadline(req.request_id, "Second extension")

    def test_consumer_data_report_hash(self):
        report = ConsumerDataReport(
            report_id="rpt_test",
            consumer_id="c1",
            request_id="req1",
            categories_collected=[CCPAPersonalInfoCategory.IDENTIFIERS],
        )
        h = report.calculate_hash()
        assert len(h) == 16
        assert report.report_hash == h


# ===========================================================================
# Sandbox
# ===========================================================================


class TestSandbox:
    """Tests for sandbox config, policies, code validation."""

    def test_sandbox_config_defaults(self):
        config = SandboxConfig()
        assert config.policy == SandboxPolicy.STANDARD
        assert config.timeout_seconds == 30
        assert config.allow_network is False

    def test_sandbox_config_strict_policy(self):
        config = SandboxConfig.from_policy(SandboxPolicy.STRICT)
        assert config.timeout_seconds == 5
        assert config.max_memory_bytes == 64 * 1024 * 1024
        assert config.allow_network is False

    def test_sandbox_config_permissive_policy(self):
        config = SandboxConfig.from_policy(SandboxPolicy.PERMISSIVE)
        assert config.timeout_seconds == 120
        assert config.allow_network is True

    def test_sandbox_config_standard_policy(self):
        config = SandboxConfig.from_policy(SandboxPolicy.STANDARD)
        assert config.timeout_seconds == 30

    def test_sandbox_result_immutable(self):
        result = SandboxResult(
            success=True,
            exit_code=0,
            stdout="hello",
            stderr="",
            duration_seconds=0.1,
            timed_out=False,
            memory_exceeded=False,
            sandbox_id="test",
        )
        assert result.success is True
        with pytest.raises(AttributeError):
            result.success = False  # type: ignore[misc]

    def test_sandbox_validate_code_safe(self):
        sandbox = Sandbox()
        is_safe, reason = sandbox.validate_code("print('hello')")
        assert is_safe is True
        assert reason is None

    def test_sandbox_validate_code_dangerous_os_system(self):
        sandbox = Sandbox()
        is_safe, reason = sandbox.validate_code("import os; os.system('rm -rf /')")
        assert is_safe is False
        assert "OS command" in reason

    def test_sandbox_validate_code_dangerous_subprocess(self):
        sandbox = Sandbox()
        is_safe, _reason = sandbox.validate_code("subprocess.run(['ls'])")
        assert is_safe is False

    def test_sandbox_validate_code_dangerous_eval(self):
        sandbox = Sandbox()
        is_safe, _reason = sandbox.validate_code("eval('1+1')")
        assert is_safe is False

    def test_sandbox_validate_code_dangerous_exec(self):
        sandbox = Sandbox()
        is_safe, _reason = sandbox.validate_code("exec('print(1)')")
        assert is_safe is False

    def test_sandbox_validate_code_dangerous_etc(self):
        sandbox = Sandbox()
        is_safe, _reason = sandbox.validate_code("open('/etc/passwd')")
        assert is_safe is False

    def test_sandbox_validate_code_dangerous_proc(self):
        sandbox = Sandbox()
        is_safe, _reason = sandbox.validate_code("open('/proc/self/environ')")
        assert is_safe is False

    def test_sandbox_run_empty_code(self):
        sandbox = Sandbox()
        result = sandbox.run("")
        assert result.success is True
        assert result.sandbox_id == "empty"

    def test_sandbox_run_whitespace_only(self):
        sandbox = Sandbox()
        result = sandbox.run("   \n  ")
        assert result.success is True

    def test_sandbox_backend_protocol(self):
        """ProcessSandbox should satisfy SandboxBackend protocol."""
        backend = ProcessSandbox()
        assert isinstance(backend, SandboxBackend)

    def test_max_output_bytes_constant(self):
        assert MAX_OUTPUT_BYTES == 1_048_576


# ===========================================================================
# SPIFFE SAN
# ===========================================================================


class TestSpiffeSan:
    """Tests for SPIFFE SAN parsing and trust domain validation."""

    def test_parse_basic_spiffe_id(self):
        sid = parse_spiffe_id("spiffe://acgs2/tenant/t1/agent/a1")
        assert sid.trust_domain == "acgs2"
        assert sid.tenant_id == "t1"
        assert sid.agent_id == "a1"
        assert sid.maci_role is None

    def test_parse_spiffe_id_with_role(self):
        sid = parse_spiffe_id("spiffe://acgs2.io/tenant/t1/agent/a1/role/proposer")
        assert sid.trust_domain == "acgs2.io"
        assert sid.maci_role == "proposer"

    def test_parse_spiffe_id_no_path(self):
        sid = parse_spiffe_id("spiffe://acgs2")
        assert sid.trust_domain == "acgs2"
        assert sid.path == ""
        assert sid.tenant_id is None

    def test_parse_spiffe_id_non_acgs_path(self):
        sid = parse_spiffe_id("spiffe://acgs2/some/other/path")
        assert sid.trust_domain == "acgs2"
        assert sid.path == "/some/other/path"
        assert sid.tenant_id is None

    def test_parse_spiffe_id_empty_raises(self):
        with pytest.raises(ValueError, match="must start with spiffe://"):
            parse_spiffe_id("")

    def test_parse_spiffe_id_no_scheme_raises(self):
        with pytest.raises(ValueError):
            parse_spiffe_id("https://acgs2/tenant/t1/agent/a1")

    def test_parse_spiffe_id_empty_trust_domain(self):
        with pytest.raises(ValueError, match="empty trust domain"):
            parse_spiffe_id("spiffe:///tenant/t1/agent/a1")

    def test_parse_spiffe_id_invalid_trust_domain_chars(self):
        with pytest.raises(ValueError, match="invalid characters"):
            parse_spiffe_id("spiffe://UPPER_CASE/tenant/t1/agent/a1")

    def test_validate_trust_domain_default(self):
        sid = parse_spiffe_id("spiffe://acgs2/tenant/t1/agent/a1")
        assert validate_spiffe_trust_domain(sid) is True

    def test_validate_trust_domain_acgs2_io(self):
        sid = parse_spiffe_id("spiffe://acgs2.io/tenant/t1/agent/a1")
        assert validate_spiffe_trust_domain(sid) is True

    def test_validate_trust_domain_unknown(self):
        sid = parse_spiffe_id("spiffe://evil.com/tenant/t1/agent/a1")
        assert validate_spiffe_trust_domain(sid) is False

    def test_validate_trust_domain_custom_list(self):
        sid = parse_spiffe_id("spiffe://custom.domain/tenant/t1/agent/a1")
        assert validate_spiffe_trust_domain(sid, allowed_domains=["custom.domain"]) is True

    def test_default_trust_domains(self):
        assert "acgs2.io" in DEFAULT_TRUST_DOMAINS
        assert "acgs2" in DEFAULT_TRUST_DOMAINS


# ===========================================================================
# SPIFFE Identity Validator
# ===========================================================================


class TestSpiffeIdentityValidator:
    """Tests for SpiffeIdentityValidator with mocked certificates."""

    def test_validator_init(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        validator = SpiffeIdentityValidator(allowed_trust_domains=["acgs2"])
        stats = validator.get_stats()
        assert stats["validations_attempted"] == 0

    def test_validator_parse_spiffe_id(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        validator = SpiffeIdentityValidator()
        sid = validator.parse_spiffe_id("spiffe://acgs2/tenant/t1/agent/a1")
        assert sid.trust_domain == "acgs2"

    def test_validator_fail_bad_cert(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        validator = SpiffeIdentityValidator()
        result = validator.validate_svid(b"not a certificate")
        assert result.valid is False
        assert "Failed to parse" in result.error

    def test_validator_stats_after_failure(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        validator = SpiffeIdentityValidator()
        validator.validate_svid(b"bad cert")
        stats = validator.get_stats()
        assert stats["validations_attempted"] == 1
        assert stats["validations_failed"] == 1


# ===========================================================================
# PQC Module (data classes, exceptions, normalization)
# ===========================================================================


class TestPQCDataClasses:
    """Tests for PQC data structures and exception hierarchy."""

    def test_pqc_keypair_properties(self):
        kp = PQCKeyPair(
            public_key=b"pub" * 100,
            private_key=b"priv" * 200,
            algorithm="dilithium3",
            security_level=3,
        )
        assert kp.public_key_size == 300
        assert kp.private_key_size == 800
        assert kp.algorithm == "dilithium3"

    def test_pqc_keypair_serialize(self):
        kp = PQCKeyPair(
            public_key=b"\x01\x02\x03",
            private_key=b"\x04\x05\x06",
            algorithm="kyber768",
            security_level=3,
        )
        serialized = kp.serialize()
        assert "key_id" in serialized
        assert "public_key" in serialized
        assert serialized["algorithm"] == "kyber768"
        # Private key must NOT be in serialized output
        assert "private_key" not in serialized

    def test_pqc_signature_properties(self):
        sig = PQCSignature(
            signature=b"\x00" * 3293,
            algorithm="dilithium3",
            signer_key_id="key-1",
        )
        assert sig.signature_size == 3293
        d = sig.to_dict()
        assert "signature" in d
        assert d["algorithm"] == "dilithium3"

    def test_kem_result_properties(self):
        kem = KEMResult(
            ciphertext=b"\x00" * 1088,
            shared_secret=b"\x00" * 32,
            algorithm="kyber768",
        )
        assert kem.ciphertext_size == 1088
        assert kem.shared_secret_size == 32
        d = kem.to_dict()
        assert "ciphertext" in d
        # shared_secret must NOT be serialized
        assert "shared_secret" not in d

    def test_pqc_exception_hierarchy(self):
        assert issubclass(PQCKeyGenerationError, PQCError)
        assert issubclass(PQCSignatureError, PQCError)
        assert issubclass(PQCVerificationError, PQCError)
        assert issubclass(PQCEncapsulationError, PQCError)
        assert issubclass(PQCDecapsulationError, PQCError)
        assert issubclass(UnsupportedAlgorithmError, PQCError)
        assert issubclass(ConstitutionalHashMismatchError, PQCError)
        assert issubclass(SignatureSubstitutionError, PQCError)
        assert issubclass(PQCConfigurationError, PQCError)

    def test_classical_key_rejected_error(self):
        err = ClassicalKeyRejectedError(
            supported_algorithms=["ML-DSA-65"],
        )
        assert err.http_status_code == 403
        assert err.supported_algorithms == ["ML-DSA-65"]

    def test_pqc_key_required_error(self):
        err = PQCKeyRequiredError(supported_algorithms=["ML-KEM-768"])
        assert err.http_status_code == 403
        assert err.supported_algorithms == ["ML-KEM-768"]

    def test_migration_required_error(self):
        err = MigrationRequiredError()
        assert err.http_status_code == 403

    def test_unsupported_pqc_algorithm_error(self):
        err = UnsupportedPQCAlgorithmError(supported_algorithms=["ML-DSA-44"])
        assert err.http_status_code == 400

    def test_normalize_to_nist_approved_pqc(self):
        assert normalize_to_nist("ML-DSA-65") == "ML-DSA-65"

    def test_normalize_to_nist_approved_classical(self):
        assert normalize_to_nist("Ed25519") == "Ed25519"

    def test_normalize_to_nist_legacy_alias(self):
        assert normalize_to_nist("dilithium3") == "ML-DSA-65"
        assert normalize_to_nist("kyber768") == "ML-KEM-768"

    def test_normalize_to_nist_unknown_raises(self):
        with pytest.raises(UnsupportedAlgorithmError):
            normalize_to_nist("rsa2048")

    def test_approved_sets(self):
        assert "ML-DSA-44" in APPROVED_PQC
        assert "Ed25519" in APPROVED_CLASSICAL
        assert len(NIST_ALGORITHM_ALIASES) > 0


# ===========================================================================
# PQC Crypto Stubs
# ===========================================================================


class TestPQCCryptoStubs:
    """Tests for PQC crypto runtime stubs."""

    def test_pqc_crypto_available(self):
        assert PQC_CRYPTO_AVAILABLE is True

    def test_pqc_config_defaults(self):
        config = PQCConfig()
        assert config.pqc_enabled is False
        assert config.pqc_mode == "classical_only"

    def test_hybrid_signature_defaults(self):
        sig = HybridSignature()
        assert sig.content_hash == ""
        assert sig.constitutional_hash == ""

    def test_pqc_metadata_defaults(self):
        meta = PQCMetadata()
        assert meta.pqc_enabled is False
        assert meta.verification_mode == "classical_only"

    def test_validation_result_defaults(self):
        result = ValidationResult()
        assert result.valid is False
        assert result.errors == []
        assert result.warnings == []


# ===========================================================================
# Tenant Context
# ===========================================================================


class TestTenantContext:
    """Tests for tenant ID validation and utilities."""

    def test_validate_valid_tenant_id(self):
        assert validate_tenant_id("my-tenant-1") is True

    def test_validate_single_char(self):
        assert validate_tenant_id("a") is True

    def test_validate_empty_raises(self):
        with pytest.raises(TenantValidationError, match="cannot be empty"):
            validate_tenant_id("")

    def test_validate_too_long_raises(self):
        long_id = "a" * (TENANT_ID_MAX_LENGTH + 1)
        with pytest.raises(TenantValidationError, match="exceeds maximum"):
            validate_tenant_id(long_id)

    def test_validate_dangerous_chars_raises(self):
        with pytest.raises(TenantValidationError, match="invalid characters"):
            validate_tenant_id("tenant<script>")

    def test_validate_path_traversal_raises(self):
        with pytest.raises(TenantValidationError, match="path characters"):
            validate_tenant_id("../etc/passwd")

    def test_validate_slash_raises(self):
        with pytest.raises(TenantValidationError, match="path characters"):
            validate_tenant_id("tenant/evil")

    def test_validate_bad_format_raises(self):
        with pytest.raises(TenantValidationError, match="alphanumeric"):
            validate_tenant_id("-starts-with-dash")

    def test_sanitize_strips_whitespace(self):
        assert sanitize_tenant_id("  tenant-1  ") == "tenant-1"

    def test_get_current_tenant_id_default(self):
        assert get_current_tenant_id() is None

    def test_require_tenant_scope_match(self):
        # Should not raise
        require_tenant_scope("tenant-1", "tenant-1")

    def test_require_tenant_scope_mismatch(self):
        with pytest.raises(HTTPException) as exc_info:
            require_tenant_scope("tenant-1", "tenant-2")
        assert exc_info.value.status_code == 403

    def test_tenant_config_defaults(self):
        config = TenantContextConfig()
        assert config.header_name == "X-Tenant-ID"
        assert "/health" in config.exempt_paths

    def test_tenant_config_from_env(self):
        with patch.dict(os.environ, {"TENANT_CONTEXT_ENABLED": "false"}):
            config = TenantContextConfig.from_env()
            assert config.enabled is False

    def test_tenant_validation_error_str(self):
        err = TenantValidationError("test error", tenant_id="t1")
        assert str(err) == "test error"


# ===========================================================================
# Key Loader
# ===========================================================================


class TestKeyLoader:
    """Tests for key material loading with path restrictions."""

    def test_load_inline_key(self):
        result = load_key_material("my-secret-key")
        assert result == "my-secret-key"

    def test_load_empty_at_path(self):
        result = load_key_material("@")
        assert result == ""

    def test_load_at_blank_path(self):
        result = load_key_material("@  ")
        assert result == ""

    def test_load_file_outside_allowed_dirs_raises(self):
        from src.core.shared.errors.exceptions import ConfigurationError

        with pytest.raises(ConfigurationError, match="not in an allowed directory"):
            load_key_material("@/tmp/evil_key.pem")

    def test_load_file_not_found_raises(self):
        from src.core.shared.errors.exceptions import ConfigurationError

        with patch.dict(os.environ, {"ACGS2_KEY_DIRS": "/tmp"}):
            # Re-import to pick up new env dirs -- but since _ALL_KEY_DIRS
            # is computed at import time, we need to mock the check instead
            pass
        # Just verify that a missing file in an allowed dir would raise
        # We can test with the existing allowed dirs and a non-existent file
        with pytest.raises(ConfigurationError):
            load_key_material("@/etc/acgs2/keys/nonexistent.pem")


# ===========================================================================
# Error Sanitizer
# ===========================================================================


class TestErrorSanitizer:
    """Tests for credential redaction in error messages."""

    def test_sanitize_none(self):
        assert sanitize_error(None) == "Unknown error"

    def test_sanitize_password(self):
        result = sanitize_error("Connection failed: password='secret123'")
        assert "secret123" not in result
        assert "REDACTED" in result

    def test_sanitize_token(self):
        result = sanitize_error("Auth failed: token='abc123def456'")
        assert "abc123def456" not in result
        assert "REDACTED" in result

    def test_sanitize_url_credentials(self):
        result = sanitize_error("redis://admin:p@ss@localhost:6379")
        assert "p@ss" not in result
        assert "REDACTED" in result

    def test_sanitize_bearer_token(self):
        result = sanitize_error("Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.test")
        assert "eyJhbGciOiJSUzI1NiJ9" not in result
        assert "REDACTED" in result

    def test_sanitize_postgres_url(self):
        result = sanitize_error("postgres://user:pass@db:5432/mydb")
        assert "user:pass" not in result

    def test_sanitize_bootstrap_servers(self):
        result = sanitize_error("bootstrap_servers='kafka:9092'")
        assert "kafka:9092" not in result

    def test_sanitize_file_paths(self):
        result = sanitize_error("File not found: /home/user/secrets/key.pem")
        assert "/home/user" not in result
        assert "<path>" in result

    def test_sanitize_query_string(self):
        result = sanitize_error("Request failed: key=abc123&other=val")
        assert "abc123" not in result

    def test_safe_error_detail_non_production(self):
        with patch("src.core.shared.security.error_sanitizer._is_production", return_value=False):
            result = safe_error_detail(ValueError("test error"), "test op")
            assert "test error" in result

    def test_safe_error_detail_production(self):
        with patch("src.core.shared.security.error_sanitizer._is_production", return_value=True):
            result = safe_error_detail(ValueError("secret info"), "create tenant")
            assert "secret info" not in result
            assert "Create tenant failed" in result

    def test_safe_error_message_non_production(self):
        with patch("src.core.shared.security.error_sanitizer._is_production", return_value=False):
            result = safe_error_message(ValueError("oops"), "data export")
            assert "data export failed" in result

    def test_safe_error_message_production(self):
        with patch("src.core.shared.security.error_sanitizer._is_production", return_value=True):
            result = safe_error_message(ValueError("oops"), "data export")
            assert "oops" not in result
            assert "data export" in result


# ===========================================================================
# Error Handler Middleware
# ===========================================================================


class TestErrorHandlerMiddleware:
    """Tests for ErrorHandlerMiddleware initialization and ASGI handling."""

    def test_init_default_production(self):
        with patch.dict(os.environ, {"ENVIRONMENT": "production"}, clear=False):
            os.environ.pop("AGENT_RUNTIME_ENVIRONMENT", None)
            mw = ErrorHandlerMiddleware(app=MagicMock())
            assert mw.debug is False

    def test_init_default_development(self):
        with patch.dict(os.environ, {"ENVIRONMENT": "development"}, clear=False):
            mw = ErrorHandlerMiddleware(app=MagicMock())
            assert mw.debug is True

    def test_init_explicit_debug(self):
        mw = ErrorHandlerMiddleware(app=MagicMock(), debug=True)
        assert mw.debug is True

    @pytest.mark.asyncio
    async def test_passthrough_non_http(self):
        app = AsyncMock()
        mw = ErrorHandlerMiddleware(app=app, debug=False)
        scope = {"type": "websocket"}
        receive = AsyncMock()
        send = AsyncMock()
        await mw(scope, receive, send)
        app.assert_awaited_once_with(scope, receive, send)

    @pytest.mark.asyncio
    async def test_handles_exception_production(self):
        async def failing_app(scope, receive, send):
            raise RuntimeError("unexpected failure")

        mw = ErrorHandlerMiddleware(app=failing_app, debug=False)
        scope = {"type": "http", "method": "GET", "path": "/test"}

        # Capture what the JSONResponse sends
        sent_messages = []

        async def mock_send(message):
            sent_messages.append(message)

        await mw(scope, AsyncMock(), mock_send)
        # Should have sent response headers and body
        assert len(sent_messages) >= 1

    @pytest.mark.asyncio
    async def test_handles_exception_debug(self):
        async def failing_app(scope, receive, send):
            raise RuntimeError("debug failure")

        mw = ErrorHandlerMiddleware(app=failing_app, debug=True)
        scope = {"type": "http", "method": "POST", "path": "/api/test"}

        sent_messages = []

        async def mock_send(message):
            sent_messages.append(message)

        await mw(scope, AsyncMock(), mock_send)
        assert len(sent_messages) >= 1


# ===========================================================================
# CORS Config
# ===========================================================================


class TestCORSConfig:
    """Tests for CORS configuration and validation."""

    def test_cors_config_development(self):
        config = CORSConfig(
            allow_origins=["http://localhost:3000"],
            environment=CORSEnvironment.DEVELOPMENT,
        )
        assert config.allow_credentials is True
        assert "http://localhost:3000" in config.allow_origins

    def test_cors_config_production_wildcard_raises(self):
        with pytest.raises(ValueError, match="SECURITY ERROR"):
            CORSConfig(
                allow_origins=["*"],
                environment=CORSEnvironment.PRODUCTION,
            )

    def test_cors_config_production_wildcard_with_credentials_raises(self):
        with pytest.raises(ValueError, match="SECURITY ERROR"):
            CORSConfig(
                allow_origins=["*"],
                allow_credentials=True,
                environment=CORSEnvironment.PRODUCTION,
            )

    def test_cors_config_invalid_origin_raises(self):
        with pytest.raises(ValueError, match="Invalid origin"):
            CORSConfig(
                allow_origins=["not-a-url"],
                environment=CORSEnvironment.DEVELOPMENT,
            )

    def test_cors_config_to_middleware_kwargs(self):
        config = CORSConfig(
            allow_origins=["http://localhost:3000"],
            environment=CORSEnvironment.DEVELOPMENT,
        )
        kwargs = config.to_middleware_kwargs()
        assert "allow_origins" in kwargs
        assert "allow_credentials" in kwargs
        assert "max_age" in kwargs

    def test_detect_environment_default(self):
        with patch.dict(os.environ, {}, clear=True):
            env = detect_environment()
            assert env == CORSEnvironment.DEVELOPMENT

    def test_detect_environment_production(self):
        with patch.dict(os.environ, {"ENVIRONMENT": "production"}, clear=True):
            env = detect_environment()
            assert env == CORSEnvironment.PRODUCTION

    def test_detect_environment_staging(self):
        with patch.dict(os.environ, {"CORS_ENVIRONMENT": "staging"}, clear=True):
            env = detect_environment()
            assert env == CORSEnvironment.STAGING

    def test_get_origins_from_env_csv(self):
        with patch.dict(
            os.environ,
            {"CORS_ALLOWED_ORIGINS": "https://a.com,https://b.com"},
            clear=False,
        ):
            origins = get_origins_from_env()
            assert origins == ["https://a.com", "https://b.com"]

    def test_get_origins_from_env_json(self):
        with patch.dict(
            os.environ,
            {"CORS_ALLOWED_ORIGINS": '["https://a.com", "https://b.com"]'},
            clear=False,
        ):
            origins = get_origins_from_env()
            assert origins == ["https://a.com", "https://b.com"]

    def test_get_origins_from_env_none(self):
        with patch.dict(os.environ, {}, clear=True):
            origins = get_origins_from_env()
            assert origins is None

    def test_get_cors_config_dev(self):
        config = get_cors_config(CORSEnvironment.DEVELOPMENT)
        assert len(config["allow_origins"]) > 0

    def test_get_cors_config_additional_origins(self):
        config = get_cors_config(
            CORSEnvironment.DEVELOPMENT,
            additional_origins=["http://extra:9000"],
        )
        assert "http://extra:9000" in config["allow_origins"]

    def test_get_strict_cors_config(self):
        with patch.dict(os.environ, {"ENVIRONMENT": "development"}, clear=True):
            config = get_strict_cors_config()
            assert len(config["allow_origins"]) == 1
            assert config["allow_methods"] == ["GET", "POST"]

    def test_validate_origin_wildcard(self):
        assert validate_origin("http://evil.com", ["*"]) is True

    def test_validate_origin_match(self):
        assert validate_origin("http://localhost:3000", ["http://localhost:3000"]) is True

    def test_validate_origin_no_match(self):
        assert validate_origin("http://evil.com", ["http://localhost:3000"]) is False

    def test_default_origins_keys(self):
        assert CORSEnvironment.PRODUCTION in DEFAULT_ORIGINS
        assert CORSEnvironment.DEVELOPMENT in DEFAULT_ORIGINS


# ===========================================================================
# CSRF
# ===========================================================================


class TestCSRF:
    """Tests for CSRF token generation and verification."""

    def test_generate_token_format(self):
        token = _generate_token("my-secret")
        parts = token.split(".")
        assert len(parts) == 2
        # raw part is hex
        assert all(c in "0123456789abcdef" for c in parts[0])

    def test_verify_token_valid(self):
        secret = "test-secret"
        token = _generate_token(secret)
        assert _verify_token(token, secret) is True

    def test_verify_token_wrong_secret(self):
        token = _generate_token("secret-a")
        assert _verify_token(token, "secret-b") is False

    def test_verify_token_tampered(self):
        token = _generate_token("secret")
        tampered = token[:-4] + "XXXX"
        assert _verify_token(tampered, "secret") is False

    def test_verify_token_no_dot(self):
        assert _verify_token("notokenformat", "secret") is False

    def test_csrf_config_defaults(self):
        config = CSRFConfig()
        assert config.enabled is True
        assert config.cookie_name == "csrf_token"
        assert config.header_name == "X-CSRF-Token"

    def test_csrf_config_get_secret_explicit(self):
        config = CSRFConfig(secret="my-explicit-secret")
        assert config.get_secret() == "my-explicit-secret"

    def test_csrf_config_get_secret_env(self):
        with patch.dict(os.environ, {"CSRF_SECRET": "env-secret"}):
            config = CSRFConfig()
            assert config.get_secret() == "env-secret"


# ===========================================================================
# Input Validator
# ===========================================================================


class TestInputValidator:
    """Tests for input validation and injection detection."""

    def test_sanitize_string_strips(self):
        assert InputValidator.sanitize_string("  hello  ") == "hello"

    def test_sanitize_string_removes_null_bytes(self):
        assert InputValidator.sanitize_string("he\x00llo") == "hello"

    def test_validate_path_safe(self, tmp_path):
        safe_path = tmp_path / "file.txt"
        safe_path.touch()
        result = InputValidator.validate_path(str(safe_path), str(tmp_path))
        assert result == safe_path

    def test_validate_path_traversal_raises(self, tmp_path):
        with pytest.raises(HTTPException) as exc_info:
            InputValidator.validate_path("../../etc/passwd", str(tmp_path))
        assert exc_info.value.status_code == 400

    def test_check_injection_sql(self):
        assert InputValidator.check_injection("'; DROP TABLE users; --") is True

    def test_check_injection_union_select(self):
        assert InputValidator.check_injection("1 UNION SELECT * FROM users") is True

    def test_check_injection_xss_script(self):
        assert InputValidator.check_injection("<script>alert(1)</script>") is True

    def test_check_injection_nosql(self):
        assert InputValidator.check_injection('{"$gt": 0}') is True

    def test_check_injection_clean(self):
        assert InputValidator.check_injection("Hello World") is False

    def test_check_injection_non_string(self):
        assert InputValidator.check_injection(42) is False  # type: ignore[arg-type]

    def test_check_injection_custom_patterns(self):
        assert InputValidator.check_injection("badword", patterns=[r"badword"]) is True

    def test_enforce_size_limit_ok(self):
        # Should not raise
        InputValidator.enforce_size_limit("small data", 1_000_000)

    def test_enforce_size_limit_exceeds(self):
        with pytest.raises(HTTPException) as exc_info:
            InputValidator.enforce_size_limit("x" * 100, 1)
        assert exc_info.value.status_code == 413

    def test_contains_injection_dict(self):
        data = {"query": "SELECT * FROM users"}
        assert _contains_injection(data) is True

    def test_contains_injection_list(self):
        data = ["safe", "<script>alert(1)</script>"]
        assert _contains_injection(data) is True

    def test_contains_injection_nested(self):
        data = {"outer": {"inner": "'; DROP TABLE x; --"}}
        assert _contains_injection(data) is True

    def test_contains_injection_clean(self):
        data = {"name": "Alice", "age": 30}
        assert _contains_injection(data) is False

    def test_contains_injection_non_string_leaf(self):
        assert _contains_injection(42) is False


# ===========================================================================
# Sandbox - ProcessSandbox.execute (extended)
# ===========================================================================


class TestProcessSandboxExecute:
    """Tests for ProcessSandbox.execute with real subprocess calls."""

    def test_execute_simple_code(self):
        sandbox = Sandbox(config=SandboxConfig.from_policy(SandboxPolicy.STRICT))
        result = sandbox.run("print('hello sandbox')")
        assert result.success is True
        assert "hello sandbox" in result.stdout

    def test_execute_syntax_error(self):
        sandbox = Sandbox(config=SandboxConfig.from_policy(SandboxPolicy.STRICT))
        result = sandbox.run("def oops(")
        assert result.success is False
        assert result.exit_code != 0

    def test_execute_runtime_error(self):
        sandbox = Sandbox(config=SandboxConfig.from_policy(SandboxPolicy.STRICT))
        result = sandbox.run("raise ValueError('boom')")
        assert result.success is False
        assert "ValueError" in result.stderr

    def test_execute_with_stderr(self):
        sandbox = Sandbox(config=SandboxConfig.from_policy(SandboxPolicy.STRICT))
        result = sandbox.run("import sys; sys.stderr.write('err msg')")
        assert "err msg" in result.stderr

    def test_execute_timeout(self):
        config = SandboxConfig(timeout_seconds=1, max_cpu_seconds=1)
        sandbox = Sandbox(config=config)
        result = sandbox.run("import time; time.sleep(10)")
        assert result.timed_out is True
        assert result.success is False

    def test_sandbox_run_logs_failure(self):
        """Non-zero exit code triggers warning log path."""
        sandbox = Sandbox(config=SandboxConfig.from_policy(SandboxPolicy.STRICT))
        result = sandbox.run("import sys; sys.exit(1)")
        assert result.success is False
        assert result.exit_code == 1

    def test_process_sandbox_oserror_handling(self):
        """OSError during subprocess should be caught."""
        backend = ProcessSandbox()
        config = SandboxConfig()
        # Use a non-existent directory to trigger OSError
        import tempfile

        with tempfile.TemporaryDirectory() as tmpdir:
            sandbox_dir = Path(tmpdir)
            # Patch subprocess.run to raise OSError
            with patch(
                "src.core.shared.security.sandbox.subprocess.run",
                side_effect=OSError("no such file"),
            ):
                result = backend.execute("print(1)", config, sandbox_dir)
                assert result.success is False
                assert "Sandbox execution failed" in result.error

    def test_resource_limits_function_exists(self):
        """_set_resource_limits should be importable (not called in-process to avoid rlimit side-effects)."""
        from src.core.shared.security.sandbox import _set_resource_limits

        assert callable(_set_resource_limits)


# ===========================================================================
# SPIFFE SAN - extract_spiffe_ids_from_cert (extended)
# ===========================================================================


class TestSpiffeSanExtract:
    """Tests for extract_spiffe_ids_from_cert with mocked certificates."""

    def test_extract_from_invalid_cert_raises(self):
        from src.core.shared.security.spiffe_san import extract_spiffe_ids_from_cert

        with pytest.raises(ValueError, match="Failed to parse"):
            extract_spiffe_ids_from_cert("not a certificate")

    def test_extract_from_cert_no_san(self):
        """Certificate without SAN extension returns empty list."""
        from cryptography import x509
        from cryptography.hazmat.primitives import hashes, serialization
        from cryptography.hazmat.primitives.asymmetric import ec
        from cryptography.x509.oid import NameOID

        from src.core.shared.security.spiffe_san import extract_spiffe_ids_from_cert

        # Generate a self-signed cert without SAN
        key = ec.generate_private_key(ec.SECP256R1())
        subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")])
        cert = (
            x509.CertificateBuilder()
            .subject_name(subject)
            .issuer_name(subject)
            .public_key(key.public_key())
            .serial_number(x509.random_serial_number())
            .not_valid_before(datetime.now(UTC))
            .not_valid_after(datetime.now(UTC) + timedelta(days=1))
            .sign(key, hashes.SHA256())
        )
        pem = cert.public_bytes(serialization.Encoding.PEM)
        result = extract_spiffe_ids_from_cert(pem)
        assert result == []

    def test_extract_from_cert_with_spiffe_san(self):
        """Certificate with spiffe:// URI SAN should extract the SPIFFE ID."""
        from cryptography import x509
        from cryptography.hazmat.primitives import hashes, serialization
        from cryptography.hazmat.primitives.asymmetric import ec
        from cryptography.x509.oid import NameOID

        from src.core.shared.security.spiffe_san import extract_spiffe_ids_from_cert

        key = ec.generate_private_key(ec.SECP256R1())
        subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "agent")])
        san = x509.SubjectAlternativeName(
            [
                x509.UniformResourceIdentifier("spiffe://acgs2/tenant/t1/agent/a1"),
            ]
        )
        cert = (
            x509.CertificateBuilder()
            .subject_name(subject)
            .issuer_name(subject)
            .public_key(key.public_key())
            .serial_number(x509.random_serial_number())
            .not_valid_before(datetime.now(UTC))
            .not_valid_after(datetime.now(UTC) + timedelta(days=1))
            .add_extension(san, critical=False)
            .sign(key, hashes.SHA256())
        )
        pem = cert.public_bytes(serialization.Encoding.PEM)
        result = extract_spiffe_ids_from_cert(pem)
        assert len(result) == 1
        assert result[0].trust_domain == "acgs2"
        assert result[0].tenant_id == "t1"
        assert result[0].agent_id == "a1"

    def test_extract_from_cert_with_non_spiffe_uri(self):
        """Certificate with non-spiffe URI SAN should return empty list."""
        from cryptography import x509
        from cryptography.hazmat.primitives import hashes, serialization
        from cryptography.hazmat.primitives.asymmetric import ec
        from cryptography.x509.oid import NameOID

        from src.core.shared.security.spiffe_san import extract_spiffe_ids_from_cert

        key = ec.generate_private_key(ec.SECP256R1())
        subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")])
        san = x509.SubjectAlternativeName(
            [
                x509.UniformResourceIdentifier("https://example.com"),
            ]
        )
        cert = (
            x509.CertificateBuilder()
            .subject_name(subject)
            .issuer_name(subject)
            .public_key(key.public_key())
            .serial_number(x509.random_serial_number())
            .not_valid_before(datetime.now(UTC))
            .not_valid_after(datetime.now(UTC) + timedelta(days=1))
            .add_extension(san, critical=False)
            .sign(key, hashes.SHA256())
        )
        pem = cert.public_bytes(serialization.Encoding.PEM)
        result = extract_spiffe_ids_from_cert(pem)
        assert result == []


# ===========================================================================
# SPIFFE Identity Validator - with real certificates (extended)
# ===========================================================================


class TestSpiffeIdentityValidatorCerts:
    """Tests for SpiffeIdentityValidator with real X.509 certificates."""

    def _make_cert(self, san_uris=None, not_before=None, not_after=None):
        from cryptography import x509
        from cryptography.hazmat.primitives import hashes, serialization
        from cryptography.hazmat.primitives.asymmetric import ec
        from cryptography.x509.oid import NameOID

        key = ec.generate_private_key(ec.SECP256R1())
        subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")])
        builder = (
            x509.CertificateBuilder()
            .subject_name(subject)
            .issuer_name(subject)
            .public_key(key.public_key())
            .serial_number(x509.random_serial_number())
            .not_valid_before(not_before or datetime.now(UTC))
            .not_valid_after(not_after or datetime.now(UTC) + timedelta(days=1))
        )
        if san_uris:
            san = x509.SubjectAlternativeName(
                [x509.UniformResourceIdentifier(uri) for uri in san_uris]
            )
            builder = builder.add_extension(san, critical=False)
        cert = builder.sign(key, hashes.SHA256())
        return cert.public_bytes(serialization.Encoding.PEM)

    def test_validate_valid_svid(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        pem = self._make_cert(san_uris=["spiffe://acgs2/tenant/t1/agent/a1"])
        validator = SpiffeIdentityValidator(allowed_trust_domains=["acgs2"])
        result = validator.validate_svid(pem)
        assert result.valid is True
        assert result.spiffe_id is not None
        assert result.spiffe_id.agent_id == "a1"

    def test_validate_expired_cert(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        pem = self._make_cert(
            san_uris=["spiffe://acgs2/tenant/t1/agent/a1"],
            not_before=datetime.now(UTC) - timedelta(days=10),
            not_after=datetime.now(UTC) - timedelta(days=1),
        )
        validator = SpiffeIdentityValidator()
        result = validator.validate_svid(pem)
        assert result.valid is False
        assert "expired" in result.error

    def test_validate_not_yet_valid_cert(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        pem = self._make_cert(
            san_uris=["spiffe://acgs2/tenant/t1/agent/a1"],
            not_before=datetime.now(UTC) + timedelta(days=1),
            not_after=datetime.now(UTC) + timedelta(days=10),
        )
        validator = SpiffeIdentityValidator()
        result = validator.validate_svid(pem)
        assert result.valid is False
        assert "not yet valid" in result.error

    def test_validate_no_spiffe_san(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        pem = self._make_cert(san_uris=["https://example.com"])
        validator = SpiffeIdentityValidator()
        result = validator.validate_svid(pem)
        assert result.valid is False
        assert "spiffe://" in result.error

    def test_validate_no_san_extension(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        pem = self._make_cert()  # no SAN
        validator = SpiffeIdentityValidator()
        result = validator.validate_svid(pem)
        assert result.valid is False

    def test_validate_untrusted_domain(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        pem = self._make_cert(san_uris=["spiffe://evil.com/tenant/t1/agent/a1"])
        validator = SpiffeIdentityValidator(allowed_trust_domains=["acgs2"])
        result = validator.validate_svid(pem)
        assert result.valid is False
        assert "not in the allowed list" in result.error

    def test_validate_stats_after_success_and_failure(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        validator = SpiffeIdentityValidator(allowed_trust_domains=["acgs2"])
        pem_good = self._make_cert(san_uris=["spiffe://acgs2/tenant/t1/agent/a1"])
        pem_bad = b"garbage"
        validator.validate_svid(pem_good)
        validator.validate_svid(pem_bad)
        stats = validator.get_stats()
        assert stats["validations_attempted"] == 2
        assert stats["validations_passed"] == 1
        assert stats["validations_failed"] == 1

    def test_validate_svid_string_input(self):
        from src.core.shared.security.spiffe_identity import SpiffeIdentityValidator

        pem = self._make_cert(san_uris=["spiffe://acgs2/tenant/t1/agent/a1"])
        validator = SpiffeIdentityValidator(allowed_trust_domains=["acgs2"])
        result = validator.validate_svid(pem.decode("utf-8"))
        assert result.valid is True


# ===========================================================================
# CSRF Middleware (extended)
# ===========================================================================


class TestCSRFMiddleware:
    """Tests for CSRFMiddleware dispatch logic."""

    @pytest.mark.asyncio
    async def test_csrf_disabled_passthrough(self):
        from starlette.applications import Starlette
        from starlette.responses import PlainTextResponse
        from starlette.routing import Route
        from starlette.testclient import TestClient

        async def homepage(request):
            return PlainTextResponse("ok")

        app = Starlette(routes=[Route("/", homepage, methods=["GET", "POST"])])
        from src.core.shared.security.csrf import CSRFMiddleware

        app.add_middleware(CSRFMiddleware, config=CSRFConfig(enabled=False))
        client = TestClient(app)
        resp = client.post("/")
        assert resp.status_code == 200

    @pytest.mark.asyncio
    async def test_csrf_safe_method_sets_cookie(self):
        from starlette.applications import Starlette
        from starlette.responses import PlainTextResponse
        from starlette.routing import Route
        from starlette.testclient import TestClient

        async def homepage(request):
            return PlainTextResponse("ok")

        app = Starlette(routes=[Route("/", homepage, methods=["GET"])])
        from src.core.shared.security.csrf import CSRFMiddleware

        app.add_middleware(CSRFMiddleware, config=CSRFConfig(secret="test-secret"))
        client = TestClient(app)
        resp = client.get("/")
        assert resp.status_code == 200
        assert "csrf_token" in resp.cookies

    @pytest.mark.asyncio
    async def test_csrf_bearer_bypasses(self):
        from starlette.applications import Starlette
        from starlette.responses import PlainTextResponse
        from starlette.routing import Route
        from starlette.testclient import TestClient

        async def homepage(request):
            return PlainTextResponse("ok")

        app = Starlette(routes=[Route("/", homepage, methods=["POST"])])
        from src.core.shared.security.csrf import CSRFMiddleware

        app.add_middleware(CSRFMiddleware, config=CSRFConfig(secret="test-secret"))
        client = TestClient(app)
        resp = client.post("/", headers={"Authorization": "Bearer some-token"})
        assert resp.status_code == 200

    @pytest.mark.asyncio
    async def test_csrf_exempt_path_bypasses(self):
        from starlette.applications import Starlette
        from starlette.responses import PlainTextResponse
        from starlette.routing import Route
        from starlette.testclient import TestClient

        async def health(request):
            return PlainTextResponse("ok")

        app = Starlette(routes=[Route("/health", health, methods=["POST"])])
        from src.core.shared.security.csrf import CSRFMiddleware

        app.add_middleware(
            CSRFMiddleware, config=CSRFConfig(secret="test-secret", exempt_paths=("/health",))
        )
        client = TestClient(app)
        resp = client.post("/health")
        assert resp.status_code == 200

    @pytest.mark.asyncio
    async def test_csrf_missing_tokens_returns_403(self):
        from starlette.applications import Starlette
        from starlette.responses import PlainTextResponse
        from starlette.routing import Route
        from starlette.testclient import TestClient

        async def endpoint(request):
            return PlainTextResponse("ok")

        app = Starlette(routes=[Route("/api", endpoint, methods=["POST"])])
        from src.core.shared.security.csrf import CSRFMiddleware

        app.add_middleware(CSRFMiddleware, config=CSRFConfig(secret="test-secret"))
        client = TestClient(app)
        resp = client.post("/api")
        assert resp.status_code == 403
        assert "missing" in resp.json()["detail"].lower()

    @pytest.mark.asyncio
    async def test_csrf_valid_double_submit(self):
        from starlette.applications import Starlette
        from starlette.responses import PlainTextResponse
        from starlette.routing import Route
        from starlette.testclient import TestClient

        secret = "test-secret-42"

        async def endpoint(request):
            return PlainTextResponse("ok")

        app = Starlette(routes=[Route("/api", endpoint, methods=["GET", "POST"])])
        from src.core.shared.security.csrf import CSRFMiddleware

        app.add_middleware(CSRFMiddleware, config=CSRFConfig(secret=secret))
        client = TestClient(app)

        # GET to get the cookie
        get_resp = client.get("/api")
        csrf_token = get_resp.cookies.get("csrf_token")
        assert csrf_token is not None
        client.cookies.set("csrf_token", csrf_token)

        # POST with matching cookie and header
        resp = client.post(
            "/api",
            headers={"X-CSRF-Token": csrf_token},
        )
        assert resp.status_code == 200

    @pytest.mark.asyncio
    async def test_csrf_mismatch_returns_403(self):
        from starlette.applications import Starlette
        from starlette.responses import PlainTextResponse
        from starlette.routing import Route
        from starlette.testclient import TestClient

        secret = "test-secret-43"

        async def endpoint(request):
            return PlainTextResponse("ok")

        app = Starlette(routes=[Route("/api", endpoint, methods=["GET", "POST"])])
        from src.core.shared.security.csrf import CSRFMiddleware

        app.add_middleware(CSRFMiddleware, config=CSRFConfig(secret=secret))
        client = TestClient(app)

        get_resp = client.get("/api")
        csrf_token = get_resp.cookies.get("csrf_token")
        client.cookies.set("csrf_token", csrf_token)

        # POST with mismatched header
        resp = client.post(
            "/api",
            headers={"X-CSRF-Token": "wrong-token"},
        )
        assert resp.status_code == 403

    @pytest.mark.asyncio
    async def test_csrf_session_cookie_skip(self):
        """When session_cookie_name is set but cookie is absent, CSRF is skipped."""
        from starlette.applications import Starlette
        from starlette.responses import PlainTextResponse
        from starlette.routing import Route
        from starlette.testclient import TestClient

        async def endpoint(request):
            return PlainTextResponse("ok")

        app = Starlette(routes=[Route("/api", endpoint, methods=["POST"])])
        from src.core.shared.security.csrf import CSRFMiddleware

        app.add_middleware(
            CSRFMiddleware,
            config=CSRFConfig(secret="test-secret"),
        )
        client = TestClient(app)
        # No CSRF token = rejected for non-exempt path
        resp = client.post("/api")
        assert resp.status_code in (200, 403)


# ===========================================================================
# Tenant Context Middleware (extended)
# ===========================================================================


class TestTenantContextMiddleware:
    """Tests for TenantContextMiddleware dispatch via TestClient."""

    def _make_app(self, config=None):
        from starlette.applications import Starlette
        from starlette.responses import JSONResponse
        from starlette.routing import Route

        async def endpoint(request):
            tenant_id = getattr(request.state, "tenant_id", None)
            return JSONResponse({"tenant_id": tenant_id})

        async def health(request):
            return JSONResponse({"status": "ok"})

        app = Starlette(
            routes=[
                Route("/api/test", endpoint),
                Route("/health", health),
            ]
        )
        from src.core.shared.security.tenant_context import TenantContextMiddleware

        app.add_middleware(TenantContextMiddleware, config=config)
        return app

    def test_middleware_missing_header_returns_400(self):
        from starlette.testclient import TestClient

        app = self._make_app(TenantContextConfig(required=True, fail_open=False))
        client = TestClient(app, raise_server_exceptions=False)
        resp = client.get("/api/test")
        assert resp.status_code == 400
        assert "MISSING_TENANT_ID" in resp.json()["code"]

    def test_middleware_valid_header(self):
        from starlette.testclient import TestClient

        app = self._make_app(TenantContextConfig(required=True))
        client = TestClient(app, raise_server_exceptions=False)
        resp = client.get("/api/test", headers={"X-Tenant-ID": "my-tenant"})
        assert resp.status_code == 200
        assert resp.json()["tenant_id"] == "my-tenant"
        # Check echo header
        assert resp.headers.get("X-Tenant-ID") == "my-tenant"

    def test_middleware_exempt_path(self):
        from starlette.testclient import TestClient

        app = self._make_app(TenantContextConfig(required=True))
        client = TestClient(app, raise_server_exceptions=False)
        resp = client.get("/health")
        assert resp.status_code == 200

    def test_middleware_invalid_tenant_id(self):
        from starlette.testclient import TestClient

        app = self._make_app(TenantContextConfig(required=True))
        client = TestClient(app, raise_server_exceptions=False)
        resp = client.get("/api/test", headers={"X-Tenant-ID": "<script>evil</script>"})
        assert resp.status_code == 400
        assert "INVALID_TENANT_ID" in resp.json()["code"]

    def test_middleware_disabled(self):
        from starlette.testclient import TestClient

        app = self._make_app(TenantContextConfig(enabled=False))
        client = TestClient(app, raise_server_exceptions=False)
        resp = client.get("/api/test")
        assert resp.status_code == 200

    def test_middleware_fail_open(self):
        from starlette.testclient import TestClient

        app = self._make_app(TenantContextConfig(required=True, fail_open=True))
        client = TestClient(app, raise_server_exceptions=False)
        resp = client.get("/api/test")
        assert resp.status_code == 200

    def test_middleware_query_param(self):
        from starlette.testclient import TestClient

        app = self._make_app(TenantContextConfig(allow_query_param=True))
        client = TestClient(app, raise_server_exceptions=False)
        resp = client.get("/api/test?tenant_id=query-tenant")
        assert resp.status_code == 200
        assert resp.json()["tenant_id"] == "query-tenant"


# ===========================================================================
# PQC Wrapper - mocked liboqs (extended)
# ===========================================================================


class TestPQCWrapperMocked:
    """Tests for PQCWrapper with mocked liboqs dependency."""

    def test_pqcwrapper_init_no_liboqs_raises(self):
        with patch("src.core.shared.security.pqc.find_spec", return_value=None):
            with pytest.raises(PQCConfigurationError):
                from src.core.shared.security.pqc import PQCWrapper as PW

                PW()

    def test_pqcwrapper_init_with_liboqs(self):
        mock_spec = MagicMock()
        with patch("src.core.shared.security.pqc.find_spec", return_value=mock_spec):
            from src.core.shared.security.pqc import PQCWrapper as PW

            wrapper = PW()
            assert wrapper is not None
