"""Decorator-driven registry for custom vault backends.

Closes the closed-box leak around third-party vault stores
(HashiCorp Vault, AWS Secrets Manager, custom HSM). Consumers
register a backend factory under a name; downstream wiring (the
future ``open_vault(settings)`` helper that consults
:class:`mgf.common.settings.VaultSettings.backend`) consults the
registry instead of the built-in env / keyring / file branches.

Pattern matches the crash-sink registry — see
``mgf.common.observability.crash_reporter.register_crash_sink``.

Example — the recommended typed-config shape::

    from typing import Literal
    from pydantic import BaseModel, ConfigDict

    from mgf.common.typing import VaultProtocol
    from mgf.common.vault import register_vault_backend


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


    @register_vault_backend("hashicorp", config_model=HashicorpVaultConfig)
    def make_hashicorp_vault(config: HashicorpVaultConfig) -> VaultProtocol:
        import hvac

        client = hvac.Client(url=config.url, token=config.token)
        return MyHashicorpAdapter(client, mount=config.mount)

When ``config_model`` is registered, ``VaultSettings`` validates
the user's ``config=`` against it at construction — so
``VaultSettings(backend="hashicorp", config={"url": "...", ...})``
either succeeds with a typed instance or fails loudly with a
pydantic ``ValidationError``. The factory always receives the
typed model.

Custom backends MAY register without a ``config_model`` (in which
case the factory receives the raw dict, just like before)::

    @register_vault_backend("legacy-store")
    def make_legacy(config: dict[str, Any]) -> VaultProtocol:
        ...

The built-in backends (``"env"`` / ``"keyring"`` / ``"file"``)
are pre-registered at import of ``mgf.common.vault`` and keep
their existing isinstance-dispatch shape (typed config OR dict
with a deprecation warning) for backward compat.

Threading: registration is single-threaded — call at
module-import / bootstrap time, not from a worker. The registry
itself is read-only on the hot path.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from pydantic import BaseModel

    from mgf.common.typing import VaultProtocol


VaultBackendFactory = Callable[[Any], "VaultProtocol"]
"""Type alias for vault-backend factory callables.

The factory receives backend-specific configuration — either a
typed pydantic model (when the backend was registered with
``config_model=``) or a raw ``dict[str, Any]`` (when the backend
was registered without a model). Returns an instance implementing
:class:`mgf.common.typing.VaultProtocol`.
"""


@dataclass(frozen=True)
class _RegistryEntry:
    """One row in the vault-backend registry: factory + optional
    typed-config schema. Frozen so re-registration mistakes are
    loud (the dict mutation in ``register_vault_backend`` is the
    only allowed write path)."""

    factory: VaultBackendFactory
    config_model: type[BaseModel] | None


_VAULT_BACKENDS: dict[str, _RegistryEntry] = {}


def register_vault_backend(
    name: str,
    *,
    config_model: type[BaseModel] | None = None,
) -> Callable[[VaultBackendFactory], VaultBackendFactory]:
    """Decorator: register a custom vault-backend factory under ``name``.

    Parameters
    ----------
    name
        The string consumers write in
        :attr:`mgf.common.settings.VaultSettings.backend` to select
        this backend. Built-in names (``"env"`` / ``"keyring"`` /
        ``"file"``) are pre-registered at import of
        ``mgf.common.vault``.
    config_model
        Optional pydantic ``BaseModel`` subclass describing the
        expected shape of ``VaultSettings.config`` for this
        backend. When set, ``VaultSettings`` validates incoming
        dicts against the model at construction — typo'd field
        names, missing required keys, and wrong types fail loudly
        at config-load time, not at vault-instantiation time. When
        unset, the factory receives the raw dict and is responsible
        for its own validation.

        Recommended for any backend with more than a trivial
        config (anything beyond a single string or two). The
        built-in backends use this shape.

    Raises :class:`ValueError` if ``name`` is already registered;
    callers MUST :func:`unregister_vault_backend` before
    re-registering (this catches accidental double-registration
    in plug-in chains).

    Threading: register at import / bootstrap time, not from a
    worker.
    """

    def decorator(fn: VaultBackendFactory) -> VaultBackendFactory:
        if name in _VAULT_BACKENDS:
            raise ValueError(
                f"vault backend {name!r} is already registered "
                f"(unregister first if you intend to replace it)"
            )
        _VAULT_BACKENDS[name] = _RegistryEntry(
            factory=fn,
            config_model=config_model,
        )
        return fn

    return decorator


def unregister_vault_backend(name: str) -> None:
    """Remove a registered vault backend. No-op if ``name`` is unknown.

    Useful for tests + for consumers swapping a backend at runtime.
    """
    _VAULT_BACKENDS.pop(name, None)


def _get_factory(name: str) -> VaultBackendFactory | None:
    """Internal: look up the factory for a registered backend."""
    entry = _VAULT_BACKENDS.get(name)
    return entry.factory if entry is not None else None


def _get_config_model(name: str) -> type[BaseModel] | None:
    """Internal: look up the config_model for a registered backend.

    Returns ``None`` if the backend isn't registered OR was
    registered without a ``config_model`` (the legacy shape).
    Used by :class:`mgf.common.settings.VaultSettings` to decide
    whether to validate the user's ``config`` payload.
    """
    entry = _VAULT_BACKENDS.get(name)
    return entry.config_model if entry is not None else None


def _registered_vault_backends() -> dict[str, VaultBackendFactory]:
    """Internal: snapshot of the registry's factories. Used by
    tests + the diagnostic CLI."""
    return {name: entry.factory for name, entry in _VAULT_BACKENDS.items()}


__all__ = [
    "VaultBackendFactory",
    "register_vault_backend",
    "unregister_vault_backend",
]
