"""Unit tests for configuration encryption module.

Tests encryption/decryption of sensitive configuration values,
key management, and configuration dictionary encryption.
"""

import os
import stat
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from cryptography.fernet import Fernet

from src.infrastructure.security.config_encryption import (
    ConfigEncryption,
    get_config_encryption,
)


class TestConfigEncryptionInit:
    """Tests for ConfigEncryption initialization."""

    def test_creates_key_file_if_not_exists(self, tmp_path: Path):
        """Key file is generated on init when it doesn't exist."""
        key_file = tmp_path / "encryption.key"
        enc = ConfigEncryption(key_file=key_file)
        assert key_file.exists()
        # Key should be valid Fernet key
        key = key_file.read_bytes()
        Fernet(key)  # Raises if invalid

    def test_uses_existing_key_file(self, tmp_path: Path):
        """Existing key file is reused without regeneration."""
        key_file = tmp_path / "encryption.key"
        original_key = Fernet.generate_key()
        key_file.write_bytes(original_key)

        enc = ConfigEncryption(key_file=key_file)
        loaded_key = key_file.read_bytes()
        assert loaded_key == original_key

    def test_default_key_file_path(self):
        """Default key_file path points to data/encryption.key."""
        with patch.object(ConfigEncryption, "_ensure_key_exists"):
            enc = ConfigEncryption.__new__(ConfigEncryption)
            enc._cipher = None
            project_root = Path(__file__).parent.parent.parent
            expected = project_root / "data" / "encryption.key"
            # Just verify the constructor logic sets a reasonable default
            enc2 = ConfigEncryption.__new__(ConfigEncryption)
            enc2._cipher = None
            enc2.key_file = expected
            assert "encryption.key" in str(enc2.key_file)

    def test_key_file_permissions_set_600(self, tmp_path: Path):
        """Generated key file should have owner-only permissions (0o600)."""
        key_file = tmp_path / "encryption.key"
        ConfigEncryption(key_file=key_file)
        mode = oct(os.stat(key_file).st_mode & 0o777)
        assert mode == "0o600"


class TestKeyManagement:
    """Tests for key generation, loading, and cipher creation."""

    def test_generate_new_key_creates_valid_fernet_key(self, tmp_path: Path):
        """Generated key is a valid Fernet key."""
        key_file = tmp_path / "encryption.key"
        enc = ConfigEncryption(key_file=key_file)
        key = key_file.read_bytes()
        cipher = Fernet(key)
        # Should be able to encrypt/decrypt
        token = cipher.encrypt(b"test")
        assert cipher.decrypt(token) == b"test"

    def test_load_key_returns_bytes(self, tmp_path: Path):
        """_load_key returns raw bytes from key file."""
        key_file = tmp_path / "encryption.key"
        enc = ConfigEncryption(key_file=key_file)
        key = enc._load_key()
        assert isinstance(key, bytes)
        assert len(key) > 0

    def test_load_key_raises_if_file_missing(self, tmp_path: Path):
        """_load_key raises FileNotFoundError when key file is absent."""
        key_file = tmp_path / "nonexistent.key"
        enc = ConfigEncryption.__new__(ConfigEncryption)
        enc._cipher = None
        enc.key_file = key_file
        with pytest.raises(FileNotFoundError):
            enc._load_key()

    def test_get_cipher_returns_fernet(self, tmp_path: Path):
        """_get_cipher returns a Fernet instance."""
        key_file = tmp_path / "encryption.key"
        enc = ConfigEncryption(key_file=key_file)
        cipher = enc._get_cipher()
        assert isinstance(cipher, Fernet)

    def test_get_cipher_caches_instance(self, tmp_path: Path):
        """_get_cipher returns the same Fernet instance on repeated calls."""
        key_file = tmp_path / "encryption.key"
        enc = ConfigEncryption(key_file=key_file)
        cipher1 = enc._get_cipher()
        cipher2 = enc._get_cipher()
        assert cipher1 is cipher2


class TestEncryptDecrypt:
    """Tests for encrypt_value and decrypt_value methods."""

    @pytest.fixture
    def encryption(self, tmp_path: Path) -> ConfigEncryption:
        """Create a ConfigEncryption instance with temp key file."""
        return ConfigEncryption(key_file=tmp_path / "encryption.key")

    def test_encrypt_decrypt_roundtrip(self, encryption: ConfigEncryption):
        """Encrypting then decrypting returns the original value."""
        original = "my_secret_password"
        encrypted = encryption.encrypt_value(original)
        decrypted = encryption.decrypt_value(encrypted)
        assert decrypted == original

    def test_encrypt_value_returns_string(self, encryption: ConfigEncryption):
        """encrypt_value returns a base64 string, not bytes."""
        result = encryption.encrypt_value("test")
        assert isinstance(result, str)

    def test_encrypt_value_raises_on_empty(self, encryption: ConfigEncryption):
        """encrypt_value raises ValueError for empty string."""
        with pytest.raises(ValueError, match="Cannot encrypt empty value"):
            encryption.encrypt_value("")

    def test_decrypt_value_raises_on_empty(self, encryption: ConfigEncryption):
        """decrypt_value raises ValueError for empty string."""
        with pytest.raises(ValueError, match="Cannot decrypt empty value"):
            encryption.decrypt_value("")

    def test_decrypt_with_wrong_key_fails(self, tmp_path: Path):
        """Decrypting with a different key raises an exception."""
        enc1 = ConfigEncryption(key_file=tmp_path / "key1.key")
        enc2 = ConfigEncryption(key_file=tmp_path / "key2.key")
        encrypted = enc1.encrypt_value("secret")
        with pytest.raises(Exception):
            enc2.decrypt_value(encrypted)

    def test_encrypt_produces_different_ciphertext_each_time(
        self, encryption: ConfigEncryption
    ):
        """Two encryptions of same value produce different ciphertext (nonce)."""
        val = "same_value"
        enc1 = encryption.encrypt_value(val)
        enc2 = encryption.encrypt_value(val)
        assert enc1 != enc2

    def test_unicode_value_roundtrip(self, encryption: ConfigEncryption):
        """Unicode strings survive encrypt/decrypt roundtrip."""
        original = "Ünïcödé_тест_日本語"
        encrypted = encryption.encrypt_value(original)
        assert encryption.decrypt_value(encrypted) == original

    def test_special_characters_roundtrip(self, encryption: ConfigEncryption):
        """Special characters survive encrypt/decrypt roundtrip."""
        original = "p@$$w0rd!#%^&*(){}[]|\\:\";<>?/~`"
        encrypted = encryption.encrypt_value(original)
        assert encryption.decrypt_value(encrypted) == original

    def test_large_value_roundtrip(self, encryption: ConfigEncryption):
        """Large string values can be encrypted and decrypted."""
        original = "A" * 100_000
        encrypted = encryption.encrypt_value(original)
        assert encryption.decrypt_value(encrypted) == original


class TestConfigDictEncryption:
    """Tests for encrypt_config and decrypt_config methods."""

    @pytest.fixture
    def encryption(self, tmp_path: Path) -> ConfigEncryption:
        """Create a ConfigEncryption instance with temp key file."""
        return ConfigEncryption(key_file=tmp_path / "encryption.key")

    def test_encrypt_config_encrypts_sensitive_fields(
        self, encryption: ConfigEncryption
    ):
        """Sensitive fields (password, token, etc.) are encrypted."""
        config = {
            "password": "secret123",
            "api_key": "abcdef",
            "username": "admin",
        }
        encrypted = encryption.encrypt_config(config)

        # password and api_key should be wrapped
        assert isinstance(encrypted["password"], dict)
        assert encrypted["password"]["encrypted"] is True
        assert isinstance(encrypted["api_key"], dict)
        assert encrypted["api_key"]["encrypted"] is True

        # username should not be encrypted
        assert encrypted["username"] == "admin"

    def test_encrypt_config_skips_non_string_sensitive_values(
        self, encryption: ConfigEncryption
    ):
        """Non-string sensitive values (int, None) are left unencrypted."""
        config = {
            "password": 12345,
            "token": None,
        }
        encrypted = encryption.encrypt_config(config)
        assert encrypted["password"] == 12345
        assert encrypted["token"] is None

    def test_encrypt_config_skips_empty_string(
        self, encryption: ConfigEncryption
    ):
        """Empty string sensitive values are left unencrypted."""
        config = {"password": ""}
        encrypted = encryption.encrypt_config(config)
        assert encrypted["password"] == ""

    def test_decrypt_config_decrypts_encrypted_fields(
        self, encryption: ConfigEncryption
    ):
        """decrypt_config restores encrypted fields to plaintext."""
        config = {
            "password": "secret123",
            "username": "admin",
        }
        encrypted = encryption.encrypt_config(config)
        decrypted = encryption.decrypt_config(encrypted)
        assert decrypted["password"] == "secret123"
        assert decrypted["username"] == "admin"

    def test_decrypt_config_passes_through_non_encrypted(
        self, encryption: ConfigEncryption
    ):
        """Non-encrypted fields in config are passed through unchanged."""
        config = {"host": "localhost", "port": 8080}
        decrypted = encryption.decrypt_config(config)
        assert decrypted == config

    def test_decrypt_config_handles_decrypt_failure(
        self, encryption: ConfigEncryption
    ):
        """Failed decryption sets field value to None."""
        config = {
            "password": {
                "encrypted": True,
                "value": "invalid_base64_data!!!"
            }
        }
        decrypted = encryption.decrypt_config(config)
        assert decrypted["password"] is None

    def test_sensitive_field_detection_case_insensitive(
        self, encryption: ConfigEncryption
    ):
        """Sensitive field detection works case-insensitively."""
        config = {
            "PASSWORD": "secret",
            "Api_Key": "key123",
            "JWT_SECRET": "jwt_val",
        }
        encrypted = encryption.encrypt_config(config)
        for key in ("PASSWORD", "Api_Key", "JWT_SECRET"):
            assert isinstance(encrypted[key], dict)
            assert encrypted[key]["encrypted"] is True

    def test_all_sensitive_field_patterns_detected(
        self, encryption: ConfigEncryption
    ):
        """All defined sensitive field patterns trigger encryption."""
        patterns = [
            "password", "passwd", "secret", "key", "token",
            "api_key", "apikey", "auth_token", "jwt_secret",
            "master_password",
        ]
        config = {p: f"value_{p}" for p in patterns}
        encrypted = encryption.encrypt_config(config)
        for p in patterns:
            assert isinstance(encrypted[p], dict), (
                f"Field '{p}' was not encrypted"
            )


class TestKeyRotation:
    """Tests for key rotation functionality."""

    def test_rotate_key_generates_new_key(self, tmp_path: Path):
        """rotate_key generates a new encryption key file."""
        key_file = tmp_path / "encryption.key"
        enc = ConfigEncryption(key_file=key_file)
        old_key = key_file.read_bytes()
        enc.rotate_key()
        new_key = key_file.read_bytes()
        assert old_key != new_key

    def test_rotate_key_backs_up_old_key(self, tmp_path: Path):
        """rotate_key creates a .key.bak backup of the old key."""
        key_file = tmp_path / "encryption.key"
        enc = ConfigEncryption(key_file=key_file)
        old_key = key_file.read_bytes()
        enc.rotate_key()
        backup = tmp_path / "encryption.key.bak"
        assert backup.exists()
        assert backup.read_bytes() == old_key

    def test_rotate_key_resets_cipher_cache(self, tmp_path: Path):
        """rotate_key clears the cached cipher so new key is used."""
        key_file = tmp_path / "encryption.key"
        enc = ConfigEncryption(key_file=key_file)
        _ = enc._get_cipher()
        assert enc._cipher is not None
        enc.rotate_key()
        assert enc._cipher is None

    def test_rotate_key_invalidates_old_encrypted_data(self, tmp_path: Path):
        """Data encrypted with old key cannot be decrypted after rotation."""
        key_file = tmp_path / "encryption.key"
        enc = ConfigEncryption(key_file=key_file)
        encrypted = enc.encrypt_value("my_secret")
        enc.rotate_key()
        with pytest.raises(Exception):
            enc.decrypt_value(encrypted)

    def test_rotate_key_to_custom_path(self, tmp_path: Path):
        """rotate_key can target a custom key file path."""
        key_file = tmp_path / "old.key"
        new_key_file = tmp_path / "new.key"
        enc = ConfigEncryption(key_file=key_file)
        enc.rotate_key(new_key_file=new_key_file)
        assert new_key_file.exists()
        assert enc.key_file == new_key_file


class TestGlobalSingleton:
    """Tests for the get_config_encryption singleton function."""

    def test_get_config_encryption_returns_instance(self):
        """get_config_encryption returns a ConfigEncryption instance."""
        import src.infrastructure.security.config_encryption as mod
        old = mod._config_encryption
        try:
            mod._config_encryption = None
            with patch.object(ConfigEncryption, "__init__", return_value=None):
                instance = get_config_encryption()
                assert isinstance(instance, ConfigEncryption)
        finally:
            mod._config_encryption = old

    def test_get_config_encryption_returns_same_instance(self):
        """Repeated calls return the same singleton instance."""
        import src.infrastructure.security.config_encryption as mod
        old = mod._config_encryption
        try:
            mod._config_encryption = None
            with patch.object(ConfigEncryption, "__init__", return_value=None):
                inst1 = get_config_encryption()
                inst2 = get_config_encryption()
                assert inst1 is inst2
        finally:
            mod._config_encryption = old
