"""Phase 5 of the v0.3 closed-box redesign — vault tests.

Covers:

  - :class:`mgf.common.vault.EnvVault` — stdlib-only env-var backend.
  - :class:`mgf.common.vault.KeyringVault` — system-keyring backend
    (driven through a fake keyring module monkey-patched into
    ``sys.modules`` so the test runs without touching the real
    keyring daemon).
  - :class:`mgf.common.vault.EncryptedFileVault` — Fernet-encrypted
    JSON file with PBKDF2 KDF (test override to ``_kdf_iterations=1000``
    to keep the suite fast).
  - The vault-backend registry — register / unregister / built-in
    pre-registration.
  - :class:`mgf.common.settings.VaultSettings` — schema shape.
  - :class:`mgf.common.typing.VaultProtocol` — every concrete impl
    satisfies the runtime check.
  - Audit logging — every backend emits ``audit=true`` records.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from pathlib import Path

import pytest

from mgf.common.exceptions import VaultError
from mgf.common.settings import MgfSettings, VaultSettings
from mgf.common.typing import VaultProtocol
from mgf.common.vault import (
    EncryptedFileVault,
    EnvVault,
    KeyringVault,
    VaultBackendFactory,
    register_vault_backend,
    unregister_vault_backend,
)
from mgf.common.vault._registry import _registered_vault_backends

# ---------------------------------------------------------------------------
# EnvVault
# ---------------------------------------------------------------------------


class TestEnvVault:
    """``EnvVault`` — stdlib-only env-var backend."""

    def test_backend_property(self) -> None:
        assert EnvVault().backend == "env"

    def test_default_prefix_is_mgf_vault(self) -> None:
        v = EnvVault()
        assert repr(v) == "EnvVault(prefix='MGF_VAULT_')"

    def test_set_and_get_round_trip(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("MGF_VAULT_API_TOKEN", raising=False)
        v = EnvVault()
        assert v.get("api_token") is None
        v.set("api_token", "secret-value")
        assert v.get("api_token") == "secret-value"

    def test_get_returns_none_for_missing_key(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        monkeypatch.delenv("MGF_VAULT_NOPE", raising=False)
        assert EnvVault().get("nope") is None

    def test_keys_are_uppercased_in_env(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        monkeypatch.delenv("MGF_VAULT_LOWER_KEY", raising=False)
        v = EnvVault()
        v.set("lower_key", "x")
        # Assert the env-var name follows our convention.
        import os

        assert os.environ["MGF_VAULT_LOWER_KEY"] == "x"

    def test_delete_removes_entry(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        monkeypatch.delenv("MGF_VAULT_TARGET", raising=False)
        v = EnvVault()
        v.set("target", "x")
        assert v.get("target") == "x"
        v.delete("target")
        assert v.get("target") is None

    def test_delete_missing_is_noop(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        monkeypatch.delenv("MGF_VAULT_NEVER_SET", raising=False)
        # No exception.
        EnvVault().delete("never_set")

    def test_list_keys_returns_only_prefixed(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # Clear any prior MGF_VAULT_* cruft from the env so the
        # assertion is deterministic.
        import os

        for k in list(os.environ):
            if k.startswith("MGF_VAULT_"):
                monkeypatch.delenv(k, raising=False)
        monkeypatch.setenv("UNRELATED_VAR", "ignore-me")

        v = EnvVault()
        v.set("alpha", "a")
        v.set("beta", "b")
        v.set("gamma", "c")
        keys = v.list_keys()
        assert keys == ["alpha", "beta", "gamma"]

    def test_custom_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
        import os

        for k in list(os.environ):
            if k.startswith(("MYAPP_", "MGF_VAULT_")):
                monkeypatch.delenv(k, raising=False)
        v = EnvVault(prefix="MYAPP_")
        v.set("token", "x")
        assert os.environ["MYAPP_TOKEN"] == "x"
        # Default-prefixed vault should not see the custom-prefixed entry.
        assert EnvVault().list_keys() == []

    def test_satisfies_protocol(self) -> None:
        assert isinstance(EnvVault(), VaultProtocol)

    def test_audit_log_emitted_on_get(
        self,
        monkeypatch: pytest.MonkeyPatch,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        monkeypatch.delenv("MGF_VAULT_K", raising=False)
        with caplog.at_level(logging.INFO, logger="mgf.common.vault._env_vault"):
            EnvVault().get("k")
        # Filter the records emitted by the EnvVault module.
        records = [r for r in caplog.records if r.name == "mgf.common.vault._env_vault"]
        assert len(records) == 1
        rec = records[0]
        assert getattr(rec, "audit", False) is True
        assert getattr(rec, "event", None) == "vault.read"
        assert getattr(rec, "vault_backend", None) == "env"
        assert getattr(rec, "vault_key", None) == "k"
        # CRITICAL: the value must NOT be in the record.
        assert "vault_value" not in rec.__dict__


# ---------------------------------------------------------------------------
# KeyringVault — driven through an in-memory fake
# ---------------------------------------------------------------------------


class _FakeKeyringErrors:
    """Module-level shim: ``keyring.errors.PasswordDeleteError``."""

    class PasswordDeleteError(Exception):
        pass


class _FakeKeyring:
    """Minimal in-memory keyring stand-in.

    Implements the three :mod:`keyring` functions our backend uses
    plus an exposed ``store`` for assertions. Substituted into
    ``sys.modules['keyring']`` for the duration of each test that
    needs it.
    """

    errors = _FakeKeyringErrors

    def __init__(self) -> None:
        self.store: dict[tuple[str, str], str] = {}

    def get_password(self, service: str, username: str) -> str | None:
        return self.store.get((service, username))

    def set_password(
        self,
        service: str,
        username: str,
        password: str,
    ) -> None:
        self.store[(service, username)] = password

    def delete_password(self, service: str, username: str) -> None:
        if (service, username) not in self.store:
            raise _FakeKeyringErrors.PasswordDeleteError(
                f"no entry: {service}/{username}",
            )
        del self.store[(service, username)]


@pytest.fixture
def fake_keyring(monkeypatch: pytest.MonkeyPatch) -> _FakeKeyring:
    """Replace the real :mod:`keyring` module with our fake."""
    fake = _FakeKeyring()
    # The `keyring` module installed by the [vault] extra is stub-able
    # via sys.modules — KeyringVault uses a lazy import.
    monkeypatch.setitem(__import__("sys").modules, "keyring", fake)
    monkeypatch.setitem(
        __import__("sys").modules,
        "keyring.errors",
        _FakeKeyringErrors,
    )
    return fake


class TestKeyringVault:
    """KeyringVault — driven through the in-memory fake."""

    def test_backend_property(self, fake_keyring: _FakeKeyring) -> None:
        v = KeyringVault()
        assert v.backend == "keyring"

    def test_default_service_is_mgf_common(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        KeyringVault().set("k", "v")
        assert ("mgf-common", "k") in fake_keyring.store

    def test_custom_service(self, fake_keyring: _FakeKeyring) -> None:
        v = KeyringVault(service="myapp")
        v.set("k", "v")
        assert ("myapp", "k") in fake_keyring.store
        assert ("mgf-common", "k") not in fake_keyring.store

    def test_set_and_get_round_trip(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        v = KeyringVault()
        assert v.get("token") is None
        v.set("token", "secret")
        assert v.get("token") == "secret"

    def test_delete_removes_entry(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        v = KeyringVault()
        v.set("k", "v")
        v.delete("k")
        assert v.get("k") is None

    def test_delete_missing_is_noop(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        # PasswordDeleteError from the fake → swallowed by the backend.
        KeyringVault().delete("never_set")

    def test_list_keys_uses_index(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        v = KeyringVault()
        v.set("alpha", "a")
        v.set("beta", "b")
        v.set("gamma", "c")
        assert v.list_keys() == ["alpha", "beta", "gamma"]

    def test_list_keys_empty_initial(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        assert KeyringVault().list_keys() == []

    def test_delete_updates_index(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        v = KeyringVault()
        v.set("a", "1")
        v.set("b", "2")
        v.delete("a")
        assert v.list_keys() == ["b"]

    def test_delete_last_clears_index(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        v = KeyringVault()
        v.set("only", "1")
        v.delete("only")
        assert v.list_keys() == []

    def test_get_failure_raises_vault_error(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        def boom(service: str, username: str) -> str | None:
            raise RuntimeError("backend down")

        fake_keyring.get_password = boom  # type: ignore[method-assign]
        with pytest.raises(VaultError, match="keyring read failed"):
            KeyringVault().get("k")

    def test_satisfies_protocol(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        assert isinstance(KeyringVault(), VaultProtocol)

    def test_missing_extra_raises_vault_error(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # Wipe both keyring AND keyring.errors so the lazy import
        # in __init__ definitely fails.
        import sys

        monkeypatch.setitem(sys.modules, "keyring", None)
        with pytest.raises(VaultError, match=r"vault.*extra"):
            KeyringVault()


# ---------------------------------------------------------------------------
# EncryptedFileVault
# ---------------------------------------------------------------------------


class TestEncryptedFileVault:
    """EncryptedFileVault — Fernet-encrypted JSON-on-disk."""

    def _vault(
        self,
        path: Path,
        *,
        passphrase: str = "test-pass",  # noqa: S107 — test fixture
    ) -> EncryptedFileVault:
        # _kdf_iterations=1000 keeps the suite fast; never use this
        # value in production.
        return EncryptedFileVault(
            path,
            passphrase=passphrase,
            _kdf_iterations=1000,
        )

    def test_backend_property(self, tmp_path: Path) -> None:
        assert self._vault(tmp_path / "v.bin").backend == "file"

    def test_empty_passphrase_rejected(self, tmp_path: Path) -> None:
        with pytest.raises(VaultError, match="passphrase MUST NOT be empty"):
            EncryptedFileVault(tmp_path / "v.bin", passphrase="")

    def test_set_creates_file_with_0600(self, tmp_path: Path) -> None:
        path = tmp_path / "v.bin"
        v = self._vault(path)
        v.set("k", "v")
        assert path.exists()
        # Owner-only mode (read+write only).
        assert (path.stat().st_mode & 0o777) == 0o600

    def test_get_missing_file_returns_none(self, tmp_path: Path) -> None:
        v = self._vault(tmp_path / "absent.bin")
        assert v.get("anything") is None
        assert v.list_keys() == []

    def test_write_tolerates_file_vanishing_mid_life(self, tmp_path: Path) -> None:
        # F-011: if the backing file disappears between the existence
        # check and the salt read (a concurrent delete, or an operator
        # wiping state), the write path must NOT leak a raw
        # FileNotFoundError — it falls back to a fresh salt. (External
        # mid-life deletion is out of the single-writer contract; we
        # only assert the error contract here, not round-trip recovery.)
        path = tmp_path / "v.bin"
        v = self._vault(path)
        v.set("a", "1")
        assert path.exists()
        path.unlink()  # simulate the file vanishing under us
        # Must not raise FileNotFoundError (would previously escape).
        v.set("b", "2")
        assert path.exists()

    def test_round_trip_after_reopen(self, tmp_path: Path) -> None:
        path = tmp_path / "v.bin"
        v1 = self._vault(path)
        v1.set("alpha", "1")
        v1.set("beta", "2")

        # Fresh vault, same path + passphrase.
        v2 = self._vault(path)
        assert v2.get("alpha") == "1"
        assert v2.get("beta") == "2"
        assert v2.list_keys() == ["alpha", "beta"]

    def test_wrong_passphrase_raises(self, tmp_path: Path) -> None:
        path = tmp_path / "v.bin"
        self._vault(path, passphrase="right").set("k", "v")
        wrong = self._vault(path, passphrase="WRONG")
        with pytest.raises(VaultError, match="failed to decrypt"):
            wrong.get("k")

    def test_delete_removes_entry(self, tmp_path: Path) -> None:
        v = self._vault(tmp_path / "v.bin")
        v.set("k", "v")
        v.delete("k")
        assert v.get("k") is None
        assert v.list_keys() == []

    def test_delete_missing_is_noop(self, tmp_path: Path) -> None:
        v = self._vault(tmp_path / "v.bin")
        v.set("a", "1")
        v.delete("never-existed")
        assert v.list_keys() == ["a"]

    def test_set_overwrite(self, tmp_path: Path) -> None:
        v = self._vault(tmp_path / "v.bin")
        v.set("k", "first")
        v.set("k", "second")
        assert v.get("k") == "second"

    def test_corrupt_magic_raises(self, tmp_path: Path) -> None:
        path = tmp_path / "v.bin"
        self._vault(path).set("k", "v")
        # Corrupt magic.
        blob = path.read_bytes()
        path.write_bytes(b"XXXX" + blob[4:])
        with pytest.raises(VaultError, match="not a vault file"):
            self._vault(path).get("k")

    def test_truncated_file_raises(self, tmp_path: Path) -> None:
        path = tmp_path / "v.bin"
        path.write_bytes(b"too-short")
        with pytest.raises(VaultError, match="too short"):
            self._vault(path).get("k")

    def test_unsupported_version_raises(self, tmp_path: Path) -> None:
        path = tmp_path / "v.bin"
        self._vault(path).set("k", "v")
        blob = bytearray(path.read_bytes())
        blob[4] = 99  # bump version byte to unsupported value
        path.write_bytes(bytes(blob))
        with pytest.raises(VaultError, match="version 99"):
            self._vault(path).get("k")

    def test_salt_persists_across_reopens(self, tmp_path: Path) -> None:
        """Salt is per-vault (not per-write); reopen must reuse it."""
        path = tmp_path / "v.bin"
        self._vault(path).set("k", "v")
        salt_before = path.read_bytes()[5:21]
        # Mutate via a fresh instance — should NOT generate a new salt.
        self._vault(path).set("k", "v2")
        salt_after = path.read_bytes()[5:21]
        assert salt_before == salt_after

    def test_satisfies_protocol(self, tmp_path: Path) -> None:
        assert isinstance(self._vault(tmp_path / "v.bin"), VaultProtocol)

    def test_atomic_write_no_temp_left_behind(self, tmp_path: Path) -> None:
        path = tmp_path / "v.bin"
        self._vault(path).set("k", "v")
        # No `.bin.tmp` left in the directory.
        assert not (tmp_path / "v.bin.tmp").exists()
        assert list(tmp_path.iterdir()) == [path]


# ---------------------------------------------------------------------------
# Vault registry
# ---------------------------------------------------------------------------


class TestVaultRegistry:
    """The decorator-driven backend registry."""

    def test_built_ins_are_pre_registered(self) -> None:
        names = set(_registered_vault_backends().keys())
        assert {"env", "keyring", "file"} <= names

    def test_register_and_unregister(self) -> None:
        @register_vault_backend("test-custom-1")
        def make(_config: dict[str, Any]) -> VaultProtocol:
            return EnvVault(prefix="TEST_CUSTOM_1_")

        assert "test-custom-1" in _registered_vault_backends()
        unregister_vault_backend("test-custom-1")
        assert "test-custom-1" not in _registered_vault_backends()

    def test_double_register_raises(self) -> None:
        @register_vault_backend("test-custom-2")
        def make(_config: dict[str, Any]) -> VaultProtocol:
            return EnvVault()

        try:
            with pytest.raises(ValueError, match="already registered"):

                @register_vault_backend("test-custom-2")
                def _(_config: dict[str, Any]) -> VaultProtocol:
                    return EnvVault()
        finally:
            unregister_vault_backend("test-custom-2")

    def test_unregister_unknown_is_noop(self) -> None:
        unregister_vault_backend("never-registered-name")  # No raise.

    def test_factory_signature_alias(self) -> None:
        # The exported alias is callable.
        def _make(_c: dict[str, Any]) -> VaultProtocol:
            return EnvVault()

        f: VaultBackendFactory = _make
        v = f({})
        assert isinstance(v, VaultProtocol)

    def test_built_in_env_factory(self) -> None:
        from mgf.common.vault import EnvVaultConfig

        registry = _registered_vault_backends()
        v = registry["env"](EnvVaultConfig(prefix="X_"))
        assert v.backend == "env"
        assert isinstance(v, EnvVault)

    def test_built_in_keyring_factory(
        self,
        fake_keyring: _FakeKeyring,
    ) -> None:
        from mgf.common.vault import KeyringVaultConfig

        registry = _registered_vault_backends()
        v = registry["keyring"](KeyringVaultConfig(service="test-svc"))
        assert v.backend == "keyring"

    def test_built_in_file_factory_requires_path_and_passphrase(self) -> None:
        """Missing required fields fail at config-construction time,
        not at factory-call time (the typed config is the gate).
        """
        from pydantic import ValidationError

        from mgf.common.vault import EncryptedFileVaultConfig

        with pytest.raises(ValidationError):
            EncryptedFileVaultConfig()  # type: ignore[call-arg]


# ---------------------------------------------------------------------------
# VaultSettings
# ---------------------------------------------------------------------------


class TestVaultSettings:
    """The new sub-model on MgfSettings."""

    def test_default_backend_is_env(self) -> None:
        assert VaultSettings().backend == "env"

    def test_default_config_coerced_to_typed_for_builtin(self) -> None:
        """``VaultSettings()`` defaults to ``backend='env'`` with an
        empty dict, which the model validator coerces to a
        default-constructed :class:`EnvVaultConfig` because the
        built-in backends now register a ``config_model``.
        """
        from mgf.common.vault import EnvVaultConfig

        cfg = VaultSettings().config
        assert isinstance(cfg, EnvVaultConfig)
        assert cfg.prefix == "MGF_VAULT_"

    def test_extra_keys_forbidden(self) -> None:
        from pydantic import ValidationError

        with pytest.raises(ValidationError):
            VaultSettings(unknown_field="x")  # type: ignore[call-arg]

    def test_round_trip_via_mgf_settings(self) -> None:
        from pathlib import Path

        from mgf.common.vault import EncryptedFileVaultConfig

        s = MgfSettings(
            vault=VaultSettings(
                backend="file",
                config={"path": "/tmp/v.bin", "passphrase": "x"},
            ),
        )
        assert s.vault.backend == "file"
        # The dict gets validated into EncryptedFileVaultConfig by
        # the VaultSettings model validator (built-in backends now
        # register a config_model).
        assert isinstance(s.vault.config, EncryptedFileVaultConfig)
        assert s.vault.config.path == Path("/tmp/v.bin")

    def test_defaults_attached_when_omitted(self) -> None:
        # MgfSettings() with no vault arg should yield default
        # VaultSettings().
        assert MgfSettings().vault.backend == "env"


# ---------------------------------------------------------------------------
# config_model — typed configs for custom backends (closes FEEDBACK VAULT-01)
# ---------------------------------------------------------------------------


class TestRegisterVaultBackendWithConfigModel:
    """register_vault_backend(name, *, config_model=...) — typed
    configs for custom backends. Closes FEEDBACK VAULT-01."""

    def test_register_without_config_model_still_works(self) -> None:
        """The legacy shape (no config_model kwarg) keeps working."""
        captured: list[Any] = []

        @register_vault_backend("legacy-no-model")
        def make(config: Any) -> VaultProtocol:
            captured.append(config)
            return EnvVault()

        try:
            v = MgfSettings(
                vault=VaultSettings(
                    backend="legacy-no-model",
                    config={"opaque": "blob"},
                ),
            )
            # Validator did NOT touch the config — dict passes through.
            assert v.vault.config == {"opaque": "blob"}
        finally:
            unregister_vault_backend("legacy-no-model")

    def test_register_with_config_model_validates_dict_at_construction(
        self,
    ) -> None:
        from typing import Literal

        from pydantic import BaseModel, ConfigDict

        class HashicorpConfig(BaseModel):
            model_config = ConfigDict(extra="forbid", frozen=True)
            backend_type: Literal["hashicorp"] = "hashicorp"
            url: str
            token: str
            mount: str = "secret"

        @register_vault_backend(
            "typed-hashicorp",
            config_model=HashicorpConfig,
        )
        def make(config: HashicorpConfig) -> VaultProtocol:
            assert isinstance(config, HashicorpConfig)
            return EnvVault()

        try:
            settings = MgfSettings(
                vault=VaultSettings(
                    backend="typed-hashicorp",
                    config={"url": "https://vault.local", "token": "abc"},
                ),
            )
            # Validator coerced dict → HashicorpConfig.
            assert isinstance(settings.vault.config, HashicorpConfig)
            assert settings.vault.config.url == "https://vault.local"
            assert settings.vault.config.token == "abc"
            assert settings.vault.config.mount == "secret"  # default
        finally:
            unregister_vault_backend("typed-hashicorp")

    def test_unknown_keys_in_dict_rejected_at_settings_construction(
        self,
    ) -> None:
        from pydantic import BaseModel, ConfigDict, ValidationError

        class StrictConfig(BaseModel):
            model_config = ConfigDict(extra="forbid", frozen=True)
            url: str

        @register_vault_backend("strict", config_model=StrictConfig)
        def make(config: StrictConfig) -> VaultProtocol:
            return EnvVault()

        try:
            with pytest.raises(ValidationError, match="rogue"):
                VaultSettings(
                    backend="strict",
                    config={"url": "https://x", "rogue": "key"},
                )
        finally:
            unregister_vault_backend("strict")

    def test_typed_instance_passes_through_unchanged(self) -> None:
        from pydantic import BaseModel, ConfigDict

        class SimpleConfig(BaseModel):
            model_config = ConfigDict(extra="forbid", frozen=True)
            label: str = "default"

        @register_vault_backend("simple", config_model=SimpleConfig)
        def make(config: SimpleConfig) -> VaultProtocol:
            return EnvVault()

        try:
            cfg = SimpleConfig(label="custom")
            settings = VaultSettings(backend="simple", config=cfg)
            # Same object — no re-validation.
            assert settings.config is cfg
        finally:
            unregister_vault_backend("simple")

    def test_wrong_basemodel_type_rejected_with_clear_error(self) -> None:
        from pydantic import BaseModel, ConfigDict

        class ExpectedConfig(BaseModel):
            model_config = ConfigDict(extra="forbid", frozen=True)
            url: str

        class OtherConfig(BaseModel):
            model_config = ConfigDict(extra="forbid", frozen=True)
            label: str

        @register_vault_backend("expected", config_model=ExpectedConfig)
        def make(config: ExpectedConfig) -> VaultProtocol:
            return EnvVault()

        try:
            with pytest.raises(
                ValueError,
                match="expects a ExpectedConfig",
            ):
                VaultSettings(
                    backend="expected",
                    config=OtherConfig(label="x"),
                )
        finally:
            unregister_vault_backend("expected")

    def test_unregistered_backend_validator_no_op(self) -> None:
        """Validator does nothing when the named backend is not in
        the registry — dict passes through. (The factory will raise
        later when the consumer tries to USE the vault.)"""
        settings = VaultSettings(
            backend="not-registered-yet",
            config={"some": "dict"},
        )
        assert settings.config == {"some": "dict"}

    def test_re_register_with_config_model_fails(self) -> None:
        from pydantic import BaseModel, ConfigDict

        class C(BaseModel):
            model_config = ConfigDict(extra="forbid", frozen=True)
            x: str = ""

        @register_vault_backend("dup", config_model=C)
        def first(config: C) -> VaultProtocol:
            return EnvVault()

        try:
            with pytest.raises(
                ValueError,
                match="already registered",
            ):

                @register_vault_backend("dup", config_model=C)
                def second(config: C) -> VaultProtocol:
                    return EnvVault()
        finally:
            unregister_vault_backend("dup")

    def test_subclass_of_config_model_accepted(self) -> None:
        from pydantic import BaseModel, ConfigDict

        class BaseConfig(BaseModel):
            model_config = ConfigDict(extra="forbid", frozen=True)
            name: str

        class SubConfig(BaseConfig):
            extra: str = ""

        @register_vault_backend("base-bk", config_model=BaseConfig)
        def make(config: BaseConfig) -> VaultProtocol:
            return EnvVault()

        try:
            cfg = SubConfig(name="x", extra="y")
            settings = VaultSettings(backend="base-bk", config=cfg)
            # isinstance check accepts the subclass.
            assert settings.config is cfg
        finally:
            unregister_vault_backend("base-bk")


# ---------------------------------------------------------------------------
# PC-09: PBKDF2 iteration floor (security-meaningful perf gate)
# ---------------------------------------------------------------------------


class TestPBKDF2IterationFloor:
    """PC-09: assert PBKDF2 iteration count meets OWASP 2023 baseline.

    A silent downgrade to a lower iteration count (e.g. someone editing
    ``_DEFAULT_KDF_ITERATIONS = 60_000`` thinking it's a perf win)
    weakens the at-rest security posture without any other test
    catching it — round-trip tests still pass; only attackers benefit.

    This is NOT a perf test in the "fast is good" sense. It's a FLOOR
    test: assert iterations >= 600 k (rule SC-05), AND assert that a
    real ``derive()`` call takes at least N ms — a fast KDF is the
    *suspicious* signal here.
    """

    # OWASP 2023 baseline for PBKDF2-HMAC-SHA256 (rule SC-05).
    _OWASP_2023_MIN_ITERATIONS = 600_000

    # Realistic lower bound for `EncryptedFileVault(...).set(...)` on
    # a 2024-era developer machine running 600 k PBKDF2 iterations.
    # Local: ~250-400 ms. CI machines are slower, not faster — a
    # measurement under 50 ms means iterations were silently reduced.
    _MIN_FIRST_OP_MS = 50

    def test_default_iterations_meet_owasp_2023_baseline(self) -> None:
        from mgf.common.vault._file_vault import _DEFAULT_KDF_ITERATIONS

        assert _DEFAULT_KDF_ITERATIONS >= self._OWASP_2023_MIN_ITERATIONS, (
            f"PC-09: _DEFAULT_KDF_ITERATIONS is "
            f"{_DEFAULT_KDF_ITERATIONS}, below the OWASP 2023 baseline "
            f"of {self._OWASP_2023_MIN_ITERATIONS} for PBKDF2-HMAC-SHA256. "
            f"This weakens the at-rest security posture (rule SC-05). "
            f"If this is intentional, update SC-05 in the security "
            f"standard AND document the threat-model decision."
        )

    def test_first_op_under_default_iterations_is_slow(
        self,
        tmp_path: Path,
    ) -> None:
        """A `set()` on a new vault with default iterations should
        take ≥ 50 ms. A fast KDF means iterations got cut.
        """
        import time

        path = tmp_path / "pc09.bin"
        # Default iterations (600 k) — NOT the test override.
        vault = EncryptedFileVault(path, passphrase="pc09-floor-test")

        start = time.perf_counter()
        vault.set("key", "value")
        elapsed_ms = (time.perf_counter() - start) * 1000

        assert elapsed_ms >= self._MIN_FIRST_OP_MS, (
            f"PC-09: first vault op took {elapsed_ms:.1f} ms — below the "
            f"{self._MIN_FIRST_OP_MS} ms floor. A fast PBKDF2 derivation "
            f"is suspicious: either _DEFAULT_KDF_ITERATIONS was lowered "
            f"or the KDF was replaced. Check src/mgf/common/vault/"
            f"_file_vault.py."
        )


class TestPassphraseNeverSerialisedPlaintext:
    """CF-05 / SC-01: the file-vault passphrase is a SecretStr, so it is
    masked in any model_dump / config serialisation, never plaintext."""

    def test_passphrase_masked_on_model_dump(self) -> None:
        from mgf.common.vault import EncryptedFileVaultConfig

        c = EncryptedFileVaultConfig(path="/tmp/v.enc", passphrase="s3cret-PASS")
        dumped = c.model_dump(mode="json")
        assert dumped["passphrase"] == "**********"
        assert "s3cret-PASS" not in str(dumped)
        # real value still reachable at the crypto boundary
        assert c.passphrase.get_secret_value() == "s3cret-PASS"

    def test_passphrase_in_default_redaction_keys(self) -> None:
        from mgf.common.observability.redaction import DEFAULT_REDACTION_KEYS

        assert "passphrase" in DEFAULT_REDACTION_KEYS
