"""Safe self-update for a hosted brain — Core refreshed, never at the vault's risk.

A hosted brain's *engine* advances every deploy (the image is rebuilt from the
repo) but its **rendered Core** — AGENTS.md, the skills, the prompts — was
written once at first boot and, before this module, was never touched again. So
every brain drifted: new engine, old instructions, sometimes referring to config
keys the engine had already dropped. This closes that gap without ever putting a
brain's contents at risk.

The bargain, in order:

1. **Refuse to start from an unclear state.** The vault must be a git repo. Any
   uncommitted work is committed first, so the pre-update point is a real commit
   we can name and return to — and that commit is *verified* to have captured
   the tree, because a rollback point that silently missed the owner's in-flight
   work is worse than no update at all.
2. **Snapshot off-host** through the configured durability backend (the R2
   bundle). Best-effort: a failed snapshot downgrades the safety net but does not
   block the update, because the git rollback below is local, exact, and always
   available.
3. **Render Core only.** `render_vault`'s bucket rules are the substance of the
   guarantee: Core is rewritten, brand-new Config/Content *seeds* are added, and
   **existing Config and Content are never touched** — the owner's settings and
   every note they have ever written are out of scope by construction.
4. **Verify the brain can still be a brain**: config loads and validates with no
   ERROR, and AGENTS.md and the Agent config are present and non-empty. This is
   a liveness gate, not a correctness one — it catches a render that died, not a
   render that produced subtly wrong Core.
5. **On any failure, roll back to the pre-update commit** (`git reset --hard`,
   then `git clean -fd` only if that reset succeeded) and report. A brain that
   cannot be updated safely keeps running exactly as it was — the old Core is a
   much smaller problem than a half-updated vault. A rollback that *itself*
   fails is reported as `rollback-failed`, never as a clean one.

Nothing here pulls code: the running image already *is* the new version, so the
update is a pure re-render. That also means an update can never fail halfway
through a network fetch.
"""
from __future__ import annotations

import json
import re
import subprocess
from pathlib import Path

MARKER = ".state/last-update.md"          # human-readable report, written by cmd_update
STATUS = ".state/update-status.json"      # machine-readable outcome for tools/ops


class UpdateResult(dict):
    """Outcome of one attempt: ``ok``, ``action``, and the detail behind it."""


def _git(vault: Path, *args, check: bool = False) -> subprocess.CompletedProcess:
    return subprocess.run(["git", "-C", str(vault), *args],
                          capture_output=True, text=True, check=check)


def _head(vault: Path) -> str:
    r = _git(vault, "rev-parse", "HEAD")
    return r.stdout.strip() if r.returncode == 0 else ""


def _is_repo(vault: Path) -> bool:
    return (vault / ".git").exists() and _git(vault, "rev-parse", "HEAD").returncode == 0


def _dirty(vault: Path) -> bool:
    return bool(_git(vault, "status", "--porcelain").stdout.strip())


def _commit_all(vault: Path, message: str) -> bool:
    """Commit the working tree under the brain's own identity. Returns True iff a
    commit was actually created (nothing staged ⇒ no empty commit)."""
    _git(vault, "add", "-A")
    if not _git(vault, "diff", "--cached", "--name-only").stdout.strip():
        return False
    r = _git(vault, "-c", "user.name=alexandria-brain",
             "-c", "user.email=brain@localhost", "commit", "-qm", message)
    return r.returncode == 0


def _rollback(vault: Path, sha: str) -> bool:
    """Return the vault to ``sha`` exactly. True iff it really got there.

    ``git clean -fd`` runs ONLY after a successful reset, and that ordering is
    the whole point. A failed reset leaves the working tree as the *update* left
    it, not as the owner left it — so sweeping its untracked files away deletes
    the owner's notes while restoring nothing in exchange. That is the one move
    that turns a failed update into lost data, so a reset we could not perform
    ends the rollback rather than escalating it."""
    if _git(vault, "reset", "--hard", sha).returncode != 0:
        return False
    return _git(vault, "clean", "-fd").returncode == 0


def _rollback_action(rolled: bool) -> str:
    """Name the two outcomes apart. A rollback that *worked* leaves a healthy
    brain on its old Core — routine, and the container is right to keep booting.
    A rollback that FAILED leaves a half-updated vault that the server will
    serve and push off-host, which is the one state this module exists to
    prevent; it must not hide inside the same word."""
    return "rolled-back" if rolled else "rollback-failed"


def _installed_version(vault: Path) -> str:
    """The version this vault was last rendered by (from render.lock)."""
    try:
        lock = json.loads((vault / ".state" / "render.lock").read_text(encoding="utf-8"))
        return str(lock.get("alexandria_version") or lock.get("version") or "")
    except (OSError, ValueError):
        return ""


def _config_unchanged(vault: Path) -> bool:
    """Is the vault's config byte-identical to the one Core was rendered from?

    False when unknowable — a lock written before the fingerprint existed, an
    unreadable config — because re-rendering is cheap and idempotent while
    serving Core built from a stale config is not."""
    try:
        lock = json.loads((vault / ".state" / "render.lock").read_text(encoding="utf-8"))
        stored = lock.get("config_sha256")
        if not stored:
            return False
        from . import config as cfgmod, manifest
        cfg = cfgmod.load_config(str(vault))
        if not cfg:
            return False
        return stored == manifest.config_fingerprint(cfg)
    except (OSError, ValueError, ImportError):
        return False


# A `{{` that is the tail of a GitHub Actions `${{ … }}` expression is not Jinja.
_GHA_EXPR = re.compile(r"\$\{\{.*?\}\}", re.DOTALL)


def verify(vault: Path) -> list:
    """Post-update health gate → a list of problems (empty means healthy).

    Deliberately cheap and dependency-free: it answers "can this still be a
    brain?" without importing the server stack, so the gate itself can never be
    the thing that fails."""
    problems = []
    try:
        from . import config as cfgmod
        cfg = cfgmod.load_config(str(vault))
        if not cfg:
            problems.append("config.json is missing or unreadable after the update")
        else:
            problems += [p for p in cfgmod.validate(cfg) if p.startswith("ERROR")]
    except Exception as e:                       # noqa: BLE001 - the gate must not raise
        problems.append(f"config could not be loaded after the update: {e}")
    for rel in ("AGENTS.md", "Agent/config.json"):
        p = vault / rel
        if not p.is_file() or p.stat().st_size == 0:
            problems.append(f"{rel} is missing or empty after the update")

    # The manifest is the render's own account of what it just wrote, so it is
    # also the only thing that can say whether the render finished. Checking two
    # fixed filenames could not: `Agent/config.json` is the CONFIG bucket, which
    # the renderer never rewrites, so that check could not fail from a bad render
    # at all — leaving "is AGENTS.md non-empty" as the entire gate. Every skill
    # and prompt could be missing and the update still passed.
    try:
        entries = json.loads(
            (vault / ".state" / "manifest.json").read_text(encoding="utf-8")).get("files")
        if not entries:
            problems.append("the render manifest is empty after the update")
            entries = []
    except (OSError, ValueError) as e:
        problems.append(f"the render manifest is unreadable after the update: {e}")
        entries = []

    missing, unrendered = [], []
    for e in entries:
        if e.get("bucket") != "core":
            continue
        p = vault / e["path"]
        if not p.is_file() or p.stat().st_size == 0:
            missing.append(e["path"])
            continue
        try:
            text = p.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue                                 # a binary Core asset; nothing to scan
        # Unresolved Jinja means the file shipped as a template, not as
        # instructions — the brain would be reading `{{ agent.name }}` at itself.
        stripped = _GHA_EXPR.sub("", text)
        if "{%" in stripped or "{{" in stripped:
            unrendered.append(e["path"])
    if missing:
        problems.append(
            f"{len(missing)} Core file(s) missing or empty after the update: "
            + ", ".join(sorted(missing)[:5]) + ("…" if len(missing) > 5 else ""))
    if unrendered:
        problems.append(
            f"{len(unrendered)} Core file(s) still contain unrendered template "
            "markup: " + ", ".join(sorted(unrendered)[:5])
            + ("…" if len(unrendered) > 5 else ""))
    return problems


def write_status(vault: Path, result: dict) -> None:
    sd = vault / ".state"
    try:
        sd.mkdir(parents=True, exist_ok=True)
        (sd / "update-status.json").write_text(
            json.dumps(result, indent=2) + "\n", encoding="utf-8")
    except OSError:
        pass                                     # status is a convenience, never a blocker


def safe_update(vault, run_update, snapshot=None, target_version: str = "") -> UpdateResult:
    """Snapshot → render Core → verify → roll back on failure.

    ``run_update(vault)`` performs the render (the CLI passes its own update
    path); ``snapshot(vault)`` is the optional off-host backup. Both are injected
    so this orchestration is testable without a renderer or a network."""
    vault = Path(vault)
    from_version = _installed_version(vault)
    base = {"from_version": from_version, "to_version": target_version}

    if not _is_repo(vault):
        res = UpdateResult(ok=False, action="refused", reason=(
            "the vault is not a git repository — refusing to update without a "
            "rollback point"), **base)
        write_status(vault, res)
        return res

    # An interrupted render leaves the tree dirty and the lock stale, so the
    # commit has to come BEFORE the version check, not after it. A render killed
    # in its last moments — after `write_state` stamped the new version, before
    # anything was committed — otherwise hit the short-circuit below on the next
    # boot and left a whole new Core sitting uncommitted, with no rollback point,
    # until some unrelated note write happened to sweep it into a commit.
    committed_stray = False
    if _dirty(vault):
        _commit_all(vault, "brain: commit working tree before Core update")
        if _dirty(vault):
            res = UpdateResult(ok=False, action="refused", reason=(
                "the working tree could not be committed, so there is no exact "
                "point to roll back to — refusing to update rather than put "
                "uncommitted work behind a reset"), **base)
            write_status(vault, res)
            return res
        committed_stray = True

    # Same engine AND the same config it was last rendered from ⇒ nothing to do.
    # Version alone was not enough: every template reads the config, so renaming
    # the agent, adding an area or enabling a feature changes what Core *should*
    # say while leaving the version untouched — and the brain then served
    # instructions built from the old config until some later release happened to
    # bump. An unknown fingerprint (a vault rendered before this was recorded)
    # counts as "moved", so those brains re-render exactly once and settle.
    if (from_version and target_version and from_version == target_version
            and _config_unchanged(vault)):
        res = UpdateResult(ok=True, action="already-current",
                           committed=committed_stray, **base)
        write_status(vault, res)
        return res

    # 1. A clean, named point to return to — committed above, and *verified*
    #    there rather than attempted. `git add -A` can stage nothing, and `git
    #    commit` can be refused outright: a pre-commit hook, a stale
    #    `.git/index.lock` left by a killed container, commit.gpgsign with no
    #    key, a full disk. Every one of those would leave the owner's in-flight
    #    work uncommitted while `before` silently pointed at an OLDER commit, and
    #    step 5 would then `reset --hard` straight through that work and report a
    #    clean rollback. A tree we cannot capture is a tree we must not update.
    before = _head(vault)

    # 2. Off-host snapshot — best effort; git rollback below is the real guarantee.
    snapshot_ok = None
    if snapshot is not None:
        try:
            snapshot_ok = bool(snapshot(vault))
        except Exception:                        # noqa: BLE001
            snapshot_ok = False

    # 3. Render Core (Config + Content untouched by the bucket rules).
    detail = {}
    try:
        out = run_update(vault)
        if isinstance(out, dict):
            # What the render actually did — which Core files changed, which were
            # retired, and which hand-edited ones it overwrote. On a hosted brain
            # the renderer's stdout goes to container logs nobody reads per-tenant,
            # so this is the only durable record.
            detail = {k: v for k, v in out.items() if k not in base}
    except Exception as e:                       # noqa: BLE001
        rolled = _rollback(vault, before)
        res = UpdateResult(ok=False, action=_rollback_action(rolled),
                           reason=f"update failed: {e}",
                           rolled_back=rolled, snapshot=snapshot_ok,
                           restore_point=before, **base)
        write_status(vault, res)
        return res

    # 4. Health gate.
    problems = verify(vault)
    if problems:
        rolled = _rollback(vault, before)
        res = UpdateResult(ok=False, action=_rollback_action(rolled),
                           reason="post-update health check failed",
                           problems=problems, rolled_back=rolled,
                           snapshot=snapshot_ok, restore_point=before, **base)
        write_status(vault, res)
        return res

    # 5. Keep it, as one reviewable commit.
    changed = _commit_all(
        vault, f"core: update {from_version or 'unknown'} → {target_version or 'current'}")
    res = UpdateResult(ok=True, action="updated" if changed else "no-changes",
                       committed=changed, snapshot=snapshot_ok,
                       restore_point=before, **detail, **base)
    write_status(vault, res)
    return res
