"""Opt-in brain features — off unless a brain is *created* with them.

A hosted brain ships deliberately quiet: no daily digests, no mailbox, no
outbound anything. That is not an oversight but the safer default — a brain
that emails on a schedule the moment it exists is a brain doing things its
owner never asked for. Capabilities that act on the world are therefore
**opt-in at creation**, and this module is the single registry of what "opt in"
actually changes.

    alexandria features list
    alexandria features apply --vault <path> --features digests,email

At birth the rack passes the chosen set through as ``ALEXANDRIA_FEATURES`` and
the brain applies it once, right after its vault is rendered. Nothing here
invents behaviour: each feature is a small, declared patch to ``config.json``,
so "what does enabling this do?" is answerable by reading one dict instead of
by grepping the engine.

**Honesty about the runtime.** A feature can only turn on what exists. Enabling
``digests`` on a *hosted* brain sets the config a scheduler will read, but the
in-process scheduler that would actually fire them is designed and not yet built
(``docs/HOSTED-SCHEDULER.md``) — so `apply` says so rather than letting an
operator believe mail is about to flow. On a laptop or appliance, where launchd
drives the rituals, the same feature is fully live.
"""
from __future__ import annotations

import copy
import json
from pathlib import Path

# name -> what it is, what it patches, and what it still depends on.
#   patch:  deep-merged into config.json (dicts merge; scalars/lists replace).
#           An EMPTY value ("" or None) declares a key rather than setting one —
#           see _merge: it fills the key in on a brain that lacks it and steps
#           aside on a brain that already has a real value.
#   needs:  human-readable prerequisites that are NOT satisfied by the patch
FEATURES = {
    "digests": {
        "summary": "Scheduled daily brief to the owner.",
        "patch": {"automation": {"runner": "in-process", "send_hour_local": 9}},
        "needs": ["an owner email in `owner.emails` (the digest has nowhere to go "
                  "without one)",
                  "on a HOSTED brain: the in-process scheduler, which is designed "
                  "but not yet built (docs/HOSTED-SCHEDULER.md) — the config will "
                  "be correct and nothing will fire until it ships"],
    },
    "email": {
        "summary": "The brain can send and receive mail at its own address.",
        # Both addresses are declared here, not set: they are per-brain, and an
        # empty value never overwrites one (see _merge). `ingress_email` is the
        # INBOUND identity: the brain refuses mail not addressed to it, so without
        # this a gateway misroute would be ingested as though it had been sent here.
        "patch": {"agent": {"email": "", "ingress_email": ""},
                  "email": {"ingest": {"enabled": True}}},
        "needs": ["an address for `agent.email` / `agent.ingress_email`",
                  "EMAIL_GATEWAY_URL + EMAIL_GATEWAY_TOKEN on the brain (outbound)",
                  "for inbound: EMAIL_WEBHOOK_SECRET on the brain, and the mail "
                  "domain's DNS verified — inbound requires ALIGNED SPF+DKIM by "
                  "default, so unverified mail is refused, not merely unsigned "
                  "(docs/EMAIL-INGRESS.md)",
                  "inbound stays OWNER-ONLY until `email.ingest.allowed_domains` or "
                  "`team[]` names someone else"],
    },
    "research": {
        "summary": "Server-side agentic tools (vault_research, think_deep).",
        "patch": {},                                 # nothing to patch: purely credential-gated
        "needs": ["ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY on the brain — rackd mints "
                  "a budgeted gateway key at birth when the rack is configured for it"],
    },
}


class FeatureError(ValueError):
    """An unknown feature, phrased so the caller can fix it."""


def parse(value) -> list:
    """A comma/space separated list (or a real list) → validated feature names."""
    if value is None:
        return []
    items = value if isinstance(value, (list, tuple)) else str(value).replace(",", " ").split()
    names, unknown = [], []
    for raw in items:
        name = str(raw).strip().lower()
        if not name:
            continue
        if name not in FEATURES:
            unknown.append(name)
        elif name not in names:
            names.append(name)
    if unknown:
        raise FeatureError(
            f"unknown feature(s): {', '.join(unknown)} — known features are "
            f"{', '.join(sorted(FEATURES))}")
    return names


# A patch value the feature *declares* but does not own. Enabling a feature is
# not the moment its per-brain content is decided, so these fill a key in and
# never overwrite one.
_UNSET = ("", None)


def _merge(base: dict, patch: dict) -> dict:
    """Deep-merge ``patch`` into ``base``: dicts merge, everything else replaces.

    Merging (rather than replacing) matters because a feature owns only the keys
    it declares — turning on digests must not silently reset an unrelated
    ``automation`` setting the owner already chose.

    An empty patch value carries that further. ``email`` patches
    ``{"agent": {"email": ""}}`` to say *this feature uses agent.email*, not to
    say the address is the empty string — the address is per-brain and set
    elsewhere. So an empty value is written only where the brain has nothing,
    and skipped where it has something. Without that, `features apply email` on
    a brain that already had an address would erase it."""
    out = copy.deepcopy(base)
    for k, v in (patch or {}).items():
        if isinstance(v, dict) and isinstance(out.get(k), dict):
            out[k] = _merge(out[k], v)
        elif v in _UNSET and out.get(k) not in _UNSET:
            continue                        # declared, already answered — leave it be
        else:
            out[k] = copy.deepcopy(v)
    return out


def apply_to_config(config: dict, names) -> tuple:
    """→ ``(new_config, notes)``. Pure: the caller decides whether to persist.

    A feature is applied **once** — the first time it is enabled — and never
    again. That is what the module says it does ("the brain applies it once"),
    but it was the *caller's* discipline, and the hosted caller has none: the
    container entrypoint re-applies `ALEXANDRIA_FEATURES` on every boot. Each
    boot therefore re-imposed the feature's defaults over whatever the owner had
    since chosen — a digest moved to 6am snapped back to 9, `automation.runner`
    was forced back to `in-process`, and, worst of the three, inbound email that
    the owner had turned off was silently switched back on. An empty patch value
    already stepped aside for an existing answer (see :func:`_merge`); this
    extends the same courtesy to the non-empty ones, which is where the owner's
    tuning actually lives.

    `features.enabled` is the record of what has already been applied, so adding
    a *new* feature to an existing brain still works — only the ones already on
    are skipped."""
    names = parse(names)
    cfg = copy.deepcopy(config or {})
    already = set((cfg.get("features") or {}).get("enabled") or [])
    notes = []
    for name in names:
        if name in already:
            continue                    # applied at birth; the owner owns it now
        spec = FEATURES[name]
        cfg = _merge(cfg, spec["patch"])
        for need in spec["needs"]:
            notes.append(f"{name}: {need}")
    if names:
        # Record what the brain was BORN with, so the state is legible later —
        # "why does this brain send digests?" is answerable from its own config.
        enabled = sorted(set((cfg.get("features") or {}).get("enabled") or []) | set(names))
        cfg["features"] = {**(cfg.get("features") or {}), "enabled": enabled}
    return cfg, notes


def config_path(vault) -> Path:
    for rel in ("Agent/config.json", "Alexandria/config.json"):
        p = Path(vault) / rel
        if p.is_file():
            return p
    raise FeatureError(f"no Alexandria config found under {vault}")


def apply_to_vault(vault, names) -> tuple:
    """Apply features to a rendered vault's config on disk. → (enabled, notes)."""
    names = parse(names)
    if not names:
        return [], []
    path = config_path(vault)
    cfg = json.loads(path.read_text(encoding="utf-8"))
    new, notes = apply_to_config(cfg, names)
    if new != cfg:
        path.write_text(json.dumps(new, indent=2, ensure_ascii=False) + "\n",
                        encoding="utf-8")
    return names, notes
