#!/bin/bash
# ci-autorelease <verb> [arg]   -- message on stdin for `notify`
# CANONICAL SOURCE (SID-607). Exec'd unchanged by `agentforge ci autorelease`
# (src/agentforge/ci/autorelease.py) — the ROOT-owned copy at
# /usr/local/bin/ci-autorelease is a stable shim over that entry point, so
# every fix here reaches the box on the next `agentforge update apply`
# instead of waiting for someone to re-run install-ci-task-creation.sh (the
# gap that left the rollback fix, SID-599, un-deployed for weeks). Kept as
# bash rather than rewritten in Python: this file already carries several
# hard-won fixes (SID-231, SID-386, SID-388, SID-599) in its argv validation,
# its git orchestration and its exact error text, and moving it unchanged is
# far lower risk than re-deriving that behaviour from scratch.
#
# The autonomous release chain (SID-388) turned three human gestures into
# machine steps: cutting the candidate, and deploying what was published. Both
# need the `agentforge` user — its git credentials, its 0600 config tree, its
# systemd user units — and neither may be spelled out by the CI runner account.
# Same shape, same reasoning and the same single sudoers rule pattern as
# ci-release, which this joins rather than replaces.
#
# The runner may therefore say "cut a candidate" or "deploy 0.4.91" and nothing
# else: argv is validated here, never interpolated into a shell.
set -euo pipefail

# The instance root and the product's origin come from the launcher
# (`agentforge.ci.launcher.exec_packaged_script`), which reads them off the
# venv running it and the package's public constants — never from the CI
# runner, never written down here (BR-AF-001, SID-758).
ROOT="${AGENTFORGE_CI_ROOT:-}"
need_root() {
  [ -n "$ROOT" ] || { echo "AGENTFORGE_CI_ROOT is unset: run this through \`agentforge ci\`" >&2; exit 2; }
}
PY="$ROOT/runtime/.venv/bin/python"
AF="$ROOT/runtime/.venv/bin/agentforge"

# A checkout that belongs to `agentforge` and to this wrapper alone.
#
# Two clones already exist on the box and neither can be used. The Actions
# workspace belongs to the runner account, and git refuses to run in a tree it
# does not own (`dubious ownership`) — which is the point of the separation.
# `git-repos/AgentForge` belongs to agentforge but is live state: the agents
# leave it on their own branches with their own untracked files, and a release
# cut that resets it would destroy work in progress. So the chain gets its own,
# and treats it as disposable.
CHECKOUT="$ROOT/runtime/release-checkout"
ORIGIN="${AGENTFORGE_CI_ORIGIN:-}"

# Two release verbs, not one (SID-386). With the short-lived release branch,
# OPENING a cycle and putting the NEXT candidate on an open one are separate
# acts with separate preconditions, so they are separate verbs here too:
#
#   open   `cut-rc.sh --open` from `main` — creates release/<YYYY-MM-DD-HH-MM>-utc,
#          dispatches release-please on it, tags rc.1.
#   cut    `cut-rc.sh` from the release branch already in flight — rc.N+1
#          after a hotfix. Refuses unless exactly one such branch exists.
usage() {
  echo "usage: ci-autorelease <open [routine|sensible] | cut [routine|sensible] | deploy <version> | verify [version] | rollback | notify <topic>>" >&2
  exit 2
}

verb="${1:-}"; [ -n "$verb" ] || usage
arg="${2:-}"

case "$verb" in
  open|cut|deploy|verify|rollback|notify) ;;
  *) echo "invalid verb: $verb" >&2; usage ;;
esac

# One allowlist per verb. Nothing reaches a command line without matching a
# pattern here, so an argument cannot carry an option, a path or a shell
# metacharacter into what runs below.
case "$verb" in
  open|cut)
    case "$arg" in
      ""|routine|sensible) ;;
      *) echo "invalid release class: $arg (expected routine or sensible)" >&2; exit 2 ;;
    esac
    ;;
  deploy)
    [[ "$arg" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid version: ${arg:-(missing)}" >&2; exit 2; }
    ;;
  verify)
    if [ -n "$arg" ]; then
      [[ "$arg" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid version: $arg" >&2; exit 2; }
    fi
    ;;
  notify)
    # A topic is one the instance declares in bot/config.json
    # (`telegram.topics`), never a name this package carries (SID-758). An
    # unknown topic is a typo, not a new channel. Shape first, so nothing odd
    # reaches the lookup.
    [[ "$arg" =~ ^[a-z][a-z0-9_-]{0,63}$ ]] || { echo "invalid topic: ${arg:-(missing)}" >&2; exit 2; }
    need_root
    python3 - "$ROOT/bot/config.json" "$arg" <<'PY' || { echo "invalid topic: $arg (not in bot/config.json telegram.topics)" >&2; exit 2; }
import json, sys
try:
    topics = json.load(open(sys.argv[1], encoding="utf-8")).get("telegram", {}).get("topics", {})
except (OSError, ValueError, AttributeError):
    sys.exit(1)
sys.exit(0 if isinstance(topics, dict) and sys.argv[2] in topics else 1)
PY
    ;;
esac

need_root

# ---------------------------------------------------------------------------

case "$verb" in

  open|cut)
    [ -n "$ORIGIN" ] || { echo "AGENTFORGE_CI_ORIGIN is unset: run this through \`agentforge ci\`" >&2; exit 2; }
    # A fresh tip, always. Anything left over from a previous run is noise at
    # best and a wrong candidate at worst.
    if [ ! -d "$CHECKOUT/.git" ]; then
      git clone --quiet "$ORIGIN" "$CHECKOUT"
    fi
    git -C "$CHECKOUT" fetch --quiet origin --prune --tags --force

    if [ "$verb" = open ]; then
      # Opening: from the tip of main. `cut-rc.sh --open` is what refuses when
      # another release branch is still in flight; nothing is decided here.
      git -C "$CHECKOUT" checkout --quiet -B main origin/main
      git -C "$CHECKOUT" reset --quiet --hard origin/main
    else
      # Cutting rc.N+1: on the ONE release branch in flight. Discovered from
      # the remote rather than passed in, because a branch name on the command
      # line is a free-text argument this wrapper exists not to accept — and
      # because "which cycle is open" is a fact of the repository, not an
      # opinion of the caller. Zero or several is not something to guess at.
      branches="$(git -C "$CHECKOUT" for-each-ref --format='%(refname:strip=3)' 'refs/remotes/origin/release/')"
      count="$(printf '%s\n' "$branches" | grep -c . || true)"
      if [ "$count" -ne 1 ]; then
        echo "ci-autorelease: expected exactly one release/* branch on origin, found $count" >&2
        [ "$count" -eq 0 ] && echo "Open a cycle first: ci-autorelease open" >&2
        printf '%s\n' "$branches" | sed 's/^/  /' >&2
        exit 1
      fi
      # `strip=3` already removed `refs/remotes/origin`, so what is left IS the
      # branch name. Re-prefixing it would ask for `origin/release/release/…`.
      branch="$branches"
      git -C "$CHECKOUT" checkout --quiet -B "$branch" "origin/$branch"
      git -C "$CHECKOUT" reset --quiet --hard "origin/$branch"
    fi
    git -C "$CHECKOUT" clean -qfd

    # `cut-rc.sh` writes an annotated tag, and git refuses to author one
    # without an identity. The agentforge account has no global git config —
    # deliberately, since every clone on the box belongs to a different
    # purpose — so the first cut died on "Please tell me who you are" after
    # having already created the Linear release issue. Set it on the checkout
    # this wrapper owns, and nowhere else. This is the *tagger*; who pushes is
    # a separate question, answered by the account's credentials below.
    git -C "$CHECKOUT" config user.name "The Architect"
    # The address is the instance's (`release.tagger_email` in
    # bot/config.json), else the account's own on this host: an instance's
    # domain is not the package's to write down (BR-AF-001, SID-758).
    tagger_email="$(python3 - "$ROOT/bot/config.json" <<'PY' 2>/dev/null || true
import json, sys
value = json.load(open(sys.argv[1], encoding="utf-8")).get("release", {}).get("tagger_email", "")
print(value if isinstance(value, str) else "")
PY
)"
    git -C "$CHECKOUT" config user.email "${tagger_email:-$(id -un)@$(hostname)}"

    # cut-rc.sh pushes the tag itself, and the push identity matters more than
    # it looks: a tag pushed with a workflow-scoped token fires no workflow,
    # so the release gate would never start and the chain would die at an
    # un-QA'd candidate. Here the push uses the agentforge account's own git
    # credentials, which are a real account and do trigger workflows. Fail
    # loudly rather than push as nobody.
    if ! git -C "$CHECKOUT" ls-remote --quiet origin HEAD >/dev/null 2>&1; then
      echo "ci-autorelease: the agentforge account has no push credentials for origin" >&2
      exit 1
    fi

    open_flag=()
    [ "$verb" = open ] && open_flag=(--open)
    if [ -n "$arg" ]; then
      exec "$CHECKOUT/scripts/cut-rc.sh" "${open_flag[@]}" --class "$arg"
    fi
    exec "$CHECKOUT/scripts/cut-rc.sh" "${open_flag[@]}"
    ;;

  deploy)
    # SID-616: this verb has known the exact version to install since the
    # argv validation above (a released, published tag) — but used to throw
    # that away and tell `update apply` to run bare, which asks pip for
    # "whatever is latest". pip caches the index it reads from for ~10
    # minutes, so a deploy landing inside that window installed the PREVIOUS
    # release while `verify` (below) expected the one just published — seen
    # live on 2026-09-16 (0.4.101 installed for a 0.4.102 deploy, run 2).
    # Passing the version through makes the install exact instead of
    # eventually-consistent. Re-running against a version already installed
    # is safe but NOT a no-op: `pip` itself reports "already satisfied", but
    # `update apply` still runs migrations, syncs assets, healthchecks and
    # restarts units, and rewrites data/version.json — every call, pinned or
    # not. What it does NOT do on a repeat call is touch `previous_version`,
    # so a redundant `deploy X` can't make `rollback` a silent no-op
    # (agentforge/updater.py, apply_update).
    export AGENTFORGE_ROOT="$ROOT"
    export AGENTFORGE_RUNTIME_ROOT="$ROOT/runtime"
    "$AF" update apply --yes --version "$arg"
    # Re-enter through the CLI dispatch (`agentforge ci autorelease verify`),
    # not `exec "$0"`: `$0` is this packaged script's own data-file path, and
    # `launcher.py` deliberately does not trust that path's +x bit surviving a
    # wheel install — re-execing it directly would depend on exactly the bit
    # the launcher exists to route around. This also means what runs next is
    # whatever `update apply` just installed, not the version that started —
    # by design, since verifying the OLD build after a deploy would prove
    # nothing.
    exec "$AF" ci autorelease verify "$arg"
    ;;

  verify)
    # JSON on stdout, exit 1 when the box does not actually run what was asked.
    # "Installed" is not "running": the 2026-09-04 deploy wrote the new version
    # to disk while every unit kept serving the old one, and nothing noticed.
    #
    # And "running" is not "running the new code" (SID-407): a unit that was
    # never restarted is running, just on the previous release. So the report
    # carries the two facts a caller needs to tell them apart, and leaves the
    # comparison to the caller (release-publish.yml, "Verify the box actually
    # runs it"): `last_update`, when data/version.json says the install
    # happened, and for each running unit `active_enter`, when systemd says it
    # last started (ISO-8601 UTC). A value that cannot be read is null, never
    # a guess - and never silently equal to the other one.
    exec "$PY" - "$ROOT" "${arg:-}" <<'PY'
import json, os, subprocess, sys
from datetime import datetime, timezone

root, wanted = sys.argv[1], sys.argv[2]
out = {
    "installed_version": None,
    "last_update": None,
    "wanted": wanted or None,
    "units": [],
    "problems": [],
}

try:
    with open(os.path.join(root, "data", "version.json"), encoding="utf-8") as fh:
        state = json.load(fh)
    out["installed_version"] = state.get("installed_version")
    out["last_update"] = state.get("last_update") or None
except Exception as exc:
    out["problems"].append(f"version.json unreadable: {exc}")

if wanted and out["installed_version"] != wanted:
    out["problems"].append(f"installed {out['installed_version']!r}, wanted {wanted!r}")

# systemctl --user needs the user bus, and under sudo the environment that
# points at it is gone. Restoring it is what turns "no service found" back into
# the truth; an unreachable bus is reported, never read as "nothing to check".
env = dict(os.environ)
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path={env['XDG_RUNTIME_DIR']}/bus")


def active_enter(unit):
    """When `unit` last started, as ISO-8601 UTC, or None if systemd won't say.

    systemd prints the timestamp in the caller's local zone unless told
    otherwise, and text like "Tue 2026-09-29 09:30:45 EDT" carries a zone
    abbreviation no parser here can be trusted to resolve. TZ=UTC makes it
    UTC; anything that still does not parse is None.
    """
    try:
        proc = subprocess.run(
            ["systemctl", "--user", "show", unit, "--property=ActiveEnterTimestamp", "--value"],
            capture_output=True, text=True, env=dict(env, TZ="UTC"), timeout=30,
        )
        if proc.returncode != 0:
            return None
        stamp = datetime.strptime(proc.stdout.strip(), "%a %Y-%m-%d %H:%M:%S %Z")
    except (OSError, ValueError, subprocess.TimeoutExpired):
        return None
    return stamp.replace(tzinfo=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


try:
    proc = subprocess.run(
        ["systemctl", "--user", "list-units", "--type=service", "--state=running",
         "--no-legend", "--plain", "agentforge-*.service"],
        capture_output=True, text=True, env=env, timeout=30,
    )
    if proc.returncode != 0:
        out["problems"].append(f"systemctl --user failed: {proc.stderr.strip()[:200]}")
    else:
        names = [line.split()[0] for line in proc.stdout.splitlines() if line.strip()]
        out["units"] = [{"unit": name, "active_enter": active_enter(name)} for name in names]
        if not out["units"]:
            out["problems"].append("no agentforge-*.service is running")
except FileNotFoundError:
    out["problems"].append("systemctl not found")
except subprocess.TimeoutExpired:
    out["problems"].append("systemctl --user timed out")

out["ok"] = not out["problems"]
json.dump(out, sys.stdout, indent=2)
sys.stdout.write("\n")
sys.exit(0 if out["ok"] else 1)
PY
    ;;

  rollback)
    # `--yes` because there is no terminal here. Without it the verb reads EOF,
    # prints "Aborted" and exits 0 — a rollback that did nothing, indis-
    # tinguishable from one that worked (fixed alongside this file in SID-388).
    # Same explicit-instance discipline as `deploy` (SID-599): without these,
    # `update rollback` under sudo -n -u agentforge (env_reset) cannot find
    # this instance's version.json and exits 1 — the one path meant to recover
    # a failed deploy.
    export AGENTFORGE_ROOT="$ROOT"
    export AGENTFORGE_RUNTIME_ROOT="$ROOT/runtime"
    exec "$AF" update rollback --yes
    ;;

  notify)
    # Message on stdin, never in argv: a release note carries newlines, quotes
    # and URLs, and none of that belongs on a command line.
    msg="$(cat)"
    [ -n "$msg" ] || { echo "ci-autorelease notify: empty message on stdin" >&2; exit 2; }
    export AGENTFORGE_ROOT="$ROOT"
    export AGENTFORGE_RUNTIME_ROOT="$ROOT/runtime"
    # `telegram_send.sh` resolves the bot token from AGENT_NAME under
    # `set -u`. Release failures are authored by the Architect identity, but
    # their destination remains the validated topic supplied above.
    export AGENT_NAME=architect
    exec "$ROOT/runtime/scripts/telegram_send.sh" "$arg" "$msg"
    ;;
esac
