"""Live config/secrets reload — the universal, no-argument callback (rm#129), now a thin view over
`ServiceSecrets` (andrea_svc_web.creds).

WHAT THIS ENDPOINT IS
---------------------
`POST /admin/reload-config` (no arguments) re-reads this service's credentials and applies what
changed. It is an **optimization, not the mechanism**: the mechanism is lazy, mtime-gated
re-resolution inside `ServiceSecrets.get()`, which converges every gunicorn worker without anybody
calling anything. This endpoint just makes the convergence immediate.

WHAT CHANGED IN 0.3.0 (and why)
-------------------------------
  * **All three tiers are re-read**, not just config-service. A rotation that lands only in
    `/etc/andrea/<svc>.env` used to be invisible to a running service FOREVER.
  * **Tier 1 is consulted only when the service opted in.** Pre-0.3 `reload_config()` called
    `fetch_config()` unconditionally, so a service that deliberately did NOT enable the
    config-service overlay still had its env overwritten by a POST.
  * **RESOLVE → VALIDATE → APPLY.** A validation failure applies NOTHING — not "the good ones" —
    and answers **409** with the rejected NAMES and reasons. Pre-0.3 wrote each changed key into
    `os.environ` inside the loop, so a failure halfway through left the process holding a mix of old
    and new, and (because `record_applied()` never ran) the NEXT reload re-diffed against a baseline
    that no longer matched reality. `ServiceSecrets` never mutates `os.environ`, so "roll back" is
    simply "don't swap".
  * **Unreachable ≠ rejected ≠ empty.** A 401 from config-service — our own client key revoked —
    now answers `reason: "source-rejected-our-key"` instead of "config-service returned nothing".
  * **The endpoint is fail-CLOSED** (`ServiceKeyRequired`): unconfigured ⇒ 503, never anonymous-200.

SECURITY: neither the response nor the logs ever carry config VALUES — only key names, their
classification, and sha256[:16] digests. A reload callback must never become a secret-readback
channel (rm#206).

A service whose connections live OUTSIDE Django's ORM (uuid_service's DBUtils pool, a redis client,
an HTTP client holding a bearer) has three hooks, in increasing order of precision:

    SECRETS.subscribe("DOCSTORE_TOKEN", cb)        # cb(name, old_digest, new_digest)
    SECRETS.on_change(cb)                          # cb(ReloadReport) — the whole generation
    SVC_CONFIG_RELOAD_HOOK = "registry.db:rebuild_pool"    # the pre-0.3 dotted hook, still honoured
"""
import logging
import os
from importlib import import_module

from django.conf import settings
from drf_spectacular.utils import OpenApiTypes, extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView

from .auth import ServiceKeyRequired, ServiceOrOIDCAuthentication
from .config import (applied_snapshot, fetch_config, is_auth_control, is_credential_material,
                     record_applied)
from .creds import _DB_FIELD, apply_databases, classify, current  # noqa: F401 — re-exported

log = logging.getLogger("andrea_svc_web")

__all__ = ["classify", "reload_config", "ReloadConfigView", "CredsStatusView"]


def _mirror_to_settings(key: str, value: str) -> bool:
    """Update a Django setting of the SAME NAME, if the service defined one. This is what makes a
    rotated value 'a service-global variable used on the next query' for every service that reads
    settings — generically, with no per-service code."""
    if hasattr(settings, key):
        setattr(settings, key, value)
        return True
    return False


def _refresh_service_keys() -> bool:
    """Keep `settings.ANDREA_SVC_KEYS` in step with the env. settings.py builds it as
    ANDREA_SVC_KEYS or ANDREA_SVC_API_KEY, so a rotation may arrive under EITHER name; the auth layer
    reads this one setting per request."""
    new = os.environ.get("ANDREA_SVC_KEYS") or os.environ.get("ANDREA_SVC_API_KEY") or None
    if new and getattr(settings, "ANDREA_SVC_KEYS", None) != new:
        settings.ANDREA_SVC_KEYS = new
        return True
    return False


def _apply_databases() -> list:
    """Re-point `settings.DATABASES` at the current env and drop the pooled connections so the next
    query reconnects. Returns the aliases touched. Only the 'default' alias is mapped generically
    (DB_HOST/PORT/NAME/USER/PASSWORD); a service with several aliases or exotic naming uses
    SVC_CONFIG_RELOAD_HOOK. The mechanism now lives in creds.apply_databases (one definition, used
    by both the legacy path here and ServiceSecrets)."""
    return apply_databases(os.environ)


def _run_hook(summary: dict):
    dotted = getattr(settings, "SVC_CONFIG_RELOAD_HOOK", None)
    if not dotted:
        return None
    mod_path, _, fn_name = str(dotted).partition(":")
    fn = getattr(import_module(mod_path), fn_name)
    fn(summary)
    return str(dotted)


def reload_config() -> dict:
    """LEGACY (pre-0.3) reload: re-read the config-service payload and apply what changed, mutating
    os.environ. Returns a summary carrying key NAMES and classes only — never values.

    ⚑ Kept intact, and still the path taken by a service whose settings module does NOT inherit
    andrea_svc_web.settings (so `settings.SECRETS` does not exist). Everything that inherits the
    base goes through `ServiceSecrets.reload()` instead — see ReloadConfigView. Do not add features
    here; add them to creds.py."""
    service = getattr(settings, "SERVICE_NAME", "") or os.environ.get("SERVICE_NAME", "")
    box = os.environ.get("BOX") or None
    new = fetch_config(service, box)
    if not new:                                              # fail-soft: unreachable/empty != "wipe"
        return {"service": service, "reloaded": False, "reason": "config-service returned nothing",
                "changed": {"db": [], "auth": [], "other": []}, "changed_count": 0}

    old = applied_snapshot()
    changed = sorted(k for k in set(new) | set(old) if str(old.get(k, "")) != str(new.get(k, "")))

    buckets: dict = {"db": [], "auth": [], "other": []}
    mirrored = []
    refused = []                                             # auth POLICY: never delivered
    withheld = []                                            # auth MATERIAL offered EMPTY: never
    #                                                          written over a live credential
    for k in changed:
        # ⚑ The THIRD route to the same switch (see config.is_auth_control). This legacy path
        # writes payload keys into os.environ AND mirrors them onto same-named settings, so it
        # could set ANDREA_AUTH_MODE / OIDC_* just as apply_to_env could. Refused here too:
        # a service does not get a weaker auth model for not having migrated yet.
        # POLICY only — `is_auth_control`, not `may_overlay_env`. For a service that does not
        # inherit andrea_svc_web.settings this env write is the ONLY delivery path for a ROTATED
        # service key (`_refresh_service_keys()` below reads os.environ), so refusing the key here
        # would break rotation for exactly the services that have no other mechanism.
        if is_auth_control(k):
            refused.append(str(k))
            continue
        # ⚑ AN EMPTY VALUE NEVER BLANKS AUTH MATERIAL, on this path either. `is_auth_control` now
        # (correctly) lets OIDC_ISSUER / OIDC_JWKS_URL through as MATERIAL, which is what an
        # unmigrated OIDC service needs — but the generic write below would happily set them to ""
        # if the payload carried an empty row (or dropped the name entirely), and two empty strings
        # is exactly `settings_auth_configured() -> False`, i.e. an open data plane. Delivering a
        # credential and blanking one are opposite operations; only the first is allowed here.
        # Counted SEPARATELY from `refused`: "we would not let the config plane decide policy" and
        # "we would not let it erase your credential" are different events, and giving them one
        # label is how a fail-open gets described as a protection.
        if is_credential_material(k) and not str(new.get(k, "")):
            withheld.append(str(k))
            continue
        if k in new:                                         # a key removed upstream is left in env:
            os.environ[str(k)] = str(new[k])                 # dropping it could strip a live credential
        buckets[classify(k)].append(k)
        if k in new and _mirror_to_settings(str(k), str(new[k])):
            mirrored.append(k)
    if refused:
        log.error("reload: REFUSED %d auth-control key(s) from the config-service payload for "
                  "svc=%s: %s", len(refused), service, sorted(refused))
    if withheld:
        log.error("reload: WITHHELD %d empty auth-material row(s) for svc=%s — an empty value is "
                  "never written over a live credential (blanking it would turn enforcement OFF): "
                  "%s", len(withheld), service, sorted(withheld))

    if _refresh_service_keys():
        mirrored.append("ANDREA_SVC_KEYS")
    reconnected = _apply_databases() if buckets["db"] else []

    record_applied({k: v for k, v in new.items() if not is_auth_control(k)})
    summary = {
        "service": service, "reloaded": True, "changed_count": len(changed),
        "changed": buckets, "mirrored_to_settings": sorted(set(mirrored)),
        "db_aliases_reconnected": reconnected,
    }
    if refused:
        summary["refused_auth_control"] = sorted(refused)     # NAMES only, never values
    if withheld:
        summary["withheld_blank_material"] = sorted(withheld)  # NAMES only, never values
    try:
        summary["hook"] = _run_hook(summary)
    except Exception as e:                                   # noqa: BLE001 — a broken hook must not 500
        log.exception("reload: SVC_CONFIG_RELOAD_HOOK failed")
        summary["hook_error"] = repr(e)
    # names + classes only — never values
    log.info("config reload: %s changed=%d db=%s auth=%s other=%s reconnected=%s",
             service, len(changed), buckets["db"], buckets["auth"], buckets["other"], reconnected)
    return summary


class ReloadConfigView(APIView):
    """POST /admin/reload-config — re-read credentials and apply what changed (no arguments).

    FAIL-CLOSED (`ServiceKeyRequired`): unconfigured ⇒ **503** (not serviceable in this posture),
    configured-and-uncredentialed ⇒ 401. It shipped with the fail-OPEN-if-unconfigured permission,
    which made it answer `200 ANONYMOUS` on every service without a key — rm#334. This closes that,
    and ONLY that: no service's data-plane auth changes.

    Status codes: 200 applied / 200 no-change / **409 rejected** (nothing applied, generation
    unchanged) / 500 only for a genuinely unexpected failure. Returns key NAMES by class and
    sha256[:16] digests — never values."""

    authentication_classes = [ServiceOrOIDCAuthentication]
    permission_classes = [ServiceKeyRequired]

    @extend_schema(operation_id="reload_config", summary="Re-read and apply rotated credentials",
                   request=None, responses={200: OpenApiTypes.OBJECT, 409: OpenApiTypes.OBJECT,
                                            503: OpenApiTypes.OBJECT})
    def post(self, request):
        try:
            secrets = current()
            if secrets is None:                              # non-basement settings: legacy path
                return Response(reload_config(), status=status.HTTP_200_OK)
            report = secrets.reload(reason="endpoint")
            code = status.HTTP_409_CONFLICT if report.reason == "rejected" else status.HTTP_200_OK
            return Response(report.as_dict(), status=code)
        except Exception as e:                               # noqa: BLE001 — reload must not take the service down
            log.exception("config reload failed")
            return Response({"reloaded": False, "applied": False, "error": repr(e)},
                            status=status.HTTP_500_INTERNAL_SERVER_ERROR)


class CredsStatusView(APIView):
    """GET /admin/creds-status — what this process is ACTUALLY holding: names + provenance +
    digests. Never values (sha256[:16] is enough to answer "did this change?", which is the only
    question — the precedent cred-drift-check.py set).

    ⚑ This is what upgrades cred-drift-check.py from INFERENCE to MEASUREMENT. Today it compares a
    process's START TIME to a file's mtime and can only produce a `candidate` verdict. Against this
    endpoint it compares the digest the process is actually holding to the digest in the file — a
    `confirmed` verdict, per key, with no restart and no credential value anywhere in the loop.

    Same fail-closed posture as the reload endpoint: 503 unconfigured, 401 configured-and-uncredentialed."""

    authentication_classes = [ServiceOrOIDCAuthentication]
    permission_classes = [ServiceKeyRequired]

    @extend_schema(operation_id="creds_status",
                   summary="Credential names, provenance and sha256[:16] digests (never values)",
                   responses={200: OpenApiTypes.OBJECT, 503: OpenApiTypes.OBJECT})
    def get(self, request):
        secrets = current()
        if secrets is None:
            return Response({"service": str(getattr(settings, "SERVICE_NAME", "")),
                             "generation": 0, "posture": "unknown", "creds": {}, "tiers": {},
                             "detail": "this service does not inherit andrea_svc_web.settings, "
                                       "so it has no ServiceSecrets singleton"},
                            status=status.HTTP_200_OK)
        return Response(secrets.status(), status=status.HTTP_200_OK)
