#!/usr/bin/env bash
#
# hypernix-t1 — run a T1 API server as a thing you manage, not a command
# you remember.
#
#   hypernix-t1 start | stop | kill | restart | status | logs
#   hypernix-t1 create | configure | test | key | autostart | remove
#
# The gap this fills: starting the server is a uvicorn incantation with
# six environment variables, and every one of them has to match what
# `gkey` and `waiter` think. Getting one wrong does not fail loudly — it
# produces a server that runs and rejects your keys, which is the single
# most expensive way for this to go wrong.
#
# So there is one config file, one place the keys live, and one command
# that reads both.
#
# Requires: bash 3.2+, python3 3.10+ with hypernix[t1api] installed.

set -euo pipefail

# The version this script reports. A fallback, not the answer.
#
# It was a hand-maintained literal and it was stale, which is one third
# of "the installed T1 thinks it is running an older version" — the
# other two thirds were install-t1.sh's two constants. `hypernix-t1
# version` now asks the installed package, because by the time this
# script does anything useful the package is installed by definition,
# and a number derived from the thing it describes cannot drift from it.
VERSION="0.72.5"

# The real versions, from the package this script drives.
#
# Deliberately not a `sed` of the source like install-t1.sh does: that
# script runs before anything is installed and has a checkout to read,
# and this one runs after and may have no checkout at all. Asking the
# interpreter is the accurate answer *here* and the impossible one
# there.
report_versions() {
  local py
  py="$(python_bin 2>/dev/null)" || { printf 'hypernix-t1 %s\n' "$VERSION"; return 0; }
  "$py" - <<'PYEOF' 2>/dev/null || printf 'hypernix-t1 %s\n' "$VERSION"
import sys

try:
    import hypernix
    from hypernix.t1api.version import T1_VERSION
except Exception:
    sys.exit(1)

print(f"hypernix-t1 (hypernix {hypernix.__version__})")
print(f"t1 api     v{T1_VERSION.short}")
print(f"python     {'.'.join(str(p) for p in sys.version_info[:3])}")
print(f"runs as    {sys.executable}")
PYEOF
}

CONFIG_DIR="${T1_CONFIG_DIR:-$HOME/.hypernix/t1api}"
ENV_FILE="$CONFIG_DIR/.env"
PID_FILE="$CONFIG_DIR/server.pid"
LOG_FILE="$CONFIG_DIR/server.log"

if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
  C_RED=$'\033[38;5;160m'; C_DIM=$'\033[38;5;245m'; C_TEXT=$'\033[38;5;253m'
  C_OK=$'\033[38;5;71m'; C_WARN=$'\033[38;5;179m'; C_BOLD=$'\033[1m'; C_OFF=$'\033[0m'
else
  C_RED=''; C_DIM=''; C_TEXT=''; C_OK=''; C_WARN=''; C_BOLD=''; C_OFF=''
fi

say()  { printf '%s\n' "${C_TEXT}$*${C_OFF}"; }
dim()  { printf '%s\n' "${C_DIM}$*${C_OFF}"; }
ok()   { printf '%s\n' "${C_OK}  ✓${C_OFF} ${C_TEXT}$*${C_OFF}"; }
warn() { printf '%s\n' "${C_WARN}  !${C_OFF} ${C_TEXT}$*${C_OFF}" >&2; }
err()  { printf '%s\n' "${C_RED}  ✗${C_OFF} ${C_TEXT}$*${C_OFF}" >&2; }
die()  { err "$*"; exit 1; }

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------

load_env() {
  [ -r "$ENV_FILE" ] || return 0
  # Read, do not source: .env is a file people edit by hand, and sourcing
  # runs whatever ends up in it.
  while IFS= read -r line; do
    case "$line" in
      ''|'#'*) continue ;;
      *=*) ;;
      *) continue ;;
    esac
    key="${line%%=*}"
    value="${line#*=}"
    value="${value%\'}"; value="${value#\'}"
    value="${value%\"}"; value="${value#\"}"
    case "$key" in
      T1_*|HYPERNIX_*) export "$key=$value" ;;
    esac
  done < "$ENV_FILE"
}

setting() {
  # setting NAME DEFAULT — from the environment, else the .env, else the default.
  local name="$1" fallback="${2:-}"
  local current
  eval "current=\${$name:-}"
  if [ -n "$current" ]; then printf '%s' "$current"; return; fi
  printf '%s' "$fallback"
}

python_bin() {
  if [ -x "$CONFIG_DIR/venv/bin/python" ]; then
    printf '%s' "$CONFIG_DIR/venv/bin/python"
  else
    command -v python3 || command -v python || true
  fi
}

require_installed() {
  local py; py="$(python_bin)"
  [ -n "$py" ] || die "No python3 found."
  "$py" -c "import hypernix.t1api" >/dev/null 2>&1 && return 0

  # Past here the import failed, and the useful part is *why*. The old
  # message said "Run: pip install 'hypernix[t1api]'" with no
  # interpreter on it. When $py is the private venv -- which it is
  # whenever install-t1.sh made one -- a bare `pip` in the operator's
  # shell installs somewhere else entirely, the check fails again, and
  # the advice repeats. Someone can follow that instruction correctly
  # any number of times without it ever working.
  local pip_cmd="$py -m pip install 'hypernix[t1api]'"

  # Two different failures wear the same message. Tell them apart:
  # the package missing needs an install, the extra missing needs the
  # extra, and installing the wrong one of those fixes nothing.
  if "$py" -c "import hypernix" >/dev/null 2>&1; then
    printf 'hypernix is installed for %s, but the [t1api] extra is not.\n' "$py" >&2
    printf 'The API server needs fastapi and uvicorn, which that extra pulls in.\n\n' >&2
    printf '  %s\n' "$pip_cmd" >&2
  else
    printf 'hypernix is not installed for %s.\n\n' "$py" >&2
    printf '  %s\n' "$pip_cmd" >&2
  fi

  # If it is importable somewhere else, say so by name. "It is installed
  # already" is a true statement about a different interpreter, and
  # without this line there is nothing on screen that explains the
  # disagreement.
  local other found=""
  for other in python3 python; do
    local path; path="$(command -v "$other" 2>/dev/null || true)"
    [ -n "$path" ] || continue
    [ "$path" != "$py" ] || continue
    if "$path" -c "import hypernix.t1api" >/dev/null 2>&1; then
      found="$path"
      break
    fi
  done
  if [ -n "$found" ]; then
    printf '\nIt *is* installed for %s — but this server runs on\n' "$found" >&2
    printf '%s, so that copy is not the one it can import.\n' "$py" >&2
    printf 'Install into the line above, or delete %s/venv\n' "$CONFIG_DIR" >&2
    printf 'to make hypernix-t1 fall back to your own interpreter.\n' >&2
  fi
  exit 1
}

# ---------------------------------------------------------------------------
# Process
# ---------------------------------------------------------------------------

server_pid() {
  [ -r "$PID_FILE" ] || return 1
  local pid; pid="$(cat "$PID_FILE" 2>/dev/null || true)"
  [ -n "$pid" ] || return 1
  # A PID file outlives the process it named, and PIDs are reused. Check
  # that what is running is actually ours before reporting it up — or,
  # worse, before killing it.
  kill -0 "$pid" 2>/dev/null || return 1
  if command -v ps >/dev/null 2>&1; then
    # pgrep searches for a process by pattern. This asks a different
    # question — "is the process already identified by $pid one of ours?"
    # — and pgrep cannot be scoped to a known PID. The fragility SC2009
    # warns about, matching some other process, is what -p rules out.
    # shellcheck disable=SC2009
    ps -p "$pid" -o args= 2>/dev/null | grep -q "hypernix.t1api" || return 1
  fi
  printf '%s' "$pid"
}

# port_in_use PYTHON HOST PORT -- 0 when something already listens there.
#
# Asked before spawning, because "is this port free" and "does this port
# answer /health" are different questions, and only the first one is
# about the server we are about to start. A bind test answers it without
# needing ss, netstat or lsof to be installed.
port_in_use() {
  "$1" - "$2" "$3" <<'PY' >/dev/null 2>&1
import socket, sys
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# SO_REUSEADDR because uvicorn sets it, and this has to ask the question
# uvicorn will actually face. Without it a port still in TIME_WAIT from a
# just-stopped server reads as busy, and `restart` refuses to start the
# thing it has only this moment stopped. With it, a bind still fails
# while another socket is really LISTENing -- which is the case worth
# refusing.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
    sock.bind((sys.argv[1], int(sys.argv[2])))
except OSError:
    raise SystemExit(0)      # in use
else:
    raise SystemExit(1)      # free
finally:
    sock.close()
PY
}

# --- who is running the server ------------------------------------------
#
# `autostart on` installs a systemd user service, so a server systemd is
# managing is every bit as much "ours" as one started from this shell --
# it is running this same script's `start-foreground`. Reading only our
# own pid file reported an eight-hour-old, perfectly healthy service as
# "not running", and sent `start` off to fight it for the port.
SYSTEMD_UNIT="hypernix-t1.service"

systemd_available() {
  command -v systemctl >/dev/null 2>&1 || return 1
  # On PATH is not the same as reachable: in a container, over plain ssh
  # and on WSL there is no user bus and every --user call fails.
  systemctl --user show-environment >/dev/null 2>&1
}

systemd_pid() {
  systemd_available || return 1
  [ "$(systemctl --user is-active "$SYSTEMD_UNIT" 2>/dev/null || true)" = "active" ] || return 1
  local pid
  pid="$(systemctl --user show -p MainPID --value "$SYSTEMD_UNIT" 2>/dev/null || true)"
  case "$pid" in
    ''|0|*[!0-9]*) return 1 ;;
  esac
  kill -0 "$pid" 2>/dev/null || return 1
  printf '%s' "$pid"
}

# The pid under either manager. server_pid() deliberately stays pid-file
# only: it is how wait_healthy notices the process *this command* spawned
# dying, and a fallback to systemd there would mask exactly that.
running_pid() {
  local pid
  pid="$(server_pid || true)"
  if [ -n "$pid" ]; then printf '%s' "$pid"; return 0; fi
  systemd_pid
}

# "script", "systemd", or nothing. Printed, because which one is running
# decides whether `stop` should use systemctl -- and a plain kill against
# a unit with Restart=on-failure is how a server appears to refuse to
# stop.
running_owner() {
  server_pid >/dev/null 2>&1 && { printf 'script'; return 0; }
  systemd_pid >/dev/null 2>&1 && { printf 'systemd'; return 0; }
  return 1
}

wait_healthy() {
  local url="$1" seconds="${2:-45}" pid="${3:-}" i=0
  while [ "$i" -lt "$seconds" ]; do
    if curl -fsS --noproxy '*' "$url/health" >/dev/null 2>&1; then
      # Something answered. That is not the same as "our server came up".
      # A server already holding this port answers /health perfectly well
      # while the process we just started is inside uvicorn's startup and
      # about to die on `address already in use` -- uvicorn logs
      # "Application startup complete" *before* it binds, so for a moment
      # there is a live pid and a healthy port belonging to two different
      # processes. Reporting success there is how `start` printed a pid
      # that `status` could not find a second later.
      [ -n "$pid" ] || return 0
      sleep 1
      kill -0 "$pid" 2>/dev/null || return 3
      return 0
    fi
    # Died during startup? Say so now rather than after the full timeout.
    server_pid >/dev/null 2>&1 || return 2
    sleep 1
    i=$((i + 1))
  done
  return 1
}

# ---------------------------------------------------------------------------
# Detaching
# ---------------------------------------------------------------------------

spawn_detached() {
  # spawn_detached PYTHON LOGFILE ARGS... -- start ARGS in a new session
  # and print the pid of the process that is actually running it.
  #
  # This used to be `setsid "$py" -m uvicorn ... &` with `echo $!` for the
  # pid, and that was wrong twice over.
  #
  # macOS has no `setsid` binary. The script advertises bash 3.2, which
  # is the bash macOS ships, so macOS is a supported platform -- and
  # there `setsid` is "command not found", the background job dies at
  # once, `echo $!` still succeeds so the `|| die` guard never fires, and
  # the only sign is a raw shell error in the log 45 seconds later.
  #
  # And `$!` is not dependable even where setsid exists: setsid(1) forks
  # when it is already a process-group leader and the parent then exits,
  # so the pid recorded can belong to a process that has already gone.
  # `launch-script` hit exactly that and reported "unknown" for jobs that
  # were running perfectly well; see _launch_setsid in
  # hypernix/system/launcher.py.
  #
  # Python is already a hard requirement here -- it is the thing being
  # launched -- and Popen(start_new_session=True) calls setsid(2) in the
  # child itself. Same new session, no fork in between, and the pid it
  # reports is the server's own. stdin goes to /dev/null so a read after
  # the terminal is gone cannot raise EIO.
  local py="$1" log="$2"; shift 2
  HNX_T1_LOG="$log" "$py" -c '
import os, subprocess, sys

log = os.environ["HNX_T1_LOG"]
with open(log, "ab") as handle:
    proc = subprocess.Popen(
        sys.argv[1:],
        stdin=subprocess.DEVNULL, stdout=handle, stderr=handle,
        start_new_session=True,
    )
print(proc.pid)
' "$@"
}

logind_kills_user_processes() {
  # systemd-logind with KillUserProcesses=yes kills everything the user
  # owns at logout, a new session included. setsid(2) does not protect
  # against it and nothing in the log explains the disappearance -- the
  # server is simply gone the next time someone looks. Lingering is the
  # exemption, so a machine that has it on is not affected.
  command -v loginctl >/dev/null 2>&1 || return 1
  local linger
  linger="$(loginctl show-user "$(id -un)" --property=Linger --value 2>/dev/null || true)"
  [ "$linger" = "yes" ] && return 1
  local setting=""
  if command -v busctl >/dev/null 2>&1; then
    # The running configuration, which is what actually decides. Drop-ins
    # under logind.conf.d override the main file, so reading only
    # /etc/systemd/logind.conf can answer the wrong question.
    setting="$(busctl --no-pager get-property org.freedesktop.login1 \
      /org/freedesktop/login1 org.freedesktop.login1.Manager \
      KillUserProcesses 2>/dev/null || true)"
    case "$setting" in
      *true) return 0 ;;
      *false) return 1 ;;
    esac
  fi
  # No busctl: fall back to the files, newest drop-in last.
  local file value=""
  for file in /etc/systemd/logind.conf /etc/systemd/logind.conf.d/*.conf \
              /run/systemd/logind.conf.d/*.conf /usr/lib/systemd/logind.conf.d/*.conf; do
    [ -r "$file" ] || continue
    local found
    found="$(sed -n 's/^[[:space:]]*KillUserProcesses[[:space:]]*=[[:space:]]*\([^[:space:]#]*\).*/\1/p' "$file" | tail -n 1)"
    [ -n "$found" ] && value="$found"
  done
  case "$value" in
    yes|true|1|on) return 0 ;;
    *) return 1 ;;
  esac
}

cmd_start() {
  load_env; require_installed
  local existing owner
  existing="$(running_pid || true)"
  if [ -n "$existing" ]; then
    owner="$(running_owner || true)"
    if [ "$owner" = "systemd" ]; then
      ok "Already running (pid $existing) — the autostart service has it."
      dim "     Manage it with:  systemctl --user {status,restart,stop} $SYSTEMD_UNIT"
      dim "     Or hand it back: hypernix-t1 autostart off"
    else
      ok "Already running (pid $existing)."
    fi
    return 0
  fi

  local host port py url
  host="$(setting T1_HOST 127.0.0.1)"
  port="$(setting T1_PORT 8000)"
  py="$(python_bin)"
  url="http://${host}:${port}"
  [ "$host" = "0.0.0.0" ] && url="http://127.0.0.1:${port}"

  mkdir -p "$CONFIG_DIR"

  # Nothing of ours is running (checked above), so anything on this port
  # belongs to someone else -- very often the systemd user service this
  # script installs itself via `autostart on`. Spawning into that gives a
  # uvicorn that binds nothing and exits, while /health goes on answering
  # from the other process.
  if port_in_use "$py" "$host" "$port"; then
    err "Something is already listening on ${host}:${port}, and it is not a"
    err "server either this script or the autostart service is running."
    dim "     If that is the autostart service:  systemctl --user status hypernix-t1"
    dim "     To take it over:                   hypernix-t1 autostart off"
    dim "     To use another port:               set T1_PORT in $ENV_FILE"
    return 1
  fi

  say "Starting the T1 API on ${host}:${port}…"
  local pid=""
  pid="$(spawn_detached "$py" "$LOG_FILE" \
    "$py" -m uvicorn hypernix.t1api.app:create_app --factory \
    --host "$host" --port "$port")" \
    || die "Could not start the server."
  case "$pid" in
    ''|*[!0-9]*) die "Could not start the server (no pid from the launcher)." ;;
  esac
  printf '%s\n' "$pid" > "$PID_FILE"

  case "$(wait_healthy "$url" "$(setting T1_START_TIMEOUT 45)" "$pid"; echo $?)" in
    0) ok "Running (pid $(server_pid)) — $url"
       if logind_kills_user_processes; then
         warn "This machine's logind has KillUserProcesses=yes, so logging out"
         warn "will kill the server even though it has its own session."
         dim "     Survive logout:  hypernix-t1 autostart on"
         dim "                      sudo loginctl enable-linger $(id -un)"
       fi ;;
    2) rm -f "$PID_FILE"
       err "The server exited during startup. Last lines:"
       tail -n 20 "$LOG_FILE" >&2 || true
       return 1 ;;
    3) rm -f "$PID_FILE"
       err "${host}:${port} answers, but the server this command started is"
       err "already gone -- so the reply comes from something else."
       tail -n 20 "$LOG_FILE" >&2 || true
       return 1 ;;
    *) err "Started, but it never answered /health. Last lines:"
       tail -n 20 "$LOG_FILE" >&2 || true
       return 1 ;;
  esac
}

cmd_stop() {
  load_env
  if [ "$(running_owner || true)" = "systemd" ]; then
    # SIGTERM straight at systemd's MainPID leaves the unit thinking it
    # crashed, and Restart=on-failure brings it back -- which reads as a
    # server that will not stop.
    say "Stopping the autostart service…"
    systemctl --user stop "$SYSTEMD_UNIT" && { ok "Stopped."; return 0; }
    err "systemctl could not stop $SYSTEMD_UNIT."
    return 1
  fi
  local pid; pid="$(server_pid || true)"
  if [ -z "$pid" ]; then ok "Not running."; rm -f "$PID_FILE"; return 0; fi
  say "Stopping (pid $pid)…"
  kill -TERM "$pid" 2>/dev/null || true
  local i=0
  while [ "$i" -lt 15 ]; do
    server_pid >/dev/null 2>&1 || { rm -f "$PID_FILE"; ok "Stopped."; return 0; }
    sleep 1; i=$((i + 1))
  done
  warn "Still running after 15s. Use \`hypernix-t1 kill\` to force it."
  return 1
}

cmd_kill() {
  load_env
  if [ "$(running_owner || true)" = "systemd" ]; then
    warn "The autostart service owns this one; stopping it through systemd."
    systemctl --user stop "$SYSTEMD_UNIT" 2>/dev/null || true
    ok "Stopped."
    return 0
  fi
  local pid; pid="$(server_pid || true)"
  if [ -z "$pid" ]; then ok "Not running."; rm -f "$PID_FILE"; return 0; fi
  warn "Force-killing pid $pid — in-flight requests are lost."
  kill -KILL "$pid" 2>/dev/null || true
  sleep 1
  rm -f "$PID_FILE"
  ok "Killed."
}

cmd_restart() {
  load_env
  if [ "$(running_owner || true)" = "systemd" ]; then
    say "Restarting the autostart service…"
    systemctl --user restart "$SYSTEMD_UNIT" || { err "systemctl could not restart $SYSTEMD_UNIT."; return 1; }
    ok "Running (pid $(running_pid)) — autostart service"
    return 0
  fi
  cmd_stop || cmd_kill
  cmd_start
}

cmd_status() {
  load_env
  local pid owner
  pid="$(running_pid || true)"
  owner="$(running_owner || true)"
  local host port url
  host="$(setting T1_HOST 127.0.0.1)"; port="$(setting T1_PORT 8000)"
  url="http://${host}:${port}"; [ "$host" = "0.0.0.0" ] && url="http://127.0.0.1:${port}"

  say ""
  say "  ${C_BOLD}HyperNix T1 API${C_OFF}"
  if [ -n "$pid" ]; then
    if [ "$owner" = "systemd" ]; then
      ok "running (pid $pid) — autostart service"
    else
      ok "running (pid $pid)"
    fi
  else
    warn "not running"
    # Why not. "not running" on its own sends people to guess, and the
    # answer is almost always in the last few lines of the log.
    local stale=""
    [ -r "$PID_FILE" ] && stale="$(cat "$PID_FILE" 2>/dev/null || true)"
    if [ -n "$stale" ]; then
      dim "     pid $stale is named in $PID_FILE but is not running: it exited."
    fi
    if port_in_use "$(python_bin)" "$host" "$port"; then
      dim "     ...yet something is listening on ${host}:${port}."
      dim "     Check:  systemctl --user status hypernix-t1"
    fi
    if [ -s "$LOG_FILE" ]; then
      dim "     last lines of $LOG_FILE:"
      tail -n 5 "$LOG_FILE" 2>/dev/null | sed -e 's/^/       /' >&2 || true
    fi
  fi
  dim "     config    $ENV_FILE"
  dim "     keys      $(setting T1_KEYMASTER_DIR "$CONFIG_DIR/keymaster")"
  dim "     log       $LOG_FILE"
  dim "     address   $url"
  # The identity a phone pins. Printed here so the comparison has two
  # sides: HyperLink shows the fingerprint it pinned, and this is where
  # someone standing at the machine reads the real one to check it
  # against. Derived from a local seed file, so it works whether or not
  # the server is running.
  local ident
  ident="$(T1_CONFIG_DIR="${T1_CONFIG_DIR:-$CONFIG_DIR}" "$(python_bin)" -c '
from hypernix.hyperlink.identity import fingerprint
value = fingerprint()
print(" ".join(value[i:i + 8] for i in range(0, len(value), 8)))
' 2>/dev/null || true)"
  [ -n "$ident" ] && dim "     identity  $ident"
  if [ -n "$pid" ]; then
    local body
    body="$(curl -fsS --noproxy '*' "$url/status" 2>/dev/null || true)"
    if [ -n "$body" ]; then
      printf '%s' "$body" | "$(python_bin)" -c '
import json, sys
try:
    d = json.load(sys.stdin)
except Exception:
    raise SystemExit
print("     version   t1 v%s" % d.get("t1_api_version", "?"))
print("     name      %s" % (d.get("server_name") or "unnamed"))
print("     env       %s" % d.get("environment", "?"))
' 2>/dev/null || true
    else
      warn "running, but /status did not answer — see the log"
    fi
  fi
  say ""
}

cmd_logs() {
  [ -r "$LOG_FILE" ] || die "No log yet at $LOG_FILE"
  if [ "${1:-}" = "-f" ] || [ "${1:-}" = "--follow" ]; then
    tail -f "$LOG_FILE"
  else
    tail -n "${1:-60}" "$LOG_FILE"
  fi
}

cmd_create() {
  # Hand off to the installer when it is here: it asks about deployment
  # kind, key policy, allowlist and pricing, and writes a configuration
  # that matches the answers. Nothing below reproduces that.
  local here=""
  here="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)" || here=""
  local candidate
  for candidate in "$here/install-t1.sh" "$(dirname "$0")/install-t1.sh" ./install-t1.sh; do
    if [ -x "$candidate" ]; then exec "$candidate" "$@"; fi
  done

  # Installed from a wheel, so there is no checkout and no installer. A
  # dead end here would mean `pip install hypernix` gives you a manager
  # that cannot create the thing it manages, so write a minimal working
  # configuration instead and say plainly what it does not cover.
  create_minimal "$@"
}

create_minimal() {
  require_installed
  local host="127.0.0.1" port="8000" force=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --host) shift; [ $# -gt 0 ] || die "--host needs an address"; host="$1" ;;
      --port) shift; [ $# -gt 0 ] || die "--port needs a number"; port="$1" ;;
      --force) force=1 ;;
      --non-interactive|--yes|-y) ;;   # accepted, since `create` forwards them
      *) die "Unknown option for create: $1" ;;
    esac
    shift
  done

  case "$port" in
    ''|*[!0-9]*) die "--port must be a number; got '$port'" ;;
  esac

  if [ -f "$ENV_FILE" ] && [ "$force" = "0" ]; then
    die "$ENV_FILE already exists. Edit it with \`hypernix-t1 configure\`, or pass --force."
  fi

  local py; py="$(python_bin)"
  local secret
  secret="$("$py" -c 'import secrets; print(secrets.token_hex(32))')" \
    || die "Could not generate a token secret."

  mkdir -p "$CONFIG_DIR"
  # Written before the file exists, so the secret is never briefly
  # world-readable on a machine with a permissive umask.
  ( umask 077; : > "$ENV_FILE" )
  cat > "$ENV_FILE" <<EOF
# Generated by hypernix-t1 $VERSION. A minimal, local-only server.
# For the guided setup — deployment kind, key policy, connection
# allowlist, pricing, models — run install-t1.sh from a checkout.
T1_HOST=$host
T1_PORT=$port
T1_TOKEN_SECRET=$secret
T1_KEYMASTER_DIR=$CONFIG_DIR/keymaster
T1_ENVIRONMENT=development
EOF
  chmod 600 "$ENV_FILE"
  mkdir -p "$CONFIG_DIR/keymaster"
  chmod 700 "$CONFIG_DIR/keymaster"

  ok "Wrote $ENV_FILE (0600)"
  ok "Key store: $CONFIG_DIR/keymaster"
  say ""
  warn "This is the minimal configuration, not the guided one."
  dim "     No connection allowlist, no rate limits, no pricing, no model"
  dim "     registry — T1_ENVIRONMENT=development, bound to $host."
  dim "     Before exposing this to anything, run install-t1.sh from a"
  dim "     checkout, or edit the file: hypernix-t1 configure"
  say ""
  say "Next:"
  dim "     hypernix-t1 start"
  dim "     hypernix-t1 status      # the bootstrap admin key is printed at"
  dim "                             # first start, and works for three days"
}

cmd_configure() {
  local editor; editor="${EDITOR:-${VISUAL:-}}"
  [ -f "$ENV_FILE" ] || die "No config at $ENV_FILE — run \`hypernix-t1 create\` first."
  if [ -n "$editor" ]; then
    "$editor" "$ENV_FILE"
  else
    say "No \$EDITOR set. The config is at:"
    say "  $ENV_FILE"
    return 0
  fi
  say ""
  dim "  Restart for changes to take effect:  hypernix-t1 restart"
}

cmd_test() {
  load_env; require_installed
  local host port url py
  host="$(setting T1_HOST 127.0.0.1)"; port="$(setting T1_PORT 8000)"
  url="http://${host}:${port}"; [ "$host" = "0.0.0.0" ] && url="http://127.0.0.1:${port}"
  py="$(python_bin)"

  server_pid >/dev/null 2>&1 || die "Not running. Start it first: hypernix-t1 start"

  say "Checking $url …"
  if curl -fsS --noproxy '*' "$url/health" >/dev/null 2>&1; then
    ok "health"
  else
    err "health check failed"; return 1
  fi
  if curl -fsS --noproxy '*' "$url/status" >/dev/null 2>&1; then
    ok "status"
  else
    err "status failed"; return 1
  fi

  # The end-to-end probe if this is a checkout that has it.
  local root=""
  root="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)" || root=""
  local probe="$root/scripts/ci/integration_probe.py"
  if [ -r "$probe" ]; then
    say ""
    "$py" "$probe" --url "$url"
  else
    dim "     (mint a key and try it: gkey create -v v2)"
  fi
}

cmd_key() {
  load_env; require_installed
  local py; py="$(python_bin)"
  exec "$py" -m hypernix.security.gkey_cli "$@"
}

cmd_launch_script() {
  # No require_installed: launching a job needs neither the server nor
  # the [t1api] extra, and the whole point is that it works over a bare
  # SSH connection to a machine where things may be half set up.
  load_env
  local py; py="$(python_bin)"
  T1_CONFIG_DIR="${T1_CONFIG_DIR:-$CONFIG_DIR}" \
    exec "$py" -m hypernix.t1api.launchscript_cli "$@"
}

cmd_training() {
  # No require_installed: the monitor reads JSON files and signals
  # processes this user already owns. It needs neither the server to be
  # running nor the [t1api] extra -- and the moment you most want it is
  # the moment the server is the thing in trouble.
  load_env
  local py; py="$(python_bin)"
  T1_CONFIG_DIR="${T1_CONFIG_DIR:-$CONFIG_DIR}" \
    exec "$py" -m hypernix.t1api.training_cli "$@"
}

cmd_runner() {
  # Talks to the running server over HTTP rather than loading a model
  # here. The runner is a process the *server* owns — starting a second
  # llama.cpp from this script would take the VRAM the server's own
  # copy is using and the failure would land on the one that was
  # working.
  require_installed
  load_env
  local py; py="$(python_bin)"
  T1_CONFIG_DIR="${T1_CONFIG_DIR:-$CONFIG_DIR}" \
    exec "$py" -m hypernix.t1api.runner_cli "$@"
}

cmd_index() {
  # No require_installed: indexing reads GGUF files and writes JSON, and
  # needs neither a configured server nor the [t1api] extra. Someone who
  # has just dropped models in a folder and has not run `create` yet
  # should still be able to build a registry.
  load_env
  local py; py="$(python_bin)"
  # T1_CONFIG_DIR so the default output lands beside this server's
  # config rather than in whatever directory the shell happens to be in.
  T1_CONFIG_DIR="${T1_CONFIG_DIR:-$CONFIG_DIR}" \
    exec "$py" -m hypernix.t1api.modelindex_cli "$@"
}

cmd_autostart() {
  local action="on" write_only=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --write-only) write_only=1 ;;
      on|off|disable|status) action="$1" ;;
      *) die "Unknown argument for autostart: $1 (on | off | status | --write-only)" ;;
    esac
    shift
  done
  local unit_dir="$HOME/.config/systemd/user"
  local unit="$unit_dir/hypernix-t1.service"

  command -v systemctl >/dev/null 2>&1 || die \
    "systemd not found. On a non-systemd machine, add \`hypernix-t1 start\` to your session startup."

  # systemctl being on PATH is not the same as there being a user
  # session to talk to. In a container, over plain ssh without lingering,
  # and on WSL, `systemctl --user` fails with "Failed to connect to bus:
  # No medium found" -- which says nothing about what to do, and is the
  # error this command produced before the check.
  if [ "$write_only" = "0" ] && ! systemctl --user show-environment >/dev/null 2>&1; then
    die "systemd is installed but there is no user session to register with (\`systemctl --user\` cannot reach a bus). This is normal in a container, over plain ssh, and on WSL. Either run \`sudo loginctl enable-linger $(id -un)\` and log in again, add \`hypernix-t1 start\` to your session startup, or pass --write-only to install the unit for a session that does not exist yet."
  fi

  case "$action" in
    off|disable)
      systemctl --user disable --now hypernix-t1.service 2>/dev/null || true
      rm -f "$unit"
      systemctl --user daemon-reload 2>/dev/null || true
      ok "Autostart off."
      return 0 ;;
    status)
      systemctl --user status hypernix-t1.service --no-pager || true
      return 0 ;;
  esac

  # systemd requires an absolute ExecStart and will not resolve "./…" or
  # anything relative to a working directory it does not share. Invoked as
  # `./bin/hypernix-t1`, $0 is relative, and the unit it wrote silently
  # refused to start.
  local self
  self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
  [ -x "$self" ] || self="$(command -v hypernix-t1 || true)"
  [ -n "$self" ] || die "Could not work out an absolute path to this script."

  mkdir -p "$unit_dir"
  # PATH is set explicitly: a user unit gets a minimal one, and tailscale
  # living in /usr/local/bin then becomes invisible — which presents as
  # "Tailscale is broken" when only PATH is.
  cat > "$unit" <<UNIT
[Unit]
Description=HyperNix T1 API
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=T1_CONFIG_DIR=$CONFIG_DIR
ExecStart=$self start-foreground
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
UNIT
  if [ "$write_only" = "1" ]; then
    ok "Unit written to $unit (not enabled)."
    dim "     Enable it from a real session: systemctl --user enable --now hypernix-t1.service"
    return 0
  fi
  systemctl --user daemon-reload
  systemctl --user enable --now hypernix-t1.service
  ok "Autostart on (systemd user service)."
  dim "     It starts at login. For a machine with no login session:"
  dim "       sudo loginctl enable-linger $USER"
}

cmd_start_foreground() {
  # What the unit runs: no PID file, no backgrounding, systemd supervises.
  load_env; require_installed
  local py host port
  py="$(python_bin)"
  host="$(setting T1_HOST 127.0.0.1)"; port="$(setting T1_PORT 8000)"
  exec "$py" -m uvicorn hypernix.t1api.app:create_app --factory \
    --host "$host" --port "$port"
}

cmd_remove() {
  say "This removes the service and its configuration."
  dim "  config : $CONFIG_DIR"
  printf '%s' "  Type the word 'remove' to confirm: "
  local answer=""; IFS= read -r answer || answer=""
  [ "$answer" = "remove" ] || { say "Left alone."; return 0; }
  cmd_stop >/dev/null 2>&1 || cmd_kill >/dev/null 2>&1 || true
  cmd_autostart off >/dev/null 2>&1 || true
  # The keys are the one thing worth keeping: they are not recoverable
  # and may still be in use elsewhere.
  local keep="$CONFIG_DIR/keymaster"
  if [ -d "$keep" ]; then
    warn "Keeping the key store at $keep — delete it yourself if you mean to."
  fi
  find "$CONFIG_DIR" -mindepth 1 -maxdepth 1 ! -name keymaster -exec rm -rf {} + 2>/dev/null || true
  ok "Removed."
}

usage() {
  cat <<USAGE
hypernix-t1 — manage a T1 API server

  start                 Start it in the background
  stop                  Ask it to stop, and wait
  kill                  Force it to stop, losing in-flight requests
  restart               Stop (or kill) then start
  status                Is it running, where, and what version
  logs [N|-f]           Last N lines, or follow

  create [--host H] [--port N] [--force]
                        Set up a new server. Runs install-t1.sh when it is
                        available (the guided setup); otherwise writes a
                        minimal local-only configuration.
  index [--dir D] [-o FILE] [--refresh] [--dry-run]
                        Read every .gguf under D (default ./hypernix/models)
                        and write the model registry from what the files
                        say. Entries you have already edited are left
                        alone unless --refresh.
  launch-script PATH [-k KEY] [--name N] [--detach] [--status|--logs|--stop N]
                        Run a script so it survives an SSH disconnect. The
                        job is supervised, its logs and exit status are
                        kept, and --status works from any later session.
  training [--active] [--show R] [--logs R] [--pause R] [--resume R]
           [--stop R] [--resources]
                        What training is doing on this machine, and the
                        controls. Runs launched with launch-script appear
                        here on their own.
  configure             Edit the configuration in \$EDITOR
  test                  Health, status, and a real end-to-end probe
  key ...               Run gkey against this server's key store
  autostart [on|off|status] [--write-only]
                        Start at login via a systemd user service
  remove                Stop, disable, and delete the config (keeps keys)

Config: $CONFIG_DIR
USAGE
}

main() {
  local cmd="${1:-status}"
  if [ $# -gt 0 ]; then shift; fi
  case "$cmd" in
    start)             cmd_start "$@" ;;
    start-foreground)  cmd_start_foreground "$@" ;;
    stop)              cmd_stop "$@" ;;
    kill|force-stop)   cmd_kill "$@" ;;
    restart)           cmd_restart "$@" ;;
    status)            cmd_status "$@" ;;
    logs)              cmd_logs "$@" ;;
    create)            cmd_create "$@" ;;
    configure|config)  cmd_configure "$@" ;;
    test)              cmd_test "$@" ;;
    key|keys)          cmd_key "$@" ;;
    index)             cmd_index "$@" ;;
    runner)            cmd_runner "$@" ;;
    launch-script)     cmd_launch_script "$@" ;;
    training)          cmd_training "$@" ;;
    autostart)         cmd_autostart "$@" ;;
    remove|uninstall)  cmd_remove "$@" ;;
    version|--version) report_versions ;;
    help|-h|--help)    usage ;;
    *) err "Unknown command: $cmd"; usage >&2; exit 2 ;;
  esac
}

main "$@"
