"""Live config/secrets reload — the universal, no-argument callback (rm#129).

Until now a basement service read the config-service ONCE, at settings-import
(`config.apply_to_env`), so rotating a secret meant RESTARTING the service. This module adds the
operator's callback: `POST /admin/reload-config` (no arguments) re-reads the service's config and
is SMART about what changed:

  * **Auth/API key changed** → mirrored onto the Django setting of the same name. The auth layer
    (`andrea_svc_auth.django_auth`) reads `settings.ANDREA_SVC_KEYS` PER REQUEST, so the new key is
    simply live on the next query — nothing to reconnect.
  * **DB credentials / endpoint changed** → `settings.DATABASES` is updated from the new env and
    `django.db.connections.close_all()` is called, so the very next query dials the new endpoint
    with the new credentials. Django reconnects lazily → no in-flight request is killed.
  * **Anything else (tunables)** → applied to `os.environ` and mirrored to a same-named setting.

A service whose connections live OUTSIDE Django's ORM (uuid_service's `DB_CONFIG` + DBUtils pool, a
redis client, an HTTP client holding a bearer) points `settings.SVC_CONFIG_RELOAD_HOOK` at its own
callable — the same escape hatch shape as the existing `SVC_HEALTHCHECK`:

    SVC_CONFIG_RELOAD_HOOK = "registry.db:rebuild_pool"   # called with the diff summary

SECURITY: the endpoint is AUTHENTICATED (unlike /health), and neither the response nor the logs ever
carry config VALUES — only key names and their classification. A reload callback must never become a
secret-readback channel.

FAIL-SOFT: an unreachable config-service yields an empty payload → treated as "nothing changed"
(never wipes live config). Hook errors are reported in the response, not raised.
"""
import logging
import os
import re
from importlib import import_module

from django.conf import settings
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView

from .auth import ServiceKeyRequiredIfConfigured, ServiceOrOIDCAuthentication
from .config import applied_snapshot, fetch_config, record_applied

log = logging.getLogger("andrea_svc_web")

# A key is DB-ish if it names a database host/port/name/user/password/socket or a DSN. Checked FIRST,
# so DB_PASSWORD classifies as `db` (→ reconnect) rather than `auth` (→ no reconnect).
_DB_RE = re.compile(r"(^|_)(DB|DATABASE)(_|$)|(^|_)DSN$", re.I)
# Secret-ish: an API key, bearer token, shared secret, or non-DB password.
_AUTH_RE = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)", re.I)
# env name -> Django DATABASES entry field, for the generic (single 'default' alias) case.
_DB_FIELD = {"DB_HOST": "HOST", "DB_PORT": "PORT", "DB_NAME": "NAME",
             "DB_USER": "USER", "DB_PASSWORD": "PASSWORD"}


def classify(key: str) -> str:
    """'db' | 'auth' | 'other' — DB wins over auth so DB_PASSWORD triggers a reconnect."""
    if _DB_RE.search(key):
        return "db"
    if _AUTH_RE.search(key):
        return "auth"
    return "other"


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."""
    touched = []
    dbs = getattr(settings, "DATABASES", None) or {}
    default = dbs.get("default")
    if isinstance(default, dict):
        for env_name, field in _DB_FIELD.items():
            val = os.environ.get(env_name)
            if val not in (None, "") and str(default.get(field, "")) != str(val):
                default[field] = val
                touched.append("default")
    try:                                                     # close AFTER re-pointing, so the next
        from django.db import connections                    # query dials the NEW endpoint
        connections.close_all()
    except Exception:                                        # noqa: BLE001 — no ORM configured is fine
        log.debug("reload: no Django DB connections to close")
    return sorted(set(touched))


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:
    """Re-read this service's config and apply what changed. Returns a summary carrying key NAMES
    and classes only — never values."""
    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 = []
    for k in changed:
        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 _refresh_service_keys():
        mirrored.append("ANDREA_SVC_KEYS")
    reconnected = _apply_databases() if buckets["db"] else []

    record_applied(new)
    summary = {
        "service": service, "reloaded": True, "changed_count": len(changed),
        "changed": buckets, "mirrored_to_settings": sorted(set(mirrored)),
        "db_aliases_reconnected": reconnected,
    }
    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 config/secrets and apply what changed (no arguments).

    AUTHENTICATED (X-Agent-Key or OIDC): this triggers a live credential swap, so it is never
    anonymous. Returns the changed key NAMES by class — never their values."""

    authentication_classes = [ServiceOrOIDCAuthentication]
    permission_classes = [ServiceKeyRequiredIfConfigured]

    def post(self, request):
        try:
            return Response(reload_config(), status=status.HTTP_200_OK)
        except Exception as e:                               # noqa: BLE001 — reload must not take the service down
            log.exception("config reload failed")
            return Response({"reloaded": False, "error": repr(e)},
                            status=status.HTTP_500_INTERNAL_SERVER_ERROR)
