"""Tests for the secrets management module."""

from __future__ import annotations

import base64
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from gaol.secrets.crypto import (
    SALT_SIZE,
    SecretCrypto,
    decode_key,
    derive_key_from_password,
    encode_key,
    generate_master_key,
    generate_salt,
)
from gaol.secrets.exceptions import (
    InvalidSecretNameError,
    MasterKeyNotAvailableError,
    SecretDecryptionError,
    SecretExistsError,
    SecretNotFoundError,
    SecretStoreNotInitializedError,
)
from gaol.secrets.keyring import (
    EnvironmentProvider,
    KeyManager,
    KeySource,
)
from gaol.secrets.models import (
    EncryptedSecret,
    SecretsConfig,
    validate_secret_name,
)
from gaol.secrets.resolver import (
    SECRET_PATTERN,
    SecretResolver,
    has_secrets,
)
from gaol.secrets.store import SecretStore


# =============================================================================
# Crypto Tests
# =============================================================================


class TestSecretCrypto:
    """Tests for AES-256-GCM encryption."""

    def test_encrypt_decrypt_roundtrip(self) -> None:
        """Test that encryption and decryption work correctly."""
        key = generate_master_key()
        crypto = SecretCrypto(key)

        plaintext = "super-secret-password-123!"
        encrypted = crypto.encrypt(plaintext)
        decrypted = crypto.decrypt(encrypted)

        assert decrypted == plaintext

    def test_different_encryptions_produce_different_ciphertext(self) -> None:
        """Test that encrypting the same value twice produces different results."""
        key = generate_master_key()
        crypto = SecretCrypto(key)

        plaintext = "test-value"
        encrypted1 = crypto.encrypt(plaintext)
        encrypted2 = crypto.encrypt(plaintext)

        # Different nonces should produce different ciphertext
        assert encrypted1 != encrypted2

        # Both should decrypt to the same value
        assert crypto.decrypt(encrypted1) == plaintext
        assert crypto.decrypt(encrypted2) == plaintext

    def test_decrypt_with_wrong_key_fails(self) -> None:
        """Test that decryption fails with the wrong key."""
        key1 = generate_master_key()
        key2 = generate_master_key()

        crypto1 = SecretCrypto(key1)
        crypto2 = SecretCrypto(key2)

        encrypted = crypto1.encrypt("secret")

        with pytest.raises(SecretDecryptionError):
            crypto2.decrypt(encrypted)

    def test_decrypt_corrupted_data_fails(self) -> None:
        """Test that decryption fails with corrupted data."""
        key = generate_master_key()
        crypto = SecretCrypto(key)

        encrypted = crypto.encrypt("secret")
        # Corrupt the ciphertext
        corrupted = encrypted[:-4] + "XXXX"

        with pytest.raises(SecretDecryptionError):
            crypto.decrypt(corrupted)

    def test_encrypt_empty_string(self) -> None:
        """Test that empty strings can be encrypted."""
        key = generate_master_key()
        crypto = SecretCrypto(key)

        encrypted = crypto.encrypt("")
        decrypted = crypto.decrypt(encrypted)

        assert decrypted == ""

    def test_encrypt_unicode(self) -> None:
        """Test that unicode strings can be encrypted."""
        key = generate_master_key()
        crypto = SecretCrypto(key)

        plaintext = "密码🔐パスワード"
        encrypted = crypto.encrypt(plaintext)
        decrypted = crypto.decrypt(encrypted)

        assert decrypted == plaintext


class TestKeyDerivation:
    """Tests for key derivation functions."""

    def test_derive_key_from_password(self) -> None:
        """Test that password derivation produces consistent results."""
        salt = generate_salt()
        password = "test-password"

        key1 = derive_key_from_password(password, salt, iterations=1000)
        key2 = derive_key_from_password(password, salt, iterations=1000)

        assert key1 == key2
        assert len(key1) == 32  # 256 bits

    def test_different_salts_produce_different_keys(self) -> None:
        """Test that different salts produce different keys."""
        salt1 = generate_salt()
        salt2 = generate_salt()
        password = "test-password"

        key1 = derive_key_from_password(password, salt1, iterations=1000)
        key2 = derive_key_from_password(password, salt2, iterations=1000)

        assert key1 != key2

    def test_different_passwords_produce_different_keys(self) -> None:
        """Test that different passwords produce different keys."""
        salt = generate_salt()

        key1 = derive_key_from_password("password1", salt, iterations=1000)
        key2 = derive_key_from_password("password2", salt, iterations=1000)

        assert key1 != key2


class TestKeyEncoding:
    """Tests for key encoding/decoding."""

    def test_encode_decode_roundtrip(self) -> None:
        """Test that encoding and decoding work correctly."""
        key = generate_master_key()
        encoded = encode_key(key)
        decoded = decode_key(encoded)

        assert decoded == key

    def test_generate_master_key_length(self) -> None:
        """Test that generated keys are the correct length."""
        key = generate_master_key()
        assert len(key) == 32  # 256 bits

    def test_generate_salt_length(self) -> None:
        """Test that generated salts are the correct length."""
        salt = generate_salt()
        assert len(salt) == SALT_SIZE  # 32 bytes


# =============================================================================
# Models Tests
# =============================================================================


class TestValidateSecretName:
    """Tests for secret name validation."""

    def test_valid_names(self) -> None:
        """Test that valid names are accepted."""
        valid_names = [
            "DB_PASSWORD",
            "api_key",
            "_private",
            "A",
            "secret123",
            "MY_SECRET_KEY_123",
        ]
        for name in valid_names:
            validate_secret_name(name)  # Should not raise

    def test_invalid_names(self) -> None:
        """Test that invalid names are rejected."""
        invalid_names = [
            "",
            "123_starts_with_number",
            "has-dashes",
            "has.dots",
            "has spaces",
            "has@special",
        ]
        for name in invalid_names:
            with pytest.raises(InvalidSecretNameError):
                validate_secret_name(name)


class TestEncryptedSecret:
    """Tests for EncryptedSecret model."""

    def test_create_encrypted_secret(self) -> None:
        """Test creating an EncryptedSecret instance."""
        from datetime import UTC, datetime

        secret = EncryptedSecret(
            name="TEST_SECRET",
            encrypted_value="base64encodedvalue",
            created_at=datetime.now(UTC),
            updated_at=datetime.now(UTC),
            description="A test secret",
        )

        assert secret.name == "TEST_SECRET"
        assert secret.encrypted_value == "base64encodedvalue"
        assert secret.description == "A test secret"


class TestSecretsConfig:
    """Tests for SecretsConfig model."""

    def test_default_config(self) -> None:
        """Test default config values."""
        config = SecretsConfig(salt="base64salt")

        assert config.version == 1
        assert config.kdf_iterations == 600_000
        assert config.encryption_algorithm == "AES-256-GCM"


# =============================================================================
# Keyring Provider Tests
# =============================================================================


class TestEnvironmentProvider:
    """Tests for EnvironmentProvider."""

    def test_is_available_with_env_var(self) -> None:
        """Test that provider is available when env var is set."""
        with patch.dict(os.environ, {"GAOL_MASTER_KEY": "testkey"}):
            provider = EnvironmentProvider()
            assert provider.is_available() is True

    def test_is_available_without_env_var(self) -> None:
        """Test that provider is unavailable when env var is not set."""
        with patch.dict(os.environ, {}, clear=True):
            # Remove the key if it exists
            os.environ.pop("GAOL_MASTER_KEY", None)
            provider = EnvironmentProvider()
            assert provider.is_available() is False

    def test_get_master_key_base64(self) -> None:
        """Test getting key from base64-encoded env var."""
        key = generate_master_key()
        encoded = base64.b64encode(key).decode("ascii")

        with patch.dict(os.environ, {"GAOL_MASTER_KEY": encoded}):
            provider = EnvironmentProvider()
            retrieved = provider.get_master_key()
            assert retrieved == key

    def test_source(self) -> None:
        """Test that source is ENVIRONMENT."""
        provider = EnvironmentProvider()
        assert provider.source == KeySource.ENVIRONMENT


class TestKeyManager:
    """Tests for KeyManager."""

    def test_get_master_key_from_first_available_provider(self) -> None:
        """Test that KeyManager uses first available provider."""
        key = generate_master_key()
        encoded = base64.b64encode(key).decode("ascii")

        with patch.dict(os.environ, {"GAOL_MASTER_KEY": encoded}):
            # Mock keyring to be unavailable
            with patch(
                "gaol.secrets.keyring.KeyringProvider.is_available",
                return_value=False,
            ):
                manager = KeyManager()
                retrieved = manager.get_master_key()
                assert retrieved == key
                assert manager.active_source == KeySource.ENVIRONMENT

    def test_get_master_key_raises_when_no_providers(self) -> None:
        """Test that error is raised when no providers are available."""
        with patch.dict(os.environ, {}, clear=True):
            os.environ.pop("GAOL_MASTER_KEY", None)
            with patch(
                "gaol.secrets.keyring.KeyringProvider.is_available",
                return_value=False,
            ):
                with patch(
                    "gaol.secrets.keyring.PromptProvider.is_available",
                    return_value=False,
                ):
                    manager = KeyManager()
                    with pytest.raises(MasterKeyNotAvailableError):
                        manager.get_master_key()


# =============================================================================
# Resolver Tests
# =============================================================================


class TestSecretPattern:
    """Tests for the secret reference pattern."""

    def test_pattern_matches_valid_refs(self) -> None:
        """Test that pattern matches valid secret references."""
        valid_refs = [
            ("${secret:DB_PASSWORD}", "DB_PASSWORD"),
            ("${secret:api_key}", "api_key"),
            ("${secret:_private}", "_private"),
            ("${secret:A}", "A"),
            ("${secret:secret123}", "secret123"),
        ]
        for text, expected_name in valid_refs:
            match = SECRET_PATTERN.search(text)
            assert match is not None
            assert match.group(1) == expected_name

    def test_pattern_rejects_invalid_refs(self) -> None:
        """Test that pattern does not match invalid references."""
        invalid_refs = [
            "${secret:}",
            "${secret:123abc}",
            "${secret:has-dash}",
            "${SECRET:name}",  # Wrong case
            "$secret:name}",  # Missing {
            "${secret:name",  # Missing }
        ]
        for text in invalid_refs:
            match = SECRET_PATTERN.fullmatch(text)
            assert match is None


class TestSecretResolver:
    """Tests for SecretResolver."""

    def test_resolve_value_single_ref(self) -> None:
        """Test resolving a single secret reference."""

        def getter(name: str) -> str:
            return {"DB_PASSWORD": "secret123"}[name]

        resolver = SecretResolver(secret_getter=getter)
        result = resolver.resolve_value("password=${secret:DB_PASSWORD}")

        assert result == "password=secret123"

    def test_resolve_value_multiple_refs(self) -> None:
        """Test resolving multiple secret references."""
        secrets = {"USER": "admin", "PASS": "secret"}

        def getter(name: str) -> str:
            return secrets[name]

        resolver = SecretResolver(secret_getter=getter)
        result = resolver.resolve_value("${secret:USER}:${secret:PASS}")

        assert result == "admin:secret"

    def test_resolve_value_no_refs(self) -> None:
        """Test that values without refs are returned unchanged."""
        resolver = SecretResolver(secret_getter=lambda n: "unused")
        result = resolver.resolve_value("plain-text-value")

        assert result == "plain-text-value"

    def test_resolve_environment(self) -> None:
        """Test resolving secrets in environment dictionary."""
        secrets = {"DB_PASS": "dbsecret", "API_KEY": "apikey123"}

        def getter(name: str) -> str:
            return secrets[name]

        resolver = SecretResolver(secret_getter=getter)
        env = {
            "DATABASE_URL": "postgres://user:${secret:DB_PASS}@localhost/db",
            "API_KEY": "${secret:API_KEY}",
            "PLAIN": "no-secrets-here",
        }

        result = resolver.resolve_environment(env)

        assert result["DATABASE_URL"] == "postgres://user:dbsecret@localhost/db"
        assert result["API_KEY"] == "apikey123"
        assert result["PLAIN"] == "no-secrets-here"

    def test_find_secret_refs(self) -> None:
        """Test finding secret references in a value."""
        resolver = SecretResolver(secret_getter=lambda n: "unused")

        refs = resolver.find_secret_refs(
            "postgres://user:${secret:PASS}@${secret:HOST}:5432/db"
        )

        assert refs == ["PASS", "HOST"]

    def test_has_secret_refs(self) -> None:
        """Test checking for secret references."""
        resolver = SecretResolver(secret_getter=lambda n: "unused")

        assert resolver.has_secret_refs("${secret:NAME}") is True
        assert resolver.has_secret_refs("no-secrets") is False


class TestHasSecrets:
    """Tests for the has_secrets helper function."""

    def test_has_secrets_true(self) -> None:
        """Test that has_secrets returns True for values with secrets."""
        assert has_secrets("${secret:NAME}") is True
        assert has_secrets("prefix${secret:NAME}suffix") is True

    def test_has_secrets_false(self) -> None:
        """Test that has_secrets returns False for values without secrets."""
        assert has_secrets("plain-text") is False
        assert has_secrets("${env:NAME}") is False


# =============================================================================
# Store Tests
# =============================================================================


class TestSecretStore:
    """Tests for SecretStore."""

    @pytest.fixture
    def temp_store_dir(self) -> Path:
        """Create a temporary store directory."""
        with tempfile.TemporaryDirectory() as tmpdir:
            yield Path(tmpdir)

    @pytest.fixture
    def master_key(self) -> bytes:
        """Generate a master key for testing."""
        return generate_master_key()

    @pytest.fixture
    def mock_keyring(self, master_key: bytes):
        """Mock keyring functions to use environment provider."""
        encoded_key = base64.b64encode(master_key).decode("ascii")

        with patch.dict(os.environ, {"GAOL_MASTER_KEY": encoded_key}):
            with patch(
                "gaol.secrets.keyring.KeyringProvider.is_available",
                return_value=False,
            ):
                with patch(
                    "gaol.secrets.store.initialize_master_key",
                    return_value=(master_key, KeySource.ENVIRONMENT),
                ):
                    yield

    def test_initialize_creates_store(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that initialize creates the secrets store."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()

        assert store.is_initialized()
        secrets_file = temp_store_dir / "secrets.yaml"
        assert secrets_file.exists()

    def test_create_and_get_secret(
        self, temp_store_dir: Path, mock_keyring, master_key: bytes
    ) -> None:
        """Test creating and retrieving a secret."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()

        metadata = store.create("TEST_SECRET", "secret-value", description="Test")

        assert metadata.name == "TEST_SECRET"
        assert metadata.description == "Test"

        retrieved = store.get("TEST_SECRET")
        assert retrieved == "secret-value"

    def test_create_duplicate_raises(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that creating a duplicate secret raises error."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()
        store.create("TEST_SECRET", "value1")

        with pytest.raises(SecretExistsError):
            store.create("TEST_SECRET", "value2")

    def test_create_with_force_overwrites(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that force=True overwrites existing secret."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()
        store.create("TEST_SECRET", "value1")
        store.create("TEST_SECRET", "value2", force=True)

        assert store.get("TEST_SECRET") == "value2"

    def test_get_nonexistent_raises(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that getting nonexistent secret raises error."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()

        with pytest.raises(SecretNotFoundError):
            store.get("NONEXISTENT")

    def test_list_secrets(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test listing secrets."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()

        store.create("SECRET_A", "value-a")
        store.create("SECRET_B", "value-b", description="B secret")

        secrets = store.list()

        assert len(secrets) == 2
        names = [s.name for s in secrets]
        assert "SECRET_A" in names
        assert "SECRET_B" in names

    def test_delete_secret(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test deleting a secret."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()
        store.create("TEST_SECRET", "value")

        assert store.exists("TEST_SECRET")
        result = store.delete("TEST_SECRET")

        assert result is True
        assert not store.exists("TEST_SECRET")

    def test_delete_nonexistent_raises(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that deleting nonexistent secret raises error."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()

        with pytest.raises(SecretNotFoundError):
            store.delete("NONEXISTENT")

    def test_update_secret(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test updating a secret value."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()
        store.create("TEST_SECRET", "old-value")

        metadata = store.update("TEST_SECRET", "new-value")

        assert metadata.name == "TEST_SECRET"
        assert store.get("TEST_SECRET") == "new-value"

    def test_update_nonexistent_raises(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that updating nonexistent secret raises error."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()

        with pytest.raises(SecretNotFoundError):
            store.update("NONEXISTENT", "value")

    def test_exists(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test checking if secret exists."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()
        store.create("TEST_SECRET", "value")

        assert store.exists("TEST_SECRET") is True
        assert store.exists("NONEXISTENT") is False

    def test_operations_on_uninitialized_store_raise(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that operations on uninitialized store raise error."""
        store = SecretStore(store_dir=temp_store_dir)

        with pytest.raises(SecretStoreNotInitializedError):
            store.create("TEST", "value")

        with pytest.raises(SecretStoreNotInitializedError):
            store.get("TEST")

    def test_list_on_uninitialized_returns_empty(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that list on uninitialized store returns empty list."""
        store = SecretStore(store_dir=temp_store_dir)
        assert store.list() == []

    def test_invalid_secret_name_raises(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that invalid secret names raise error."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()

        with pytest.raises(InvalidSecretNameError):
            store.create("invalid-name", "value")

    def test_secrets_file_permissions(
        self, temp_store_dir: Path, mock_keyring
    ) -> None:
        """Test that secrets file has restrictive permissions."""
        store = SecretStore(store_dir=temp_store_dir)
        store.initialize()

        secrets_file = temp_store_dir / "secrets.yaml"
        mode = secrets_file.stat().st_mode & 0o777
        assert mode == 0o600


# =============================================================================
# Integration Tests
# =============================================================================


class TestSecretsIntegration:
    """Integration tests for secrets module."""

    @pytest.fixture
    def temp_store_dir(self) -> Path:
        """Create a temporary store directory."""
        with tempfile.TemporaryDirectory() as tmpdir:
            yield Path(tmpdir)

    @pytest.fixture
    def master_key(self) -> bytes:
        """Generate a master key for testing."""
        return generate_master_key()

    @pytest.fixture
    def initialized_store(
        self, temp_store_dir: Path, master_key: bytes
    ) -> SecretStore:
        """Create an initialized secret store."""
        encoded_key = base64.b64encode(master_key).decode("ascii")

        with patch.dict(os.environ, {"GAOL_MASTER_KEY": encoded_key}):
            with patch(
                "gaol.secrets.keyring.KeyringProvider.is_available",
                return_value=False,
            ):
                with patch(
                    "gaol.secrets.store.initialize_master_key",
                    return_value=(master_key, KeySource.ENVIRONMENT),
                ):
                    store = SecretStore(store_dir=temp_store_dir)
                    store.initialize()
                    yield store

    def test_full_secret_lifecycle(
        self, initialized_store: SecretStore, master_key: bytes
    ) -> None:
        """Test creating, reading, updating, and deleting a secret."""
        store = initialized_store
        encoded_key = base64.b64encode(master_key).decode("ascii")

        with patch.dict(os.environ, {"GAOL_MASTER_KEY": encoded_key}):
            with patch(
                "gaol.secrets.keyring.KeyringProvider.is_available",
                return_value=False,
            ):
                # Create
                store.create("MY_SECRET", "initial-value", description="Test secret")
                assert store.get("MY_SECRET") == "initial-value"

                # Update
                store.update("MY_SECRET", "updated-value")
                assert store.get("MY_SECRET") == "updated-value"

                # List
                secrets = store.list()
                assert len(secrets) == 1
                assert secrets[0].name == "MY_SECRET"

                # Delete
                store.delete("MY_SECRET")
                assert not store.exists("MY_SECRET")

    def test_resolver_with_store(
        self, initialized_store: SecretStore, master_key: bytes
    ) -> None:
        """Test resolver integration with store."""
        store = initialized_store
        encoded_key = base64.b64encode(master_key).decode("ascii")

        with patch.dict(os.environ, {"GAOL_MASTER_KEY": encoded_key}):
            with patch(
                "gaol.secrets.keyring.KeyringProvider.is_available",
                return_value=False,
            ):
                store.create("DB_USER", "admin")
                store.create("DB_PASS", "secret123")

                resolver = SecretResolver(secret_getter=store.get)

                env = {
                    "DATABASE_URL": "postgres://${secret:DB_USER}:${secret:DB_PASS}@localhost/db",
                    "LOG_LEVEL": "debug",
                }

                resolved = resolver.resolve_environment(env)

                assert resolved["DATABASE_URL"] == "postgres://admin:secret123@localhost/db"
                assert resolved["LOG_LEVEL"] == "debug"

    def test_resolver_missing_secret_raises(
        self, initialized_store: SecretStore, master_key: bytes
    ) -> None:
        """Test that resolver raises when secret doesn't exist."""
        store = initialized_store
        encoded_key = base64.b64encode(master_key).decode("ascii")

        with patch.dict(os.environ, {"GAOL_MASTER_KEY": encoded_key}):
            with patch(
                "gaol.secrets.keyring.KeyringProvider.is_available",
                return_value=False,
            ):
                resolver = SecretResolver(secret_getter=store.get)

                with pytest.raises(SecretNotFoundError):
                    resolver.resolve_value("${secret:NONEXISTENT}")
