"""Update a folder-brain's software from an Alexandria.zip — the zip channel.

A zip-installed brain (v3.1+ layout: the vault at the folder root, the software
hidden in ``.alexandria/app/``) has no git checkout to pull and no CI runner to
trigger: **the zip is its update channel.** Before this module the swap lived as
a shell recipe in the router — ``rm -rf .alexandria/app`` first, rebuild after —
which destroyed the only working software *before* the replacement was proven.
A failed download, a half-extracted zip, or an offline ``pip`` left the brain
with no CLI at all, mid-update.

This module makes the swap survivable, in order:

1. **Verify before touching anything.** The source (a ``.zip`` or an unzipped
   folder) must carry a real Alexandria app snapshot, and its version must be
   *newer* than the app the vault is running — same-version re-renders skip the
   swap entirely, and downgrades are refused rather than silently applied.
2. **Swap aside, never delete.** The running app moves to ``.alexandria/app.prev``
   and the new snapshot takes its place. Every failure *before* the render moves
   it straight back — the brain keeps working software no matter what.
3. **Rebuild the environment at the final path** (a venv never survives a path
   move, so it can't be pre-built in the staging area).
4. **Render through the NEW code.** Core is re-rendered by re-exec'ing the new
   venv's ``alexandria update --no-pull`` — the running process still holds the
   old modules in memory, so the render must not happen in-process (the same
   re-exec rule as the git-pull path).
5. **Health-check, then clean up.** ``doctor`` must exit 0 before ``app.prev``
   and the staging area are removed. After the render has written files the swap
   is no longer auto-reverted (the vault is a git repo; a half-rendered Core is
   visible and recoverable there) — instead ``app.prev`` is *kept* on disk and
   the failure is reported honestly.

The router copy of ``CLAUDE.md``, the vault's ``.alexandria/router.md``, and the
human ``README.md`` refresh from the zip on success. The zip's root ``AGENTS.md``
is deliberately **not** copied: in the distribution it is the installer adapter,
and on an installed brain that path holds the rendered operating manual.

Committing the result stays with the caller (the skill / the owner's agent) —
same split as ``init``: the CLI renders, the session commits.
"""
from __future__ import annotations

import contextlib
import os
import re
import shutil
import subprocess
import sys
import time
import zipfile
from pathlib import Path

#: Paths inside the distribution zip, relative to its single top-level folder.
APP_SUBDIR = ".alexandria/app"
VERSION_FILE = ".alexandria/app/scripts/__init__.py"
ROUTER_FILE = ".alexandria/router.md"
README_FILE = "README.md"

_VERSION_RE = re.compile(r'^__version__ = "([^"]+)"', re.MULTILINE)


class ZipUpdateError(Exception):
    """A refusal or failure with a message meant for the owner, verbatim."""


_RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+$")


def _parse_version(text: str) -> str:
    m = _VERSION_RE.search(text)
    if not m:
        raise ZipUpdateError("could not read __version__ from the source's scripts/__init__.py")
    v = m.group(1)
    # Releases are strict x.y.z; a suffixed or garbage version would otherwise
    # slip through version_tuple's lenient parse and mis-order silently.
    if not _RELEASE_VERSION.match(v):
        raise ZipUpdateError(f"'{v}' is not a release version (expected x.y.z)")
    return v


def version_tuple(v: str) -> tuple:
    """``"3.1.2" → (3, 1, 2)`` — non-numeric segments compare as 0."""
    parts = []
    for p in str(v).split("."):
        try:
            parts.append(int(p))
        except ValueError:
            parts.append(0)
    return tuple(parts)


def source_root(source: Path) -> Path:
    """The unzipped-folder form of ``source``: the directory that CONTAINS
    ``.alexandria/app/``. Accepts the extract parent (holding ``Alexandria/``)
    or the ``Alexandria`` folder itself. Zip sources are resolved by
    :func:`stage` after extraction."""
    source = Path(source)
    for cand in (source, source / "Alexandria"):
        if (cand / VERSION_FILE).is_file():
            return cand
    raise ZipUpdateError(
        f"{source} is not an unzipped Alexandria distribution "
        f"(no {VERSION_FILE} under it or under an Alexandria/ folder inside it)")


def read_source_version(source: Path) -> str:
    """The app version a ``.zip`` file or unzipped folder ships, without
    extracting anything."""
    source = Path(source)
    if source.is_dir():
        return _parse_version((source_root(source) / VERSION_FILE).read_text(encoding="utf-8"))
    if not source.is_file():
        raise ZipUpdateError(f"{source} does not exist")
    try:
        with zipfile.ZipFile(source) as zf:
            names = set(zf.namelist())
            member = "Alexandria/" + VERSION_FILE
            if member not in names:
                raise ZipUpdateError(
                    f"{source} is not an Alexandria distribution zip (no {member} inside)")
            return _parse_version(zf.read(member).decode("utf-8"))
    except zipfile.BadZipFile:
        raise ZipUpdateError(f"{source} is not a zip file")


def installed_app_version(vault: Path) -> str:
    """The version of the app THIS VAULT runs (``.alexandria/app``) — not the
    version of whatever package happens to be executing this code."""
    vf = Path(vault) / APP_SUBDIR / "scripts" / "__init__.py"
    if not vf.is_file():
        raise ZipUpdateError(
            f"{Path(vault) / APP_SUBDIR} is missing — this vault is not a zip-layout "
            "brain (use `alexandria update` without --from-zip)")
    return _parse_version(vf.read_text(encoding="utf-8"))


# The signing authority of the INSTALLED build (F15/F16), stamped into the app dir at
# install time. A marker inside app/ travels with the swap (a fresh app dir starts with no
# marker, so the installer re-stamps each swap). Absent ⇒ "release": a pre-feature install
# is assumed genuine, so convergence never spuriously re-swaps an existing release brain;
# only an explicit "beta"/"unsigned" marker (written by THIS installer) triggers a converge.
_AUTHORITY_FILE = ".signed-by"


def installed_build_authority(vault: Path) -> str:
    """``'release'`` | ``'beta'`` | ``'unsigned'`` for the app this vault runs; defaults to
    ``'release'`` when unmarked (see above)."""
    p = Path(vault) / APP_SUBDIR / _AUTHORITY_FILE
    try:
        v = p.read_text(encoding="utf-8").strip().lower()
    except OSError:
        return "release"
    return v if v in ("release", "beta", "unsigned") else "release"


def set_build_authority(vault: Path, authority: str) -> None:
    """Stamp the installed app's signing authority. ``authority`` is 'release'/'beta', or
    '' (a skipped/allow-unsigned install) which records 'unsigned'. Best-effort."""
    app = Path(vault) / APP_SUBDIR
    if not app.is_dir():
        return
    value = authority if authority in ("release", "beta") else "unsigned"
    try:
        (app / _AUTHORITY_FILE).write_text(value + "\n", encoding="utf-8")
    except OSError:
        pass


def stage(source: Path, tmp_dir: Path) -> Path:
    """Materialize ``source`` as an unzipped tree under ``tmp_dir`` (extracting
    if it's a zip, copying if it's already a folder) and return its
    Alexandria root. Staging COPIES a folder source so the swap never moves the
    owner's own download out from under them."""
    source = Path(source)
    tmp_dir = Path(tmp_dir)
    tmp_dir.mkdir(parents=True, exist_ok=True)
    stage_dir = tmp_dir / "zip-update-stage"
    # A crashed run leaves the old stage behind; pointing --from-zip AT that
    # leftover would rmtree the source before copying it. Refuse, don't destroy.
    if str(Path(source).resolve()).startswith(str(stage_dir.resolve())):
        raise ZipUpdateError(
            "the source is inside the staging area of a previous attempt — "
            "point --from-zip at the original zip or folder instead.")
    if stage_dir.exists():
        shutil.rmtree(stage_dir)
    stage_dir.mkdir()
    if source.is_dir():
        root = source_root(source)
        # A symlink in a folder source could alias the staged app outside the
        # vault before anything is verified — refuse rather than follow or copy.
        for p in root.rglob("*"):
            if p.is_symlink():
                raise ZipUpdateError(
                    f"refusing a source containing symlinks: {p.relative_to(root)}")
        shutil.copytree(root, stage_dir / "Alexandria")
    else:
        with zipfile.ZipFile(source) as zf:
            infos = zf.infolist()
            if len(infos) > MAX_ENTRIES:
                raise ZipUpdateError(f"zip has {len(infos)} entries — not a release artifact")
            total = 0
            for info in infos:
                p = Path(info.filename)
                if p.is_absolute() or ".." in p.parts:
                    raise ZipUpdateError(
                        f"refusing to extract unsafe path in zip: {info.filename}")
                if info.file_size > MAX_ENTRY_UNCOMPRESSED:
                    raise ZipUpdateError(
                        f"zip entry {info.filename} claims {info.file_size} bytes — "
                        "not a release artifact")
                total += info.file_size
            if total > MAX_TOTAL_UNCOMPRESSED:
                raise ZipUpdateError(
                    f"zip claims {total} uncompressed bytes — not a release artifact")
            zf.extractall(stage_dir)
    return source_root(stage_dir)


def _app_viable(app: Path) -> bool:
    """Can this app copy plausibly run? Package marker + its venv's python."""
    app = Path(app)
    return ((app / "scripts" / "__init__.py").is_file()
            and (app / ".venv" / "bin" / "python").exists())


def _promote_swap_to_prev(app: Path) -> None:
    """Retire the retained ``app.prev`` and make ``app.swap`` (this operation's
    just-superseded software) the new retained one-version-back copy. Idempotent
    building block shared by ``apply``'s success path and ``heal``'s completion
    of an interrupted rotation."""
    prev, swap = app.with_name("app.prev"), app.with_name("app.swap")
    if not swap.exists():
        return
    if not _app_viable(swap):
        # A broken just-superseded copy (e.g. a reinstall over a venv-less app)
        # is not worth retaining as the rollback target — discard it.
        shutil.rmtree(swap)
        return
    if prev.exists():
        shutil.rmtree(prev)
    swap.rename(prev)


def heal_interrupted(vault: Path) -> str:
    """Reconcile the software layout after an update that died mid-swap, using
    the two set-aside slots: ``app.swap`` (this operation's own just-moved-aside
    copy — the most-recent-good software) and ``app.prev`` (the retained
    one-version-back copy). Returns a message when something was healed, ``""``
    when the layout was already sane.

    Three interrupted states, in the order they're checked:

    1. **Running app fine, a swap still present** — the update built its new venv
       but died before promoting the retention rotation. Finish it: ``app.swap``
       becomes the new ``app.prev``. (Never DROP a viable swap here — it is a
       newer copy than ``app.prev``, so discarding it would silently roll the
       retained copy a version too far back.)
    2. **Running app missing or broken** — a crash between the two swap renames,
       or during the venv build. Restore the most-recent-good copy: ``app.swap``
       if present (this op's copy), else ``app.prev`` (the retained one). A
       broken half-install is set aside as ``app.broken``, never deleted.
    3. **Nothing to restore from** — already sane, or genuinely unrecoverable
       here (report ``""``; the caller's reinstall path handles it).

    Runs before every update and rollback: without it, a crash left the brain
    CLI-less and a retry destroyed the one working copy it had."""
    vault = Path(vault).resolve()
    app = vault / APP_SUBDIR
    prev, swap = app.with_name("app.prev"), app.with_name("app.swap")

    if app.exists() and _app_viable(app):
        if swap.exists() and _app_viable(swap):
            _promote_swap_to_prev(app)
            return ("completed a retention rotation an interrupted update left "
                    "unfinished (the previous software is at .alexandria/app.prev)")
        return ""

    source = (swap if swap.exists() and _app_viable(swap)
              else prev if prev.exists() and _app_viable(prev)
              else None)
    if source is None:
        return ""
    set_aside = False
    if app.exists():
        wreck = app.with_name("app.broken")
        if wreck.exists():
            shutil.rmtree(wreck)
        app.rename(wreck)
        set_aside = True
    # Restoring FROM app.swap leaves app.prev (the retained copy) exactly as it
    # was — correct. Restoring from app.prev consumes the retained copy, which is
    # the honest outcome when it was the only good software left.
    source.rename(app)
    return ("recovered the previous software from an interrupted update"
            + (" (a broken copy was set aside at .alexandria/app.broken)"
               if set_aside else ""))


def require_git_rollback_surface(vault: Path) -> str:
    """The pipeline's post-render safety story is the vault's git history —
    every rendered file's pre-update state must be one commit away. Returns an
    error message when that story doesn't hold (not a repo, git broken, or
    uncommitted work that a rollback would trample), ``""`` when it does."""
    vault = Path(vault)
    if not (vault / ".git").exists():
        return ("the vault is not a git repository — the update's rollback "
                "surface is git history. `git init . && git add -A && git "
                "commit` first (the installer normally does this).")
    r = subprocess.run(["git", "-C", str(vault), "status", "--porcelain"],
                       capture_output=True, text=True)
    if r.returncode != 0:
        return f"git is not working in this vault: {(r.stderr or '').strip()[:200]}"
    dirty = [ln for ln in r.stdout.splitlines() if ln.strip()]
    if dirty:
        return (f"the vault has {len(dirty)} uncommitted change(s) (e.g. "
                f"{dirty[0][3:]}) — commit first so the update lands as its own "
                "reviewable commit with an exact point to return to (the "
                "alexandria-update skill commits in-flight work automatically).")
    return ""


LOCK_STALE_SECONDS = 2 * 3600


def _lock_holder_is_running(lock: Path) -> bool:
    """True only when the lock names a live pid AND is younger than the stale
    window — so a recycled pid (an unrelated live process reusing a dead
    holder's number) can't wedge updates forever: past the window, a readable
    pid ages out exactly like an unreadable one."""
    try:
        holder = int((lock / "pid").read_text(encoding="utf-8").strip())
        os.kill(holder, 0)
    except (OSError, ValueError):
        return False                      # no pid / unreadable / dead
    try:
        age = time.time() - lock.stat().st_mtime
    except OSError:
        return True
    return age <= LOCK_STALE_SECONDS


@contextlib.contextmanager
def update_lock(vault: Path):
    """One update at a time per vault, safe under crashes and concurrency.

    The lock is a directory at ``.alexandria/update.lock`` claimed by an ATOMIC
    rename of a fully-formed temp dir (its ``pid`` already inside) onto the lock
    name — so a claim either wins whole or loses whole; there is no
    mkdir-then-write window that a crash could leave pid-less. A lock held by a
    live, recent process refuses; a dead or aged-out one is broken by atomically
    renaming it aside (only one racer's rename wins, so two processes can't both
    "break and retake"). Release removes the lock only if it is still ours."""
    base = Path(vault).resolve() / ".alexandria"
    base.mkdir(parents=True, exist_ok=True)
    lock = base / "update.lock"
    mypid = os.getpid()

    def _claim() -> bool:
        staging = base / f"update.lock.mk.{mypid}"
        if staging.exists():
            shutil.rmtree(staging)
        staging.mkdir()
        (staging / "pid").write_text(f"{mypid}\n", encoding="utf-8")
        try:
            os.rename(staging, lock)      # atomic; fails if lock is a non-empty dir
            return True
        except OSError:
            shutil.rmtree(staging, ignore_errors=True)
            return False

    if not _claim():
        if _lock_holder_is_running(lock):
            raise ZipUpdateError(
                "another update is already running — wait for it to finish. "
                "(If you are certain none is, remove .alexandria/update.lock.)")
        # Stale (dead or aged out): break it atomically. Whoever wins the rename
        # clears it; a loser just falls through and re-claims (or refuses again).
        try:
            os.rename(lock, base / f"update.lock.dead.{mypid}")
            shutil.rmtree(base / f"update.lock.dead.{mypid}", ignore_errors=True)
        except OSError:
            pass
        if not _claim():
            raise ZipUpdateError(
                "the update lock is being contended right now — try again in a "
                "moment.")
    try:
        yield
    finally:
        try:
            if (lock / "pid").read_text(encoding="utf-8").strip() == str(mypid):
                shutil.rmtree(lock, ignore_errors=True)
        except OSError:
            pass                          # not ours any more (broken as stale) — leave it


#: Extraction ceilings — the real artifact is ~6 MB zipped / ~30 MB unpacked /
#: ~700 entries, so these refuse a decompression bomb long before disk fills.
MAX_TOTAL_UNCOMPRESSED = 800 * 1024 * 1024
MAX_ENTRY_UNCOMPRESSED = 200 * 1024 * 1024
MAX_ENTRIES = 50_000


def _scrubbed_env() -> dict:
    """An allowlisted environment for the pip build and the re-exec: the code
    being installed/run is freshly downloaded, so it must not inherit secrets
    (API keys, SMTP creds) or import-steering vars (PYTHONPATH, VIRTUAL_ENV)
    from the calling session. Allowlist, not blocklist — new secret names must
    not leak by default."""
    keep = ("PATH", "HOME", "TMPDIR", "USER", "LOGNAME", "SHELL", "TERM", "TZ",
            "LANG", "LC_ALL", "LC_CTYPE", "XDG_CACHE_HOME", "XDG_CONFIG_HOME",
            "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
            "http_proxy", "https_proxy", "no_proxy",
            # Without the CA bundle the parent's pip works but the child's fails
            # cert-verify — the misleading "is the network up?" behind a
            # TLS-intercepting corporate proxy.
            "SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE",
            "CURL_CA_BUNDLE", "PIP_CERT")
    return {k: os.environ[k] for k in keep if k in os.environ}


def _base_python() -> str:
    """An interpreter safe to build the new venv with. ``sys.executable`` may
    live inside the app dir being swapped (its path dangles mid-swap), so prefer
    the interpreter behind it, then a PATH python3."""
    for cand in (getattr(sys, "_base_executable", None), shutil.which("python3"), sys.executable):
        if cand and Path(cand).exists():
            return str(cand)
    raise ZipUpdateError("no python3 interpreter found to build the new environment")


def build_venv(app_dir: Path) -> None:
    """Create ``app_dir/.venv`` and install the app into it (the one networked
    step besides the download — pip resolves jinja2/pyyaml)."""
    app_dir = Path(app_dir)
    py = _base_python()
    steps = [
        [py, "-m", "venv", str(app_dir / ".venv")],
        [str(app_dir / ".venv" / "bin" / "pip"), "install", "-q",
         "--upgrade", "pip", "setuptools", "wheel"],
        [str(app_dir / ".venv" / "bin" / "pip"), "install", "-qe", str(app_dir)],
    ]
    for cmd in steps:
        try:
            r = subprocess.run(cmd, capture_output=True, text=True,
                               env=_scrubbed_env(), timeout=900)
        except subprocess.TimeoutExpired:
            raise ZipUpdateError(
                f"environment build timed out after 15 minutes at "
                f"`{Path(cmd[0]).name} {' '.join(cmd[1:3])}…` — is the network up?")
        if r.returncode != 0:
            raise ZipUpdateError(
                f"environment build failed at `{' '.join(Path(c).name for c in cmd[:1]) } "
                f"{' '.join(cmd[1:3])}…` (is the network up? pip needs it once):\n"
                + (r.stderr or r.stdout).strip()[-2000:])


def _run_new_cli(app_dir: Path, vault: Path, *cli_args: str) -> int:
    """Run the NEW app's CLI via its own venv, streaming output through.
    Paths resolve to absolute here because the subprocess cwd is the app dir —
    a relative interpreter path would be looked up against the wrong root."""
    app_dir, vault = Path(app_dir).resolve(), Path(vault).resolve()
    py = app_dir / ".venv" / "bin" / "python"
    if not py.exists():
        raise ZipUpdateError(
            f"{py} is missing — the app has no environment (an interrupted "
            "update?). Re-run `alexandria update --from-zip <zip>`: it rebuilds "
            "the environment as part of the reinstall.")
    cmd = [str(py), "-m", "scripts.cli", *cli_args, "--vault", str(vault)]
    # The child inherits our stdout; flush first or the parent's buffered
    # narration prints AFTER the child's output (the transcript reads backwards).
    sys.stdout.flush(); sys.stderr.flush()
    try:
        return subprocess.run(cmd, cwd=str(app_dir), env=_scrubbed_env(),
                              timeout=1800).returncode
    except subprocess.TimeoutExpired:
        raise ZipUpdateError(
            f"`{' '.join(cli_args)}` timed out after 30 minutes — the update did "
            "not complete.")


def apply(vault: Path, source: Path,
          venv_builder=None, cli_runner=None, authority=None) -> dict:
    """The full swap: stage → app.prev aside → new app in → venv → render →
    doctor → router/README refresh → cleanup. Returns a small result dict;
    raises :class:`ZipUpdateError` with the brain intact for every failure
    before the render, and with ``app.prev`` kept for every failure after it.

    ``venv_builder`` and ``cli_runner`` are injected so tests exercise the
    orchestration without pip or a rendered vault. Late-bound (None → the real
    implementations) so a monkeypatched module attribute is honored."""
    venv_builder = venv_builder or build_venv
    cli_runner = cli_runner or _run_new_cli
    vault = Path(vault).resolve()
    app = vault / APP_SUBDIR
    prev = app.with_name("app.prev")
    swap = app.with_name("app.swap")
    tmp = vault / ".alexandria" / "tmp"

    staged = stage(source, tmp)          # validates the source before anything moves
    staged_app = staged / APP_SUBDIR

    # The current software goes to a TRANSIENT slot (app.swap), NOT straight over
    # the retained app.prev: the retained copy — the owner's one rollback away —
    # must survive a FAILED update untouched. It is retired only once the new
    # version is proven (the promote below), the whole point of this two-slot
    # dance. heal_interrupted (the CLI runs it first) has already reconciled any
    # earlier crash, so a surviving app.swap here is an orphan we can clear.
    if swap.exists():
        shutil.rmtree(swap)

    app.rename(swap)
    try:
        staged_app.rename(app)           # same filesystem (both under .alexandria/)
    except OSError:
        shutil.move(str(staged_app), str(app))

    try:
        venv_builder(app)
    except BaseException:
        # BaseException, deliberately: Ctrl-C during pip is a KeyboardInterrupt,
        # and an aborted build must restore the world exactly like a failure.
        # Nothing has been rendered yet — put the current software back from the
        # transient slot, and the RETAINED app.prev is never touched.
        shutil.rmtree(app, ignore_errors=True)
        if app.exists():
            raise ZipUpdateError(
                "the venv build failed AND the torn new app could not be fully "
                "removed — your previous software is safe at .alexandria/app.swap; "
                "remove .alexandria/app by hand and rename app.swap back.")
        swap.rename(app)
        shutil.rmtree(tmp / "zip-update-stage", ignore_errors=True)
        raise

    # The venv built: the swap is committed. Promote the just-superseded software
    # into the retained slot NOW, so every later outcome — success OR a
    # post-render failure — leaves app.prev holding it (and the older retained
    # copy aged out). A crash between here and cleanup is reconciled by heal.
    _promote_swap_to_prev(app)

    # Stamp the new build's signing authority HERE — right after the swap commits and
    # BEFORE the render that may fail (F15). The fresh app dir carries no marker, so
    # stamping only after apply() returns would leave a failed-render install mislabeled
    # "release" (absent ⇒ release) and it would never converge. authority=None ⇒ leave
    # unmarked (an internal reinstall that doesn't know the source's authority).
    if authority is not None:
        set_build_authority(vault, authority)

    # From here the new software writes into the vault: failures roll FORWARD
    # (report + keep app.prev), never auto-revert — the vault's git history is
    # the recovery surface for rendered files, and app.prev for the software.
    rc = cli_runner(app, vault, "update", "--no-pull")
    if rc != 0:
        raise ZipUpdateError(
            f"the new version's Core render failed (exit {rc}). The previous software "
            "is kept at .alexandria/app.prev; the vault's git history has the "
            "pre-update state of any rendered file. Nothing was cleaned up.")
    rc = cli_runner(app, vault, "doctor", "--quiet")
    if rc != 0:
        raise ZipUpdateError(
            f"doctor failed after the update (exit {rc}). The previous software is "
            "kept at .alexandria/app.prev until this is resolved.")

    landed = installed_app_version(vault)   # what actually swapped in, not the pre-read
    refreshed = _refresh_channel_files(staged, vault)

    # app.prev is KEPT (a one-version rollback is always on disk); transient
    # wreckage from older attempts is cleaned up.
    shutil.rmtree(tmp / "zip-update-stage", ignore_errors=True)
    shutil.rmtree(app.with_name("app.broken"), ignore_errors=True)
    shutil.rmtree(app.with_name("app.next"), ignore_errors=True)
    # prev_kept means a RUNNABLE rollback target is on disk — a reinstall over a
    # broken app leaves a non-viable prev that rollback would rightly refuse.
    return {"refreshed": refreshed, "prev_kept": _app_viable(prev), "landed": landed}


def _refresh_channel_files(staged: Path, vault: Path) -> list:
    """Copy the zip-carried, non-rendered files into the vault: the router (both
    copies) and the human README. NOT the zip's ``AGENTS.md`` — on a brain that
    path is the rendered manual."""
    refreshed = []
    router_src = staged / ROUTER_FILE
    if router_src.is_file():
        shutil.copyfile(router_src, vault / ROUTER_FILE)
        shutil.copyfile(router_src, vault / "CLAUDE.md")
        refreshed += [ROUTER_FILE, "CLAUDE.md"]
    readme_src = staged / README_FILE
    if readme_src.is_file():
        shutil.copyfile(readme_src, vault / README_FILE)
        refreshed.append(README_FILE)
    return refreshed


def repair_in_place(vault: Path, source: Path, cli_runner=None) -> dict:
    """The same-version path, made a full repair rather than a bare re-render:
    re-render Core through the vault's own app, health-check it, and refresh the
    router/README from the source. This is what a RETRY lands on after a crash
    late in an update (the swap already happened, so versions match) — without
    the doctor and the channel-file refresh, such a retry "succeeded" while
    leaving the router describing the previous version and the health never
    checked."""
    cli_runner = cli_runner or _run_new_cli
    vault = Path(vault).resolve()
    app = vault / APP_SUBDIR
    staged = stage(source, vault / ".alexandria" / "tmp")
    rc = cli_runner(app, vault, "update", "--no-pull")
    if rc != 0:
        raise ZipUpdateError(f"Core re-render failed (exit {rc}).")
    rc = cli_runner(app, vault, "doctor", "--quiet")
    if rc != 0:
        raise ZipUpdateError(f"doctor failed after the re-render (exit {rc}).")
    refreshed = _refresh_channel_files(staged, vault)
    shutil.rmtree(vault / ".alexandria" / "tmp" / "zip-update-stage", ignore_errors=True)
    return {"refreshed": refreshed}


def rollback(vault: Path, cli_runner=None) -> dict:
    """Swap the kept previous software back into place and re-render Core with
    it. The venv inside ``app.prev`` was built at ``.alexandria/app`` and its
    editable install is pinned to that path — so it runs again the moment the
    directory is renamed back. The newer copy is set aside at ``app.next``
    (removed by the next successful update), and the version gate deliberately
    does not apply: a rollback IS a downgrade. Committing stays with the caller."""
    cli_runner = cli_runner or _run_new_cli
    vault = Path(vault).resolve()
    app = vault / APP_SUBDIR
    prev = app.with_name("app.prev")
    if not prev.exists() or not _app_viable(prev):
        raise ZipUpdateError(
            "no previous software is kept at .alexandria/app.prev — nothing to "
            "roll back to. (The copy survives until the next successful update.)")
    nxt = app.with_name("app.next")
    if nxt.exists():
        shutil.rmtree(nxt)
    if app.exists():
        app.rename(nxt)
    prev.rename(app)
    rc = cli_runner(app, vault, "update", "--no-pull")
    if rc != 0:
        raise ZipUpdateError(
            f"Core re-render failed after the rollback swap (exit {rc}). The "
            "rolled-back software is in place; the newer copy is at "
            ".alexandria/app.next; the vault's git history has every rendered "
            "file's prior state.")
    rc = cli_runner(app, vault, "doctor", "--quiet")
    if rc != 0:
        raise ZipUpdateError(
            f"doctor failed after the rollback (exit {rc}). The newer copy is "
            "kept at .alexandria/app.next until this is resolved.")
    return {"newer_kept": nxt.exists()}


def _cli_heal(vault_arg: str) -> int:
    """The no-venv recovery entry point's body — see __main__."""
    vault = Path(vault_arg).resolve()
    app = vault / APP_SUBDIR
    if not (app.exists() or app.with_name("app.swap").exists()
            or app.with_name("app.prev").exists()):
        print(f"error: no Alexandria software found under {app.parent} — is this "
              "the vault root? (pass the folder that contains .alexandria/)")
        return 1
    try:
        with update_lock(vault):          # serialize against any concurrent update
            msg = heal_interrupted(vault)
    except ZipUpdateError as e:
        print(f"error: {e}")
        return 1
    except Exception as e:                # a bare-python last resort must not traceback
        print(f"error: recovery failed: {e!r} — nothing was deleted; the copies "
              "under .alexandria/ (app / app.swap / app.prev) are all still there.")
        return 1
    if msg:
        print(msg + " — now run `alexandria update --from-zip <zip>` to finish.")
        return 0
    if _app_viable(app):
        print("nothing to heal — the software is intact and runnable.")
        return 0
    print("could not recover automatically: no runnable software copy was found "
          "under .alexandria/ (app / app.swap / app.prev). Reinstall by unzipping "
          "a fresh Alexandria.zip's .alexandria/app over this one, or re-run the "
          "installer.")
    return 1


if __name__ == "__main__":
    # The no-venv recovery entry point. When an update dies during the venv
    # build, EVERY installed CLI is dead — the app's venv is gone, and the
    # set-aside copies' console scripts shebang into paths that moved. This
    # module is deliberately stdlib-only so a bare system python can run the
    # heal. Run the copy at the LIVE app path (it is the newest one on disk once
    # apply() has placed the new tree, and always carries this entry point):
    #
    #   python3 .alexandria/app/scripts/zipupdate.py heal .
    #
    # (or app.prev/app.swap if app is gone entirely), after which the restored
    # CLI takes over and a normal `update --from-zip` finishes the job.
    if len(sys.argv) == 3 and sys.argv[1] == "heal":
        sys.exit(_cli_heal(sys.argv[2]))
    print("usage: python3 zipupdate.py heal <vault-path>")
    sys.exit(2)
