#!/usr/bin/env python3
"""
Centralized secrets management for the GenAI / CoursIA infrastructure.

MODEL
-----
``.secrets/master.env`` is the SINGLE canonical source for every shared
secret (API tokens, service passwords, session keys). Each service
``.env`` (and the GenAI notebooks ``.env``) is a mix of:

  * service-specific CONFIG (ports, paths, GPU ids, model names) -> stays
    in the service ``.env``, never touched here;
  * shared SECRETS (the keys listed in ``SECRET_KEYS`` below) -> their
    VALUE is propagated from ``master.env`` by this script.

Rotate a shared secret:
  1. Edit ``.secrets/master.env``.
  2. ``python scripts/secrets/render_envs.py``          (propagate)
  3. ``docker compose restart <impacted-services>``     (ComfyUI-Login
     regenerates its bcrypt hash from the env at restart; a running
     container keeps a STALE hash until restarted -- the original cause
     of the "drift" incident).

MODES
-----
  (default)   sync: propagate master.env values into every .env that
              references a SECRET key. Idempotent (re-running is a no-op
              when already in sync).
  --check     report drift only (any service .env whose value for a
              SECRET key differs from master), exit 1 on drift. Use as a
              CI / pre-commit gate.
  --strict    exit 1 when a SECRET key declared in SECRET_KEYS is absent
              from master.env (so a CI can distinguish "in sync" from
              "in sync on what I watch" -- a declared key that drifts can
              only drift if master actually serves it, #14373), OR when a
              key declared REQUIRED for a target (REQUIRED_KEYS, #15145)
              has no line in that target / the target .env is missing on
              disk. Combines with --check. Without --strict, --check stays
              exit 0 on a declared-but-absent key and only NAMES it.
  (#15145) --check also NAMES, per target: master keys with no line in the
              target ("not provisioned" -- without a REQUIRED_KEYS entry
              that is indistinguishable from "not needed"), and declared
              targets missing from disk. sync() APPENDS the missing lines
              for REQUIRED keys (master-served values only).
  --bootstrap ONE-SHOT: scan existing .env files, extract SECRET values,
              write master.env (first-seen value per key; conflicts
              reported). Use only to initialize master.env from a legacy
              scattered layout.
  --bootstrap-missing  close the .env blind spot (#9351): for every
              service dir with a docker-compose.yml but no .env, parse
              the compose file's ${KEY} references, and write a fresh
              .env containing every referenced SECRET_KEY whose value is
              present in master.env. Idempotent (services with an
              existing .env are left to sync()). Use after pulling this
              change to recover services that have NEVER had an .env
              provisioned -- running sync() alone would leave them
              invisible to --check.

All printed output masks secret values (only the last 4 chars shown).
Neither master.env nor any .env is committed (all gitignored).
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
MASTER_ENV = REPO_ROOT / ".secrets" / "master.env"
SERVICES_ROOT = REPO_ROOT / "docker-configurations" / "services"

# Service + notebooks .env files managed by this script.
#
# Note: TARGET_ENVS only enumerates .env files that ALREADY EXIST. A service
# directory that lacks a .env is invisible to ``--check`` and to sync until
# ``--bootstrap-missing`` has run (see ``bootstrap_missing_envs`` + the
# ``--bootstrap-missing`` CLI flag). The whisper-api drift incident (#9351)
# was undetectable by ``--check`` precisely because whisper-api/ had no .env;
# the running container's API_KEY was injected at ``docker run``-time, drifted
# from master.env, and the auditor saw ``[OK]`` because there was no file to
# compare against. ``--bootstrap-missing`` closes this blind spot by writing
# the missing .env from master.env so future ``--check`` runs SEE drift.
TARGET_ENVS = [
    *sorted(SERVICES_ROOT.glob("*/.env")),
    REPO_ROOT / "MyIA.AI.Notebooks" / "GenAI" / ".env",
    # Lean prover harness: agent_tests/prover/config.py loads this .env.
    # Centralizes MISTRAL_API_KEY (Leanstral trial, #5475) + ANTHROPIC_API_KEY
    # so rotation = edit master.env + render (cf #16 "rotation facile").
    # Only keys present in master.env are rewritten; the prover's own
    # ZAI/LOCAL/OPENROUTER config (absent from master) is left untouched.
    REPO_ROOT / "MyIA.AI.Notebooks" / "SymbolicAI" / "Lean" / ".env",
    # Trading paper harness: Portfolio-IBKR-Coinbase-Hybrid/paper_harness/config.py
    # loads this .env. Centralizes the IBKR paper login (#1199) so a re-provision =
    # edit master.env + render. The credential was lost 3x when it lived only in a
    # per-machine .env with no canonical anchor -- master.env is now that anchor.
    REPO_ROOT / "MyIA.AI.Notebooks" / "QuantConnect" / "projects" / "Portfolio-IBKR-Coinbase-Hybrid" / ".env",
    # --- Notebook-side .env targets (#9929, c.10186) -----------------------
    # Closes the structural blind-spot documented in ai-01's c.9929 finding:
    # `--check` reports `[OK] No drift` whenever a `.env` is **absent** from
    # TARGET_ENVS, even if it carries a stale shared key (e.g. a revoked
    # OpenAI token duplicated into two series, sha=4a2fac0e1714 -> HTTP 401).
    # Six escalations in a row (#6255 #8519 #8624 #9059 #9929 + the
    # GPU-1 "vLLM DOWN" phantom) evaporated to a 10-second measurement that
    # TARGET_ENVS would have surfaced. The lists below are **optional**: a
    # path that does not exist on the current machine is silently skipped
    # (see ``sync()``'s ``if not env.exists(): continue``), so adding them is
    # a no-op on machines where the series is absent and a real check on
    # machines where the series is present (e.g. ai-01 confirmed SmartContracts
    # + QuantConnect + SymbolicLearning carry real OPENAI/OPENROUTER keys).
    # Per-series rationale lives in the comments above each entry below.
    # AgenticDataScience (ML/DataScienceWithAgents series). ECE TP uses
    # OPENAI_API_KEY + OPENAI_BASE_URL; OPENROUTER is a duplicate alias.
    REPO_ROOT / "MyIA.AI.Notebooks" / "ML" / "DataScienceWithAgents" / "AgenticDataScience" / ".env",
    # Track2-GoogleADK (ML/DataScienceWithAgents series, #13955). Lab11 reads
    # OPENAI_API_KEY + OPENROUTER_API_KEY + OPENAI_BASE_URL via
    # config/providers.py (Path(__file__).parent.parent / ".env"). Without
    # this entry, --check sees [OK] on machines that have NOT provisioned the
    # .env yet (silent skip), but on machines that DID provision a stale key
    # from before master.env was canonical, the drift is invisible. Adding
    # the path closes that blind spot for the series that PR #13933
    # introduced.
    REPO_ROOT / "MyIA.AI.Notebooks" / "ML" / "DataScienceWithAgents" / "Track2-GoogleADK" / ".env",
    # SemanticKernel notebooks (.NET Interactive). 0-AI-settings.ipynb +
    # 09-SemanticKernel-Building-CLR consume this via Settings.LoadFromFile
    # (config/settings.json is gitignored and derived from this key -- see
    # render_settings_json.py).
    REPO_ROOT / "MyIA.AI.Notebooks" / "SemanticKernel" / ".env",
    # SmartContracts series (Solidity, foundry). OPENAI_API_KEY is an
    # OpenRouter-key alias used by SC-11 LLM-Assisted notebook.
    REPO_ROOT / "MyIA.AI.Notebooks" / "SymbolicAI" / "SmartContracts" / ".env",
    # QuantConnect series. May carry OPENAI_API_KEY on machines that use
    # OpenAI for QC LLM summaries (not all do -- some route via QC Cloud).
    REPO_ROOT / "MyIA.AI.Notebooks" / "QuantConnect" / ".env",
    # SymbolicLearning series. SL-* notebooks may consume OPENAI/OPENROUTER
    # for LLM-assisted proof search. Path may not exist on machines that
    # never provisioned it.
    REPO_ROOT / "MyIA.AI.Notebooks" / "SymbolicAI" / "SymbolicLearning" / ".env",
]

# Keys whose VALUE is a shared secret and must be synced from master.env.
# Everything else (ports, paths, GPU ids, model names, TZ, ...) is
# service-specific CONFIG and is left untouched in each .env.
#
# Per-instance passwords (each ComfyUI / Forge instance has its OWN
# password) are INTENTIONALLY excluded -- they are not shared and must
# not be collapsed to one value. Their drift prevention is the
# restart-after-.env-change rule (+ entrypoint self-check), not
# centralization.
SECRET_KEYS: frozenset[str] = frozenset({
    # Hugging Face (aliased -- same logical token, two names)
    "HF_TOKEN", "HUGGINGFACE_TOKEN",
    # Paid LLM APIs (centrally managed, rotation-sensitive)
    "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "MISTRAL_API_KEY",
    # MiniMax cloud (Hailuo Video API, #10244). Le fournisseur n'emet **qu'une
    # seule** cle par souscription : elle est donc plus sensible que les autres,
    # pas moins -- la perdre coute l'acces API de l'abonnement entier.
    # Le suffixe _GENAI est la convention deja recue par la flotte : il distingue
    # cette cle de souscription du credential MiniMax *texte* servi via claudish,
    # qui ne porte aucun entitlement video.
    "MINIMAX_GENAI_API_KEY",
    # Model hubs / git
    "CIVITAI_TOKEN", "GITHUB_TOKEN", "GITHUB_ACCESS_TOKEN",
    # Per-service client API keys (server defines the value; clients must match)
    "WHISPER_API_KEY", "VLLM_API_KEY", "TTS_API_KEY",
    # Direct backend key for the hosted "medium" vLLM endpoint (ai-01 :5002,
    # qwen3.6-35b-a3b), provisioned 2026-09-24 via RooSync private DM
    # (ai01-vllm-medium-key-po2026-20260924, #16755). Distinct from
    # CLAUDISH_PROXY_KEY, which authenticates the proxy path; this one is the
    # server-side vLLM API key and must match the backend's --api-key.
    # Consumers read it under TGWUI_MEDIUM_API_KEY (see ALIASES) -- the pair
    # naming mirrors TGWUI_MEDIUM_API_URL in the GenAI .env.
    "VLLM_API_KEY_MEDIUM", "TGWUI_MEDIUM_API_KEY",
    "QWEN_ASR_API_KEY", "MUSICGEN_API_KEY", "DEMUCS_API_KEY",
    "FUNASR_API_KEY",
    # Qdrant vector DB -- CLIENT side (notebooks RAG / SemanticKernel / Argument).
    # NB: the Qdrant SERVER reads the SAME value under the double-underscore name
    # ``QDRANT__SERVICE__API_KEY`` (config.yaml convention). The server compose
    # lives in the ``roo-extensions`` repo (NOT CoursIA); only the CLIENT key is
    # centralized here so notebook consumers stay in lock-step with the server on
    # rotation. Both names MUST carry the same value.
    "QDRANT_API_KEY",
    # claudish proxy -- CLIENT side (consumers authenticate to the proxy with the
    # claudish security header instead of carrying the provider key, #14926). The
    # proxy SERVER compose lives OUTSIDE CoursIA; only the CLIENT key is
    # centralized here so notebook consumers stay in lock-step with the server on
    # rotation. The proxy auth middleware accepts THREE input headers --
    # ``x-proxy-key``, ``x-api-key`` and ``Authorization: Bearer`` -- all carrying
    # the same value (verified firsthand: Bearer and x-proxy-key both return 200
    # on a live completion).
    "CLAUDISH_PROXY_KEY",
    # OWUI native API (NB-20, #417) + TTS multi-voice gateway (#16, po-2023)
    "OWUI_API_KEY", "TTS_GATEWAY_API_KEY",
    # ComfyUI client tokens (notebook client <-> service must agree).
    # COMFYUI_AUTH_TOKEN is the canonical notebook-client name (#16 flip:
    # notebooks read ``os.getenv("COMFYUI_AUTH_TOKEN") or os.getenv("COMFYUI_API_TOKEN")``).
    # Aliased to COMFYUI_API_TOKEN -- both names carry the credential the
    # ComfyUI-Login middleware validates (bind-mounted token file, #14382).
    "COMFYUI_VIDEO_TOKEN", "COMFYUI_API_TOKEN", "COMFYUI_AUTH_TOKEN",
    # ComfyUI-Video web login password (user decision #10985, 2026-08-20):
    # centralized in master.env under an INSTANCE-SCOPED name -- plain
    # COMFYUI_PASSWORD must NOT enter SECRET_KEYS because comfyui-qwen carries
    # a legitimately different per-instance value (bootstrap would flag a hard
    # same-priority conflict). The compose maps master's
    # COMFYUI_VIDEO_PASSWORD -> container env COMFYUI_PASSWORD.
    "COMFYUI_VIDEO_PASSWORD",
    # Qwen / ComfyUI-Login bearer token (#10265). Consumed by:
    #   - MyIA.AI.Notebooks/GenAI/00-5-ComfyUI-Local-Test.ipynb cell as
    #     ``os.getenv("COMFYUI_API_TOKEN") or os.getenv("QWEN_API_TOKEN")``
    #   - scripts/genai-stack/core/auth_manager.py:281
    #   - scripts/genai-stack/_archive/utils/reconstruct_env.py:46 (legacy
    #     sync path used in Phase 30 docs)
    # Real auth is via the bind-mounted .secrets/qwen-api-user.token (ComfyUI-Login
    # middleware reads it directly); the env-var form is a notebook-side fallback.
    # Both env-var names (``QWEN_API_TOKEN`` / ``QWEN_API_USER_TOKEN`` legacy)
    # carry the same value -- see ALIASES below.
    "QWEN_API_TOKEN",
    # IBKR paper/simulated trading login (Portfolio-IBKR-Coinbase-Hybrid, #1199).
    # A single shared credential (not per-instance) -> centralized so a re-provision
    # is edit-master + render, never a scattered per-machine .env that gets lost.
    # IBKR_ACCOUNT_ID stays out (it is an identifier discovered post-login, not a
    # secret; it lives in the consumer .env as config).
    "IBKR_USERNAME", "IBKR_PASSWORD",
    # Session
    "SECRET_KEY",
})

# Explicit value aliases: these key pairs must always carry the SAME
# value (one logical secret under two names). On sync, both are written
# from master; on bootstrap, a conflict between an aliased pair is a
# hard error (the two names must agree).
ALIASES: dict[str, str] = {
    "HUGGINGFACE_TOKEN": "HF_TOKEN",
    "GITHUB_ACCESS_TOKEN": "GITHUB_TOKEN",
    # ComfyUI-Login bearer -- the legacy env-var name used by
    # scripts/genai-stack/core/auth_manager.py:233 (still referenced as
    # ``QWEN_API_USER_TOKEN`` in legacy code paths). Both names MUST
    # carry the same value; bootstrap enforces this on first sync. #10265.
    "QWEN_API_USER_TOKEN": "QWEN_API_TOKEN",
    # ComfyUI-Login bearer, notebook-client canonical name (#14382): notebooks
    # read COMFYUI_AUTH_TOKEN first; must equal COMFYUI_API_TOKEN (the
    # credential the middleware validates). A stale non-empty AUTH_TOKEN
    # shadows the correct API_TOKEN in the ``or`` fallback chain -> 401.
    "COMFYUI_AUTH_TOKEN": "COMFYUI_API_TOKEN",
    # Hosted "medium" vLLM backend key. Direction matters: the canonical
    # (target-side) name is TGWUI_MEDIUM_API_KEY -- what GenAI consumers read,
    # mirroring TGWUI_MEDIUM_API_URL. VLLM_API_KEY_MEDIUM is ai-01's DM-side
    # name and is also kept in master.env so both spellings exist there; a
    # rotation is edit-master (either name) + render. #16755.
    "VLLM_API_KEY_MEDIUM": "TGWUI_MEDIUM_API_KEY",
}

# Per-target REQUIRED keys (#15145). sync() only rewrites lines that already
# EXIST in a target, so a key the target's consumers read -- but that never
# had a line there -- is invisible to ``--check`` ("[OK]" was exact about
# what it looked at and false about the question asked: the GenAI/.env
# VLLM_API_KEY incident served empty Authorization headers -> HTTP 401 while
# ``--check`` stayed green). A key listed here for a target:
#   * is APPENDED by sync() when the target exists and master serves it;
#   * is NAMED by --check, and fails --check --strict (exit 1) when absent.
# Keys enter this table only when grep-verified against the target's actual
# consumers; everything else stays indistinguishable from "not needed" and
# is reported as informational "not provisioned" lines, never silently OK.
REQUIRED_KEYS: dict[str, frozenset[str]] = {
    # GenAI notebooks .env: 9 notebooks read os.getenv("VLLM_API_KEY")
    # against the vLLM endpoint (GenAI/Texte 10/10b/10c, GenAI/_research,
    # RAG-09, SK-04, Aspire-02 + GameTheory-03c/28b which walk up to the
    # same file). A missing line = None -> empty bearer -> 401.
    "MyIA.AI.Notebooks/GenAI/.env": frozenset({"VLLM_API_KEY"}),
}


def env_label(env: Path) -> str:
    """Repo-relative posix path for reports and REQUIRED_KEYS lookup;
    absolute posix when the path is outside the repo (hermetic tmp tests)."""
    try:
        return env.relative_to(REPO_ROOT).as_posix()
    except ValueError:
        return env.as_posix()


# --------------------------------------------------------------------------- #
# dotenv parse / serialize (minimal, dependency-free)
# --------------------------------------------------------------------------- #
import re

_LINE_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")


def parse_kv(value: str) -> str:
    """Strip surrounding quotes / inline comment from a dotenv value."""
    v = value.strip()
    if len(v) >= 2 and v[0] in "\"'" and v[-1] == v[0]:
        v = v[1:-1]
    return v.strip()


def read_env(path: Path) -> dict[str, str]:
    """Return {KEY: raw_value} for every assignment line in path."""
    out: dict[str, str] = {}
    if not path.exists():
        return out
    for line in path.read_text(encoding="utf-8").splitlines():
        m = _LINE_RE.match(line)
        if m:
            out[m.group(1)] = parse_kv(m.group(2))
    return out


def mask(value: str) -> str:
    """Mask a secret for display: show only the last 4 chars."""
    if not value:
        return "<empty>"
    if len(value) <= 4:
        return "*" * len(value)
    return f"***{value[-4:]}"


# --------------------------------------------------------------------------- #
# bootstrap: build master.env from the scattered legacy .env values
# --------------------------------------------------------------------------- #
def _source_priority(env: Path) -> int:
    """Canonical-source priority: a service .env (the server that DEFINES a
    key) outranks the GenAI notebooks .env (a client that CONSUMES it).
    Lower number = higher priority."""
    if "docker-configurations" in env.parts and "services" in env.parts:
        return 0  # service = canonical
    return 1      # GenAI notebooks = client


def bootstrap() -> int:
    if MASTER_ENV.exists():
        print(f"[!] {MASTER_ENV} already exists -- bootstrap is one-shot.")
        print("    Delete it first if you really want to re-bootstrap.")
        return 1

    # value + the (priority, source) that supplied it.
    gathered: dict[str, str] = {}
    src: dict[str, tuple[int, str]] = {}
    client_drift: list[str] = []   # service-vs-client: resolvable (service wins)
    hard_conflicts: list[str] = []  # same-priority: genuinely per-instance / misclassified

    for env in TARGET_ENVS:
        if not env.exists():
            continue
        prio = _source_priority(env)
        for key, val in read_env(env).items():
            if key not in SECRET_KEYS or not val:
                continue
            if key not in gathered:
                gathered[key] = val
                src[key] = (prio, env.parent.name)
            elif gathered[key] == val:
                continue
            else:
                prev_prio, prev_src = src[key]
                if prio < prev_prio:
                    # current (service) outranks stored (client): record drift, swap.
                    client_drift.append(f"  {key}: {env.parent.name}({mask(val)}) "
                                        f"overrides stale {prev_src}({mask(gathered[key])})")
                    gathered[key] = val
                    src[key] = (prio, env.parent.name)
                elif prio > prev_prio:
                    # stored (service) outranks current (client): record drift, keep.
                    client_drift.append(f"  {key}: {prev_src}({mask(gathered[key])}) "
                                        f"overrides stale {env.parent.name}({mask(val)})")
                else:
                    # same priority, different values -> genuinely per-instance
                    hard_conflicts.append(f"  {key}: {env.parent.name}={mask(val)} "
                                          f"vs {prev_src}={mask(gathered[key])}")

    # Enforce alias agreement on the gathered values.
    for alias, canonical in ALIASES.items():
        if alias in gathered and canonical in gathered and gathered[alias] != gathered[canonical]:
            hard_conflicts.append(f"  ALIAS MISMATCH: {alias}={mask(gathered[alias])} "
                                  f"!= {canonical}={mask(gathered[canonical])}")

    if hard_conflicts:
        print("[X] Bootstrap aborted -- same-priority conflicts (per-instance or "
              "misclassified key):")
        for c in hard_conflicts:
            print(c)
        print("\n    These keys have legitimately different values across peers")
        print("    (e.g. one password per instance). Remove them from SECRET_KEYS")
        print("    in render_envs.py -- they are config, not shared secrets.")
        return 2

    MASTER_ENV.parent.mkdir(parents=True, exist_ok=True)
    lines = [
        "# Centralized secrets -- SINGLE source of truth.",
        "# Edit a value HERE, then run: python scripts/secrets/render_envs.py",
        "# Never commit (gitignored). Service-specific config (incl. per-instance",
        "# passwords) stays in each service .env; only shared SECRET values are",
        "# synced from this file.",
        "",
    ]
    for key in sorted(gathered):
        lines.append(f"{key}={gathered[key]}")
    MASTER_ENV.write_text("\n".join(lines) + "\n", encoding="utf-8")

    print(f"[+] Wrote {MASTER_ENV} with {len(gathered)} secret keys:")
    for key in sorted(gathered):
        print(f"      {key} = {mask(gathered[key])}")
    if client_drift:
        print(f"\n[i] {len(client_drift)} client/notebook drift(s) resolved "
              f"(service value taken as canonical):")
        for d in client_drift:
            print(d)
        print("    Run `python scripts/secrets/render_envs.py` to propagate the "
              "canonical values into the drifted notebooks .env.")
    return 0


# --------------------------------------------------------------------------- #
# sync: propagate master.env -> every .env
# --------------------------------------------------------------------------- #
def sync(check_only: bool, strict: bool = False) -> int:
    if not MASTER_ENV.exists():
        print(f"[X] {MASTER_ENV} not found. Run with --bootstrap first.")
        return 1
    master = read_env(MASTER_ENV)
    missing_in_master = SECRET_KEYS - master.keys()
    propagated = len(master)
    if missing_in_master:
        print(f"[!] {len(missing_in_master)} declared SECRET keys are absent from "
              f"master.env (left untouched in services): {sorted(missing_in_master)}")

    drift: list[str] = []
    written: list[str] = []
    absent_targets: list[str] = []
    not_provisioned: list[str] = []
    required_gaps: list[str] = []
    provisioned: list[str] = []
    for env in TARGET_ENVS:
        if not env.exists():
            # #15145 geste 2 : une cible declaree absente du disque etait
            # sautee sans un mot -- invisible a --check par construction.
            absent_targets.append(env_label(env))
            continue
        label = env_label(env)
        target_keys = set(read_env(env).keys())
        original = env.read_text(encoding="utf-8").splitlines()
        changed = False
        out_lines: list[str] = []
        for line in original:
            m = _LINE_RE.match(line)
            if m and m.group(1) in master:
                key, new_val = m.group(1), master[m.group(1)]
                cur = parse_kv(m.group(2))
                if cur != new_val:
                    drift.append(f"  {env.parent.name}: {key} "
                                 f"{mask(cur)} -> {mask(new_val)}")
                    out_lines.append(f"{key}={new_val}")
                    changed = True
                else:
                    out_lines.append(line)
            else:
                out_lines.append(line)
        # #15145 geste 1 : nommer les cles du master sans ligne ici. Sans
        # REQUIRED_KEYS, "cette cible n'en a pas besoin" et "elle aurait du
        # la recevoir" rendent la meme sortie -- c'est l'indiscernabilite
        # qui etait le defaut, pas un chiffre.
        gap = sorted(k for k in master
                     if k not in target_keys and ALIASES.get(k, k) not in target_keys)
        if gap:
            not_provisioned.append(
                f"  {label}: {len(gap)} master key(s) have no line here "
                f"(not provisioned): {gap}")
        # #15145 geste 3 : les cles DECLAREES requisent deviennent mesurables.
        required = REQUIRED_KEYS.get(label)
        if required:
            missing_req = sorted(k for k in required
                                 if k not in target_keys
                                 and ALIASES.get(k, k) not in target_keys)
            if missing_req:
                if check_only:
                    required_gaps.append(
                        f"  {label}: REQUIRED key(s) with no line: {missing_req}")
                else:
                    out_lines.append("# provisioned by render_envs.py -- declared "
                                     "REQUIRED key (cf #15145)")
                    for k in missing_req:
                        if k in master:
                            out_lines.append(f"{k}={master[k]}")
                            provisioned.append(f"  {label}: {k} {mask(master[k])}")
                            changed = True
                        else:
                            required_gaps.append(
                                f"  {label}: REQUIRED key {k} absent from "
                                f"master.env (cannot provision)")
        if changed and not check_only:
            env.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
            written.append(env.parent.name)

    if absent_targets:
        for lbl in absent_targets:
            req = REQUIRED_KEYS.get(lbl)
            if req:
                required_gaps.append(
                    f"  {lbl}: .env missing on disk -- REQUIRED key(s) "
                    f"{sorted(req)} invisible to --check")
        print(f"[!] {len(absent_targets)} declared target .env missing on disk "
              f"(skipped, invisible to drift comparison): {absent_targets}")
    if not_provisioned:
        print(f"[i] {len(not_provisioned)} target(s) carry master key(s) with no "
              f"line -- not provisioned (indistinguishable from 'not needed' "
              f"absent a REQUIRED_KEYS entry, cf #15145):")
        for r in not_provisioned:
            print(r)
    if required_gaps:
        print(f"[!] {len(required_gaps)} REQUIRED-key gap(s) "
              f"(fix: run sync without --check; keys absent from master.env "
              f"need a master.env edit):")
        for g in required_gaps:
            print(g)

    if drift:
        if check_only:
            print(f"[X] DRIFT detected ({len(drift)} key(s) differ from master):")
            for d in drift:
                print(d)
            print("\n    Run `python scripts/secrets/render_envs.py` to resync.")
            return 1
        print(f"[+] Resynced {len(drift)} value(s) across: {', '.join(written)}")
        for d in drift:
            print(d)
        print("\n[i] Restart impacted containers (ComfyUI-Login hashes regen at restart).")
        return 0

    if provisioned:
        print(f"[+] Provisioned {len(provisioned)} REQUIRED key line(s):")
        for p in provisioned:
            print(p)

    on_disk = len(TARGET_ENVS) - len(absent_targets)
    if missing_in_master:
        print(f"[OK] {on_disk}/{len(TARGET_ENVS)} target .env on disk, in sync "
              f"with master.env within the {propagated} propagated key(s). "
              f"{len(missing_in_master)} declared secret key(s) are OUT OF SCOPE "
              f"(absent from master.env): {sorted(missing_in_master)}.")
    else:
        print(f"[OK] {on_disk}/{len(TARGET_ENVS)} target .env on disk, in sync "
              f"with master.env ({len(master)} secret keys). No drift.")
    if strict and (missing_in_master or required_gaps):
        return 1
    return 0


# --------------------------------------------------------------------------- #
# --bootstrap-missing: close the .env-blind-spot (#9351)
#
# When a service's docker-compose.yml references ${SOME_KEY} to interpolate a
# master.env value, the SERVICE runs fine (docker compose expands the var) but
# render_envs.py has nothing to compare against — ``--check`` reads ``[OK]``
# because the .env file is missing, while the canonical divergence is silently
# INVISIBLE. The fix: when invoked with --bootstrap-missing, scan every
# service directory for a docker-compose.yml (or -hybrid sibling), parse its
# ${KEY} references, and for each KEY that exists in master.env and that the
# service does NOT yet have in its .env, write KEY=<master_value> to a fresh
# .env. Future ``--check`` runs now have a file to compare against.
# --------------------------------------------------------------------------- #
_COMPOSE_GLOBS = ("docker-compose.yml", "docker-compose-hybrid.yml")
# Matches ${VAR} and ${VAR:-default} interpolation tokens in compose YAML.
# Intentionally naive: a token whose body is "}" or ":-" is ignored (defensive).
_COMPOSE_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}")


def _compose_referenced_keys(compose_path: Path, secret_keys: frozenset[str]) -> set[str]:
    """Return the subset of ``secret_keys`` referenced by ${KEY} in compose_path.

    Pure parser: no YAML library required (the patterns we look for are
    trivial), no false positives from YAML comments (they don't contain ``${``).
    A service that references ``${UNREGISTERED_KEY}`` is ignored — only keys
    declared in ``SECRET_KEYS`` are considered, so the writer never emits a
    value the script cannot later keep in sync.
    """
    if not compose_path.exists():
        return set()
    text = compose_path.read_text(encoding="utf-8")
    found = set()
    for m in _COMPOSE_VAR_RE.finditer(text):
        key = m.group(1)
        if key in secret_keys:
            found.add(key)
    return found


def _service_compose_paths(service_dir: Path) -> list[Path]:
    """Return the compose files for a service dir, in deterministic order."""
    return [service_dir / g for g in _COMPOSE_GLOBS if (service_dir / g).exists()]


def bootstrap_missing_envs(
    services_root: Path | None = None,
    master_path: Path | None = None,
    secret_keys: frozenset[str] | None = None,
) -> list[str] | None:
    """Auto-create .env for services whose compose file references a SECRET_KEY
    but who lack a .env. Returns the list of service-dir names that were
    newly written. Hermetic test paths: (a) caller passes ``services_root`` /
    ``master_path`` / ``secret_keys`` explicitly, OR (b) caller monkeypatches
    the module globals (``render_envs.MASTER_ENV`` / ``SERVICES_ROOT``) and
    invokes via ``main()`` — the ``None`` defaults below defer to the LIVE
    module globals at call time, so such monkeypatches bite.

    Precedence: if the service already has an .env, it is left untouched
    (sync() handles updates). If the service has NO .env but has at least one
    compose file, the union of ${KEY} references across BOTH compose files
    becomes the candidate key set; only keys present in master.env get
    written. Values originate from master.env — never from the running
    container, the host shell, or an interactive prompt.

    Returns ``None`` when master.env is missing (vs ``[]`` when no gaps
    need filling), so the CLI can distinguish "no-op success" from "cannot
    run" via different exit codes.
    """
    # Resolve module globals at CALL TIME, not import time. Binding these as
    # default args (= SERVICES_ROOT / MASTER_ENV / SECRET_KEYS) would freeze
    # them to their import-time values, making a test's
    # ``monkeypatch.setattr(render_envs, "MASTER_ENV", tmp_path/...)`` INERT --
    # the #10085 hermeticity defect: the function read the REAL master.env on
    # every cluster machine (writing real service .env files with live key
    # material) while CI stayed spuriously green only because it owns no
    # master.env. The None-sentinel defers to the live module global so test
    # monkeypatches bite; explicit callers (tests passing tmp_path) override
    # exactly as before.
    if services_root is None:
        services_root = SERVICES_ROOT
    if master_path is None:
        master_path = MASTER_ENV
    if secret_keys is None:
        secret_keys = SECRET_KEYS
    if not master_path.exists():
        print(f"[X] {master_path} not found. Run with --bootstrap first.")
        return None
    master = read_env(master_path)
    available = {k: v for k, v in master.items() if k in secret_keys and v}

    written: list[str] = []
    for service_dir in sorted(p for p in services_root.iterdir() if p.is_dir()):
        env_path = service_dir / ".env"
        if env_path.exists():
            continue  # sync() handles this; we only fill gaps.
        compose_paths = _service_compose_paths(service_dir)
        if not compose_paths:
            continue  # no docker-compose* => nothing to auto-provision.
        referenced: set[str] = set()
        for cp in compose_paths:
            referenced |= _compose_referenced_keys(cp, secret_keys)
        keys_to_emit = sorted(referenced & available.keys())
        if not keys_to_emit:
            continue
        lines = [
            f"# Auto-generated by render_envs.py --bootstrap-missing (cf #9351).",
            f"# Edit values in .secrets/master.env, then re-run this script.",
            "",
        ]
        lines.extend(f"{k}={available[k]}" for k in keys_to_emit)
        lines.append("")
        env_path.write_text("\n".join(lines), encoding="utf-8")
        written.append(service_dir.name)
        print(f"[+] {service_dir.name}: created .env with {len(keys_to_emit)} "
              f"key(s) {[mask(available[k]) for k in keys_to_emit]}")
    if not written:
        print(f"[OK] No missing .env in {services_root} (all services have "
              f".env OR no compose-referenced SECRET_KEYS).")
    return written


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    mode = p.add_mutually_exclusive_group()
    mode.add_argument("--bootstrap", action="store_true",
                      help="one-shot: build master.env from existing .env values")
    mode.add_argument("--check", action="store_true",
                      help="report drift only, exit 1 on drift (CI / pre-commit)")
    mode.add_argument("--bootstrap-missing", action="store_true",
                      help="auto-create .env for service dirs that have a "
                           "docker-compose.yml but no .env (closes the "
                           "--check blind spot, cf #9351)")
    p.add_argument("--strict", action="store_true",
                   help="exit 1 if a declared SECRET key is absent from "
                        "master.env (CI gate, #14373), or a REQUIRED key "
                        "has no line in its target / the target .env is "
                        "missing on disk (#15145)")
    args = p.parse_args()
    if args.bootstrap:
        return bootstrap()
    if args.bootstrap_missing:
        # bootstrap_missing_envs returns None on missing master.env, [] on
        # successful no-op, list on writes. Map None -> 1 (cannot run),
        # otherwise -> 0 (success regardless of whether anything was written).
        return 1 if bootstrap_missing_envs() is None else 0
    return sync(check_only=args.check, strict=args.strict)


if __name__ == "__main__":
    sys.exit(main())
