"""Service-key permissions.

TWO classes, and the difference between them is the whole of rm#334 / andrea-svc-auth#1 item 3:

  ServiceKeyRequiredIfConfigured   fail-OPEN when unconfigured, fail-CLOSED when configured.
                                   Matches the hand-rolled Andrea services (`if SVC_KEY: require
                                   it; else: allow`). UNCHANGED default behaviour — it is what the
                                   data plane of every unmigrated service still uses.

  ServiceKeyRequired               UNCONDITIONALLY fail-CLOSED. Unconfigured ⇒ HTTP 503, never open.

⚑ WHY THE CONTROL PLANE NEEDS THE SECOND ONE. `POST /admin/reload-config` triggers a live credential
swap, and it shipped with `ServiceKeyRequiredIfConfigured`. On a service with no key provisioned
that resolves to OPEN — which is why a fleet sweep answered `POST /admin/reload-config → 200
ANONYMOUS` on word-detect. A credential-mutating endpoint open to anyone who can reach the port is a
defect regardless of how narrow the network around it is.

⚑ WHY 503 AND NOT 401. A 401 asserts "present a credential and you are in". When the service has NO
auth material at all there is no credential that would work, so a 401 is a lie that sends the caller
hunting for the right header. 503 states the truth — this endpoint is not serviceable in this
posture — and it is a shape an operator can sweep the fleet for.

Pair either class with ServiceOrOIDCAuthentication: authentication resolves the credential, the
permission decides whether one is REQUIRED for this deployment.
"""
import logging
import os

from django.conf import settings
from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission

from .creds import announce_open_divergence as _announce_open_divergence
from .creds import current as _current_secrets
from .creds import settings_auth_configured

log = logging.getLogger("andrea_svc_web")

#: auto     — exactly the pre-0.3 behaviour (open when unconfigured). The DEFAULT; nothing changes.
#: enforce  — ServiceKeyRequiredIfConfigured behaves as ServiceKeyRequired (the wave-6 fleet close).
#: dev-open — a DECLARATION that this service is meant to be open, for a service that has no
#:            credential at all. This is what task-ingest should DECLARE instead of
#:            `unset ANDREA_SVC_API_KEY` in its entrypoint, because a declaration survives a future
#:            loader where a shell `unset` does not.
#:            ⚑ It is NOT a switch that can open a CONFIGURED service — see below.
AUTH_MODES = ("auto", "enforce", "dev-open")

#: Modes the PROCESS ENV may still select when a service does not define settings.ANDREA_AUTH_MODE.
#: "dev-open" is deliberately absent: the env is the tier this contract trusts LEAST (it is a spawn
#: snapshot that anything in the boot path can write, which is how a config-service row reached this
#: decision at all), so the fallback may only HARDEN the posture, never weaken it. A service that
#: really is open declares it in settings — where the config plane cannot reach.
ENV_SELECTABLE_MODES = ("auto", "enforce")

_dev_open_ignored_announced = False


def auth_mode() -> str:
    """settings.ANDREA_AUTH_MODE, then the env (hardening values only), then "auto". An UNKNOWN
    value falls back to "auto" rather than failing the request — a typo in a unit file must not
    take a service down."""
    mode = str(getattr(settings, "ANDREA_AUTH_MODE", "") or "").strip().lower()
    if mode in AUTH_MODES:
        return mode
    env_mode = str(os.environ.get("ANDREA_AUTH_MODE", "") or "").strip().lower()
    return env_mode if env_mode in ENV_SELECTABLE_MODES else "auto"


def _announce_dev_open_ignored(service: str) -> None:
    """Say it ONCE per process, at ERROR: a declared dev-open was refused because this service is
    configured. Silence here would be the same silence the contract exists to end — an operator who
    declared dev-open and got enforcement must be able to find out why from the log."""
    global _dev_open_ignored_announced
    if _dev_open_ignored_announced:
        return
    _dev_open_ignored_announced = True
    log.error("auth svc=%s: ANDREA_AUTH_MODE=dev-open IGNORED — this service HAS credentials, so "
              "the data plane stays enforcing. dev-open is only honoured for a service with no "
              "auth material at all; to run open, do not provision a key.", service)


class CredentialsNotProvisioned(APIException):
    """503 — this endpoint is not serviceable because the service has no auth material at all."""

    status_code = status.HTTP_503_SERVICE_UNAVAILABLE
    default_code = "credential_not_provisioned"
    default_detail = "service credential not provisioned"

    def __init__(self, service: str = "", posture: str = "unconfigured"):
        # A dict detail is rendered VERBATIM by DRF's exception handler, so the body carries the
        # posture and the service name — names and states only, never a value.
        super().__init__({"detail": self.default_detail, "posture": posture,
                          "service": service or str(getattr(settings, "SERVICE_NAME", ""))})


def _posture() -> str:
    secrets = _current_secrets()
    if secrets is not None:
        return secrets.posture
    return "enforcing" if settings_auth_configured() else "unconfigured"


def _tripwire_open_with_material() -> None:
    """About to serve an anonymous request — but does this process HOLD a credential?

    ⚑ THE SAME DIVERGENCE `ServiceSecrets.posture` reports, caught on the ENFORCEMENT side instead
    of the reporting side. `settings_auth_configured()` said no; if the resolved credential set says
    yes, the credential never reached the settings the auth layer reads and this service is open
    while provisioned. Reporting it from both ends is deliberate: the posture branch catches it when
    somebody LOOKS, this catches it when somebody KNOCKS, and a hole should not depend on a sweep
    being run to become visible. One ERROR line per process (see creds.announce_open_divergence);
    never a value."""
    secrets = _current_secrets()
    try:
        if secrets is not None and secrets.auth_material_present():
            _announce_open_divergence(secrets.service, secrets.auth_kind)
    except Exception:                                        # noqa: BLE001 — a tripwire must never
        log.debug("auth: open-with-material tripwire failed", exc_info=True)  # 500 a request


def _authenticated(request) -> bool:
    user = getattr(request, "user", None)
    return bool(user is not None and getattr(user, "is_authenticated", False))


class ServiceKeyRequiredIfConfigured(BasePermission):
    """Require an authenticated principal ONLY when service auth is configured
    (settings.ANDREA_SVC_KEYS or an OIDC issuer). Unconfigured (dev/local) => open, so a service
    behaves exactly like its hand-rolled predecessor did before a key was provisioned.

    0.3.0 adds ANDREA_AUTH_MODE. The default ("auto") is byte-identical to the pre-0.3 decision;
    "enforce" makes this class fail closed like ServiceKeyRequired; "dev-open" DECLARES that a
    service with no credential is meant to be open. The open decision is no longer SILENT either
    way: it is announced once at startup (§6.1) and reported as posture "unconfigured" by /health
    and /admin/creds-status, so a fleet sweep answers "who is open?" in one pass.

    ⚑ `configured` IS CONSULTED FIRST, AND THAT ORDERING IS THE FIX. This shipped as
    `if mode == "dev-open": return True` ABOVE the `configured` lookup, so one flag disabled the
    whole DATA PLANE of a service that HAD credentials — a fully-provisioned service served
    anonymous 200. A mode is a statement about a service's POSTURE, and no statement about posture
    may override the FACT that a credential exists: if there is a credential that would work, the
    caller must present it. dev-open therefore only ever answers the question that is still open —
    what to do when there is NO auth material — which is also the only question it was introduced
    for. For a genuinely unconfigured dev service it is unchanged and still open."""

    message = "service credential required"

    def has_permission(self, request, view):
        configured = settings_auth_configured()
        mode = auth_mode()
        if configured:
            # A credential EXISTS. Nothing below this line may hand out an exemption from it.
            if mode == "dev-open":
                _announce_dev_open_ignored(str(getattr(settings, "SERVICE_NAME", "")))
            return _authenticated(request)
        if mode == "enforce":
            raise CredentialsNotProvisioned(posture=_posture())
        _tripwire_open_with_material()
        return True                                          # unconfigured: auto/dev-open => open


class ServiceKeyRequired(BasePermission):
    """Unconditionally fail-CLOSED. Unconfigured ⇒ 503 (not serviceable), never open.

    This is rm#334 option (a), scoped to the CONTROL PLANE only (/admin/*). It changes no service's
    data-plane auth, so it is not the fleet-wide auth-model decision — it removes the dangerous
    surface without waiting on the infra#242 key distribution.

    ANDREA_AUTH_MODE=dev-open does NOT open this class. That is deliberate: an escape hatch that can
    re-open a credential-mutating endpoint is the same defect wearing a flag."""

    message = "service credential required"

    def has_permission(self, request, view):
        if not settings_auth_configured():
            raise CredentialsNotProvisioned(posture=_posture())
        return _authenticated(request)
