"""System-keyring backed vault.

Wraps the third-party :mod:`keyring` library which dispatches to
the host OS native credential store:

  - Linux: libsecret (Secret Service API) via ``secretstorage`` —
    GNOME Keyring, KDE Wallet, KeePassXC's Secret Service emulator.
  - macOS: Keychain.
  - Windows: Credential Manager.

The :mod:`keyring` dependency is part of the optional ``[vault]``
extra; importing this module without the extra installed raises
:class:`mgf.common.exceptions.VaultError` with an actionable hint
(rule EH-09). Lazy import — see ``__init__``.

Audit logging: every ``get`` / ``set`` / ``delete`` emits an
``audit=true`` log record per rule SC-17. The KEY is logged; the
VALUE is not.

Threading: :mod:`keyring` operations serialise through the OS
credential store; safe to call from multiple threads. Each call
involves an IPC round-trip to the keyring daemon (~1-10 ms);
not suitable for hot paths — cache values at the consumer if
needed.
"""

from __future__ import annotations

import threading
from typing import TYPE_CHECKING

from mgf.common.exceptions import VaultError
from mgf.common.observability import get_logger

if TYPE_CHECKING:
    from types import ModuleType

_LOG = get_logger(__name__)

_IMPORT_HINT = (
    "the [vault] extra is not installed. Install with "
    "`pip install mgf-common[vault]` (or add `mgf-common[vault]` "
    "to your project's dependencies)."
)


def _load_keyring() -> ModuleType:
    """Lazy import of the :mod:`keyring` library.

    Raises :class:`VaultError` (chained from the original
    :class:`ImportError`) when the ``[vault]`` extra is not
    installed — keeps the failure mode loud + actionable per
    rule EH-09.
    """
    try:
        import keyring
    except ImportError as e:
        raise VaultError(f"KeyringVault requires the keyring library, but {_IMPORT_HINT}") from e
    return keyring


class KeyringVault:
    """Vault backed by the system credential store.

    Each key is stored under ``service`` (a constant identifying
    the consumer app) so multiple ``mgf-common`` consumers can
    share a single keyring without colliding. The default service
    name is ``"mgf-common"``; consumer apps SHOULD override it
    with their own (e.g., ``"vmanager"``) per rule SC-12.

    Implements :class:`mgf.common.typing.VaultProtocol`.

    Storage model
    -------------
    The :mod:`keyring` library stores ``(service, username) ->
    password`` triples. We map ``key`` to ``username`` directly
    (case-preserving) and never store anything in the password
    field other than the secret itself.

    For :meth:`list_keys`, :mod:`keyring` does not provide a
    portable ``iter_keys`` — different backends offer different
    enumeration APIs, and several (Windows Credential Manager
    notably) do not enumerate at all. We track every key we have
    written ourselves under a separate index entry
    (``__mgf_index__``) so :meth:`list_keys` is deterministic
    across backends. The index is a newline-joined sorted list of
    keys; cheap to read / write at our scale (hundreds of keys
    per consumer in practice).

    Threading: individual :mod:`keyring` get/set/delete calls
    serialise at the OS-IPC layer, but the per-service index
    (``__mgf_index__``) is maintained with a read-modify-write that
    is NOT atomic at the keyring layer. An in-process
    :class:`threading.Lock` serialises that read-modify-write so
    concurrent :meth:`set` / :meth:`delete` from multiple threads of
    ONE process keep the index consistent. CROSS-PROCESS concurrency
    (two processes sharing the same OS keyring service) is NOT
    protected — the index can still lose an entry under a true
    cross-process race; that is out of scope for this in-process
    backend (use a coordinating store if you need it).
    """

    __slots__ = ("_index_lock", "_keyring", "_service")

    _INDEX_KEY = "__mgf_index__"

    def __init__(self, *, service: str = "mgf-common") -> None:
        """Create a keyring-backed vault.

        Parameters
        ----------
        service
            The keyring service name. Defaults to ``"mgf-common"``.
            Consumer apps SHOULD override per rule SC-12 (e.g.,
            ``service="vmanager"``) so a single host running
            multiple ``mgf-common`` consumers does not collide.
        """
        self._service = service
        self._keyring = _load_keyring()
        # Serialises the index read-modify-write below (F-001). Index
        # ops are rare (only on set/delete), so contention is
        # negligible; correctness is the point.
        self._index_lock = threading.Lock()

    @property
    def backend(self) -> str:
        """Returns ``"keyring"`` — see rule DP-10 (no silent fallback)."""
        return "keyring"

    def get(self, key: str) -> str | None:
        """Return the secret for ``key``, or ``None`` if absent.

        Raises :class:`VaultError` chained from the underlying
        :mod:`keyring` exception if the call fails (e.g., locked
        keyring, IPC error).
        """
        try:
            value = self._keyring.get_password(self._service, key)
        except Exception as e:
            raise VaultError(
                f"keyring read failed for key {key!r} on service {self._service!r}: {e}"
            ) from e
        _LOG.info(
            "vault read",
            extra={
                "audit": True,
                "event": "vault.read",
                "vault_key": key,
                "vault_backend": "keyring",
                "found": value is not None,
            },
        )
        # Type narrowing: keyring stub returns `str | None` but our
        # `_keyring` is typed as ModuleType, so the call result is Any.
        return value if value is None else str(value)

    def set(self, key: str, value: str) -> None:
        """Store ``value`` under ``key``. Idempotent.

        Raises :class:`VaultError` chained from the underlying
        :mod:`keyring` exception if the call fails.
        """
        try:
            self._keyring.set_password(self._service, key, value)
            self._add_to_index(key)
        except Exception as e:
            raise VaultError(
                f"keyring write failed for key {key!r} on service {self._service!r}: {e}"
            ) from e
        _LOG.info(
            "vault write",
            extra={
                "audit": True,
                "event": "vault.write",
                "vault_key": key,
                "vault_backend": "keyring",
            },
        )

    def delete(self, key: str) -> None:
        """Remove the entry for ``key``. No-op if absent.

        Raises :class:`VaultError` chained from the underlying
        :mod:`keyring` exception for unexpected failures (NOT for
        the no-such-key case — that is a no-op, signalled by
        :class:`keyring.errors.PasswordDeleteError`).
        """
        # Lazy local import — avoids a top-level dep that wouldn't
        # work without the [vault] extra.
        try:
            from keyring.errors import PasswordDeleteError as missing_entry_err  # noqa: N813
        except ImportError:  # pragma: no cover — _load_keyring already
            # raised in __init__ if keyring is missing.
            missing_entry_err = Exception  # type: ignore[misc,assignment]

        existed = True
        try:
            self._keyring.delete_password(self._service, key)
        except missing_entry_err:
            existed = False
        except Exception as e:
            raise VaultError(
                f"keyring delete failed for key {key!r} on service {self._service!r}: {e}"
            ) from e
        else:
            self._remove_from_index(key)
        _LOG.info(
            "vault delete",
            extra={
                "audit": True,
                "event": "vault.delete",
                "vault_key": key,
                "vault_backend": "keyring",
                "existed": existed,
            },
        )

    def list_keys(self) -> list[str]:
        """Return every stored key (sorted; deterministic).

        Reads the per-service index entry written by :meth:`set`.
        Returns ``[]`` if the index has not been written (fresh
        vault). The index is reconstructed automatically as
        :meth:`set` is called.
        """
        try:
            raw = self._keyring.get_password(self._service, self._INDEX_KEY)
        except Exception as e:
            raise VaultError(f"keyring index read failed on service {self._service!r}: {e}") from e
        if not raw:
            return []
        return sorted(line for line in raw.split("\n") if line)

    def _add_to_index(self, key: str) -> None:
        """Add ``key`` to the per-service index entry. Idempotent.

        The read (``list_keys``) + write (``set_password``) is
        serialised under ``_index_lock`` so two concurrent in-process
        :meth:`set` calls cannot lose each other's index entry (F-001).
        """
        with self._index_lock:
            existing = set(self.list_keys())
            existing.add(key)
            new_index = "\n".join(sorted(existing))
            self._keyring.set_password(
                self._service,
                self._INDEX_KEY,
                new_index,
            )

    def _remove_from_index(self, key: str) -> None:
        """Remove ``key`` from the per-service index entry. No-op if absent.

        Read-modify-write serialised under ``_index_lock`` (F-001).
        """
        with self._index_lock:
            existing = set(self.list_keys())
            existing.discard(key)
            if existing:
                new_index = "\n".join(sorted(existing))
                self._keyring.set_password(
                    self._service,
                    self._INDEX_KEY,
                    new_index,
                )
            else:
                # Empty index — drop the entry so a fresh vault stays
                # clean. Failure here is harmless (next list_keys returns
                # []), and we don't want a registry-level glitch to
                # cascade into a delete() call. Log at DEBUG so the
                # failure isn't invisible.
                try:
                    self._keyring.delete_password(
                        self._service,
                        self._INDEX_KEY,
                    )
                except Exception as e:
                    _LOG.debug("keyring index entry cleanup failed: %s", e)

    def __repr__(self) -> str:
        return f"KeyringVault(service={self._service!r})"


__all__ = ["KeyringVault"]
