"""Feature flags — kill-switch + gradual-rollout primitive.

Stability tier: **experimental** (AP-11) — added in 0.35.0 to close
the federation-wide pattern where every consumer was rolling its
own ad-hoc env-var-based feature gates. Promoted to ``stable`` at
v1.0 per the federation roadmap.

Why this module exists
----------------------

Three real consumers (PlasmaMapper, inthewords, vm-vmanager-app — see
`docs/inprogress/pre_release_additions.md` §3) currently
hand-roll feature flags via ``os.getenv("FEATURE_X") == "1"``
checks scattered across their code. The pattern works for one
project; it breaks down across the federation:

- No audit trail when a flag flips (who turned it on? when?).
- No central registry of which flags exist (each consumer
  reinvents the naming convention).
- No way to swap the backend (env-var → Redis → LaunchDarkly)
  without touching every call site.

This module ships a tiny **Protocol** (so consumers swap
backends freely) plus a stdlib-only **env-var default**
implementation (zero extra dependencies). Production-grade
backends (Redis-watched, LaunchDarkly, ConfigCat) live in
the consumer's tree OR in optional future siblings.

Usage
-----

.. code-block:: python

    from mgf.common.flags import is_enabled

    if is_enabled("new-checkout-flow", default=False):
        return new_flow(request)
    return legacy_flow(request)

The default backend reads ``MGF_FLAG_<NAME>`` env vars (the name
is uppercased + dashes / dots become underscores). Tests
override::

    from mgf.common.flags import configure_flags, StaticFlags

    @pytest.fixture
    def flags():
        impl = StaticFlags({"new-checkout-flow": True})
        configure_flags(impl)
        yield impl
        configure_flags(EnvVarFlags())  # restore default

Audit emission
--------------

``configure_flags(impl)`` emits a structured INFO log line with
``event="flag.backend_configured"`` + ``audit=True`` per
**LG-04** + **SC-17**. The previous backend's name + the new
backend's name are recorded so the SIEM trail shows when a
production process flipped to a different feature-flag source.

``is_enabled(name)`` itself does NOT audit on every call (it's
hot-path; audit-on-read would flood logs). Consumers wanting
per-flag observability can wrap their `is_enabled` call with
a span / log themselves; the federation-standard primitive
stays cheap.

Cross-references
----------------

- **AP-11** — stability tiers (this module ships as
  ``experimental``).
- **LG-04** — audit-log records carry ``audit=True``.
- **SC-17** — security audit-log discipline.
- **DOC-14** — consumers track adoption of this primitive in
  their ``MGF_ADOPTION.md``.

Cross-consumer pattern: see
`docs/inprogress/pre_release_additions.md` §5 (C-1 row).
"""

from __future__ import annotations

import logging
import os
import threading
from typing import (
    TYPE_CHECKING,
    Any,
    Protocol,
    runtime_checkable,
)

if TYPE_CHECKING:
    from collections.abc import Mapping

_LOGGER = logging.getLogger(__name__)


__all__ = [
    "EnvVarFlags",
    "FeatureFlag",
    "StaticFlags",
    "configure_flags",
    "get_flags",
    "is_enabled",
]


# ---------------------------------------------------------------------------
# The Protocol
# ---------------------------------------------------------------------------


@runtime_checkable
class FeatureFlag(Protocol):
    """A feature-flag backend.

    Implementations decide a flag's state when asked. The Protocol
    is intentionally minimal: one read method. Backends that need
    pre-warming, subscription, or batch invalidation handle that
    in their own constructor / lifecycle; the read shape is
    universal.
    """

    def is_enabled(
        self,
        name: str,
        *,
        default: bool = False,
        context: Mapping[str, Any] | None = None,
    ) -> bool:
        """Return whether the named flag is enabled.

        Parameters
        ----------
        name
            The flag's stable identifier. Convention: lowercase
            with dashes (``new-checkout-flow``,
            ``rust-rewrite-pilot``). The backend may normalise
            internally (e.g., upper-case for env-var lookup),
            but the consumer-facing name is what gets persisted
            in commit messages, FEEDBACK papers, and runbooks.
        default
            Returned when the backend has no opinion about the
            flag (e.g., env var unset). Defaults to ``False``
            (deny-by-default — safer for kill-switch shapes).
        context
            Optional per-call context for backends that support
            it (consumer ID, request attributes, ...). The
            default ``EnvVarFlags`` ignores it; richer backends
            (LaunchDarkly etc.) use it for targeting rules.
        """
        ...


# ---------------------------------------------------------------------------
# Default backend — env-var-driven
# ---------------------------------------------------------------------------


def _normalise_name_for_env(name: str) -> str:
    """``new.checkout-flow`` → ``MGF_FLAG_NEW_CHECKOUT_FLOW``."""
    return "MGF_FLAG_" + name.upper().replace("-", "_").replace(".", "_")


_TRUTHY = frozenset({"1", "true", "yes", "on", "enabled"})
_FALSY = frozenset({"0", "false", "no", "off", "disabled", ""})


class EnvVarFlags:
    """Read flags from environment variables. The default backend.

    Lookup: ``MGF_FLAG_<UPPER_NAME>`` (dashes + dots become
    underscores).

    Truthy values (case-insensitive): ``1``, ``true``, ``yes``,
    ``on``, ``enabled``. Falsy values: ``0``, ``false``, ``no``,
    ``off``, ``disabled``, the empty string. Other values are
    rejected with a WARN log + the ``default`` is returned (the
    safer behaviour — don't trust a typo).

    The env var is read on **every** ``is_enabled`` call (not
    cached). That's intentional: production flips are usually
    done via env-var rotation + worker restart, so the read is
    cheap (single ``os.environ.get``) and always-fresh.
    """

    backend: str = "env"

    def is_enabled(
        self,
        name: str,
        *,
        default: bool = False,
        context: Mapping[str, Any] | None = None,
    ) -> bool:
        env_name = _normalise_name_for_env(name)
        raw = os.environ.get(env_name)
        if raw is None:
            return default
        normalised = raw.strip().lower()
        if normalised in _TRUTHY:
            return True
        if normalised in _FALSY:
            return False
        _LOGGER.warning(
            "mgf.common.flags: env var %s has non-boolean value %r; "
            "returning default=%s. Accept any of: %s (true) / %s (false).",
            env_name,
            raw,
            default,
            sorted(_TRUTHY),
            sorted(_FALSY),
            extra={
                "event": "flag.invalid_env_value",
                "flag_name": name,
                "env_var": env_name,
                "raw_value": raw,
            },
        )
        return default


# ---------------------------------------------------------------------------
# In-memory backend — for tests + scratch usage
# ---------------------------------------------------------------------------


class StaticFlags:
    """In-memory feature-flag backend. **For tests + scratch only.**

    Backends used in production SHOULD respect external
    state changes (env var, Redis pub/sub, vendor SDK).
    ``StaticFlags`` does NOT — once constructed, the only way
    to change a flag is to call :meth:`set`. That's the right
    shape for test fixtures + REPL exploration; the wrong shape
    for production.

    Constructor takes an initial dict::

        impl = StaticFlags({"new-flow": True, "old-flow": False})
        configure_flags(impl)
    """

    backend: str = "static"

    def __init__(self, initial: Mapping[str, bool] | None = None) -> None:
        self._flags: dict[str, bool] = dict(initial or {})
        self._lock = threading.Lock()

    def is_enabled(
        self,
        name: str,
        *,
        default: bool = False,
        context: Mapping[str, Any] | None = None,
    ) -> bool:
        with self._lock:
            return self._flags.get(name, default)

    def set(self, name: str, value: bool) -> None:
        """Set a flag's value. Test-fixture ergonomics."""
        with self._lock:
            self._flags[name] = value

    def unset(self, name: str) -> None:
        """Remove a flag — subsequent ``is_enabled`` reads return
        the call-site default."""
        with self._lock:
            self._flags.pop(name, None)


# ---------------------------------------------------------------------------
# Module-level state + public functions
# ---------------------------------------------------------------------------


_state_lock = threading.Lock()
_active_impl: FeatureFlag = EnvVarFlags()


def configure_flags(impl: FeatureFlag) -> None:
    """Install a new feature-flag backend.

    Emits a structured INFO log line with ``event=
    "flag.backend_configured"`` and ``audit=True`` per
    **LG-04** + **SC-17** so the operational trail records the
    transition.

    Thread-safe; serialises against ``is_enabled`` reads.

    Parameters
    ----------
    impl
        Any object satisfying :class:`FeatureFlag`. The runtime
        ``isinstance(impl, FeatureFlag)`` check is `@runtime_checkable`,
        so duck-typed backends pass.
    """
    if not isinstance(impl, FeatureFlag):
        raise TypeError(
            f"configure_flags(impl=...) expected a FeatureFlag-conformant "
            f"object; got {type(impl).__name__} which lacks an "
            f"`is_enabled(name, *, default, context)` method. "
            f"See mgf.common.flags.FeatureFlag for the Protocol."
        )

    global _active_impl
    with _state_lock:
        previous = _active_impl
        _active_impl = impl

    _LOGGER.info(
        "feature-flag backend configured",
        extra={
            "event": "flag.backend_configured",
            "audit": True,
            "previous_backend": getattr(previous, "backend", type(previous).__name__),
            "new_backend": getattr(impl, "backend", type(impl).__name__),
        },
    )


def get_flags() -> FeatureFlag:
    """Return the currently-installed feature-flag backend.

    Useful when consumers want to call methods beyond the
    Protocol (e.g., :meth:`StaticFlags.set` in a test fixture
    after :func:`configure_flags`).
    """
    return _active_impl


def is_enabled(
    name: str,
    *,
    default: bool = False,
    context: Mapping[str, Any] | None = None,
) -> bool:
    """Module-level convenience — delegates to the installed backend.

    Equivalent to ``get_flags().is_enabled(name, default=default,
    context=context)``. Most consumers want this shape — the
    backend is a process-global concern, the read site cares
    about the answer not the source.

    Parameters
    ----------
    name
        Flag identifier. Convention: lowercase-with-dashes.
    default
        Returned when the backend has no opinion. Defaults to
        ``False`` (deny-by-default — safer for kill-switches).
    context
        Forward-compat: per-call targeting context for backends
        that support it. Ignored by the default ``EnvVarFlags``.
    """
    return _active_impl.is_enabled(name, default=default, context=context)
