"""ConfigServer client — source a service's secrets + tweaks from the central config-service, so a
basement service stops reading dot-envs (operator 2026-07-19: "ConfigServer for secrets and tweaks").

STDLIB-ONLY (urllib) + FAIL-SOFT by contract (any error / non-200 / bad JSON → {}), vendored here —
mirroring andrea_svc_auth/client.py and common/config_client.py — so a standalone service IMAGE needs
no extra dep and a config-service outage degrades to the baked-in env/default, never an outage.

Config-service wire contract (services/config-service): GET /config/{service}(?box=<host>) with the
X-Agent-Key header → merged {k: v} (the box overlays host-specific knobs over the global '*' defaults).

⚑ TWO ENTRY POINTS, AND THE TYPED ONE IS THE ONE TO PREFER (0.3.0)
------------------------------------------------------------------
`fetch_config()` collapses **unreachable**, **401/403 (our own client key was revoked)** and
**genuinely empty** into the same `{}`. Downstream, all three then read as "nothing changed" — so a
revoked client key looks EXACTLY like a healthy no-op. That is a REPORTING failure more than a fetch
one, and it is the shape behind rm#209's 401-three-days-after-the-rotation and infra#263's eight
lost days of ingestion.

`fetch_config_result()` keeps them distinct as a typed `ConfigFetch`. `fetch_config()` is kept and is
behaviourally unchanged (it is just `.payload`), because `common/` and several services call it.
"""
import json
import logging
import os
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field

log = logging.getLogger("andrea_svc_web")

API_KEY_ENV = "ANDREA_SVC_API_KEY"
URL_ENV = "CONFIG_SERVICE_URL"
DEFAULT_URL = "http://10.121.1.1:9079"
API_KEY_HEADER = "X-Agent-Key"

# ── THE CONFIG PLANE MAY NOT WRITE THE AUTH PLANE ───────────────────────────────────────────────
# ⚑ THE DEFECT THIS CLOSES, in one line: `apply_to_env()` wrote ARBITRARY config-service payload
# keys into os.environ, and `settings.py` read ANDREA_AUTH_MODE out of that same os.environ one
# screen further down — so ONE config-service row (`ANDREA_AUTH_MODE=dev-open`) was an auth kill
# switch for every service that overlays. Reachable the same way: ANDREA_SVC_KEYS (re-point the
# accepted credential), OIDC_ISSUER/OIDC_JWKS_URL (mint a token the service will accept),
# ANDREA_SECRETS_ENV_FILES (add a tier-2 source), ANDREA_CREDS_* (arm the withdrawal path, move
# the generation stamp, disable the DB validation probe), ANDREA_CONFIG_* (turn this very gate off).
#
# ⚑ WHY AN ALLOWLIST IS THE RIGHT SHAPE AND WHY IT IS NOT THE DEFAULT *YET*.
# A default-deny allowlist is strictly the better control: it fails closed for knobs nobody has
# thought of yet, which is exactly the class this defect belongs to. It cannot be the DEFAULT here
# today because `apply_to_env()` exists precisely to carry a service's OWN arbitrary knobs (each of
# ~23 services has its own names, and none of them are enumerated anywhere in this repo) — a
# default-deny would silently stop delivering half the fleet's config, and a security fix that
# breaks 23 services gets reverted, not kept. So this is BOTH:
#   * a FLOOR that cannot be turned off — the auth-control names below are refused unconditionally,
#     with no env knob to re-enable them (a switch that can re-open the switch is not a fix);
#   * an opt-in ALLOWLIST, ANDREA_CONFIG_ENV_ALLOW, read from the process env only (the config
#     plane cannot set it: ANDREA_CONFIG_* is itself refused). Set it per-unit and that service
#     goes default-deny — one line, no code change. That is the migration path to allowlist-only.
#
# ⚑ POLICY vs MATERIAL — the distinction the two call sites turn on. Delivering a service's
# SECRET is the config-service's actual job (it is the secrets SoT; a rotated key arriving from it
# is the feature). Deciding whether that secret is ever CHECKED is not. Conflating the two breaks
# key rotation for every unmigrated service, which is how a security fix gets reverted.
#
# ⚑ ROUND 2 COLLAPSED THE TWO INTO ONE LIST, AND THAT WAS ITSELF A FAIL-OPEN (round 3).
# `AUTH_CONTROL_PREFIXES` carried "OIDC_" and "ANDREA_SECRETS_", so this module REFUSED to deliver
# OIDC_ISSUER / OIDC_JWKS_URL / ANDREA_SECRETS_ENV_FILES. Refusing them hardens NOTHING — it
# REMOVES the credential:
#
#   * settings.OIDC_ISSUER / OIDC_JWKS_URL are what `creds.settings_auth_configured()` reads. With
#     both empty, `ServiceKeyRequiredIfConfigured` returns True and the service serves ANONYMOUS
#     200 on its whole data plane. An OIDC-configured service that sources its issuer/JWKS from the
#     config plane was therefore OPENED by the refusal that was supposed to protect it.
#   * ANDREA_SECRETS_ENV_FILES declares a tier-2 credential FILE. Refusing it un-declares the file,
#     the key inside it never loads, and the service lands in the same fail-open branch.
#
# The same tree already knew this: `creds.AUTH_ENFORCEMENT_NAMES` lists OIDC_ISSUER/OIDC_JWKS_URL
# *because removing them opens the service*, so creds.py refused to REMOVE exactly the names
# config.py refused to DELIVER. Same names, opposite conclusions — the self-evident sign that one
# list was doing two jobs.
#
# The rule, stated once:
#     POLICY   decides WHETHER/HOW this service authenticates.  Never from the config plane.
#     MATERIAL is the credential, or a thing whose ABSENCE weakens the check.  Delivered — but
#              through `ServiceSecrets` (in memory, with provenance), never through os.environ.
AUTH_CONTROL_KEYS = frozenset({
    "ANDREA_API_KEY_HEADER",    # which header the credential is read from
    "CONFIG_SERVICE_URL",       # where "central config" IS (chicken-and-egg: never delivered by it)
})
#: Prefix families, refused whole — MINUS the material carve-outs below. ANDREA_AUTH* (the mode),
#: ANDREA_SECRETS_* / ANDREA_CREDS_* (the loader's own controls: the IGNORE list that would make a
#: service refuse to resolve its own key, the withdrawal opt-in, the stamp path, the DB probe),
#: ANDREA_CONFIG_* (this gate and the overlay switch themselves), and the OIDC_* tuning knobs.
#: The prefixes stay the FLOOR so a knob nobody has thought of yet is refused by default; the
#: carve-out is an explicit, enumerated, reviewable list — never a second prefix family.
AUTH_CONTROL_PREFIXES = ("ANDREA_AUTH", "OIDC_", "ANDREA_SECRETS_", "ANDREA_CREDS_",
                         "ANDREA_CONFIG_")
#: CREDENTIAL MATERIAL — the names whose absence REMOVES a credential or weakens the check, so
#: refusing them fails OPEN. Enumerated, and each one earns its place:
#:   ANDREA_SVC_KEYS / ANDREA_SVC_API_KEY  the service key, under both accepted spellings
#:   OIDC_ISSUER / OIDC_JWKS_URL           the pair `settings_auth_configured()` reads; without
#:                                         BOTH, an OIDC-only service enforces nothing
#:   OIDC_AUDIENCE                         narrows which tokens are accepted; dropping it widens
#:   ANDREA_SECRETS_ENV_FILES              declares a tier-2 credential FILE; un-declaring it
#:                                         un-loads whatever key lives there
#:
#: ⚑ WHY DELIVERING THESE GRANTS THE CONFIG PLANE NO NEW POWER. It already delivers ANDREA_SVC_KEYS
#: (that is the rotation this whole contract exists for), so a config-service that can re-point the
#: accepted credential can already authenticate as anybody. Refusing OIDC_ISSUER does not take that
#: away; it only disarms the HONEST deployment. What it does add is an ADDITIVE issuer — silent
#: where a swapped key is noisy — so provenance is reported: `/admin/creds-status` carries
#: `source: config-service` for every one of these names, and the startup line carries
#: `auth-source=`. Delivered, and never invisible.
CREDENTIAL_MATERIAL_KEYS = frozenset({
    "ANDREA_SVC_KEYS", "ANDREA_SVC_API_KEY",
    "OIDC_ISSUER", "OIDC_JWKS_URL", "OIDC_AUDIENCE",
    "ANDREA_SECRETS_ENV_FILES",
})
ENV_ALLOW_ENV = "ANDREA_CONFIG_ENV_ALLOW"


def is_credential_material(name) -> bool:
    """Is `name` credential MATERIAL — the key itself, or something whose ABSENCE opens/weakens the
    service? Material is DELIVERED (through ServiceSecrets, in memory, with provenance); it is
    never auth POLICY, so it is never refused as such."""
    return str(name).strip().upper() in CREDENTIAL_MATERIAL_KEYS


def is_auth_control(name) -> bool:
    """Is `name` auth POLICY — a key that decides whether/how this service authenticates, or that
    controls what the auth path reads? Never true for credential MATERIAL.

    ⚑ MATERIAL IS CHECKED FIRST, and that ordering is the round-3 fix. `OIDC_ISSUER` matches the
    `OIDC_` prefix and `ANDREA_SECRETS_ENV_FILES` matches `ANDREA_SECRETS_`, so a prefix-only test
    classified both as policy and refused them — which does not harden the service, it strips its
    credential and lands it in the fail-open branch of `ServiceKeyRequiredIfConfigured`.

    Compared case-INSENSITIVELY and stripped. os.environ is case-sensitive on Linux, so
    `andrea_auth_mode` could not actually flip the switch today — refusing it anyway costs nothing
    and is the fail-closed answer when uncertain."""
    n = str(name).strip().upper()
    if n in CREDENTIAL_MATERIAL_KEYS:
        return False
    return n in AUTH_CONTROL_KEYS or n.startswith(AUTH_CONTROL_PREFIXES)


def may_overlay_env(name) -> bool:
    """May `apply_to_env()` write this name into os.environ?

    NO for auth policy (above) and NO for credential MATERIAL — and the two NOs mean opposite
    things, which is the whole point of keeping the predicates apart:

      * POLICY is REFUSED. It is not delivered anywhere, by any path, ever.
      * MATERIAL is DELIVERED — just not through this door. os.environ is the wrong door for it:
        `ServiceSecrets` already resolves the same tier-1 payload IN MEMORY, with provenance
        (`source_of`), and settings.py reads the key, the OIDC pair and the tier-2 file declaration
        from there. The env write adds nothing except a second, untracked copy that
        `apply_to_env`'s own docstring calls superseded.

    The one path that genuinely still needs the env write is the LEGACY `reload.reload_config()`
    (a service that does not inherit this settings module and therefore has no SECRETS), and that
    path checks `is_auth_control` only — policy refused, material delivered."""
    return not (is_auth_control(name) or is_credential_material(name))


def env_allowlist() -> frozenset | None:
    """The opt-in ALLOWLIST (ANDREA_CONFIG_ENV_ALLOW, ','/':' separated), or None when unset.

    Read from os.environ at call time, which is safe precisely because ANDREA_CONFIG_* is in the
    refusal set above — the config plane cannot widen its own allowlist."""
    raw = os.environ.get(ENV_ALLOW_ENV, "") or ""
    names = {chunk.strip().upper() for chunk in raw.replace(":", ",").split(",") if chunk.strip()}
    return frozenset(names) or None


@dataclass(frozen=True)
class ConfigFetch:
    """The outcome of one tier-1 fetch, with the failure modes kept APART.

    status  "ok" | "unreachable" | "rejected" | "malformed" | "off"
    payload {} unless status == "ok"
    detail  an exception CLASS NAME or an HTTP code — never a value, and never the URL (rm#206:
            assert on presence/properties, never on or with a credential)
    """
    status: str
    payload: dict = field(default_factory=dict)
    detail: str = ""

    @property
    def ok(self) -> bool:
        return self.status == "ok"


def fetch_config_result(service: str, box: str | None = None, *, timeout: float = 5) -> ConfigFetch:
    """GET {CONFIG_SERVICE_URL}/config/{service}(?box=) with X-Agent-Key → a typed ConfigFetch.

    NEVER raises. A 401/403 is `rejected`, and that is the loud case: it means THIS service's own
    client key was refused — now diagnosable in one call instead of being indistinguishable from
    "nothing changed"."""
    try:
        base = (os.getenv(URL_ENV) or DEFAULT_URL).rstrip("/")
        url = f"{base}/config/{urllib.parse.quote(str(service))}"
        if box:
            url += "?" + urllib.parse.urlencode({"box": box})
        headers = {}
        key = os.getenv(API_KEY_ENV, "")
        if key:
            headers[API_KEY_HEADER] = key
        req = urllib.request.Request(url, method="GET", headers=headers)
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            status = getattr(resp, "status", None) or resp.getcode()
            if status != 200:
                return ConfigFetch(status="unreachable", detail=f"HTTP {status}")
            raw = resp.read()
    except urllib.error.HTTPError as e:              # urllib RAISES on non-2xx rather than returning
        code = getattr(e, "code", 0)
        if code in (401, 403):
            return ConfigFetch(status="rejected", detail=f"HTTP {code}")
        return ConfigFetch(status="unreachable", detail=f"HTTP {code}")
    except Exception as e:                                   # noqa: BLE001 — fail-soft by contract
        return ConfigFetch(status="unreachable", detail=type(e).__name__)
    try:
        parsed = json.loads(raw)
    except Exception as e:                                   # noqa: BLE001
        return ConfigFetch(status="malformed", detail=type(e).__name__)
    if not isinstance(parsed, dict):
        return ConfigFetch(status="malformed", detail=type(parsed).__name__)
    return ConfigFetch(status="ok", payload=parsed)


def fetch_config(service: str, box: str | None = None, *, timeout: float = 5) -> dict:
    """GET {CONFIG_SERVICE_URL}/config/{service}(?box=) with X-Agent-Key → merged {k: v}.
    FAIL-SOFT: any error / non-200 / bad JSON → {} (never raises).

    Unchanged fail-soft wrapper, kept for `common/` and the existing service callers. New code
    should prefer fetch_config_result(), so an unreachable source is distinguishable from a
    revoked key."""
    return fetch_config_result(service, box, timeout=timeout).payload


def config_value(service: str, key: str, default=None, box: str | None = None):
    """One value with a config-service → env → default fallback (fail-soft)."""
    return fetch_config(service, box).get(key, os.environ.get(key, default))


# The config-service payload most recently APPLIED in this process. It is the diff baseline for the
# live reload (rm#129, andrea_svc_web.reload): "what changed since we last applied?" — without it a
# reload could not tell a rotated DB password (→ reconnect) from an unchanged one (→ no-op). Values are
# held in-process only, never logged or returned by the reload endpoint (names/classes only).
_APPLIED: dict = {}


def applied_snapshot() -> dict:
    """A COPY of the config payload last applied in this process (empty before the first apply)."""
    return dict(_APPLIED)


def record_applied(cfg: dict) -> None:
    """Set the diff baseline. Called by apply_to_env() at startup and by the reload path after it
    applies a new payload."""
    global _APPLIED
    _APPLIED = dict(cfg or {})


def apply_to_env(service: str, box: str | None = None, *, prefetched: dict | None = None) -> int:
    """Overlay the ConfigServer values onto os.environ so settings that read env transparently pick up
    ConfigServer secrets/tweaks (the config-service is authoritative → it OVERRIDES env; a fetch failure
    leaves env untouched). Call it FIRST in settings. Returns the number of keys applied. Fail-soft.

    Also records the payload as the reload diff baseline (rm#129).

    ⚑ SUPERSEDED by andrea_svc_web.creds.ServiceSecrets, and kept ONLY for backward compatibility.
    Writing central values INTO os.environ destroys the distinction between "central value" and
    "spawn snapshot" — which is precisely why `source_of()` is unanswerable for anything this
    function touched, and why a half-applied reload used to be unrecoverable. ServiceSecrets keeps
    the tiers separate in memory and never mutates os.environ.

    settings.py STILL calls this by default, so a service that reads os.environ directly keeps
    working unchanged; set ANDREA_CONFIG_APPLY_TO_ENV=0 once a service reads through SECRETS.

    `prefetched` lets the caller hand over a payload it already has, so enabling both this and
    ServiceSecrets costs ONE HTTP round trip at startup rather than two.

    ⚑ AUTH-CONTROL KEYS ARE REFUSED, unconditionally (see `may_overlay_env` above): the config
    plane may deliver a service's secrets and knobs, but it may NOT decide whether that service
    authenticates anybody. Refusals are LOUD (log.error, NAMES only — a refused name is not a
    value) because a config-service row that tries to set ANDREA_AUTH_MODE is either a mistake
    worth fixing or an attack worth seeing. The return value counts what was APPLIED, not what was
    offered.

    ⚑ THE TWO NON-APPLIED CATEGORIES ARE LOGGED SEPARATELY, and that is not cosmetic. Round 2
    logged `REFUSED OIDC_ISSUER` — which reads as "blocked, you are protected" when the truth was
    "this credential is now missing and your data plane is open". A line that misdescribes a
    fail-open is worse than no line. `not-overlaid` says what actually happened: delivered through
    SECRETS instead."""
    cfg = fetch_config(service, box) if prefetched is None else dict(prefetched)
    allow = env_allowlist()
    applied: dict[str, str] = {}
    refused: list[str] = []                                  # auth POLICY / not on the allowlist
    material: list[str] = []                                 # credential MATERIAL: delivered, but
    #                                                          through SECRETS, not through env
    for k, v in cfg.items():
        name = str(k)
        if is_credential_material(name):
            material.append(name)
            continue
        if not may_overlay_env(name) or (allow is not None and name.strip().upper() not in allow):
            refused.append(name)
            continue
        applied[name] = str(v)
    for name, value in applied.items():
        os.environ[name] = value
    if refused:
        log.error("config: REFUSED %d config-service key(s) for svc=%s — auth-control names are "
                  "never overlaid onto os.environ, and the opt-in allowlist is %s: %s",
                  len(refused), service, "ON" if allow is not None else "off", sorted(refused))
    if material:
        log.info("config: %d credential-material key(s) for svc=%s not overlaid onto os.environ — "
                 "they are DELIVERED through ServiceSecrets (in memory, with provenance): %s",
                 len(material), service, sorted(material))
    if applied:
        # The diff baseline is what this process ACTUALLY has in its env. Recording the refused
        # names here would claim they were applied, and the next reload would diff against a lie.
        record_applied(applied)
    return len(applied)
