"""Base Django settings for an Andrea web service — the framework-only common denominator
factored out of uuid_service (the proven Django/DRF template). A service does:

    from andrea_svc_web.settings import *          # noqa: F401,F403

then sets only what is service-specific: SERVICE_NAME, ROOT_URLCONF, WSGI_APPLICATION, its app
in INSTALLED_APPS, its SPECTACULAR_SETTINGS title/description, and (optionally) SVC_HEALTHCHECK.

Design invariants (operator directives):
  * No ORM apps (no contenttypes/auth): modern Django runs as a pure framework. DATABASES is
    intentionally empty — a service owns its own storage (raw SQL / an external store).
  * OpenAPI for free via drf-spectacular at /api/schema + /api/docs (the "all API methods in
    wiki + OpenAPI" directive).
  * Auth is config-driven via andrea_svc_auth: set ANDREA_SVC_KEYS (done here from the standard
    ANDREA_SVC_API_KEY env) to turn on API-key auth; OIDC turns on when OIDC_ISSUER/JWKS are set.
  * Credentials come from ONE process-wide `SECRETS` object (andrea_svc_web.creds.ServiceSecrets),
    created and loaded at the bottom of this file: config-service > declared env files > the
    process-env SNAPSHOT, re-readable tiers beating the frozen one, fail-closed, never a value in a
    log or an HTTP body. A service reads through it:

        from django.conf import settings
        token = settings.SECRETS.require("DOCSTORE_TOKEN")    # 503 (named) if never provisioned
        settings.SECRETS.subscribe("DOCSTORE_TOKEN", _rebuild_client)
"""
import os

BASE_DIR = os.path.dirname(os.path.abspath(__file__))


def _bool(key: str, default: str = "0") -> bool:
    return os.environ.get(key, default).lower() in ("1", "true", "yes", "on")


# ── ConfigServer overlay (operator 2026-07-19: "ConfigServer for secrets and tweaks") ─────────────
# BEFORE reading any env-based setting below, overlay this service's central config — secrets + live
# tweaks — from the config-service onto os.environ, so everything downstream (SECRET_KEY, DB_*, auth
# keys, service knobs) transparently picks it up and dot-envs can retire. Opt-in + FAIL-SOFT: enabled
# by ANDREA_CONFIG_SERVICE=1 with SERVICE_NAME (+ optional BOX) set; a config-service outage leaves
# env untouched (degrades to the baked-in defaults, never an outage).
#
# ⚑ SUPERSEDED but NOT REMOVED (0.3.0). ServiceSecrets (bottom of this file) keeps the tiers
# separate IN MEMORY and never mutates os.environ — which is what makes `source_of()` answerable at
# all and a half-applied reload impossible. But a service that reads os.environ DIRECTLY still
# depends on this overlay, so it stays ON BY DEFAULT and is retired per-service by setting
# ANDREA_CONFIG_APPLY_TO_ENV=0. The fetch happens ONCE and is handed to both consumers, so having
# both enabled costs one HTTP round trip at startup, not two.
#
# ⚑ READ THE AUTH MODE *BEFORE* THE OVERLAY RUNS. ANDREA_AUTH_MODE is consumed further down (and by
# permissions.auth_mode()), and `apply_to_env` used to write ARBITRARY payload keys into os.environ
# right here — one screen ABOVE that read. A single config-service row therefore set this service's
# auth mode. `config.is_auth_control()` now refuses those keys at the source, and capturing the mode
# from the SPAWN environment here means the fix no longer depends on the order of two blocks in one
# file: even if some future overlay wrote the env, this value was already taken.
_AUTH_MODE_AT_SPAWN = os.environ.get("ANDREA_AUTH_MODE", "auto")

_CONFIG_SERVICE_ON = _bool("ANDREA_CONFIG_SERVICE")
_CONFIG_FETCH = None
if _CONFIG_SERVICE_ON:
    from andrea_svc_web.config import apply_to_env as _apply_config
    from andrea_svc_web.config import fetch_config_result as _fetch_config_result
    _CONFIG_FETCH = _fetch_config_result(os.environ.get("SERVICE_NAME", ""), os.environ.get("BOX"))
    if _bool("ANDREA_CONFIG_APPLY_TO_ENV", "1"):
        _apply_config(os.environ.get("SERVICE_NAME", ""), os.environ.get("BOX"),
                      prefetched=_CONFIG_FETCH.payload)


# Internal service (service_pool); the nginx/network ACL is the gate. A dev-only secret by default.
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-not-for-prod-change-me")
DEBUG = _bool("DJANGO_DEBUG")
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",")

# staticfiles is only for the Swagger UI; rest_framework + drf_spectacular are the API stack.
# andrea_svc_web is listed so its AppConfig.ready() registers the shared OpenAPI auth-scheme
# extension. A service APPENDS its own app: INSTALLED_APPS = INSTALLED_APPS + ["myservice.app"].
INSTALLED_APPS = [
    "django.contrib.staticfiles",
    "rest_framework",
    "drf_spectacular",
    "andrea_svc_web",
]

MIDDLEWARE = [
    # Re-resolve the service key (mtime-gated, no network) BEFORE the view runs — DRF authenticates
    # inside the view's initial(), so a permission class would be one request too late. This is what
    # makes a key dropped into a declared env file live on the NEXT request, with no restart and no
    # reload POST: provisioning must not require the endpoint the provisioning would authorize.
    "andrea_svc_web.middleware.CredsRefreshMiddleware",
    "django.middleware.common.CommonMiddleware",
]

TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "APP_DIRS": True,
    "DIRS": [],
    "OPTIONS": {},
}]

# Django manages NO database — a service owns its storage directly (operator: "you can have a
# Django service without a DB at all"). A service that DOES want the ORM can override this.
DATABASES = {}

REST_FRAMEWORK = {
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
    "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
    "UNAUTHENTICATED_USER": None,
    # Transparent pass-through to DRF's default UNLESS ANDREA_FASTAPI_COMPAT_422 is on (below).
    "EXCEPTION_HANDLER": "andrea_svc_web.exceptions.fastapi_compat_exception_handler",
}

# Optional FastAPI-compat: render validation errors as HTTP 422 {"detail":[{loc,msg,type}]} instead
# of DRF's standard 400 — for a service migrating from FastAPI that must preserve that wire shape.
# OFF by default (standard 400). A useful option to have, not a default.
ANDREA_FASTAPI_COMPAT_422 = _bool("ANDREA_FASTAPI_COMPAT_422")

# A service overrides TITLE/DESCRIPTION/VERSION: SPECTACULAR_SETTINGS = {**SPECTACULAR_SETTINGS, ...}
SPECTACULAR_SETTINGS = {
    "TITLE": "Andrea Service",
    "DESCRIPTION": "An Andrea web service (andrea_svc_web base).",
    "VERSION": "1.0.0",
    "SERVE_INCLUDE_SCHEMA": False,
}

# ── service identity ────────────────────────────────────────────────────────────────────────────
SERVICE_NAME = os.environ.get("SERVICE_NAME", "andrea-service")
BOX = os.environ.get("BOX") or None

# ── credentials: the ServiceSecrets singleton (rm#129 / rm#209 / rm#334) ────────────────────────
# THE PROTOTYPE IS A SINGLETON, NOT AN ANCESTOR CLASS. Services inherit from this base by
# `from andrea_svc_web.settings import *` + `service_urls()`, not by subclassing — so the credential
# contract is realised as one process-wide object created here and reachable everywhere else as
# `andrea_svc_web.creds.current()`.
#
# Precedence: config-service > declared env files (first wins) > the process-env SNAPSHOT.
# ⚑ Process env is LAST, always. It is the ONLY tier that cannot be re-read, so it is the only one
# that can hold a REVOKED secret for the life of the process (rm#209; infra#263's eight lost days).
#
# ⚑ `env_files` defaults to EMPTY and the shared /usr/work/infrastructure/.env is NEVER a default
# source. That single decision is what makes this non-breaking across the whole basement fleet:
# every hypothetical break comes from a loader reaching for the SHARED file, and pdf-feed's
# GARAGE_GITLAB_SECRET and docwell's per-service key both DIFFER from the shared file's values —
# a shared-.env-first loader there swaps in the WRONG credential and produces signature failures.
# A service declares its own file explicitly, in code or via ANDREA_SECRETS_ENV_FILES on its unit:
#
#     SECRETS.declare_required("DB_PASSWORD", "config store")
#     ANDREA_SECRETS_ENV_FILES=/etc/andrea/word-detect.env      # unit / compose, no code change
#
# FAIL-SOFT construction: a service must never fail to start because the credential layer had a bad
# day. On any unexpected error SECRETS is None and every consumer degrades to today's behaviour.
from andrea_svc_web.creds import MissingCredential as _MissingCredential  # noqa: E402
from andrea_svc_web.creds import ServiceSecrets as _ServiceSecrets  # noqa: E402

try:
    SECRETS = _ServiceSecrets(service=SERVICE_NAME, box=BOX, config_service=_CONFIG_SERVICE_ON)
    SECRETS_LOAD_REPORT = SECRETS.load(prefetched=_CONFIG_FETCH).as_dict()
except _MissingCredential:
    # ANDREA_SECRETS_STRICT=1 only. The DEFAULT is start-and-503: under Restart=always a hard exit
    # is a crash-loop, and a crash-loop destroys exactly the observability this contract builds —
    # a 503 that NAMES the missing credential is strictly more diagnosable than a restarting unit.
    # Opt in per-unit where fail-fast is genuinely wanted; this re-raise is what makes it abort.
    raise
except Exception as _creds_exc:                              # noqa: BLE001
    import logging as _logging                               # noqa: E402
    _logging.getLogger("andrea_svc_web").exception(
        "creds: ServiceSecrets init failed — falling back to process env only")
    SECRETS = None
    SECRETS_LOAD_REPORT = {"error": repr(_creds_exc)}

# ── service auth (andrea_svc_auth) ──────────────────────────────────────────────────────────────
# API-key path: set ANDREA_SVC_KEYS (a single secret str or a {key_id: secret} map). We default it
# from the standard ANDREA_SVC_API_KEY env so a service inherits API-key auth with zero config.
# Unset (dev) => ServiceKeyRequiredIfConfigured is a no-op (open), matching the hand-rolled services.
#
# Read THROUGH SECRETS so a key that lives in a declared env file wins over a stale spawn snapshot.
# For an unmigrated service (no declared files, no config-service) SECRETS.get() IS os.environ.get()
# on the startup snapshot — byte-identical to the pre-0.3 line. Both spellings of the name are
# honoured, as reload.py's `_refresh_service_keys()` has always done.
#
# ⚑ THE DEGRADED BRANCH READS THE FETCHED PAYLOAD DIRECTLY. `apply_to_env` no longer writes auth
# MATERIAL into os.environ (config.may_overlay_env — a credential belongs in SECRETS, with
# provenance, not in a second untracked copy). Normally that is invisible: SECRETS resolves the
# same tier-1 payload itself. But if ServiceSecrets construction FAILED, the old code fell back to
# os.environ — which would now be empty for a config-service-provisioned service, leaving it with
# NO credential at all, i.e. `unconfigured`, i.e. OPEN. A fallback that turns a fetch failure into
# an open service is the exact class of defect this file is being fixed for, so the fallback reads
# the payload we already have in hand. `_material()` below implements all three steps for EVERY
# auth-material name, not just the service key — the OIDC pair had no such fallback at all.
#
# ⚑ A FUNCTION, and lower-case, deliberately. Django copies every module attribute whose name
# `.isupper()` onto the settings object — and "_CONFIG_PAYLOAD".isupper() is True, underscore and
# all. Holding the payload in a module-level dict would therefore publish a plaintext copy of every
# secret the config-service serves as `settings._CONFIG_PAYLOAD`, where Django's own error-page
# masking (which only hides names matching API|TOKEN|KEY|SECRET|PASS|...) would not hide it.
# rm#206: a value gets read and used, never parked somewhere observable.
def _from_config_fetch(*names):
    payload = getattr(_CONFIG_FETCH, "payload", None) or {}
    for _n in names:
        _v = payload.get(_n)
        if _v:
            return _v
    return None


def _material(*names, default=""):
    """Resolve auth MATERIAL through SECRETS (all three tiers, with provenance), falling back to
    the process env and then to the payload we already hold if ServiceSecrets failed to construct.

    ⚑ WHY EVERY AUTH-MATERIAL NAME MUST COME THROUGH HERE AND NOT THROUGH os.environ. `apply_to_env`
    deliberately does not overlay credential material onto os.environ (config.may_overlay_env — the
    credential belongs in SECRETS, with provenance, not in a second untracked copy). An
    `os.environ.get()` for such a name therefore reads EMPTY on a config-service-provisioned
    service — and for OIDC_ISSUER/OIDC_JWKS_URL "empty" is not a degradation, it is
    `settings_auth_configured() -> False`, i.e. `ServiceKeyRequiredIfConfigured` serving anonymous
    200 on the whole data plane. That was the round-3 fail-open, in one line: the config plane
    delivered the credential and this file looked for it in the one place it was guaranteed not to
    be."""
    if SECRETS is not None:
        for _n in names:
            _v = SECRETS.get(_n)
            if _v:
                return _v
        return default
    for _n in names:
        _v = os.environ.get(_n)
        if _v:
            return _v
    return _from_config_fetch(*names) or default


ANDREA_SVC_KEYS = _material("ANDREA_SVC_KEYS", "ANDREA_SVC_API_KEY", default=None) or None
ANDREA_API_KEY_HEADER = os.environ.get("ANDREA_API_KEY_HEADER", "X-Agent-Key")
# OIDC path (off unless both are set): OIDC_ISSUER, OIDC_JWKS_URL, OIDC_AUDIENCE. Read through
# SECRETS for exactly the reason spelled out in `_material` above — these two ARE half of the
# enforcement predicate, so sourcing them from a tier the config plane cannot write to is not
# "safer", it is the fail-open.
OIDC_ISSUER = _material("OIDC_ISSUER")
OIDC_JWKS_URL = _material("OIDC_JWKS_URL")
# Narrows WHICH tokens are accepted; absent => any audience. Material for the same reason (dropping
# it widens the check), and it was never read from the env here before, so defaulting to None is
# byte-identical for every service that does not set it.
OIDC_AUDIENCE = _material("OIDC_AUDIENCE", default=None) or None
# auto (default, = the pre-0.3 behaviour) | enforce (fail closed everywhere) | dev-open (a
# DECLARATION that a service with NO credential is meant to be open — it can no longer open a
# service that HAS one; see permissions.ServiceKeyRequiredIfConfigured). The wave-6 fleet decision
# is a one-line flip of this default, once the startup warnings have shown for weeks exactly which
# services would break. Taken from the spawn snapshot ABOVE, before any overlay could touch it.
ANDREA_AUTH_MODE = _AUTH_MODE_AT_SPAWN

# ── health ──────────────────────────────────────────────────────────────────────────────────────
# Optional deep health-check: a "module.path:callable" returning truthy when healthy (e.g. a real
# DB SELECT 1). None => /health is pure liveness.
SVC_HEALTHCHECK = None

# ── Syslog logging (operator 2026-07-19: "Syslog logging") ────────────────────────────────────────
# Ship logs to stdout (docker/journald still capture them) AND syslog, tagged with SERVICE_NAME.
# Env-tunable: SYSLOG_ADDRESS (default /dev/log), SYSLOG_FACILITY (local0), LOG_LEVEL, ANDREA_SYSLOG=0
# to disable. Fail-soft: syslog is skipped if its target isn't present (no /dev/log socket) → stdout only.
# A service that OVERRIDES SERVICE_NAME after `from andrea_svc_web.settings import *` should re-run
# `LOGGING = build_logging(SERVICE_NAME)` (or set SERVICE_NAME via env) so the syslog tag matches.
from andrea_svc_web.logging_conf import build_logging as _build_logging  # noqa: E402
LOGGING = _build_logging(SERVICE_NAME)
# Rebuild LOGGING from the FINAL SERVICE_NAME at startup (after the whole settings module loads), so a
# service that overrides SERVICE_NAME after the star-import is still syslog-tagged correctly WITHOUT a
# per-service `LOGGING = build_logging(SERVICE_NAME)` re-run. Fixes the fleet-wide mis-tag-as-"andrea-service".
LOGGING_CONFIG = "andrea_svc_web.logging_conf.configure"

STATIC_URL = "static/"
USE_TZ = True
