#!/usr/bin/env -S uv run --no-project --python >=3.12 python
"""scripts/mcp-image-refresh — rebuild the session MCP's image when it is stale.

The session MCP (precis-mcp-http) runs from the operator-side precis-mcp:dev
image, whose venv bakes autocatpath at build time (gr493148). Workers get the
pinned wheel from the wheelhouse; this image does not, so a uv.lock bump leaves
the MCP on an older engine until the image is rebuilt.

  scripts/mcp-image-refresh              refresh if stale, else "up to date"
  scripts/mcp-image-refresh --dry-run    print the plan, the install decision
                                         and the diff; change nothing

Steps (stops at the first failure):
  1. compare the image's `precis.autocatpath_rev` label with the uv.lock pin
     and decide what to do with the installed ensure script (step 2). Image
     current AND script in sync -> "up to date", exit 0
  2. install decision, taken before anything is built or changed:
       in sync    -> nothing to install
       auto-apply -> the installed file is byte-identical to some revision of
                     deploy/mcp-http/precis-mcp-http-ensure.sh in this repo's
                     git history, i.e. no local edits; the repo copy is newer
       refuse     -> the installed file matches NO revision (local edits) or is
                     missing: STOP (exit 3), change nothing. The only human stop.
  3. image stale: scripts/build-image precis-dev, tag it precis-mcp:dev,
     re-check the label
  4. auto-apply: scripts/mcp-http-install --apply (backs up the old copy)
  5. <ensure script> --recreate (blue-green), then poll --status until the
     backend answers 401 (serving) directly and via the proxy, within
     --timeout; else exit 4

Reads two labels with `docker image inspect --format`; never the env, never a
whole inspect. Owned by the release owner, run after a deploy that moved the
autocatpath pin or the ensure script (docs/conventions/shipping.md). Not wired
into scripts/deploy: the repo copy compared is this checkout's HEAD, so the run
belongs to whoever holds the deployed sha.
"""

from __future__ import annotations

import argparse
import difflib
import hashlib
import os
import re
import subprocess
import sys
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
IMAGE = "precis-mcp:dev"
BUILD_SERVICE = "precis-dev"  # compose service and the tag it builds
REV_LABEL = "precis.autocatpath_rev"
SHA_LABEL = "precis.git_sha"
REPO_ENSURE_REL = "deploy/mcp-http/precis-mcp-http-ensure.sh"
REPO_ENSURE = ROOT / REPO_ENSURE_REL
EXIT_DIFF = 3
EXIT_UNHEALTHY = 4


def installed_ensure() -> Path:
    default = (
        Path.home() / "work/infrastructure/precis-mcp/scripts/precis-mcp-http-ensure.sh"
    )
    return Path(os.environ.get("PRECIS_MCP_ENSURE", str(default)))


def locked_sha(lock: Path) -> str:
    """The catpath commit uv.lock pins for autocatpath ('' when not git-sourced)."""
    text = lock.read_text(encoding="utf-8")
    m = re.search(
        r'^name = "autocatpath"\n(?:(?!\[\[).*\n)*?source = .*#([0-9a-f]{7,40})"',
        text,
        re.M,
    )
    return m.group(1) if m else ""


def image_label(image: str, key: str) -> str:
    """One label value via a narrow --format; '' when absent or the image is missing."""
    fmt = '{{index .Config.Labels "' + key + '"}}'
    r = subprocess.run(
        ["docker", "image", "inspect", "--format", fmt, image],
        capture_output=True,
        text=True,
        encoding="utf-8",
    )
    out = r.stdout.strip()
    return "" if r.returncode != 0 or out in ("", "<no value>") else out


def is_current(baked: str, pinned: str) -> bool:
    """Current = the baked rev is the pin (prefix match: labels may be short)."""
    if not baked or not pinned:
        return False
    return baked.startswith(pinned) or pinned.startswith(baked)


def ensure_diff(repo: Path, installed: Path) -> str:
    """Unified diff installed -> repo; '' when identical."""
    a = (
        installed.read_text(encoding="utf-8").splitlines(True)
        if installed.exists()
        else []
    )
    b = repo.read_text(encoding="utf-8").splitlines(True)
    return "".join(difflib.unified_diff(a, b, str(installed), str(repo)))


def diff_summary(repo: Path, installed: Path) -> str:
    """Hashes and changed-line counts only: the installed file may hold secrets."""
    diff = ensure_diff(repo, installed)
    plus = sum(1 for ln in diff.splitlines() if ln.startswith("+") and ln[:3] != "+++")
    minus = sum(1 for ln in diff.splitlines() if ln.startswith("-") and ln[:3] != "---")
    have = (
        hashlib.sha256(installed.read_bytes()).hexdigest()[:8]
        if installed.exists()
        else "missing"
    )
    want = hashlib.sha256(repo.read_bytes()).hexdigest()[:8]
    return f"installed {have} -> repo {want}: +{plus}/-{minus} lines (content not printed)\n"


def repo_copy_clean(rel: str = REPO_ENSURE_REL) -> bool:
    """True when the repo copy has no uncommitted edits (else --apply would ship them)."""
    try:
        r = subprocess.run(["git", "diff", "--quiet", "HEAD", "--", rel], cwd=ROOT)
    except OSError:
        return False
    return r.returncode == 0


def history_hashes(rel: str = REPO_ENSURE_REL) -> set[str]:
    """sha256 of every committed revision of the repo ensure script (HEAD history).

    Empty when git cannot answer (missing binary, shallow or non-git tree)."""
    try:
        r = subprocess.run(
            ["git", "rev-list", "HEAD", "--", rel],
            cwd=ROOT,
            capture_output=True,
            text=True,
            encoding="utf-8",
        )
    except OSError:
        return set()
    if r.returncode != 0:
        return set()
    out: set[str] = set()
    for rev in r.stdout.split():
        blob = subprocess.run(
            ["git", "show", f"{rev}:{rel}"], cwd=ROOT, capture_output=True
        )
        if blob.returncode == 0:
            out.add(hashlib.sha256(blob.stdout).hexdigest())
    return out


def install_decision(repo: Path, installed: Path, history: set[str]) -> str:
    """'in-sync' | 'auto-apply' | 'refuse' for the installed ensure script."""
    if not installed.exists():
        return "refuse"
    have = hashlib.sha256(installed.read_bytes()).hexdigest()
    if have == hashlib.sha256(repo.read_bytes()).hexdigest():
        return "in-sync"
    return "auto-apply" if have in history else "refuse"


def run(cmd: list[str]) -> int:
    print("+ " + " ".join(cmd), flush=True)
    return subprocess.run(cmd, cwd=ROOT).returncode


def serving(status_text: str) -> bool:
    codes = re.findall(r"\(no token, [^)]*\) -> (\d+)", status_text)
    return len(codes) >= 2 and all(c == "401" for c in codes)


def wait_serving(script: Path, timeout: float, interval: float = 3.0) -> bool:
    deadline = time.monotonic() + timeout
    while True:
        r = subprocess.run(
            [str(script), "--status"], capture_output=True, text=True, encoding="utf-8"
        )
        if serving(r.stdout):
            return True
        if time.monotonic() >= deadline:
            return False
        time.sleep(interval)


_VERDICT = {
    "in-sync": "installed ensure script: in sync",
    "auto-apply": "installed ensure script: a past repo revision (no local edits); "
    "decision AUTO-APPLY",
    "refuse": "installed ensure script: matches NO repo revision (local edit) or is "
    "missing; decision REFUSE, the real run stops here (exit 3)",
}


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument(
        "--dry-run",
        action="store_true",
        help="print plan, decision and diff; change nothing",
    )
    ap.add_argument(
        "--timeout",
        type=float,
        default=180.0,
        help="seconds to wait for the new colour",
    )
    args = ap.parse_args(argv)

    pinned = locked_sha(ROOT / "uv.lock")
    baked = image_label(IMAGE, REV_LABEL)
    built = image_label(IMAGE, SHA_LABEL)
    print(f"autocatpath pin (uv.lock): {pinned or 'none (not git-sourced)'}")
    print(
        f"image {IMAGE}: autocatpath {baked or 'unlabelled'}, built from {built[:12] or 'unknown'}"
    )

    installed = installed_ensure()
    stale = not is_current(baked, pinned)
    history = history_hashes()
    decision = install_decision(REPO_ENSURE, installed, history)
    reason = "local edits matching no revision of the repo script, or missing"
    if decision == "auto-apply" and not repo_copy_clean():
        decision, reason = "refuse", "the repo copy has uncommitted edits"
    elif decision == "refuse" and not history:
        reason = (
            "git history of the repo script is unreadable (shallow or non-git tree?)"
        )
    if not stale and decision == "in-sync":
        print("up to date")
        return 0

    diff = "" if decision == "in-sync" else diff_summary(REPO_ENSURE, installed)
    plan: list[str] = []
    if stale:
        plan.append(
            "scripts/build-image precis-dev  (then docker tag precis-dev precis-mcp:dev)"
        )
    if decision == "auto-apply":
        plan.append("scripts/mcp-http-install --apply   (backs up the installed copy)")
    plan += [
        f"{installed} --recreate   (blue-green)",
        f"{installed} --status   (wait up to {args.timeout:.0f}s for 401 on both probes)",
    ]
    if args.dry_run:
        print("plan (dry run, nothing executed):")
        if decision == "refuse":
            print("  (none: refused)")
        else:
            for i, step in enumerate(plan, 1):
                print(f"  {i}. {step}")
        print(_VERDICT[decision])
        if diff:
            sys.stdout.write(diff)
        return 0

    if stale and not pinned:
        print(
            "uv.lock pins no git-sourced autocatpath; nothing to compare. Stopping.",
            file=sys.stderr,
        )
        return 1
    if decision == "refuse":
        print(
            f"STOP: not installing {installed}: {reason}. Inspect it yourself "
            "(diff against the repo copy); if you accept losing any edits run "
            "scripts/mcp-http-install --apply, then re-run. Nothing was built or changed.",
            file=sys.stderr,
        )
        sys.stdout.write(diff)
        return EXIT_DIFF
    if stale:
        if run(["scripts/build-image", BUILD_SERVICE]) != 0:
            return 1
        # compose tags the build `precis-dev`; the ensure script runs `precis-mcp:dev`.
        if run(["docker", "tag", BUILD_SERVICE, IMAGE]) != 0:
            return 1
        rebuilt = image_label(IMAGE, REV_LABEL)
        if not is_current(rebuilt, pinned):
            print(
                f"built image carries autocatpath {rebuilt or 'unlabelled'}, not the pin; not recreating",
                file=sys.stderr,
            )
            return 1
    if decision == "auto-apply":
        # the build can take minutes: re-check the installed file before overwriting it
        if install_decision(REPO_ENSURE, installed, history) != "auto-apply":
            print(
                f"STOP: {installed} changed during the build; not installing.",
                file=sys.stderr,
            )
            return EXIT_DIFF
        if run(["scripts/mcp-http-install", "--apply"]) != 0:
            return 1
    if run([str(installed), "--recreate"]) != 0:
        return 1
    if not wait_serving(installed, args.timeout):
        print(
            f"new backend not serving (no 401 from both probes) within "
            f"{args.timeout:.0f}s; the old colour may still be up. Run "
            f"{installed} --status and check docker logs.",
            file=sys.stderr,
        )
        return EXIT_UNHEALTHY
    print("refreshed: new colour is serving")
    return 0


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