# #region TestSecurityOrthogonal [C:3] [TYPE Module] [SEMANTICS test, security, orthogonal, edge-cases]
# @BRIEF Orthogonal security tests for critical auth/encryption modules.
#   Tests edge cases and invariants that existing tests miss:
#   - bcrypt 72-char limit, unicode passwords
#   - API key format validation, edge characters
#   - Fernet encryption: invalid ciphertext, empty data, corruption
#   - Encryption key lifecycle: missing .env, unwritable dir
#   - Log injection: CRLF in username, long messages
#   - JWT: expired, malformed, missing claims
# @RELATION BINDS_TO -> [AuthSecurityModule]
# @RELATION BINDS_TO -> [APIKeyUtilities]
# @RELATION BINDS_TO -> [EncryptionCore]
# @RELATION BINDS_TO -> [EncryptionKeyModule]
# @RELATION BINDS_TO -> [AuthLoggerModule]
# @TEST_CONTRACT: PasswordSecurity -> edge-case password handling
# @TEST_CONTRACT: ApiKeyFormat -> key structure invariants
# @TEST_CONTRACT: EncryptionRobustness -> error handling on invalid data
# @TEST_CONTRACT: KeyLifecycle -> encryption key resolution paths
# @TEST_CONTRACT: LogInjectionProtection -> CRLF/control chars in log input
# @TEST_INVARIANT: bcrypt_truncation -> bcrypt silently truncates at 72 bytes
# @TEST_INVARIANT: api_key_format -> ssk_ prefix + hash uniqueness
# @TEST_INVARIANT: fernet_symmetric -> encrypt/decrypt is reversible
# @TEST_INVARIANT: log_no_injection -> CRLF in input doesn't forge log entries
import os
from pathlib import Path
import pytest
import tempfile
from unittest.mock import patch

# ──────────────────────────────────────────────
# AUTH: Password security edge cases
# ──────────────────────────────────────────────


class TestPasswordSecurity:
    """Orthogonal tests for password hashing — edge cases bcrypt doesn't handle well."""

    # #region test_password_bcrypt_72_byte_limit [C:2] [TYPE Function]
    # @BRIEF bcrypt truncates passwords longer than 72 bytes — the first 72 bytes
    #   determine the hash. A password of 73 bytes where byte 73 differs should
    #   produce the SAME hash.
    # @TEST_EDGE: password_over_72_bytes -> bcrypt silently truncates
    def test_password_bcrypt_72_byte_limit(self):
        """bcrypt truncates at 72 bytes: passwords differing only after byte 72 match."""
        from src.core.auth.security import get_password_hash, verify_password

        # 80-byte passwords differing only at position 73+
        pwd1 = "A" * 72 + "B" * 8
        pwd2 = "A" * 72 + "C" * 8

        h1 = get_password_hash(pwd1)
        assert verify_password(pwd2, h1)  # bcrypt sees same first 72 bytes
    # #endregion test_password_bcrypt_72_byte_limit

    # #region test_password_unicode_normalization [C:2] [TYPE Function]
    # @BRIEF Unicode composed vs decomposed forms — bcrypt operates on bytes,
    #   so "é" (U+00E9) != "e" + combining accent (U+0065 U+0301).
    # @TEST_EDGE: unicode_decomposition -> different byte sequences fail
    def test_password_unicode_normalization(self):
        """Unicode normalization: NFC != NFD produces different hashes."""
        import unicodedata

        from src.core.auth.security import get_password_hash, verify_password
        nfc = unicodedata.normalize("NFC", "café")      # single codepoint é
        nfd = unicodedata.normalize("NFD", "café")      # e + combining accent

        assert nfc != nfd  # different byte sequences

        h = get_password_hash(nfc)
        assert verify_password(nfd, h) is False  # should NOT match
    # #endregion test_password_unicode_normalization

    # #region test_password_empty_and_whitespace [C:2] [TYPE Function]
    # @BRIEF Empty password, space-only password, null hash.
    def test_password_empty_and_whitespace(self):
        from src.core.auth.security import get_password_hash, verify_password

        # Empty password — should hash and verify
        h = get_password_hash("")
        assert verify_password("", h)

        # Space-only password
        h = get_password_hash("   ")
        assert verify_password("   ", h)

        # Verify against None — should return False, not crash
        assert not verify_password("password", None)
        assert not verify_password("password", "")
    # #endregion test_password_empty_and_whitespace


# ──────────────────────────────────────────────
# AUTH: API key format invariants
# ──────────────────────────────────────────────


class TestApiKeyFormat:
    """Orthogonal tests for API key generation — format invariants and edge cases."""

    # #region test_api_key_format_invariants [C:2] [TYPE Function]
    # @BRIEF Every generated key must start with ssk_, prefix is exactly 11 chars,
    #   key_hash is 64 hex chars, raw_key contains only URL-safe base64.
    def test_api_key_format_invariants(self):
        from src.core.auth.api_key import generate_api_key

        for _ in range(100):
            raw, prefix, key_hash = generate_api_key()

            assert raw.startswith("ssk_"), f"Key must start with ssk_: {raw[:10]}"
            assert len(prefix) == 11, f"Prefix must be 11 chars: {prefix}"
            assert prefix == raw[:11], f"Prefix mismatch: {prefix} != {raw[:11]}"
            assert len(key_hash) == 64, f"Hash must be 64 hex chars: {key_hash}"
            int(key_hash, 16)  # must be valid hex — raises ValueError if not

            # Raw key is URL-safe base64 after ssk_ prefix
            body = raw[4:]  # after "ssk_"
            import string
            allowed = set(string.ascii_letters + string.digits + "-_")
            assert all(c in allowed for c in body), f"Invalid chars in key body: {body[:10]}"
    # #endregion test_api_key_format_invariants

    # #region test_api_key_hash_uniqueness [C:2] [TYPE Function]
    # @BRIEF 1000 generated keys must all have unique raw keys and unique hashes.
    def test_api_key_hash_uniqueness(self):
        from src.core.auth.api_key import generate_api_key

        raws = set()
        hashes = set()
        prefixes = set()

        for _ in range(1000):
            raw, prefix, key_hash = generate_api_key()
            raws.add(raw)
            hashes.add(key_hash)
            prefixes.add(prefix)

        assert len(raws) == 1000   # all raws unique
        assert len(hashes) == 1000 # all hashes unique
        assert len(prefixes) <= 1000  # prefixes may collide (only 7 chars)
    # #endregion test_api_key_hash_uniqueness

    # #region test_hash_api_key_consistency [C:2] [TYPE Function]
    # @BRIEF hash_api_key is deterministic — same input = same output.
    def test_hash_api_key_consistency(self):
        from src.core.auth.api_key import hash_api_key

        key = "ssk_test-key-for-consistency-check-123"
        h1 = hash_api_key(key)
        h2 = hash_api_key(key)
        assert h1 == h2
        assert len(h1) == 64
    # #endregion test_hash_api_key_consistency


# ──────────────────────────────────────────────
# ENCRYPTION: Fernet robustness
# ──────────────────────────────────────────────


class TestEncryptionRobustness:
    """Orthogonal tests for EncryptionManager — error handling on invalid data."""

    @pytest.fixture(autouse=True)
    def _setup_key(self):
        """Ensure ENCRYPTION_KEY is set."""
        if not os.getenv("ENCRYPTION_KEY"):
            from cryptography.fernet import Fernet
            os.environ["ENCRYPTION_KEY"] = Fernet.generate_key().decode()

    # #region test_encrypt_decrypt_empty [C:2] [TYPE Function]
    # @BRIEF Empty string should encrypt and decrypt back to empty string.
    def test_encrypt_decrypt_empty(self):
        from src.core.encryption import EncryptionManager

        mgr = EncryptionManager()
        encrypted = mgr.encrypt("")
        decrypted = mgr.decrypt(encrypted)
        assert decrypted == ""
    # #endregion test_encrypt_decrypt_empty

    # #region test_decrypt_invalid_data [C:2] [TYPE Function]
    # @BRIEF Decrypting garbage, wrong key, truncated data must raise.
    def test_decrypt_invalid_data(self):
        from src.core.encryption import EncryptionManager

        mgr = EncryptionManager()

        # Garbage data
        with pytest.raises(Exception):
            mgr.decrypt("not-encrypted-data")

        # Empty string
        with pytest.raises(Exception):
            mgr.decrypt("")

        # Truncated valid token
        valid = mgr.encrypt("test")
        with pytest.raises(Exception):
            mgr.decrypt(valid[:-10])
    # #endregion test_decrypt_invalid_data

    # #region test_encrypt_decrypt_large_data [C:2] [TYPE Function]
    # @BRIEF Large strings (1MB) should encrypt/decrypt without error.
    def test_encrypt_decrypt_large_data(self):
        from src.core.encryption import EncryptionManager

        mgr = EncryptionManager()
        large = "x" * (1024 * 1024)  # 1MB
        encrypted = mgr.encrypt(large)
        decrypted = mgr.decrypt(encrypted)
        assert decrypted == large
    # #endregion test_encrypt_decrypt_large_data


# ──────────────────────────────────────────────
# ENCRYPTION KEY: lifecycle paths
# ──────────────────────────────────────────────


class TestEncryptionKeyLifecycle:
    """Orthogonal tests for ensure_encryption_key — resolution strategy."""

    # #region test_ensure_encryption_key_from_env [C:2] [TYPE Function]
    # @BRIEF When ENCRYPTION_KEY is set in environment, return it without side effects.
    def test_ensure_encryption_key_from_env(self):
        from cryptography.fernet import Fernet

        from src.core.encryption_key import ensure_encryption_key

        expected = Fernet.generate_key().decode()
        with patch.dict(os.environ, {"ENCRYPTION_KEY": expected}, clear=False):
            # Ensure no .env file is accessed — use a non-existent path
            result = ensure_encryption_key(Path("/nonexistent/.env"))
            assert result == expected
    # #endregion test_ensure_encryption_key_from_env

    # #region test_ensure_encryption_key_missing_raises [C:2] [TYPE Function]
    # @BRIEF When neither env nor .env has a key, crash-early with RuntimeError.
    # @TEST_EDGE: missing_encryption_key -> RuntimeError with clear message
    def test_ensure_encryption_key_missing_raises(self):
        from src.core.encryption_key import ensure_encryption_key

        with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
            f.write("# empty .env\n")
            env_path = Path(f.name)

        try:
            old = os.environ.pop("ENCRYPTION_KEY", None)
            try:
                with pytest.raises(RuntimeError, match="ENCRYPTION_KEY"):
                    ensure_encryption_key(env_path)
            finally:
                if old is not None:
                    os.environ["ENCRYPTION_KEY"] = old
        finally:
            env_path.unlink(missing_ok=True)
    # #endregion test_ensure_encryption_key_missing_raises

    # #region test_ensure_encryption_key_loads_from_file [C:2] [TYPE Function]
    # @BRIEF When env is empty but .env file has the key, load it.
    def test_ensure_encryption_key_loads_from_file(self):
        from cryptography.fernet import Fernet

        from src.core.encryption_key import ensure_encryption_key

        key = Fernet.generate_key().decode()
        old = os.environ.pop("ENCRYPTION_KEY", None)

        try:
            with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
                f.write(f"ENCRYPTION_KEY={key}\n")
                env_path = Path(f.name)

            try:
                result = ensure_encryption_key(env_path)
                assert result == key
                assert os.environ["ENCRYPTION_KEY"] == key
            finally:
                env_path.unlink(missing_ok=True)
        finally:
            if old is not None:
                os.environ["ENCRYPTION_KEY"] = old
    # #endregion test_ensure_encryption_key_loads_from_file


# ──────────────────────────────────────────────
# AUTH LOGGER: log injection protection
# ──────────────────────────────────────────────


class TestLogInjectionProtection:
    """Orthogonal tests for auth logger — verify CRLF/control chars don't forge logs."""

    # #region test_log_security_event_injection [C:2] [TYPE Function]
    # @BRIEF CRLF in username or event_type must not create forged log entries.
    #   Our fix uses %s-formatting which prevents CRLF injection.
    def test_log_security_event_injection(self):
        from src.core.auth.logger import log_security_event

        # Username with embedded newline — this would forge a log entry with plain f-strings
        malicious = "admin\n[AUDIT][FAKE] User: attacker"
        # Should not raise, should not create a fake entry
        log_security_event("LOGIN_SUCCESS", malicious, {"source": "test"})
        # (We verify by checking that no exception occurs — actual log inspection
        #  would require a log handler fixture)
    # #endregion test_log_security_event_injection

    # #region test_log_security_event_long_input [C:2] [TYPE Function]
    # @BRIEF Very long username/event_type must not crash the logger.
    def test_log_security_event_long_input(self):
        from src.core.auth.logger import log_security_event

        long_username = "u" * 10000
        log_security_event("TEST_EVENT", long_username, {"detail": "x" * 10000})
    # #endregion test_log_security_event_long_input

    # #region test_log_security_event_special_chars [C:2] [TYPE Function]
    # @BRIEF Unicode, control chars, emoji in username must not break logging.
    def test_log_security_event_special_chars(self):
        from src.core.auth.logger import log_security_event

        log_security_event("LOGIN_SUCCESS", "user@domain.com\u0000null_bytes")
        log_security_event("LOGIN_SUCCESS", "user\u0001\u0002\u001fcontrol_chars")
        log_security_event("LOGIN_SUCCESS", "user🔥emoji")
        log_security_event("LOGIN_SUCCESS", "пользователь_кириллица")
    # #endregion test_log_security_event_special_chars


# ──────────────────────────────────────────────
# DEPENDENCIES: JWT edge cases
# ──────────────────────────────────────────────


class TestJwtEdgeCases:
    """Orthogonal tests for JWT token handling — edge cases in decode/validation."""

    # #region test_decode_expired_token [C:2] [TYPE Function]
    # @BRIEF Token with past expiration must raise JWTError.
    def test_decode_expired_token(self):
        # Create an already-expired token
        import time

        from jose import JWTError, jwt

        from src.core.auth.config import auth_config
        payload = {"sub": "testuser", "exp": int(time.time()) - 3600}  # 1 hour ago
        token = jwt.encode(payload, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM)

        from src.core.auth.jwt import decode_token
        with pytest.raises(JWTError):
            decode_token(token)
    # #endregion test_decode_expired_token

    # #region test_decode_malformed_token [C:2] [TYPE Function]
    # @BRIEF Malformed JWT (wrong format, wrong key) must raise JWTError.
    def test_decode_malformed_token(self):
        from jose import JWTError

        from src.core.auth.jwt import decode_token

        with pytest.raises(JWTError):
            decode_token("not.a.token")

        with pytest.raises(JWTError):
            decode_token("")

        # Token with wrong algorithm
        from jose import jwt
        token = jwt.encode({"sub": "test"}, "wrong-key", algorithm="HS256")
        with pytest.raises(JWTError):
            decode_token(token)
    # #endregion test_decode_malformed_token

    # #region test_decode_token_missing_sub [C:2] [TYPE Function]
    # @BRIEF Token without 'sub' claim should decode but downstream should handle.
    def test_decode_token_missing_sub(self):
        from src.core.auth.jwt import create_access_token, decode_token

        token = create_access_token(data={"role": "admin"})  # no 'sub'
        payload = decode_token(token)
        assert "sub" not in payload
        assert payload.get("role") == "admin"
    # #endregion test_decode_token_missing_sub


# ──────────────────────────────────────────────
# RATE LIMITER
# ──────────────────────────────────────────────


class TestRateLimiter:
    """Orthogonal tests for in-memory rate limiter."""

    # #region test_rate_limiter_allows_under_limit [C:2] [TYPE Function]
    # @BRIEF Under limit: not banned, attempts recorded.
    def test_rate_limiter_allows_under_limit(self):
        from src.core.rate_limiter import RateLimiter

        rl = RateLimiter()
        assert not rl.is_banned("1.2.3.4")
        for _ in range(5):
            rl.record_attempt("1.2.3.4")
        assert not rl.is_banned("1.2.3.4")
    # #endregion test_rate_limiter_allows_under_limit

    # #region test_rate_limiter_bans_over_limit [C:2] [TYPE Function]
    # @BRIEF Over limit: IP is banned, success clears history.
    def test_rate_limiter_bans_over_limit(self):
        from src.core.rate_limiter import MAX_ATTEMPTS, RateLimiter

        rl = RateLimiter()
        ip = "5.6.7.8"
        for _ in range(MAX_ATTEMPTS + 1):
            rl.record_attempt(ip)
        assert rl.is_banned(ip)

        # After ban, success should clear (but may still be banned)
        rl.record_success(ip)
    # #endregion test_rate_limiter_bans_over_limit

    # #region test_rate_limiter_success_clears_history [C:2] [TYPE Function]
    # @BRIEF Successful auth clears attempt history for that IP.
    def test_rate_limiter_success_clears_history(self):
        from src.core.rate_limiter import RateLimiter

        rl = RateLimiter()
        rl.record_attempt("9.9.9.9")
        rl.record_attempt("9.9.9.9")
        rl.record_success("9.9.9.9")
        # After success, the attempts dict is cleared — we can make more without ban
        for _ in range(5):
            rl.record_attempt("9.9.9.9")
        assert not rl.is_banned("9.9.9.9")
    # #endregion test_rate_limiter_success_clears_history

    # #region test_rate_limiter_different_ips_independent [C:2] [TYPE Function]
    # @BRIEF Different IPs are tracked independently.
    def test_rate_limiter_different_ips_independent(self):
        from src.core.rate_limiter import MAX_ATTEMPTS, RateLimiter

        rl = RateLimiter()
        # One IP gets banned
        for _ in range(MAX_ATTEMPTS + 1):
            rl.record_attempt("bad-ip")
        assert rl.is_banned("bad-ip")
        # Other IP is not affected
        assert not rl.is_banned("good-ip")
    # #endregion test_rate_limiter_different_ips_independent


# ──────────────────────────────────────────────
# PASSWORD POLICY
# ──────────────────────────────────────────────


class TestPasswordPolicy:
    """Orthogonal tests for password strength validation."""

    # #region test_password_too_short [C:2] [TYPE Function]
    def test_password_too_short(self):
        from pydantic import ValidationError

        from src.schemas.auth import UserCreate

        with pytest.raises(ValidationError):
            UserCreate(username="test", password="Ab1")  # only 3 chars
    # #endregion test_password_too_short

    # #region test_password_no_uppercase [C:2] [TYPE Function]
    def test_password_no_uppercase(self):
        from pydantic import ValidationError

        from src.schemas.auth import UserCreate

        with pytest.raises(ValidationError):
            UserCreate(username="test", password="abcdefgh1")  # no uppercase
    # #endregion test_password_no_uppercase

    # #region test_password_no_digit [C:2] [TYPE Function]
    def test_password_no_digit(self):
        from pydantic import ValidationError

        from src.schemas.auth import UserCreate

        with pytest.raises(ValidationError):
            UserCreate(username="test", password="Abcdefgh")  # no digit
    # #endregion test_password_no_digit

    # #region test_password_valid [C:2] [TYPE Function]
    def test_password_valid(self):
        from src.schemas.auth import UserCreate

        u = UserCreate(username="test", password="ValidPass1")
        assert u.password == "ValidPass1"
    # #endregion test_password_valid


# ──────────────────────────────────────────────
# LOG MASKING
# ──────────────────────────────────────────────


class TestLogMasking:
    """Orthogonal tests for sensitive field masking in log_security_event."""

    # #region test_mask_sensitive_fields [C:2] [TYPE Function]
    def test_mask_sensitive_fields(self):
        from src.core.auth.logger import _mask_details

        masked = _mask_details({
            "username": "john",
            "password": "supersecret",
            "token": "eyJhbGciOiJIUzI1NiJ9.xxx",
            "api_key": "ssk_abc123",
            "source": "LOCAL",
        })
        assert masked["username"] == "john"
        assert masked["password"] == "***"
        assert masked["token"] == "***"
        assert masked["api_key"] == "***"
        assert masked["source"] == "LOCAL"
    # #endregion test_mask_sensitive_fields

    # #region test_mask_nested_details [C:2] [TYPE Function]
    def test_mask_nested_details(self):
        from src.core.auth.logger import _mask_details

        masked = _mask_details({
            "user": {"username": "john", "password": "secret"},
        })
        assert masked["user"]["username"] == "john"
        assert masked["user"]["password"] == "***"
    # #endregion test_mask_nested_details


# #endregion TestSecurityOrthogonal
