"""
Comprehensive coverage tests for:
- src.core.shared.security.pqc
- src.core.shared.security.auth
- src.core.shared.utilities.config_merger
- src.core.shared.utilities.tenant_normalizer
"""

from __future__ import annotations

import base64
import uuid
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import jwt
import pytest
from fastapi import HTTPException

from src.core.shared.constants import CONSTITUTIONAL_HASH
from src.core.shared.errors.exceptions import ConfigurationError

# ============================================================================
# PQC Tests
# ============================================================================
from src.core.shared.security.pqc import (
    APPROVED_CLASSICAL,
    APPROVED_PQC,
    NIST_ALGORITHM_ALIASES,
    ClassicalKeyRejectedError,
    ConstitutionalHashMismatchError,
    KEMResult,
    KeyRegistryUnavailableError,
    MigrationRequiredError,
    PQCConfigurationError,
    PQCDecapsulationError,
    PQCEncapsulationError,
    PQCError,
    PQCKeyGenerationError,
    PQCKeyPair,
    PQCKeyRequiredError,
    PQCSignature,
    PQCSignatureError,
    PQCVerificationError,
    PQCWrapper,
    SignatureSubstitutionError,
    UnsupportedAlgorithmError,
    UnsupportedPQCAlgorithmError,
    normalize_to_nist,
)


class TestPQCExceptions:
    """Test all PQC exception classes and their attributes."""

    def test_pqc_error_base(self):
        err = PQCError("test error")
        assert "test error" in str(err)
        assert err.http_status_code == 500
        assert err.error_code == "PQC_ERROR"

    def test_pqc_key_generation_error(self):
        err = PQCKeyGenerationError("keygen failed")
        assert isinstance(err, PQCError)

    def test_pqc_signature_error(self):
        err = PQCSignatureError("sign failed")
        assert isinstance(err, PQCError)

    def test_pqc_verification_error(self):
        err = PQCVerificationError("verify failed")
        assert isinstance(err, PQCError)

    def test_pqc_encapsulation_error(self):
        err = PQCEncapsulationError("encap failed")
        assert isinstance(err, PQCError)

    def test_pqc_decapsulation_error(self):
        err = PQCDecapsulationError("decap failed")
        assert isinstance(err, PQCError)

    def test_unsupported_algorithm_error(self):
        err = UnsupportedAlgorithmError("bad algo")
        assert isinstance(err, PQCError)

    def test_constitutional_hash_mismatch_error(self):
        err = ConstitutionalHashMismatchError("hash mismatch")
        assert isinstance(err, PQCError)

    def test_signature_substitution_error(self):
        err = SignatureSubstitutionError("substitution")
        assert isinstance(err, PQCError)

    def test_pqc_configuration_error(self):
        err = PQCConfigurationError("bad config")
        assert isinstance(err, PQCError)

    def test_key_registry_unavailable_error(self):
        err = KeyRegistryUnavailableError("registry down")
        assert isinstance(err, PQCError)

    def test_classical_key_rejected_error_defaults(self):
        err = ClassicalKeyRejectedError()
        assert err.http_status_code == 403
        assert err.error_code == "CLASSICAL_KEY_REJECTED"
        assert err.supported_algorithms == []

    def test_classical_key_rejected_error_with_algorithms(self):
        err = ClassicalKeyRejectedError(
            message="rejected",
            supported_algorithms=["ML-DSA-44"],
            details={"info": "test"},
        )
        assert err.supported_algorithms == ["ML-DSA-44"]

    def test_pqc_key_required_error_defaults(self):
        err = PQCKeyRequiredError()
        assert err.http_status_code == 403
        assert err.error_code == "PQC_KEY_REQUIRED"
        assert err.supported_algorithms == []

    def test_pqc_key_required_error_with_algorithms(self):
        err = PQCKeyRequiredError(message="need pqc", supported_algorithms=["ML-KEM-768"])
        assert err.supported_algorithms == ["ML-KEM-768"]

    def test_migration_required_error_defaults(self):
        err = MigrationRequiredError()
        assert err.http_status_code == 403
        assert err.error_code == "MIGRATION_REQUIRED"
        assert err.supported_algorithms == []

    def test_migration_required_error_with_algorithms(self):
        err = MigrationRequiredError(message="migrate", supported_algorithms=["ML-DSA-65"])
        assert err.supported_algorithms == ["ML-DSA-65"]

    def test_unsupported_pqc_algorithm_error_defaults(self):
        err = UnsupportedPQCAlgorithmError()
        assert err.http_status_code == 400
        assert err.error_code == "UNSUPPORTED_PQC_ALGORITHM"
        assert err.supported_algorithms == []

    def test_unsupported_pqc_algorithm_error_with_algorithms(self):
        err = UnsupportedPQCAlgorithmError(
            message="not supported", supported_algorithms=["ML-DSA-87"]
        )
        assert err.supported_algorithms == ["ML-DSA-87"]


class TestPQCKeyPair:
    """Test PQCKeyPair dataclass."""

    def test_creation_and_properties(self):
        kp = PQCKeyPair(
            public_key=b"pubkey123",
            private_key=b"privkey456",
            algorithm="kyber768",
            security_level=3,
        )
        assert kp.public_key_size == 9
        assert kp.private_key_size == 10
        assert kp.algorithm == "kyber768"
        assert kp.security_level == 3
        assert isinstance(kp.created_at, datetime)
        assert isinstance(kp.key_id, str)

    def test_serialize(self):
        kp = PQCKeyPair(
            public_key=b"\x01\x02\x03",
            private_key=b"\x04\x05\x06",
            algorithm="dilithium3",
            security_level=3,
            key_id="test-key-id",
        )
        serialized = kp.serialize()
        assert serialized["key_id"] == "test-key-id"
        assert serialized["algorithm"] == "dilithium3"
        assert serialized["security_level"] == 3
        assert serialized["public_key"] == base64.b64encode(b"\x01\x02\x03").decode()
        assert "created_at" in serialized
        # private key must NOT be in serialized output
        assert "private_key" not in serialized


class TestPQCSignature:
    """Test PQCSignature dataclass."""

    def test_creation_and_properties(self):
        sig = PQCSignature(
            signature=b"sig_bytes_here",
            algorithm="dilithium3",
            signer_key_id="key-1",
        )
        assert sig.signature_size == 14
        assert sig.algorithm == "dilithium3"

    def test_to_dict(self):
        sig = PQCSignature(
            signature=b"\xaa\xbb",
            algorithm="dilithium2",
            signer_key_id="key-2",
        )
        d = sig.to_dict()
        assert d["signature"] == base64.b64encode(b"\xaa\xbb").decode()
        assert d["algorithm"] == "dilithium2"
        assert d["signer_key_id"] == "key-2"
        assert "signed_at" in d


class TestKEMResult:
    """Test KEMResult dataclass."""

    def test_creation_and_properties(self):
        kr = KEMResult(
            ciphertext=b"ct" * 16,
            shared_secret=b"ss" * 16,
            algorithm="kyber768",
        )
        assert kr.ciphertext_size == 32
        assert kr.shared_secret_size == 32

    def test_to_dict_excludes_shared_secret(self):
        kr = KEMResult(
            ciphertext=b"\x01\x02",
            shared_secret=b"\x03\x04",
            algorithm="kyber512",
        )
        d = kr.to_dict()
        assert "ciphertext" in d
        assert "algorithm" in d
        assert "encapsulated_at" in d
        assert "shared_secret" not in d


class TestNormalizeToNist:
    """Test normalize_to_nist function."""

    def test_approved_pqc_returned_unchanged(self):
        for algo in APPROVED_PQC:
            assert normalize_to_nist(algo) == algo

    def test_approved_classical_returned_unchanged(self):
        for algo in APPROVED_CLASSICAL:
            assert normalize_to_nist(algo) == algo

    def test_legacy_aliases_resolved(self):
        for alias, canonical in NIST_ALGORITHM_ALIASES.items():
            assert normalize_to_nist(alias) == canonical

    def test_legacy_aliases_case_insensitive(self):
        assert normalize_to_nist("Dilithium3") == "ML-DSA-65"
        assert normalize_to_nist("KYBER768") == "ML-KEM-768"

    def test_unknown_algorithm_raises(self):
        with pytest.raises(UnsupportedAlgorithmError) as exc_info:
            normalize_to_nist("unknown-algo")
        assert "unknown-algo" in str(exc_info.value)


class TestPQCWrapperInit:
    """Test PQCWrapper initialization."""

    def test_init_without_liboqs_raises(self):
        with patch("src.core.shared.security.pqc.find_spec", return_value=None):
            with pytest.raises(PQCConfigurationError):
                PQCWrapper()

    def test_init_with_liboqs_succeeds(self):
        with patch("src.core.shared.security.pqc.find_spec", return_value=MagicMock()):
            wrapper = PQCWrapper()
            assert wrapper is not None


class TestPQCWrapperKyberKeypair:
    """Test PQCWrapper.generate_kyber_keypair."""

    def _make_wrapper(self):
        with patch("src.core.shared.security.pqc.find_spec", return_value=MagicMock()):
            return PQCWrapper()

    def test_generate_kyber_keypair_success(self):
        wrapper = self._make_wrapper()
        mock_kem = MagicMock()
        mock_kem.generate_keypair.return_value = b"pub" * 100
        mock_kem.export_secret_key.return_value = b"priv" * 100
        mock_kem.__enter__ = MagicMock(return_value=mock_kem)
        mock_kem.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(KeyEncapsulation=MagicMock(return_value=mock_kem))}
        ):
            import sys

            oqs_mod = sys.modules["oqs"]
            oqs_mod.KeyEncapsulation.return_value = mock_kem
            result = wrapper.generate_kyber_keypair(768)

        assert result.algorithm == "kyber768"
        assert result.security_level == 3

    def test_generate_kyber_keypair_ml_kem_name_splits_to_string(self):
        """ml-kem-512 splits to string '512', which doesn't match int key 512 in nist_level_map.
        This is the actual code behavior -- it raises UnsupportedAlgorithmError."""
        wrapper = self._make_wrapper()
        mock_kem = MagicMock()
        mock_kem.generate_keypair.return_value = b"pub" * 100
        mock_kem.export_secret_key.return_value = b"priv" * 100
        mock_kem.__enter__ = MagicMock(return_value=mock_kem)
        mock_kem.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(KeyEncapsulation=MagicMock(return_value=mock_kem))}
        ):
            with pytest.raises(UnsupportedAlgorithmError):
                wrapper.generate_kyber_keypair("ml-kem-512")

    def test_generate_kyber_keypair_unsupported_level(self):
        wrapper = self._make_wrapper()
        with patch.dict("sys.modules", {"oqs": MagicMock()}):
            with pytest.raises(UnsupportedAlgorithmError):
                wrapper.generate_kyber_keypair(256)

    def test_generate_kyber_keypair_runtime_error(self):
        wrapper = self._make_wrapper()
        mock_oqs = MagicMock()
        mock_oqs.KeyEncapsulation.side_effect = RuntimeError("liboqs fail")
        with patch.dict("sys.modules", {"oqs": mock_oqs}):
            with pytest.raises(PQCKeyGenerationError):
                wrapper.generate_kyber_keypair(768)


class TestPQCWrapperDilithiumKeypair:
    """Test PQCWrapper.generate_dilithium_keypair."""

    def _make_wrapper(self):
        with patch("src.core.shared.security.pqc.find_spec", return_value=MagicMock()):
            return PQCWrapper()

    def test_generate_dilithium_keypair_success(self):
        wrapper = self._make_wrapper()
        mock_sig = MagicMock()
        mock_sig.generate_keypair.return_value = b"pub" * 100
        mock_sig.export_secret_key.return_value = b"priv" * 100
        mock_sig.__enter__ = MagicMock(return_value=mock_sig)
        mock_sig.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(Signature=MagicMock(return_value=mock_sig))}
        ):
            result = wrapper.generate_dilithium_keypair(3)

        assert result.algorithm == "dilithium3"
        assert result.security_level == 3

    def test_generate_dilithium_keypair_ml_dsa_name(self):
        wrapper = self._make_wrapper()
        mock_sig = MagicMock()
        mock_sig.generate_keypair.return_value = b"pub" * 100
        mock_sig.export_secret_key.return_value = b"priv" * 100
        mock_sig.__enter__ = MagicMock(return_value=mock_sig)
        mock_sig.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(Signature=MagicMock(return_value=mock_sig))}
        ):
            result = wrapper.generate_dilithium_keypair("ml-dsa-44")

        assert result.algorithm == "dilithium2"
        assert result.security_level == 2

    def test_generate_dilithium_keypair_unsupported_level(self):
        wrapper = self._make_wrapper()
        with patch.dict("sys.modules", {"oqs": MagicMock()}):
            with pytest.raises(UnsupportedAlgorithmError):
                wrapper.generate_dilithium_keypair(7)

    def test_generate_dilithium_keypair_runtime_error(self):
        wrapper = self._make_wrapper()
        mock_oqs = MagicMock()
        mock_oqs.Signature.side_effect = RuntimeError("fail")
        with patch.dict("sys.modules", {"oqs": mock_oqs}):
            with pytest.raises(PQCKeyGenerationError):
                wrapper.generate_dilithium_keypair(3)


class TestPQCWrapperSphincsKeypair:
    """Test PQCWrapper.generate_sphincs_keypair."""

    def _make_wrapper(self):
        with patch("src.core.shared.security.pqc.find_spec", return_value=MagicMock()):
            return PQCWrapper()

    def test_generate_sphincs_keypair_default(self):
        wrapper = self._make_wrapper()
        mock_sig = MagicMock()
        mock_sig.generate_keypair.return_value = b"pub" * 100
        mock_sig.export_secret_key.return_value = b"priv" * 100
        mock_sig.__enter__ = MagicMock(return_value=mock_sig)
        mock_sig.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(Signature=MagicMock(return_value=mock_sig))}
        ):
            result = wrapper.generate_sphincs_keypair()

        assert result.algorithm == "sphincssha2128ssimple"
        assert result.security_level == 1

    def test_generate_sphincs_keypair_256f(self):
        wrapper = self._make_wrapper()
        mock_sig = MagicMock()
        mock_sig.generate_keypair.return_value = b"pub" * 100
        mock_sig.export_secret_key.return_value = b"priv" * 100
        mock_sig.__enter__ = MagicMock(return_value=mock_sig)
        mock_sig.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(Signature=MagicMock(return_value=mock_sig))}
        ):
            result = wrapper.generate_sphincs_keypair("sha2-256f-simple")

        assert result.algorithm == "sphincssha2256fsimple"
        assert result.security_level == 5

    def test_generate_sphincs_keypair_unsupported_variant(self):
        wrapper = self._make_wrapper()
        with patch.dict("sys.modules", {"oqs": MagicMock()}):
            with pytest.raises(UnsupportedAlgorithmError):
                wrapper.generate_sphincs_keypair("sha2-999-bad")

    def test_generate_sphincs_keypair_runtime_error(self):
        wrapper = self._make_wrapper()
        mock_oqs = MagicMock()
        mock_oqs.Signature.side_effect = RuntimeError("fail")
        with patch.dict("sys.modules", {"oqs": mock_oqs}):
            with pytest.raises(PQCKeyGenerationError):
                wrapper.generate_sphincs_keypair()


class TestPQCWrapperSignVerify:
    """Test PQCWrapper signing and verification."""

    def _make_wrapper(self):
        with patch("src.core.shared.security.pqc.find_spec", return_value=MagicMock()):
            return PQCWrapper()

    def test_sign_dilithium_success(self):
        wrapper = self._make_wrapper()
        mock_sig = MagicMock()
        mock_sig.sign.return_value = b"signature_bytes"
        mock_sig.__enter__ = MagicMock(return_value=mock_sig)
        mock_sig.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(Signature=MagicMock(return_value=mock_sig))}
        ):
            result = wrapper.sign_dilithium(b"hello", b"privkey", 3)

        assert result == b"signature_bytes"

    def test_sign_dilithium_ml_dsa_name(self):
        wrapper = self._make_wrapper()
        mock_sig = MagicMock()
        mock_sig.sign.return_value = b"sig"
        mock_sig.__enter__ = MagicMock(return_value=mock_sig)
        mock_sig.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(Signature=MagicMock(return_value=mock_sig))}
        ):
            result = wrapper.sign_dilithium(b"msg", b"key", "ml-dsa-87")

        assert result == b"sig"

    def test_sign_dilithium_failure(self):
        wrapper = self._make_wrapper()
        mock_oqs = MagicMock()
        mock_oqs.Signature.side_effect = RuntimeError("sign error")
        with patch.dict("sys.modules", {"oqs": mock_oqs}):
            with pytest.raises(PQCSignatureError):
                wrapper.sign_dilithium(b"msg", b"key", 3)

    def test_verify_dilithium_success(self):
        wrapper = self._make_wrapper()
        mock_sig = MagicMock()
        mock_sig.verify.return_value = True
        mock_sig.__enter__ = MagicMock(return_value=mock_sig)
        mock_sig.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(Signature=MagicMock(return_value=mock_sig))}
        ):
            result = wrapper.verify_dilithium(b"msg", b"sig", b"pubkey", 3)

        assert result is True

    def test_verify_dilithium_invalid_signature(self):
        wrapper = self._make_wrapper()
        mock_sig = MagicMock()
        mock_sig.verify.return_value = False
        mock_sig.__enter__ = MagicMock(return_value=mock_sig)
        mock_sig.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(Signature=MagicMock(return_value=mock_sig))}
        ):
            result = wrapper.verify_dilithium(b"msg", b"bad_sig", b"pubkey", 3)

        assert result is False

    def test_verify_dilithium_ml_dsa_name(self):
        wrapper = self._make_wrapper()
        mock_sig = MagicMock()
        mock_sig.verify.return_value = True
        mock_sig.__enter__ = MagicMock(return_value=mock_sig)
        mock_sig.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(Signature=MagicMock(return_value=mock_sig))}
        ):
            result = wrapper.verify_dilithium(b"msg", b"sig", b"pub", "ml-dsa-44")

        assert result is True

    def test_verify_dilithium_failure(self):
        wrapper = self._make_wrapper()
        mock_oqs = MagicMock()
        mock_oqs.Signature.side_effect = RuntimeError("verify error")
        with patch.dict("sys.modules", {"oqs": mock_oqs}):
            with pytest.raises(PQCVerificationError):
                wrapper.verify_dilithium(b"msg", b"sig", b"pub", 3)


class TestPQCWrapperKEM:
    """Test PQCWrapper KEM operations."""

    def _make_wrapper(self):
        with patch("src.core.shared.security.pqc.find_spec", return_value=MagicMock()):
            return PQCWrapper()

    def test_encapsulate_kyber_success(self):
        wrapper = self._make_wrapper()
        mock_kem = MagicMock()
        mock_kem.encap_secret.return_value = (b"ciphertext", b"shared_secret")
        mock_kem.__enter__ = MagicMock(return_value=mock_kem)
        mock_kem.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(KeyEncapsulation=MagicMock(return_value=mock_kem))}
        ):
            result = wrapper.encapsulate_kyber(b"pubkey", 768)

        assert isinstance(result, KEMResult)
        assert result.ciphertext == b"ciphertext"
        assert result.shared_secret == b"shared_secret"
        assert result.algorithm == "kyber768"

    def test_encapsulate_kyber_ml_kem_name(self):
        wrapper = self._make_wrapper()
        mock_kem = MagicMock()
        mock_kem.encap_secret.return_value = (b"ct", b"ss")
        mock_kem.__enter__ = MagicMock(return_value=mock_kem)
        mock_kem.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(KeyEncapsulation=MagicMock(return_value=mock_kem))}
        ):
            result = wrapper.encapsulate_kyber(b"pubkey", "ml-kem-1024")

        assert result.algorithm == "kyber1024"

    def test_encapsulate_kyber_failure(self):
        wrapper = self._make_wrapper()
        mock_oqs = MagicMock()
        mock_oqs.KeyEncapsulation.side_effect = RuntimeError("encap fail")
        with patch.dict("sys.modules", {"oqs": mock_oqs}):
            with pytest.raises(PQCEncapsulationError):
                wrapper.encapsulate_kyber(b"pubkey", 768)

    def test_decapsulate_kyber_success(self):
        wrapper = self._make_wrapper()
        mock_kem = MagicMock()
        mock_kem.decap_secret.return_value = b"shared_secret_32bytes"
        mock_kem.__enter__ = MagicMock(return_value=mock_kem)
        mock_kem.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(KeyEncapsulation=MagicMock(return_value=mock_kem))}
        ):
            result = wrapper.decapsulate_kyber(b"ct", b"privkey", 768)

        assert result == b"shared_secret_32bytes"

    def test_decapsulate_kyber_ml_kem_name(self):
        wrapper = self._make_wrapper()
        mock_kem = MagicMock()
        mock_kem.decap_secret.return_value = b"ss"
        mock_kem.__enter__ = MagicMock(return_value=mock_kem)
        mock_kem.__exit__ = MagicMock(return_value=False)

        with patch.dict(
            "sys.modules", {"oqs": MagicMock(KeyEncapsulation=MagicMock(return_value=mock_kem))}
        ):
            result = wrapper.decapsulate_kyber(b"ct", b"key", "ml-kem-512")

        assert result == b"ss"

    def test_decapsulate_kyber_failure(self):
        wrapper = self._make_wrapper()
        mock_oqs = MagicMock()
        mock_oqs.KeyEncapsulation.side_effect = RuntimeError("decap fail")
        with patch.dict("sys.modules", {"oqs": mock_oqs}):
            with pytest.raises(PQCDecapsulationError):
                wrapper.decapsulate_kyber(b"ct", b"key", 768)


class TestPQCConstants:
    """Test PQC module-level constants."""

    def test_approved_classical_contains_expected(self):
        assert "Ed25519" in APPROVED_CLASSICAL
        assert "X25519" in APPROVED_CLASSICAL

    def test_approved_pqc_contains_expected(self):
        assert "ML-DSA-44" in APPROVED_PQC
        assert "ML-KEM-768" in APPROVED_PQC

    def test_nist_algorithm_aliases_mapping(self):
        assert NIST_ALGORITHM_ALIASES["dilithium3"] == "ML-DSA-65"
        assert NIST_ALGORITHM_ALIASES["kyber768"] == "ML-KEM-768"


# ============================================================================
# Auth Tests
# ============================================================================

from src.core.shared.security import auth

TEST_JWT_SECRET = "test-secret-key-that-is-at-least-32-chars-long"


def _make_settings(
    *,
    jwt_algorithm: str = "HS256",
    jwt_private_key: str = "",
    jwt_public_key: str = "SYSTEM_PUBLIC_KEY_PLACEHOLDER",
    jwt_secret_value: str | None = None,
) -> SimpleNamespace:
    jwt_secret = None
    if jwt_secret_value is not None:
        jwt_secret = SimpleNamespace(
            get_secret_value=lambda secret=jwt_secret_value: secret,
        )
    return SimpleNamespace(
        jwt_algorithm=jwt_algorithm,
        jwt_private_key=jwt_private_key,
        jwt_public_key=jwt_public_key,
        security=SimpleNamespace(
            jwt_secret=jwt_secret,
            jwt_public_key=jwt_public_key,
        ),
    )


class TestAuthHelpers:
    """Test auth helper functions."""

    def test_current_jwt_secret_from_env(self, monkeypatch):
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        result = auth._current_jwt_secret()
        assert result == TEST_JWT_SECRET

    def test_current_jwt_secret_from_env_key(self, monkeypatch):
        monkeypatch.delenv("JWT_SECRET", raising=False)
        monkeypatch.setenv("JWT_SECRET_KEY", "other-secret")
        monkeypatch.setattr(auth, "settings", _make_settings())
        result = auth._current_jwt_secret()
        assert result == "other-secret"

    def test_current_jwt_secret_from_settings(self, monkeypatch):
        monkeypatch.delenv("JWT_SECRET", raising=False)
        monkeypatch.delenv("JWT_SECRET_KEY", raising=False)
        monkeypatch.setattr(
            auth,
            "settings",
            _make_settings(jwt_secret_value="settings-secret"),
        )
        result = auth._current_jwt_secret()
        assert result == "settings-secret"

    def test_current_jwt_secret_returns_none(self, monkeypatch):
        monkeypatch.delenv("JWT_SECRET", raising=False)
        monkeypatch.delenv("JWT_SECRET_KEY", raising=False)
        monkeypatch.setattr(auth, "settings", _make_settings())
        result = auth._current_jwt_secret()
        assert result is None

    def test_has_jwt_secret_true(self, monkeypatch):
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        assert auth.has_jwt_secret() is True

    def test_has_jwt_secret_false(self, monkeypatch):
        monkeypatch.delenv("JWT_SECRET", raising=False)
        monkeypatch.delenv("JWT_SECRET_KEY", raising=False)
        monkeypatch.setattr(auth, "settings", _make_settings())
        assert auth.has_jwt_secret() is False

    def test_configured_jwt_algorithm_default_rs256(self, monkeypatch):
        monkeypatch.delenv("JWT_ALGORITHM", raising=False)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_algorithm=""))
        result = auth._configured_jwt_algorithm()
        assert result == "RS256"

    def test_configured_jwt_algorithm_from_env(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        result = auth._configured_jwt_algorithm()
        assert result == "HS256"

    def test_configured_jwt_algorithm_unsupported_raises(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "UNSUPPORTED")
        with pytest.raises(ConfigurationError) as exc_info:
            auth._configured_jwt_algorithm()
        assert exc_info.value.error_code == "JWT_ALGORITHM_NOT_ALLOWED"

    def test_configured_jwt_algorithm_case_insensitive(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "es256")
        result = auth._configured_jwt_algorithm()
        assert result == "ES256"


class TestCurrentJwtPrivateKey:
    """Test _current_jwt_private_key."""

    def test_from_env_direct_value(self, monkeypatch):
        monkeypatch.setenv("JWT_PRIVATE_KEY", "raw-key-material")
        with patch(
            "src.core.shared.security.auth.load_key_material", return_value="raw-key-material"
        ):
            monkeypatch.setattr(auth, "settings", _make_settings())
            result = auth._current_jwt_private_key()
            assert result == "raw-key-material"

    def test_from_settings_attribute(self, monkeypatch):
        monkeypatch.delenv("JWT_PRIVATE_KEY", raising=False)
        settings_obj = _make_settings()
        settings_obj.jwt_private_key = "settings-private-key"
        monkeypatch.setattr(auth, "settings", settings_obj)
        result = auth._current_jwt_private_key()
        assert result == "settings-private-key"

    def test_returns_none_when_unconfigured(self, monkeypatch):
        monkeypatch.delenv("JWT_PRIVATE_KEY", raising=False)
        monkeypatch.setattr(auth, "settings", _make_settings())
        result = auth._current_jwt_private_key()
        assert result is None

    def test_empty_env_returns_none(self, monkeypatch):
        monkeypatch.setenv("JWT_PRIVATE_KEY", "  ")
        monkeypatch.setattr(auth, "settings", _make_settings())
        result = auth._current_jwt_private_key()
        assert result is None


class TestCurrentJwtPublicKey:
    """Test _current_jwt_public_key."""

    def test_from_env_direct_value(self, monkeypatch):
        monkeypatch.setenv("JWT_PUBLIC_KEY", "raw-public-key")
        with patch(
            "src.core.shared.security.auth.load_key_material", return_value="raw-public-key"
        ):
            monkeypatch.setattr(auth, "settings", _make_settings())
            result = auth._current_jwt_public_key()
            assert result == "raw-public-key"

    def test_from_settings_security(self, monkeypatch):
        monkeypatch.delenv("JWT_PUBLIC_KEY", raising=False)
        settings_obj = _make_settings(jwt_public_key="actual-public-key")
        monkeypatch.setattr(auth, "settings", settings_obj)
        result = auth._current_jwt_public_key()
        assert result == "actual-public-key"

    def test_returns_none_for_placeholder(self, monkeypatch):
        monkeypatch.delenv("JWT_PUBLIC_KEY", raising=False)
        # Ensure settings has no jwt_public_key attribute (or empty)
        settings_obj = _make_settings()
        # Remove the attribute so getattr falls through to security check
        if hasattr(settings_obj, "jwt_public_key"):
            delattr(settings_obj, "jwt_public_key")
        monkeypatch.setattr(auth, "settings", settings_obj)
        result = auth._current_jwt_public_key()
        assert result is None

    def test_from_settings_attribute(self, monkeypatch):
        monkeypatch.delenv("JWT_PUBLIC_KEY", raising=False)
        settings_obj = _make_settings()
        settings_obj.jwt_public_key = "attr-public-key"
        monkeypatch.setattr(auth, "settings", settings_obj)
        result = auth._current_jwt_public_key()
        assert result == "attr-public-key"


class TestResolveJwtMaterial:
    """Test _resolve_jwt_material."""

    def test_hs256_signing(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))
        key, algo = auth._resolve_jwt_material(for_signing=True)
        assert key == TEST_JWT_SECRET
        assert algo == "HS256"

    def test_hs256_missing_secret_raises(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.delenv("JWT_SECRET", raising=False)
        monkeypatch.delenv("JWT_SECRET_KEY", raising=False)
        monkeypatch.setattr(auth, "settings", _make_settings())
        with pytest.raises(ConfigurationError) as exc_info:
            auth._resolve_jwt_material(for_signing=True)
        assert exc_info.value.error_code == "JWT_SECRET_MISSING"

    def test_rs256_missing_keys_raises(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "RS256")
        monkeypatch.delenv("JWT_PRIVATE_KEY", raising=False)
        monkeypatch.delenv("JWT_PUBLIC_KEY", raising=False)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_algorithm="RS256"))
        with pytest.raises(ConfigurationError) as exc_info:
            auth._resolve_jwt_material(for_signing=True)
        assert exc_info.value.error_code == "JWT_RSA_KEYS_MISSING"

    def test_es256_missing_keys_raises(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "ES256")
        monkeypatch.delenv("JWT_PRIVATE_KEY", raising=False)
        monkeypatch.delenv("JWT_PUBLIC_KEY", raising=False)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_algorithm="ES256"))
        with pytest.raises(ConfigurationError) as exc_info:
            auth._resolve_jwt_material(for_signing=True)
        assert exc_info.value.error_code == "JWT_ASYMMETRIC_KEYS_MISSING"

    def test_asymmetric_with_keys_signing(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "ES256")
        monkeypatch.delenv("JWT_PRIVATE_KEY", raising=False)
        monkeypatch.delenv("JWT_PUBLIC_KEY", raising=False)
        settings_obj = _make_settings(jwt_algorithm="ES256")
        settings_obj.jwt_private_key = "priv"
        settings_obj.jwt_public_key = "pub"
        settings_obj.security.jwt_public_key = "pub"
        monkeypatch.setattr(auth, "settings", settings_obj)
        key, algo = auth._resolve_jwt_material(for_signing=True)
        assert key == "priv"
        assert algo == "ES256"

    def test_asymmetric_with_keys_verification(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "ES256")
        monkeypatch.delenv("JWT_PRIVATE_KEY", raising=False)
        monkeypatch.delenv("JWT_PUBLIC_KEY", raising=False)
        settings_obj = _make_settings(jwt_algorithm="ES256")
        settings_obj.jwt_private_key = "priv"
        settings_obj.jwt_public_key = "pub"
        settings_obj.security.jwt_public_key = "pub"
        monkeypatch.setattr(auth, "settings", settings_obj)
        key, algo = auth._resolve_jwt_material(for_signing=False)
        assert key == "pub"
        assert algo == "ES256"


class TestCreateAccessToken:
    """Test create_access_token."""

    def test_creates_valid_token(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        token = auth.create_access_token(
            user_id="user-1",
            tenant_id="tenant-1",
            roles=["admin"],
            permissions=["read"],
        )
        assert isinstance(token, str)
        payload = jwt.decode(token, TEST_JWT_SECRET, algorithms=["HS256"], audience="acgs2-api")
        assert payload["sub"] == "user-1"
        assert payload["tenant_id"] == "tenant-1"
        assert payload["roles"] == ["admin"]
        assert payload["permissions"] == ["read"]
        assert payload["iss"] == "acgs2"
        assert payload["aud"] == "acgs2-api"
        assert payload["constitutional_hash"] == CONSTITUTIONAL_HASH
        assert "jti" in payload

    def test_default_expiration(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        token = auth.create_access_token(user_id="u", tenant_id="t")
        payload = jwt.decode(token, TEST_JWT_SECRET, algorithms=["HS256"], audience="acgs2-api")
        assert payload["roles"] == []
        assert payload["permissions"] == []

    def test_custom_expiration(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        token = auth.create_access_token(
            user_id="u",
            tenant_id="t",
            expires_delta=timedelta(minutes=5),
        )
        payload = jwt.decode(token, TEST_JWT_SECRET, algorithms=["HS256"], audience="acgs2-api")
        # Token should expire within ~5 min
        exp_dt = datetime.fromtimestamp(payload["exp"], tz=UTC)
        now = datetime.now(UTC)
        assert (exp_dt - now).total_seconds() < 310


class TestVerifyToken:
    """Test verify_token."""

    def _create_token(self, monkeypatch, **overrides):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        payload = {
            "sub": "user-1",
            "tenant_id": "tenant-1",
            "roles": ["admin"],
            "permissions": ["read"],
            "exp": datetime.now(UTC) + timedelta(hours=1),
            "iat": datetime.now(UTC),
            "iss": "acgs2",
            "aud": "acgs2-api",
            "jti": uuid.uuid4().hex,
            "constitutional_hash": CONSTITUTIONAL_HASH,
        }
        payload.update(overrides)
        return jwt.encode(payload, TEST_JWT_SECRET, algorithm="HS256")

    def test_verify_valid_token(self, monkeypatch):
        token = self._create_token(monkeypatch)
        claims = auth.verify_token(token)
        assert claims.sub == "user-1"
        assert claims.tenant_id == "tenant-1"

    def test_verify_expired_token(self, monkeypatch):
        token = self._create_token(
            monkeypatch,
            exp=datetime.now(UTC) - timedelta(hours=1),
        )
        with pytest.raises(HTTPException) as exc_info:
            auth.verify_token(token)
        assert exc_info.value.status_code == 401
        assert "expired" in exc_info.value.detail

    def test_verify_invalid_issuer(self, monkeypatch):
        token = self._create_token(monkeypatch, iss="evil-issuer")
        with pytest.raises(HTTPException) as exc_info:
            auth.verify_token(token)
        assert exc_info.value.status_code == 401
        assert "issuer" in exc_info.value.detail

    def test_verify_wrong_constitutional_hash(self, monkeypatch):
        token = self._create_token(
            monkeypatch,
            constitutional_hash="wrong_hash",
        )
        with pytest.raises(HTTPException) as exc_info:
            auth.verify_token(token)
        assert exc_info.value.status_code == 401
        assert "constitutional hash" in exc_info.value.detail

    def test_verify_missing_jti(self, monkeypatch):
        token = self._create_token(monkeypatch, jti="")
        with pytest.raises(HTTPException) as exc_info:
            auth.verify_token(token)
        assert exc_info.value.status_code == 401
        assert "JTI" in exc_info.value.detail

    def test_verify_invalid_token_string(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        with pytest.raises(HTTPException) as exc_info:
            auth.verify_token("not.a.valid.token")
        assert exc_info.value.status_code == 401

    def test_verify_config_error_becomes_500(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "RS256")
        monkeypatch.delenv("JWT_PRIVATE_KEY", raising=False)
        monkeypatch.delenv("JWT_PUBLIC_KEY", raising=False)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_algorithm="RS256"))

        with pytest.raises(HTTPException) as exc_info:
            auth.verify_token("any-token")
        assert exc_info.value.status_code == 500


class TestGetCurrentUser:
    """Test get_current_user dependency."""

    @pytest.mark.asyncio
    async def test_missing_credentials_raises(self):
        with pytest.raises(HTTPException) as exc_info:
            await auth.get_current_user(credentials=None)
        assert exc_info.value.status_code == 401

    @pytest.mark.asyncio
    async def test_valid_credentials(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))
        # Reset revocation service to avoid stale state
        monkeypatch.setattr(auth, "_revocation_service_initialized", False)
        monkeypatch.setattr(auth, "_revocation_service", None)
        monkeypatch.delenv("REDIS_URL", raising=False)

        token = auth.create_access_token(
            user_id="user-1",
            tenant_id="tenant-1",
            roles=["admin"],
            permissions=["read"],
        )
        creds = SimpleNamespace(credentials=token)
        claims = await auth.get_current_user(credentials=creds)
        assert claims.sub == "user-1"

    @pytest.mark.asyncio
    async def test_revoked_token_raises(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        mock_revocation = AsyncMock()
        mock_revocation.is_token_revoked = AsyncMock(return_value=True)
        monkeypatch.setattr(auth, "_revocation_service_initialized", True)
        monkeypatch.setattr(auth, "_revocation_service", mock_revocation)

        token = auth.create_access_token(
            user_id="user-1",
            tenant_id="tenant-1",
        )
        creds = SimpleNamespace(credentials=token)
        with pytest.raises(HTTPException) as exc_info:
            await auth.get_current_user(credentials=creds)
        assert exc_info.value.status_code == 401
        assert "revoked" in exc_info.value.detail

    @pytest.mark.asyncio
    async def test_revocation_check_failure_is_graceful(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        mock_revocation = AsyncMock()
        mock_revocation.is_token_revoked = AsyncMock(side_effect=Exception("redis down"))
        monkeypatch.setattr(auth, "_revocation_service_initialized", True)
        monkeypatch.setattr(auth, "_revocation_service", mock_revocation)

        token = auth.create_access_token(
            user_id="user-1",
            tenant_id="tenant-1",
        )
        creds = SimpleNamespace(credentials=token)
        # Should not raise - graceful degradation
        claims = await auth.get_current_user(credentials=creds)
        assert claims.sub == "user-1"


class TestGetCurrentUserOptional:
    """Test get_current_user_optional."""

    @pytest.mark.asyncio
    async def test_no_credentials_returns_none(self):
        request = MagicMock()
        request.headers = {}
        result = await auth.get_current_user_optional(request, credentials=None)
        assert result is None

    @pytest.mark.asyncio
    async def test_bearer_header_fallback(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        token = auth.create_access_token(user_id="u", tenant_id="t")
        request = MagicMock()
        request.headers = {"Authorization": f"Bearer {token}"}
        result = await auth.get_current_user_optional(request, credentials=None)
        assert result is not None
        assert result.sub == "u"

    @pytest.mark.asyncio
    async def test_invalid_token_returns_none(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        request = MagicMock()
        request.headers = {"Authorization": "Bearer invalid.token.here"}
        result = await auth.get_current_user_optional(request, credentials=None)
        assert result is None

    @pytest.mark.asyncio
    async def test_with_valid_credentials_object(self, monkeypatch):
        monkeypatch.setenv("JWT_ALGORITHM", "HS256")
        monkeypatch.setenv("JWT_SECRET", TEST_JWT_SECRET)
        monkeypatch.setattr(auth, "settings", _make_settings(jwt_secret_value=TEST_JWT_SECRET))

        token = auth.create_access_token(user_id="u2", tenant_id="t2")
        creds = SimpleNamespace(credentials=token)
        request = MagicMock()
        result = await auth.get_current_user_optional(request, credentials=creds)
        assert result is not None
        assert result.sub == "u2"


class TestRequireRole:
    """Test require_role dependency factory."""

    @pytest.mark.asyncio
    async def test_role_present(self):
        checker = auth.require_role("admin")
        user = auth.UserClaims(
            sub="u",
            tenant_id="t",
            roles=["admin"],
            permissions=[],
            exp=9999999999,
            iat=1000000000,
        )
        result = await checker(user=user)
        assert result.sub == "u"

    @pytest.mark.asyncio
    async def test_role_missing_raises(self):
        checker = auth.require_role("superadmin")
        user = auth.UserClaims(
            sub="u",
            tenant_id="t",
            roles=["admin"],
            permissions=[],
            exp=9999999999,
            iat=1000000000,
        )
        with pytest.raises(HTTPException) as exc_info:
            await checker(user=user)
        assert exc_info.value.status_code == 403


class TestRequirePermission:
    """Test require_permission dependency factory."""

    @pytest.mark.asyncio
    async def test_permission_present(self):
        checker = auth.require_permission("write")
        user = auth.UserClaims(
            sub="u",
            tenant_id="t",
            roles=[],
            permissions=["write"],
            exp=9999999999,
            iat=1000000000,
        )
        result = await checker(user=user)
        assert result.sub == "u"

    @pytest.mark.asyncio
    async def test_permission_missing_raises(self):
        checker = auth.require_permission("delete")
        user = auth.UserClaims(
            sub="u",
            tenant_id="t",
            roles=[],
            permissions=["read"],
            exp=9999999999,
            iat=1000000000,
        )
        with pytest.raises(HTTPException) as exc_info:
            await checker(user=user)
        assert exc_info.value.status_code == 403


class TestRequireTenantAccess:
    """Test require_tenant_access dependency factory."""

    @pytest.mark.asyncio
    async def test_matching_tenant(self):
        checker = auth.require_tenant_access("tenant-1")
        user = auth.UserClaims(
            sub="u",
            tenant_id="tenant-1",
            roles=[],
            permissions=[],
            exp=9999999999,
            iat=1000000000,
        )
        result = await checker(user=user, request=None)
        assert result.tenant_id == "tenant-1"

    @pytest.mark.asyncio
    async def test_mismatched_tenant_raises(self):
        checker = auth.require_tenant_access("tenant-1")
        user = auth.UserClaims(
            sub="u",
            tenant_id="tenant-2",
            roles=[],
            permissions=[],
            exp=9999999999,
            iat=1000000000,
        )
        with pytest.raises(HTTPException) as exc_info:
            await checker(user=user, request=None)
        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_request_state_tenant_mismatch(self):
        checker = auth.require_tenant_access()
        user = auth.UserClaims(
            sub="u",
            tenant_id="tenant-1",
            roles=[],
            permissions=[],
            exp=9999999999,
            iat=1000000000,
        )
        request = MagicMock()
        request.state.tenant_id = "tenant-2"
        with pytest.raises(HTTPException) as exc_info:
            await checker(user=user, request=request)
        assert exc_info.value.status_code == 403

    @pytest.mark.asyncio
    async def test_no_tenant_constraint_passes(self):
        checker = auth.require_tenant_access()
        user = auth.UserClaims(
            sub="u",
            tenant_id="tenant-1",
            roles=[],
            permissions=[],
            exp=9999999999,
            iat=1000000000,
        )
        result = await checker(user=user, request=None)
        assert result.tenant_id == "tenant-1"


class TestRevocationService:
    """Test _get_revocation_service."""

    def test_returns_cached_after_init(self, monkeypatch):
        monkeypatch.setattr(auth, "_revocation_service_initialized", True)
        monkeypatch.setattr(auth, "_revocation_service", "cached")
        assert auth._get_revocation_service() == "cached"

    def test_no_redis_url_returns_none(self, monkeypatch):
        monkeypatch.setattr(auth, "_revocation_service_initialized", False)
        monkeypatch.setattr(auth, "_revocation_service", None)
        monkeypatch.delenv("REDIS_URL", raising=False)
        result = auth._get_revocation_service()
        assert result is None
        # Reset for other tests
        monkeypatch.setattr(auth, "_revocation_service_initialized", False)

    def test_init_failure_returns_none(self, monkeypatch):
        monkeypatch.setattr(auth, "_revocation_service_initialized", False)
        monkeypatch.setattr(auth, "_revocation_service", None)
        monkeypatch.setenv("REDIS_URL", "redis://localhost:6379")
        # Patch the import of redis inside _get_revocation_service to fail
        with patch.dict("sys.modules", {"redis": None}):
            result = auth._get_revocation_service()
            # Should return None gracefully due to exception handler
            assert result is None
        # Reset
        monkeypatch.setattr(auth, "_revocation_service_initialized", False)


class TestUserClaimsModel:
    """Test UserClaims pydantic model."""

    def test_defaults(self):
        claims = auth.UserClaims(
            sub="u",
            tenant_id="t",
            roles=["r"],
            permissions=["p"],
            exp=999,
            iat=100,
        )
        assert claims.iss == "acgs2"
        assert claims.aud == "acgs2-api"
        assert claims.constitutional_hash == CONSTITUTIONAL_HASH
        assert len(claims.jti) > 0


class TestTokenResponse:
    """Test TokenResponse model."""

    def test_defaults(self):
        tr = auth.TokenResponse(access_token="abc")
        assert tr.token_type == "bearer"
        assert tr.access_token == "abc"


# ============================================================================
# ConfigMerger Tests
# ============================================================================

from src.core.shared.utilities.config_merger import ConfigMerger


class TestConfigMergerMerge:
    """Test ConfigMerger.merge."""

    def test_merge_none_base(self):
        result = ConfigMerger.merge(None, {"a": 1})
        assert result == {"a": 1}

    def test_merge_none_override(self):
        result = ConfigMerger.merge({"a": 1}, None)
        assert result == {"a": 1}

    def test_merge_both_none(self):
        result = ConfigMerger.merge(None, None)
        assert result == {}

    def test_deep_merge_nested_dicts(self):
        base = {"a": {"b": 1, "c": 2}}
        override = {"a": {"c": 3, "d": 4}}
        result = ConfigMerger.merge(base, override)
        assert result == {"a": {"b": 1, "c": 3, "d": 4}}

    def test_shallow_merge(self):
        base = {"a": {"b": 1, "c": 2}}
        override = {"a": {"d": 4}}
        result = ConfigMerger.merge(base, override, deep=False)
        assert result == {"a": {"d": 4}}

    def test_multiple_overrides(self):
        result = ConfigMerger.merge(
            {"a": 1},
            {"b": 2},
            {"c": 3},
        )
        assert result == {"a": 1, "b": 2, "c": 3}

    def test_does_not_mutate_base(self):
        base = {"a": {"b": 1}}
        override = {"a": {"c": 2}}
        ConfigMerger.merge(base, override)
        assert base == {"a": {"b": 1}}

    def test_deep_merge_list_values_copied(self):
        base = {"a": [1, 2]}
        override = {"a": [3, 4]}
        result = ConfigMerger.merge(base, override)
        assert result == {"a": [3, 4]}
        # Verify deep copy
        override["a"].append(5)
        assert result["a"] == [3, 4]

    def test_deep_merge_new_dict_value_copied(self):
        override = {"new_key": {"nested": "val"}}
        result = ConfigMerger.merge({}, override)
        override["new_key"]["nested"] = "changed"
        assert result["new_key"]["nested"] == "val"


class TestConfigMergerDeepMerge:
    """Test ConfigMerger.deep_merge."""

    def test_empty_args(self):
        result = ConfigMerger.deep_merge()
        assert result == {}

    def test_single_config(self):
        result = ConfigMerger.deep_merge({"a": 1})
        assert result == {"a": 1}

    def test_multiple_configs(self):
        result = ConfigMerger.deep_merge(
            {"a": 1, "b": 2},
            {"b": 3, "c": 4},
            {"c": 5, "d": 6},
        )
        assert result == {"a": 1, "b": 3, "c": 5, "d": 6}

    def test_skip_none_configs(self):
        result = ConfigMerger.deep_merge(None, {"a": 1}, None, {"b": 2})
        assert result == {"a": 1, "b": 2}

    def test_nested_deep_merge(self):
        result = ConfigMerger.deep_merge(
            {"db": {"host": "localhost", "port": 5432}},
            {"db": {"port": 5433, "name": "test"}},
        )
        assert result == {"db": {"host": "localhost", "port": 5433, "name": "test"}}


class TestConfigMergerMergeWithEnv:
    """Test ConfigMerger.merge_with_env."""

    def test_env_override(self):
        config = {"redis_url": "redis://default", "port": 8000}
        env = {"ACGS2_REDIS_URL": "redis://override", "ACGS2_PORT": "9000"}
        result = ConfigMerger.merge_with_env(config, "ACGS2", env_vars=env)
        assert result["redis_url"] == "redis://override"
        assert result["port"] == 9000

    def test_no_env_vars_returns_copy(self):
        config = {"a": 1}
        result = ConfigMerger.merge_with_env(config, "PREFIX", env_vars={})
        assert result == {"a": 1}
        assert result is not config

    def test_bool_coercion(self):
        config = {"debug": False}
        env = {"APP_DEBUG": "true"}
        result = ConfigMerger.merge_with_env(config, "APP", env_vars=env)
        assert result["debug"] is True

    def test_bool_coercion_variants(self):
        for truthy in ("true", "1", "yes", "on"):
            config = {"flag": False}
            result = ConfigMerger.merge_with_env(config, "X", env_vars={"X_FLAG": truthy})
            assert result["flag"] is True

        for falsy in ("false", "0", "no", "off"):
            config = {"flag": True}
            result = ConfigMerger.merge_with_env(config, "X", env_vars={"X_FLAG": falsy})
            assert result["flag"] is False

    def test_int_coercion_invalid_fallback(self):
        config = {"port": 8000}
        env = {"APP_PORT": "not_a_number"}
        result = ConfigMerger.merge_with_env(config, "APP", env_vars=env)
        assert result["port"] == 8000

    def test_float_coercion(self):
        config = {"rate": 1.5}
        env = {"APP_RATE": "2.5"}
        result = ConfigMerger.merge_with_env(config, "APP", env_vars=env)
        assert result["rate"] == 2.5

    def test_float_coercion_invalid_fallback(self):
        config = {"rate": 1.5}
        env = {"APP_RATE": "bad"}
        result = ConfigMerger.merge_with_env(config, "APP", env_vars=env)
        assert result["rate"] == 1.5

    def test_list_coercion(self):
        config = {"hosts": ["a"]}
        env = {"APP_HOSTS": "x, y, z"}
        result = ConfigMerger.merge_with_env(config, "APP", env_vars=env)
        assert result["hosts"] == ["x", "y", "z"]

    def test_list_coercion_empty_items_filtered(self):
        config = {"items": ["a"]}
        env = {"APP_ITEMS": "x,,y,  ,z"}
        result = ConfigMerger.merge_with_env(config, "APP", env_vars=env)
        assert result["items"] == ["x", "y", "z"]

    def test_none_reference_returns_string(self):
        config = {"val": None}
        env = {"APP_VAL": "hello"}
        result = ConfigMerger.merge_with_env(config, "APP", env_vars=env)
        assert result["val"] == "hello"

    def test_uses_os_environ_by_default(self, monkeypatch):
        monkeypatch.setenv("TEST_A", "env_val")
        config = {"a": "default"}
        result = ConfigMerger.merge_with_env(config, "TEST")
        assert result["a"] == "env_val"


class TestConfigMergerGetNested:
    """Test ConfigMerger.get_nested."""

    def test_simple_path(self):
        config = {"a": {"b": {"c": 42}}}
        assert ConfigMerger.get_nested(config, "a.b.c") == 42

    def test_missing_path_returns_default(self):
        config = {"a": 1}
        assert ConfigMerger.get_nested(config, "a.b.c", default="fallback") == "fallback"

    def test_non_dict_intermediate(self):
        config = {"a": "string_value"}
        assert ConfigMerger.get_nested(config, "a.b", default=None) is None

    def test_custom_separator(self):
        config = {"a": {"b": 99}}
        assert ConfigMerger.get_nested(config, "a/b", separator="/") == 99

    def test_none_value_returns_default(self):
        config = {"a": None}
        assert ConfigMerger.get_nested(config, "a", default="def") == "def"


class TestConfigMergerSetNested:
    """Test ConfigMerger.set_nested."""

    def test_set_simple_path(self):
        config: dict = {}
        ConfigMerger.set_nested(config, "a.b.c", 42)
        assert config == {"a": {"b": {"c": 42}}}

    def test_set_overwrites_existing(self):
        config = {"a": {"b": 1}}
        ConfigMerger.set_nested(config, "a.b", 2)
        assert config["a"]["b"] == 2

    def test_set_creates_intermediate_dicts(self):
        config = {"a": "not_a_dict"}
        ConfigMerger.set_nested(config, "a.b", 1)
        assert config["a"] == {"b": 1}

    def test_custom_separator(self):
        config: dict = {}
        ConfigMerger.set_nested(config, "a/b", 5, separator="/")
        assert config == {"a": {"b": 5}}

    def test_returns_config(self):
        config: dict = {}
        result = ConfigMerger.set_nested(config, "x", 1)
        assert result is config


class TestConfigMergerFilterKeys:
    """Test ConfigMerger.filter_keys."""

    def test_include_only(self):
        config = {"a": 1, "b": 2, "c": 3}
        result = ConfigMerger.filter_keys(config, include=["a", "c"])
        assert result == {"a": 1, "c": 3}

    def test_exclude_only(self):
        config = {"a": 1, "b": 2, "c": 3}
        result = ConfigMerger.filter_keys(config, exclude=["b"])
        assert result == {"a": 1, "c": 3}

    def test_include_and_exclude(self):
        config = {"a": 1, "b": 2, "c": 3}
        result = ConfigMerger.filter_keys(config, include=["a", "b"], exclude=["b"])
        assert result == {"a": 1}

    def test_no_filters(self):
        config = {"a": 1, "b": 2}
        result = ConfigMerger.filter_keys(config)
        assert result == {"a": 1, "b": 2}


class TestConfigMergerRedactSecrets:
    """Test ConfigMerger.redact_secrets."""

    def test_default_patterns(self):
        config = {
            "database_url": "postgres://...",
            "api_key": "secret123",
            "password": "pass",
            "name": "app",
        }
        result = ConfigMerger.redact_secrets(config)
        assert result["api_key"] == "***REDACTED***"
        assert result["password"] == "***REDACTED***"
        assert result["name"] == "app"
        assert result["database_url"] == "postgres://..."

    def test_custom_patterns(self):
        config = {"ssn": "123-45-6789", "name": "test"}
        result = ConfigMerger.redact_secrets(config, secret_patterns=["ssn"])
        assert result["ssn"] == "***REDACTED***"
        assert result["name"] == "test"

    def test_nested_redaction(self):
        config = {
            "db": {
                "password": "secret",
                "host": "localhost",
            }
        }
        result = ConfigMerger.redact_secrets(config)
        assert result["db"]["password"] == "***REDACTED***"
        assert result["db"]["host"] == "localhost"

    def test_list_with_dicts_redaction(self):
        config = {
            "services": [
                {"name": "svc1", "api_key": "key1"},
                {"name": "svc2"},
            ]
        }
        result = ConfigMerger.redact_secrets(config)
        assert result["services"][0]["api_key"] == "***REDACTED***"
        assert result["services"][0]["name"] == "svc1"
        assert result["services"][1]["name"] == "svc2"

    def test_custom_redacted_value(self):
        config = {"password": "secret"}
        result = ConfigMerger.redact_secrets(config, redacted_value="[HIDDEN]")
        assert result["password"] == "[HIDDEN]"

    def test_list_with_non_dict_items(self):
        config = {"tags": ["public", "v1"]}
        result = ConfigMerger.redact_secrets(config)
        assert result["tags"] == ["public", "v1"]


# ============================================================================
# TenantNormalizer Tests
# ============================================================================

from src.core.shared.utilities.tenant_normalizer import TenantNormalizer


class TestTenantNormalizerNormalize:
    """Test TenantNormalizer.normalize."""

    def test_none_input(self):
        assert TenantNormalizer.normalize(None) is None

    def test_empty_string(self):
        assert TenantNormalizer.normalize("") is None

    def test_whitespace_only(self):
        assert TenantNormalizer.normalize("   ") is None

    def test_strips_whitespace(self):
        assert TenantNormalizer.normalize("  tenant-1  ") == "tenant-1"

    def test_lowercases(self):
        assert TenantNormalizer.normalize("TENANT-ABC") == "tenant-abc"

    def test_unicode_normalization(self):
        # NFKC normalization
        result = TenantNormalizer.normalize("te\u0301nant")
        assert result is not None
        assert result == "t\xe9nant"


class TestTenantNormalizerValidate:
    """Test TenantNormalizer.validate."""

    def test_none_is_invalid(self):
        assert TenantNormalizer.validate(None) is False

    def test_empty_string_is_invalid(self):
        assert TenantNormalizer.validate("") is False

    def test_too_short(self):
        assert TenantNormalizer.validate("ab") is False

    def test_too_long(self):
        assert TenantNormalizer.validate("a" * 65) is False

    def test_valid_tenant(self):
        assert TenantNormalizer.validate("tenant-123") is True

    def test_valid_with_underscores(self):
        assert TenantNormalizer.validate("my_tenant_id") is True

    def test_special_characters_invalid(self):
        assert TenantNormalizer.validate("tenant@123") is False
        assert TenantNormalizer.validate("tenant 123") is False
        assert TenantNormalizer.validate("tenant.123") is False

    def test_reserved_tenants_invalid(self):
        for reserved in TenantNormalizer.RESERVED_TENANTS:
            assert TenantNormalizer.validate(reserved) is False

    def test_min_length_valid(self):
        assert TenantNormalizer.validate("abc") is True

    def test_max_length_valid(self):
        assert TenantNormalizer.validate("a" * 64) is True


class TestTenantNormalizerNormalizeAndValidate:
    """Test TenantNormalizer.normalize_and_validate."""

    def test_valid_input(self):
        normalized, valid = TenantNormalizer.normalize_and_validate("  ACME-Corp  ")
        assert normalized == "acme-corp"
        assert valid is True

    def test_none_input(self):
        normalized, valid = TenantNormalizer.normalize_and_validate(None)
        assert normalized is None
        assert valid is False

    def test_reserved_input(self):
        normalized, valid = TenantNormalizer.normalize_and_validate("  ADMIN  ")
        assert normalized == "admin"
        assert valid is False


class TestTenantNormalizerGetSafeTenant:
    """Test TenantNormalizer.get_safe_tenant."""

    def test_valid_tenant_returned(self):
        result = TenantNormalizer.get_safe_tenant("  valid-tenant  ")
        assert result == "valid-tenant"

    def test_invalid_tenant_returns_default(self):
        result = TenantNormalizer.get_safe_tenant(None)
        assert result == "default"

    def test_custom_default(self):
        result = TenantNormalizer.get_safe_tenant(None, default="fallback")
        assert result == "fallback"

    def test_reserved_tenant_returns_default(self):
        result = TenantNormalizer.get_safe_tenant("admin")
        assert result == "default"


class TestTenantNormalizerTenantsMatch:
    """Test TenantNormalizer.tenants_match."""

    def test_both_none(self):
        assert TenantNormalizer.tenants_match(None, None) is True

    def test_one_none(self):
        assert TenantNormalizer.tenants_match("tenant-1", None) is False
        assert TenantNormalizer.tenants_match(None, "tenant-1") is False

    def test_same_after_normalization(self):
        assert TenantNormalizer.tenants_match("  TENANT-1  ", "tenant-1") is True

    def test_different_tenants(self):
        assert TenantNormalizer.tenants_match("tenant-1", "tenant-2") is False

    def test_both_empty(self):
        assert TenantNormalizer.tenants_match("", "") is True  # both normalize to None


class TestTenantNormalizerIsReserved:
    """Test TenantNormalizer.is_reserved."""

    def test_reserved_tenant(self):
        assert TenantNormalizer.is_reserved("admin") is True
        assert TenantNormalizer.is_reserved("SYSTEM") is True

    def test_non_reserved_tenant(self):
        assert TenantNormalizer.is_reserved("my-tenant") is False

    def test_none_input(self):
        assert TenantNormalizer.is_reserved(None) is False

    def test_empty_input(self):
        assert TenantNormalizer.is_reserved("") is False
