"""Load and validate an Alexandria vault's Config (``Agent/config.json``).

Two readers share this file format:
  * the in-vault **engine** (``digest.py`` etc.) reads it *defensively* — missing keys
    fall back to built-in defaults, so a newer Core version never needs to rewrite an
    existing config;
  * the **CLI** (init / update / doctor) uses ``validate()`` here for full schema +
    consistency checks (digest partition, runner choice, addresses-vs-secrets).

Design rule enforced here: **email addresses are Config, credentials are secrets.**
Anything in ``secrets.required`` must look like a credential NAME, never an address or a
literal value.
"""
from __future__ import annotations

import json
import os
import re

CONFIG_RELPATH = os.path.join("Agent", "config.json")
# Vaults created before the Alexandria/ -> Agent/ system-folder rename keep their Config
# here. Readers fall back to it so doctor/status/digest keep working until `update` moves
# the folder (see cli._migrate_legacy_folder). Writes always target the current path.
_LEGACY_CONFIG_RELPATH = os.path.join("Alexandria", "config.json")

# "in-process" is the hosted-brain runner (docs/HOSTED-SCHEDULER.md): a container
# has no launchd or cron, so its rituals are driven by a daemon thread beside the
# sync loop. Accepted here because the `digests` feature sets it at creation; the
# driver itself is designed and not yet built, so the config is correct and
# nothing fires until it ships (scripts/features.py says so out loud).
ALLOWED_RUNNERS = {"laptop-github-actions", "server-macos", "in-process", "none"}
_EMAILISH = re.compile(r".+@.+\..+")
_DOMAINISH = re.compile(r"^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$")
# Allowlisting one of these would let anyone in the world into the semi-trusted
# tier by signing up for a free mailbox.
_PUBLIC_MAIL_DOMAINS = {"gmail.com", "googlemail.com", "outlook.com", "hotmail.com",
                        "live.com", "yahoo.com", "icloud.com", "me.com", "aol.com",
                        "proton.me", "protonmail.com", "gmx.com", "mail.com"}
_ATTACH_HARD_MAX = 5 * 1024 * 1024      # parity with tools._FILE_MAX_BYTES


def detect_timezone(default: str = "America/New_York") -> str:
    """Best-effort local IANA timezone — so onboarding needn't ask for it.

    macOS and Linux point ``/etc/localtime`` at ``…/zoneinfo/<Zone>``; read that, then fall
    back to the ``TZ`` env var, then ``default``. Each candidate is validated against the tz
    database (``zoneinfo``) so single-component zones like ``UTC``/``Singapore``/``Japan``
    resolve too — not just ``Area/City``. Never raises."""
    candidates = []
    try:
        link = os.readlink("/etc/localtime")
        marker = "/zoneinfo/"
        if marker in link:
            candidates.append(link.split(marker, 1)[1].strip("/"))
    except OSError:
        pass
    env = os.environ.get("TZ", "").strip().lstrip(":")
    if env:
        candidates.append(env)
    try:
        from zoneinfo import available_timezones
        zones = available_timezones()
    except Exception:
        zones = None
    for tz in candidates:
        if tz and (zones is None or tz in zones):
            return tz
    return default


def config_path(vault_path: str) -> str:
    new = os.path.join(vault_path, CONFIG_RELPATH)
    if not os.path.exists(new):
        legacy = os.path.join(vault_path, _LEGACY_CONFIG_RELPATH)
        if os.path.exists(legacy):
            return legacy
    return new


def load_config(vault_path: str) -> dict:
    """Return the parsed config dict, or ``{}`` if not present yet."""
    path = config_path(vault_path)
    if not os.path.exists(path):
        return {}
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def normalize(config: dict) -> dict:
    """Canonicalize the agent-identity keys, in place, and return the dict.

    **`agent.address` is the canonical key** (2026-08-28 — a brain running
    Alexandria has an *address*, not a name; the owner has the name). `agent.name`
    is the legacy key that every deployed vault's config, the rendered templates,
    and older engine code still carry. Mirroring whichever is present into the
    other keeps all four combinations working: old config × new engine, new
    config × old engine, and every template that reads either key.

    Deliberately **not** applied at load time: ``load_config`` must return the
    file's bytes as-is, because the self-update short-circuit fingerprints the
    loaded config against the raw file (``manifest.config_fingerprint``) and the
    engine's config contract is raw equality. Instead this runs inside
    ``validate()`` — i.e. on every path that *writes* a config (init, update,
    config_set, the interview) — so any touched config lands with both keys,
    while pure readers use an address-or-name fallback. Idempotent and safe on
    partial configs."""
    agent = config.get("agent")
    if isinstance(agent, dict):
        address, name = agent.get("address"), agent.get("name")
        if address and not name:
            agent["name"] = address
        elif name and not address:
            agent["address"] = name
    return config


# Federation is core (docs/V4-FEDERATION.md §9.4): every brain ships push-capable, so a
# rendered vault carries a default federation block pointing at the shared relay. Only
# infrastructure defaults live here — never an identity, key, or peer (§0 "never hardcode
# an identity"; a relay URL is infrastructure). relay_url is the brain's address on the
# network; poll_seconds/fire mirror the engine's own fallbacks made explicit so the
# rendered config self-documents the options. review_first_n is RETIRED: receiving is
# capture-first for every peer (each inbound intent lands in Agent/inbox/ for the owner's
# review); the key stays accepted so older configs validate, but nothing reads it. A peer
# the owner trusts as an automated feed can opt back into direct filing with
# peers[].auto_apply: true.
# announce (docs/RELAY-CENSUS.md §3): the brain tells the relay it exists — at birth and
# on every boot — with the closed field list in docs/FEDERATION.md; on by default because
# the relay address is, and one key to turn off.
DEFAULT_RELAY_URL = "https://relay.keystone.computer"
DEFAULT_FEDERATION = {
    "relay_url": DEFAULT_RELAY_URL,
    "poll_seconds": 60,
    "fire": "chat",
    "review_first_n": 3,
    "announce": True,
}


def ensure_federation_defaults(config: dict) -> dict:
    """Seed the default federation block in place, non-clobbering, and return the dict.

    Called on the write/use paths (``init`` and the federation CLI setup) — deliberately
    NOT at load or inside ``validate``/``normalize``, so the self-update config fingerprint
    keeps reading the file's raw bytes. An absent block becomes the full default; a partial
    block keeps whatever the owner set and fills only the missing keys. A malformed
    (non-dict) ``federation`` is left untouched for ``validate()`` to reject."""
    fed = config.get("federation")
    if fed is None:
        config["federation"] = dict(DEFAULT_FEDERATION)
    elif isinstance(fed, dict):
        for k, v in DEFAULT_FEDERATION.items():
            fed.setdefault(k, v)
    return config


def enabled_modules(config: dict) -> list:
    mods = ((config.get("modules") or {}).get("enabled")) or []
    return list(mods)


def _area_names(config: dict) -> set:
    return {(a.get("name") or "").strip().lower()
            for a in (config.get("areas") or []) if a.get("name")}


def validate(config: dict) -> list:
    """Validate ``config`` against the schema + cross-field rules.

    Returns a list of problem strings, each prefixed ``ERROR:`` (blocks a real install)
    or ``WARN:`` (works, but worth flagging). An empty list means all good. Pure checks —
    never reads secret values or touches the network.
    """
    normalize(config)  # agent.address ⇄ agent.name mirroring — see normalize()
    problems = []

    def err(m):
        problems.append("ERROR: " + m)

    def warn(m):
        problems.append("WARN: " + m)

    # --- owner / agent identity -------------------------------------------------
    owner = config.get("owner") or {}
    agent = config.get("agent") or {}
    # `owner.name` is the owner's DISPLAY name (how the agent addresses them, and their
    # personal Area). It's supplied by the owner during first-run personalization ("Hello"),
    # NOT by the technician install — so it is only *required* once the vault is personalized.
    # A neutral pre-personalization vault (`agent.personalized: false`) legitimately has none;
    # missing `personalized` (an older vault) is treated as personalized, so this never relaxes
    # an existing vault's check.
    personalized = agent.get("personalized", True)
    if not owner.get("name"):
        (err if personalized else warn)(
            "owner.name is required." if personalized else
            "owner.name is unset — a neutral pre-personalization vault; the owner sets it at "
            'first-run ("Hello").')
    if not owner.get("timezone_default"):
        warn("owner.timezone_default is unset — digests will fall back to UTC.")
    if "emails" in owner and not isinstance(owner.get("emails"), list):
        err("owner.emails must be a JSON array of addresses (got "
            f"{type(owner.get('emails')).__name__}).")
    if "personalized" in agent and not isinstance(agent.get("personalized"), bool):
        err("agent.personalized must be a boolean (got "
            f"{type(agent.get('personalized')).__name__}).")
    for addr in owner.get("emails") or []:
        if not _EMAILISH.match(str(addr)):
            warn(f"owner.emails entry '{addr}' doesn't look like an email address.")

    if not agent.get("address"):
        err("agent.address is required (the brain's address — what the owner calls "
            "it; legacy key agent.name is accepted and mirrored).")

    if agent.get("ingress_email") and not _EMAILISH.match(str(agent["ingress_email"])):
        warn(f"agent.ingress_email '{agent['ingress_email']}' doesn't look like an "
             "email address.")

    # Optional outbound recipient allowlist (addresses are Config, not secrets).
    email_cfg = config.get("email") or {}
    for addr in email_cfg.get("allowed_recipients") or []:
        if not _EMAILISH.match(str(addr)):
            warn(f"email.allowed_recipients entry '{addr}' doesn't look like an email address.")

    # --- email.ingest: who may write to this brain by email ---------------------
    # EVERY key here is optional and absent-is-valid. That is load-bearing, not
    # tidiness: a hosted brain re-renders its Core on every boot and rolls the whole
    # update back if validate() returns any ERROR, so a new ERROR firing on an
    # already-deployed vault would silently freeze that brain's Core forever.
    # Judgment calls are therefore WARN; only genuinely malformed values are ERROR.
    ingest = email_cfg.get("ingest")
    if ingest is not None:
        if not isinstance(ingest, dict):
            err(f"email.ingest must be an object (got {type(ingest).__name__}).")
            ingest = {}
        for key in ("enabled", "require_auth"):
            if key in ingest and not isinstance(ingest[key], bool):
                err(f"email.ingest.{key} must be a boolean (got "
                    f"{type(ingest[key]).__name__}).")
        if ingest.get("require_auth") is False:
            warn("email.ingest.require_auth is false — inbound sender identity is "
                 "UNVERIFIED, so any address can be spoofed into a trusted tier.")

        domains = ingest.get("allowed_domains")
        if domains is not None and not isinstance(domains, list):
            err("email.ingest.allowed_domains must be a JSON array of bare domains.")
            domains = []
        for d in domains or []:
            text = str(d).strip().lower()
            if "@" in text:
                err(f"email.ingest.allowed_domains entry '{d}' is an address — this key "
                    "takes bare domains (an address belongs in team[] or owner.emails).")
            elif not _DOMAINISH.match(text):
                err(f"email.ingest.allowed_domains entry '{d}' doesn't look like a domain.")
            elif text in _PUBLIC_MAIL_DOMAINS:
                warn(f"email.ingest.allowed_domains includes '{text}', a public mail "
                     "domain — that makes the semi-trusted tier world-writable, since "
                     "anyone can create an address there.")

        for tier in ("owner", "team", "domain"):
            key = f"{tier}_posture"
            if key not in ingest:
                continue
            value = str(ingest[key]).lower()
            if value not in ("file", "capture", "ignore"):
                err(f"email.ingest.{key} must be one of file / capture / ignore "
                    f"(got '{ingest[key]}').")
            elif value == "file" and tier != "owner":
                warn(f"email.ingest.{key} is 'file' — a {tier} sender's mail would drive "
                     "a model routing pass holding write scope. See the ingest-≠-send "
                     "guardrail; 'capture' stages it for review instead.")

        reply = ingest.get("reply") or {}
        if not isinstance(reply, dict):
            err(f"email.ingest.reply must be an object (got {type(reply).__name__}).")
            reply = {}
        for tier in ("owner", "team", "domain"):
            if tier in reply and not isinstance(reply[tier], bool):
                err(f"email.ingest.reply.{tier} must be a boolean.")
        for key in ("max_per_thread", "max_per_sender_per_day"):
            if key in reply and (not isinstance(reply[key], int)
                                 or isinstance(reply[key], bool) or reply[key] < 0):
                err(f"email.ingest.reply.{key} must be an integer >= 0.")

        atts = ingest.get("attachments") or {}
        if not isinstance(atts, dict):
            err(f"email.ingest.attachments must be an object (got {type(atts).__name__}).")
            atts = {}
        if "enabled" in atts and not isinstance(atts["enabled"], bool):
            err("email.ingest.attachments.enabled must be a boolean.")
        if "sink" in atts and str(atts["sink"]).lower() not in (
                "auto", "filestore", "vault", "none"):
            err(f"email.ingest.attachments.sink must be one of auto / filestore / vault / "
                f"none (got '{atts['sink']}').")
        if "max_bytes" in atts:
            mb = atts["max_bytes"]
            if not isinstance(mb, int) or isinstance(mb, bool) or mb < 0:
                err("email.ingest.attachments.max_bytes must be an integer >= 0.")
            elif mb > _ATTACH_HARD_MAX:
                err(f"email.ingest.attachments.max_bytes {mb} exceeds the {_ATTACH_HARD_MAX} "
                    "byte per-call ceiling the file store enforces.")
        if "max_count" in atts and (not isinstance(atts["max_count"], int)
                                    or isinstance(atts["max_count"], bool)
                                    or not 0 <= atts["max_count"] <= 50):
            err("email.ingest.attachments.max_count must be an integer between 0 and 50.")

        # Cross-field: configuration that is quietly inert.
        if ingest.get("enabled") and not agent.get("ingress_email"):
            warn("email.ingest.enabled is true but agent.ingress_email is unset — the brain "
                 "cannot verify that inbound mail was addressed to it.")
        if domains and str(ingest.get("domain_posture") or "capture").lower() == "ignore":
            warn("email.ingest.allowed_domains is set but domain_posture is 'ignore' — "
                 "those domains are listed and then never ingested.")

    # --- areas ------------------------------------------------------------------
    areas = config.get("areas") or []
    if not areas:
        warn("no areas defined — the vault will have an empty Areas/ section.")
    area_names = _area_names(config)

    # --- owner_digests: the per-area routing partition --------------------------
    seen_rest = 0
    claimed = {}  # area_key -> digest id that claimed it
    for od in config.get("owner_digests") or []:
        did = od.get("id") or od.get("title") or "<unnamed>"
        if not od.get("to"):
            err(f"owner_digests[{did}].to (recipient address) is required.")
        elif not _EMAILISH.match(str(od["to"])):
            warn(f"owner_digests[{did}].to '{od['to']}' doesn't look like an email address.")
        areas_spec = od.get("areas")
        if areas_spec in (None, "rest"):
            seen_rest += 1
            continue
        if not isinstance(areas_spec, list):
            err(f"owner_digests[{did}].areas must be a list of area names or the keyword \"rest\".")
            continue
        for a in areas_spec:
            k = (a or "").strip().lower()
            if k in claimed:
                err(f"area '{a}' is claimed by two digests ({claimed[k]} and {did}); "
                    "each area belongs to at most one owner_digest.")
            claimed[k] = did
            if area_names and k not in area_names:
                warn(f"owner_digests[{did}] routes area '{a}', which isn't in `areas`.")
    if seen_rest > 1:
        err('more than one owner_digest uses "rest"; only one catch-all is allowed.')
    if config.get("owner_digests") and seen_rest == 0:
        unclaimed = area_names - set(claimed)
        if unclaimed:
            warn("no \"rest\" digest, so these areas surface in no digest: "
                 + ", ".join(sorted(unclaimed)) + ".")

    # --- automation runner ------------------------------------------------------
    runner = (config.get("automation") or {}).get("runner")
    if runner is not None and runner not in ALLOWED_RUNNERS:
        err(f"automation.runner '{runner}' is not one of {sorted(ALLOWED_RUNNERS)}.")

    # --- modules ----------------------------------------------------------------
    mods = enabled_modules(config)
    if "core" not in mods:
        warn('modules.enabled should include "core".')

    # --- secrets: names only, never addresses or values ------------------------
    sec = config.get("secrets") or {}
    for name in sec.get("required") or []:
        if _EMAILISH.match(str(name)):
            err(f"secrets.required entry '{name}' looks like an address — addresses are "
                "Config (owner_digests[].to / team[].email), not secrets.")
        elif not re.fullmatch(r"[A-Z][A-Z0-9_]*", str(name)):
            warn(f"secrets.required entry '{name}' isn't an ENV_VAR-style name; "
                 "secrets are referenced by name, with values kept off-repo.")

    # --- V3 blocks: model + vault_mcp (engine reads stay schema-less) -----------
    model = config.get("model")
    if model is not None and not isinstance(model, dict):
        err("model must be an object (e.g. {\"default\": \"<model-id>\"}).")
    elif isinstance(model, dict):
        # Values are model-id strings; a value MAY instead be a per-tier object
        # (e.g. model.research.synthesize) for the tier dimension (V3-PLAN §4 P2).
        for key, val in model.items():
            if isinstance(val, dict):
                for tk, tv in val.items():
                    if not isinstance(tv, str):
                        err(f"model.{key}.{tk} must be a model-id string.")
            elif not isinstance(val, str):
                err(f"model.{key} must be a model-id string (or a per-tier object "
                    "like {\"gather\": \"...\", \"synthesize\": \"...\"}).")

    # vault.ignore_paths — corpus policy (vault_core.ignore). Config, not
    # .obsidian/app.json, because a hosted brain has no Obsidian and no local
    # folder: the same vault must answer graph questions the same way whether it
    # is served or opened locally.
    vault_block = config.get("vault")
    if vault_block is not None:
        if not isinstance(vault_block, dict):
            err("vault must be an object.")
        else:
            paths = vault_block.get("ignore_paths")
            if paths is not None:
                if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
                    err("vault.ignore_paths must be a list of path-prefix strings "
                        "(or /regex/ entries), e.g. [\"Agent/scripts/\", \".state/\"].")
                elif any(p.startswith("/") and not p.endswith("/") for p in paths):
                    err("vault.ignore_paths entries are vault-relative — an absolute "
                        "path never matches. Use \"Folder/\" or a /regex/ pattern.")

    vm = config.get("vault_mcp")
    if vm is not None:
        if not isinstance(vm, dict):
            err("vault_mcp must be an object.")
        else:
            if "port" in vm and not isinstance(vm["port"], int):
                err("vault_mcp.port must be an integer.")
            if "host" in vm and not isinstance(vm["host"], str):
                err("vault_mcp.host must be a string (bind address).")
            if "tailscale_serve" in vm and not isinstance(vm["tailscale_serve"], bool):
                err("vault_mcp.tailscale_serve must be true or false.")
            if "sync_interval_seconds" in vm and (
                    not isinstance(vm["sync_interval_seconds"], int)
                    or vm["sync_interval_seconds"] < 30):
                err("vault_mcp.sync_interval_seconds must be an integer >= 30.")
            seats = vm.get("seats")
            if seats is not None and not isinstance(seats, list):
                err("vault_mcp.seats must be a list of seat objects.")
            for i, seat in enumerate(seats if isinstance(seats, list) else []):
                if not isinstance(seat, dict):
                    err(f"vault_mcp.seats[{i}] must be an object with id/scope/secret.")
                    continue
                sid = seat.get("id") or f"[{i}]"
                scope = seat.get("scope")
                if scope not in (None, "read-write", "read-only"):
                    err(f"vault_mcp.seats[{sid}].scope must be \"read-write\" or \"read-only\".")
                secret = seat.get("secret")
                if not secret:
                    err(f"vault_mcp.seats[{sid}].secret (the Bearer's env-var NAME) is required.")
                elif not re.fullmatch(r"[A-Z][A-Z0-9_]*", str(secret)):
                    err(f"vault_mcp.seats[{sid}].secret '{secret}' must be an ENV_VAR-style name — "
                        "seat keys are referenced by name, with values kept off-repo.")
            research = vm.get("research")
            if research is not None and not isinstance(research, dict):
                err("vault_mcp.research must be an object.")
            elif isinstance(research, dict):
                for key in ("max_agents", "max_depth", "max_notes_per_agent", "token_budget"):
                    if key in research and not isinstance(research[key], int):
                        err(f"vault_mcp.research.{key} must be an integer.")
                if "model" in research and not isinstance(research["model"], str):
                    err("vault_mcp.research.model must be a model-id string.")

    # vault.durability — the off-host backend (git-remote default | volume | object-store).
    # Mirrors secrets.backend: config names the backend; object-store's own keys are env-var
    # NAMES (values kept off-repo, the secrets guardrail).
    vault_cfg = config.get("vault")
    if vault_cfg is not None and not isinstance(vault_cfg, dict):
        err("vault must be an object.")
    elif isinstance(vault_cfg, dict):
        dur = vault_cfg.get("durability")
        if dur is not None and not isinstance(dur, dict):
            err("vault.durability must be an object.")
        elif isinstance(dur, dict):
            backend = dur.get("backend")
            if backend not in (None, "git-remote", "volume", "object-store"):
                err(f"vault.durability.backend '{backend}' must be "
                    '"git-remote", "volume", or "object-store".')
            elif backend == "object-store":
                osc = dur.get("object_store")
                if not isinstance(osc, dict):
                    err("vault.durability.object_store must be an object when backend "
                        "is 'object-store'.")
                else:
                    if not osc.get("bucket_env"):
                        err("vault.durability.object_store.bucket_env is required "
                            "(the ENV NAME holding the bucket).")
                    for k in ("bucket_env", "endpoint_url_env", "access_key_env",
                              "secret_key_env", "tenant_env"):
                        if k in osc and not isinstance(osc[k], str):
                            err(f"vault.durability.object_store.{k} must be an env-var "
                                "NAME (string), not a value.")
                    if "force_path_style" in osc and not isinstance(
                            osc["force_path_style"], bool):
                        err("vault.durability.object_store.force_path_style must be "
                            "a boolean (true for R2/MinIO).")
                    if "@" in str(osc.get("bucket_env", "")) or \
                       "/" in str(osc.get("bucket_env", "")):
                        warn("vault.durability.object_store.bucket_env looks like a "
                             "value, not an env-var name — store secrets off-repo.")

    # --- V4 federation: relay config + v2 peers (docs/V4-FEDERATION.md §3.3) -----
    _validate_federation(config, err, warn)

    return problems


_HANDLE_RE = re.compile(r"[a-z][a-z0-9-]{0,23}")
_FINGERPRINT_RE = re.compile(r"ax1[a-z2-7]+")
# A key whose NAME hints it holds a secret must carry an env-var name, never a value —
# tokens/bearers live in the state home, never in the vault-repo config (§0, §8).
_SECRET_KEY_RE = re.compile(r"token|bearer|secret", re.I)
_ENV_NAME_RE = re.compile(r"[A-Z][A-Z0-9_]*")


def _looks_like_env_name(value):
    return isinstance(value, str) and bool(_ENV_NAME_RE.fullmatch(value))


def _relay_url_ok(url):
    """Config-time relay-URL check — delegates to the federation deposit-time SSRF
    guard so validation and the actual outbound check share ONE policy in ONE place
    (they used to drift: both claimed SSRF protection, both admitted "https
    anywhere", so an internal / metadata / link-local host passed). What is really
    enforced: https to a PUBLIC host, or plaintext http only to loopback
    ``127.0.0.1[:port]``; internal / private / link-local IP literals and embedded
    credentials are refused (see :func:`is_safe_relay_url` for the full policy).

    Imported lazily, and from ``relay_client`` (stdlib-only), so validating a config
    never triggers — nor requires — the PyNaCl ``federation`` extra."""
    from alexandria_vault.federation.relay_client import is_safe_relay_url
    return is_safe_relay_url(url)


def _fingerprint_of_pubkey(pubkey_bytes):
    import base64
    import hashlib
    digest = hashlib.sha256(pubkey_bytes).digest()[:20]
    return "ax1" + base64.b32encode(digest).decode("ascii").lower().rstrip("=")


def _guard_no_secret_values(where, obj, err):
    """No key named like a secret may hold a non-env-name value (the names-only rule
    extended to federation, §3.3). Recurses into nested dicts/lists so a token buried
    one level down (e.g. peers[i].auth.token) can't slip past."""
    if isinstance(obj, dict):
        for key, val in obj.items():
            if _SECRET_KEY_RE.search(str(key)) and not _looks_like_env_name(val) \
                    and not isinstance(val, (dict, list)):
                err(f"{where}.{key} looks like a secret value in config — tokens/bearers "
                    "live in the state home (~/.alexandria/federation/), never in the "
                    "vault repo. Remove it (pairing stores it off-repo automatically).")
            _guard_no_secret_values(f"{where}.{key}", val, err)
    elif isinstance(obj, list):
        for i, item in enumerate(obj):
            _guard_no_secret_values(f"{where}[{i}]", item, err)


def _validate_federation(config, err, warn):
    fed = config.get("federation")
    if isinstance(fed, dict):
        url = fed.get("relay_url")
        if url is not None and not _relay_url_ok(url):
            err("federation.relay_url must be an https:// URL "
                "(http://127.0.0.1[:port] allowed only for local tests).")
        fire = fed.get("fire")
        if fire is not None and fire not in ("chat", "cli"):
            err("federation.fire must be 'chat' or 'cli'.")
        ps = fed.get("poll_seconds")
        if ps is not None and (not isinstance(ps, int) or isinstance(ps, bool) or ps < 15):
            err("federation.poll_seconds must be an integer ≥ 15.")
        rn = fed.get("review_first_n")
        if rn is not None and (not isinstance(rn, int) or isinstance(rn, bool) or rn < 0):
            err("federation.review_first_n must be a non-negative integer (retired — receiving "
                "is capture-first for every peer; the key is ignored).")
        an = fed.get("announce")
        if an is not None and not isinstance(an, bool):
            err("federation.announce must be true or false.")
        _guard_no_secret_values("federation", fed, err)
    elif fed is not None:
        # A non-dict `federation` block is malformed — catch it here so it's a clean
        # config error at init/doctor, not an AttributeError deep in a runtime reader
        # (ensure_federation_defaults leaves it untouched, trusting this guard).
        err("federation must be an object.")

    seen_handles = set()
    for i, peer in enumerate(config.get("peers") or []):
        if not isinstance(peer, dict):
            err(f"peers[{i}] must be an object.")
            continue
        _guard_no_secret_values(f"peers[{i}]", peer, err)
        handle = peer.get("handle")
        # A v2 (relay) peer carries a pubkey; a v1 email-only peer legitimately has none.
        is_v2 = bool(peer.get("pubkey") or peer.get("fingerprint"))
        if handle is not None:
            if not _HANDLE_RE.fullmatch(str(handle)):
                err(f"peers[{i}].handle '{handle}' must match [a-z][a-z0-9-]{{0,23}}.")
            elif handle in seen_handles:
                err(f"peers[{i}].handle '{handle}' is not unique.")
            else:
                seen_handles.add(handle)
        elif is_v2:
            err(f"peers[{i}] (a v2 peer) needs a handle.")
        if "auto_apply" in peer and not isinstance(peer.get("auto_apply"), bool):
            err(f"peers[{i}].auto_apply must be true or false (default false: their pushes "
                "are captured to Agent/inbox/ for your review, never filed unattended).")
        if not is_v2:
            continue
        pub_bytes = None
        pub = peer.get("pubkey")
        if pub is not None:
            import base64
            try:
                pub_bytes = base64.b64decode(str(pub), validate=True)
            except Exception:
                pub_bytes = None
            if pub_bytes is None or len(pub_bytes) != 32:
                err(f"peers[{i}].pubkey must be base64 of exactly 32 bytes.")
                pub_bytes = None
        fp = peer.get("fingerprint")
        if fp is not None and not _FINGERPRINT_RE.fullmatch(str(fp)):
            err(f"peers[{i}].fingerprint '{fp}' must match ax1[a-z2-7]+.")
            fp = None
        if fp and pub_bytes is not None and _fingerprint_of_pubkey(pub_bytes) != fp:
            err(f"peers[{i}].fingerprint does not match peers[{i}].pubkey.")
        # A peer's relay is where WE deposit to reach them — an outbound target, so it
        # must be https (or loopback for tests), never an internal/metadata host (SSRF).
        prelay = peer.get("relay")
        if prelay is not None and not _relay_url_ok(prelay):
            err(f"peers[{i}].relay must be an https:// URL (http://127.0.0.1 for tests) — "
                "a non-loopback http/internal host is refused (SSRF).")
