#!/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 human gestures into machine
# steps; what remains of them here is deploying what was published. That needs
# the `agentforge` user — its 0600 config tree, its systemd user units — and
# may not be spelled out by the CI runner account.
#
# The runner may therefore say "deploy 0.4.91" and nothing else: argv is
# validated here, never interpolated into a shell.
set -euo pipefail

# The instance root comes 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"

# The release verbs `open` and `cut` (SID-386) left with the release chain
# (SID-873): a release is a Livela release item now. What stays is
# AgentForge's own deployment — PyPI to the box — and its notifications.
usage() {
  echo "usage: ci-autorelease <deploy <version> | verify [version] | rollback | notify <topic>>" >&2
  exit 2
}

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

case "$verb" in
  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
  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

  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 service bot, never an
    # agent persona; their destination remains the validated topic supplied
    # above (SID-604).
    export AGENT_NAME=agentforge
    exec "$ROOT/runtime/scripts/telegram_send.sh" "$arg" "$msg"
    ;;
esac
