#!/bin/bash

# omarchy:summary=Install OpenClaw for the default agent as the self-updating copy under ~/.openclaw
# omarchy:args=<--check|--now>
# omarchy:examples=omarchy install openclaw cli --now | omarchy install openclaw cli --check
# omarchy:requires-sudo=true

# There is one OpenClaw on a machine, and it is the one under ~/.openclaw that
# upstream's install-cli.sh makes: a private Node and the package in a prefix
# the user owns, which `openclaw update` and the Control UI's Update button
# replace in place. A copy in /usr belongs to pacman, and upstream's updater
# refuses to touch it. The openclaw package is therefore the seed rather than
# the runtime: the release Omarchy ships and the installer that came with it.
# The CLI, the gateway service and the Install > AI web app all run this copy.
#
# Each mode is named outright, like omarchy-install-hermes-cli: the default
# agent asks --check first and opens a terminal for --now only on a no.

set -euo pipefail

mode=${1:-}

# Everything here runs the user's own files, --check included.
if (( EUID == 0 )); then
  echo "Run this command as your desktop user, without sudo." >&2
  exit 1
fi

# Omarchy's OpenClaw is the default one, in ~/.openclaw with the default unit,
# whatever another profile, unit or state directory a shell selects; a service
# installed from here would otherwise carry that selection.
unset OPENCLAW_PROFILE OPENCLAW_SYSTEMD_UNIT OPENCLAW_WRAPPER OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH

prefix="$HOME/.openclaw"
runtime_command="$prefix/bin/openclaw"
command_path="$HOME/.local/bin/openclaw"
seed=/usr/share/openclaw
# The services a run stopped, kept until a run completes: one that fails after
# the stop leaves them stopped, and the next would take them for stopped by the
# user.
stopped_dir="$HOME/.local/state/omarchy/openclaw-stopped"

# Usable means the command answers, not that the file is there: upstream's
# wrapper execs the prefix's own Node, so a prefix missing either fails here.
runtime_runs() {
  [[ -f $runtime_command && -x $runtime_command ]] &&
    timeout 30 "$runtime_command" --version >/dev/null 2>&1
}

# The name on PATH is a link to the runtime's command. A link already aimed
# there, even one left dangling by a removed runtime, is Omarchy's to rewrite;
# anything else at the path is the user's.
command_ours() {
  [[ ! -e $command_path && ! -L $command_path ]] || [[ $(readlink -- "$command_path") == "$runtime_command" ]]
}

# What omarchy-agent, the web app and a terminal run is whichever openclaw their
# PATH finds first, and the order differs between them: the desktop puts mise's
# shims first, a terminal adds mise's tools ahead of those, and omarchy update
# runs migrations on a fixed system PATH without /usr/local/bin, the shims or
# ~/.local/bin. So the runtime has to be the only openclaw in any of those
# places, wherever each sits. mise keeps its shims wherever MISE_SHIMS_DIR,
# MISE_DATA_DIR or XDG_DATA_HOME says, and the update passes them through from
# the session.
session_openclaws() {
  local shims=${MISE_SHIMS_DIR:-${MISE_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/mise}/shims}
  PATH="$shims:$HOME/.local/share/mise/shims:/usr/local/bin:$PATH:$HOME/.local/bin" type -aP openclaw
}

# Any openclaw a session might run other than the expected one, the runtime's
# unless another is named.
foreign_openclaw() {
  local expected=${1:-$runtime_command} found
  while IFS= read -r found; do
    if [[ $(realpath -m -- "$found") != "$(realpath -m -- "$expected")" ]]; then
      echo "$found"
      return 0
    fi
  done < <(session_openclaws)
  return 1
}

on_path() {
  session_openclaws >/dev/null && ! foreign_openclaw >/dev/null
}

# Nothing yet, or only the runtime's command.
path_clear() {
  ! foreign_openclaw >/dev/null
}

# Until the channel serves the seed, the openclaw package is still OpenClaw
# itself, in /usr, as it was before this release. Until then that is the
# OpenClaw a machine has: --check takes it as installed, and --now installs it
# and stops there.
package_is_openclaw() {
  [[ ! -r $seed/install-cli.sh || ! -r $seed/openclaw.tgz ]]
}

# A machine that already moved has its OpenClaw in ~/.openclaw, and the package
# being OpenClaw again only puts an older copy ahead of it on PATH. That is not
# an installation to accept; the update that brings the seed back puts it right.
moved_before() {
  [[ -e $runtime_command ]] || { [[ -L $command_path ]] && command_ours; }
}

# While the package is OpenClaw itself its command is the one OpenClaw, and
# anything a session would run ahead of it is refused as it is for the runtime.
package_command=/usr/bin/openclaw

package_shadowed() {
  local found
  found=$(foreign_openclaw "$package_command") || return 1
  echo "Another openclaw is on PATH at $found, which Armbian could run instead of $package_command." >&2
  echo "Remove it, then run omarchy-install-openclaw-cli --now again." >&2
}

package_in_the_way() {
  echo "OpenClaw is set up in $prefix, but the openclaw package is OpenClaw itself again and comes first on PATH." >&2
  echo "The update that brings the openclaw package back as the seed puts this right." >&2
}

# The package counts too: without it --now has a pacman step to take, and
# answering yes here would run that where no terminal can ask for a password.
installed() {
  omarchy-pkg-present openclaw && runtime_runs && on_path
}

# A unit's ExecStart, as the program and the script it is handed: the first
# argument that is not an option. Later arguments and other lines can name
# ~/.openclaw whichever OpenClaw the unit starts.
unit_program() {
  local words
  read -ra words <<<"$(sed -n 's/^ExecStart=//p' "$1" | head -1)"
  echo "${words[0]:-}"
}

unit_script() {
  local words word
  read -ra words <<<"$(sed -n 's/^ExecStart=//p' "$1" | head -1)"
  for word in "${words[@]:1}"; do
    if [[ $word != -* ]]; then
      echo "$word"
      return
    fi
  done
}

# The runtime's own OpenClaw, as upstream's installer lays it out.
runs_runtime() {
  [[ $(unit_script "$1") == "$prefix"/tools/*/lib/node_modules/openclaw/* ]]
}

# A gateway service already running some other OpenClaw. Upstream's installer
# rewrites a loaded gateway service to the copy it has just installed, so
# seeding beside one would take it over.
foreign_gateway() {
  local unit
  unit=$(unit_path gateway)
  [[ -f $unit ]] && ! runs_runtime "$unit" && ! on_old_package "$unit"
}

# A service the openclaw package's own copy installed runs from
# /usr/lib/node_modules/openclaw, which that package no longer ships, and is
# moved to the runtime, where `openclaw update` can replace its code. A service
# running any other OpenClaw is the user's arrangement and stays. The --now
# steps below list these, and which were running, before anything changes.
unit_path() {
  echo "$HOME/.config/systemd/user/openclaw-$1.service"
}

# Stopped before anything is installed: still running the old code from deleted
# files, such a service holds the state directory the newer runtime has to
# migrate, which upstream's installer does the moment it finds one loaded.
stop_legacy_services() {
  local role
  for role in "${moving[@]}"; do
    if [[ " ${running[*]} " == *" $role "* ]]; then
      mkdir -p "$stopped_dir"
      touch "$stopped_dir/$role"
    fi
    if ! systemctl --user stop "openclaw-$role.service"; then
      echo "Could not stop the OpenClaw $role service, so OpenClaw was not moved to $prefix." >&2
      return 1
    fi
    if [[ " ${running[*]} " == *" $role "* ]]; then
      stopped+=("$role")
    fi
  done
}

# Still the old package's: its script, or the runtime's script on the old
# package's Node. Upstream's installer keeps the Node a service already ran, so
# a moved unit can end up running the runtime's OpenClaw on /usr/bin/node, from
# the nodejs package the seed no longer depends on. Any other Node, Bun or
# script is the user's.
on_old_package() {
  [[ $(unit_script "$1") == /usr/lib/node_modules/openclaw/* ]] ||
    { [[ $(unit_program "$1") == "/usr/bin/node" ]] && runs_runtime "$1"; }
}

# Not moved yet: still on the old package, or running before and not now.
unsettled() {
  on_old_package "$(unit_path "$1")" ||
    { [[ " ${running[*]} " == *" $1 "* ]] && ! systemctl --user is-active --quiet "openclaw-$1.service"; }
}

# The local gateway's own answer, on the port its service is started with. health
# exits 0 while a gateway is still starting too, and a caller's OPENCLAW_GATEWAY_*
# could point it at another one.
gateway_answers() {
  local out port
  port=$(systemctl --user show -P ExecStart openclaw-gateway.service 2>/dev/null |
    sed -n 's/.*argv\[\]=[^;]* --port[= ]\([0-9][0-9]*\).*/\1/p' | head -1)
  out=$(env -u OPENCLAW_GATEWAY_URL -u OPENCLAW_GATEWAY_PORT -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD \
    ${port:+OPENCLAW_GATEWAY_PORT="$port"} timeout -k 5 10 "$runtime_command" health --json --timeout 3000 2>/dev/null) &&
    jq -e '.ok == true' <<<"$out" >/dev/null 2>&1
}

# Waits out systemd's restarts, for two minutes at most; a gateway that failed
# or stopped is not coming back on its own.
gateway_serves() {
  local deadline=$((SECONDS + 120)) state
  while ((SECONDS < deadline)); do
    gateway_answers && return 0
    state=$(systemctl --user show -P ActiveState openclaw-gateway.service 2>/dev/null) || state=failed
    [[ $state == active || $state == activating || $state == reloading ]] || return 1
    sleep 1
  done
  return 1
}

# A started gateway can still exit seconds later: a newer runtime refuses the
# agent database an older OpenClaw left until doctor migrates it. So one that
# is meant to be running has to answer, after one doctor run if need be.
settle_gateway() {
  gateway_serves && return 0
  echo "Repairing OpenClaw's state for $prefix..."
  if ! systemctl --user stop openclaw-gateway.service; then
    echo "Could not stop the OpenClaw gateway service to repair it. Stop it, then run omarchy-install-openclaw-cli --now again." >&2
    return 1
  fi
  "$runtime_command" doctor --fix --non-interactive </dev/null || true
  systemctl --user start openclaw-gateway.service || true
  gateway_serves && return 0
  echo "The OpenClaw gateway does not answer after moving to $prefix. See: journalctl --user -u openclaw-gateway.service" >&2
  return 1
}

# Upstream's installer rewrites a loaded gateway itself, but only warns when
# the restart fails, so whatever is still unsettled is installed again from the
# runtime, on the runtime's own Node, and then has to have settled.
rehome_services() {
  local role node pin=()
  if node=$(realpath -e -- "$prefix/tools/node/bin/node" 2>/dev/null); then
    pin=(--runtime-path "$node")
  fi
  for role in "${moving[@]}"; do
    if unsettled "$role"; then
      echo "Moving the OpenClaw $role service to $prefix..."
      "$runtime_command" "$role" install --force "${pin[@]}" || true
    fi
    # Whether a gateway that was running is up is settle_gateway's to judge,
    # through systemd's restart wait or after an exit doctor can repair.
    if on_old_package "$(unit_path "$role")" || { [[ $role != gateway ]] && unsettled "$role"; }; then
      echo "Could not move the OpenClaw $role service to $prefix. Finish with: openclaw $role install --force" >&2
      return 1
    fi
    # Installing a service enables and starts it; one the user had left
    # stopped or disabled goes back to that.
    if [[ " ${running[*]} " != *" $role "* ]]; then
      systemctl --user stop "openclaw-$role.service" || true
    fi
    if [[ " ${enabled[*]} " != *" $role "* ]]; then
      systemctl --user disable "openclaw-$role.service" 2>/dev/null || true
    fi
    if [[ $role == gateway && " ${running[*]} " == *" gateway "* ]]; then
      settle_gateway || return 1
    fi
    if systemctl --user is-active --quiet "openclaw-$role.service"; then
      rm -f "$stopped_dir/$role"
    fi
  done
}

# Said on any failure that leaves a service this run stopped not running.
stopped_note() {
  local role
  for role in "${stopped[@]}"; do
    if ! systemctl --user is-active --quiet "openclaw-$role.service"; then
      echo "The OpenClaw $role service was stopped for this and is not running now; it starts again once this completes." >&2
    fi
  done
}

case "$mode" in
--check)
  if omarchy-pkg-present openclaw && package_is_openclaw; then
    if moved_before || package_shadowed 2>/dev/null; then exit 1; else exit 0; fi
  fi
  if installed; then exit 0; else exit 1; fi
  ;;
--now)
  if omarchy-pkg-present openclaw && package_is_openclaw; then
    if moved_before; then
      package_in_the_way
      exit 1
    fi
    if package_shadowed; then exit 1; fi
    exit 0
  fi
  ;;
*)
  echo "Usage: omarchy-install-openclaw-cli <--check|--now>" >&2
  exit 1
  ;;
esac

# Refused before anything is installed or touched: the command's name, what
# PATH finds, and a gateway running another OpenClaw all have to be clear. Each
# is an OpenClaw of the user's own, and these refusals exit with a status no
# tool run here uses, so the migration can tell them from a failed setup.
users_own=79
if ! command_ours; then
  echo "$command_path is not the OpenClaw Armbian set up. Move it aside, then run omarchy-install-openclaw-cli --now again." >&2
  exit "$users_own"
fi
if ! path_clear; then
  echo "Another openclaw is on PATH at $(foreign_openclaw), which Armbian could run instead of $runtime_command." >&2
  echo "Remove it, then run omarchy-install-openclaw-cli --now again." >&2
  exit "$users_own"
fi

# A runtime that already answers is never reinstalled: it may be past the
# packaged release by its own updates, and seeding would take it back.
seeding=false
if ! runtime_runs; then
  seeding=true
fi
if [[ $seeding == "true" ]] && foreign_gateway; then
  echo "The OpenClaw gateway service runs another OpenClaw, which setting one up in $prefix would take over." >&2
  echo "Remove that gateway with 'openclaw gateway uninstall', then run omarchy-install-openclaw-cli --now again." >&2
  exit "$users_own"
fi

if ! omarchy-pkg-present openclaw; then
  echo "Installing OpenClaw..."
  omarchy-pkg-add openclaw
fi
if package_is_openclaw; then
  if moved_before; then
    package_in_the_way
    exit 1
  fi
  if package_shadowed; then exit 1; fi
  echo "OpenClaw runs from its package until an update brings the release that sets up the self-updating copy."
  exit 0
fi

# What the old package installed, and what of it was running and enabled, read
# before anything changes.
moving=()
running=()
enabled=()
stopped=()
for role in gateway node; do
  if [[ -f $(unit_path "$role") ]] && on_old_package "$(unit_path "$role")"; then
    moving+=("$role")
    if systemctl --user is-active --quiet "openclaw-$role.service"; then
      running+=("$role")
    fi
    if systemctl --user is-enabled --quiet "openclaw-$role.service"; then
      enabled+=("$role")
    fi
  fi
done

trap 'status=$?; (( status == 0 )) || stopped_note' EXIT
stop_legacy_services

if [[ $seeding == "true" ]]; then
  # Every choice the installer reads from the environment is named, so an
  # OPENCLAW_INSTALL_METHOD or OPENCLAW_PREFIX left in a shell cannot move it.
  echo "Setting up OpenClaw in $prefix..."
  bash "$seed/install-cli.sh" --install-method npm --prefix "$prefix" --version "$seed/openclaw.tgz" --no-onboard || true
  if ! runtime_runs; then
    echo "OpenClaw setup did not complete. Re-run this command after resolving the installer error." >&2
    exit 1
  fi
fi

mkdir -p "${command_path%/*}"
ln -sfn "$runtime_command" "$command_path"

rehome_services

# A service a failed run stopped was running before it, so it runs again now,
# whether this run moved it or an earlier one already had.
# One whose file is in none of the directories the running user manager loads
# units from was removed since; its cached state could still call it loaded, and
# this caller's environment can name other directories. Without the list it is tried.
# The list comes from D-Bus as JSON because systemctl show quotes a path with spaces.
unit_removed() {
  local paths dir
  local -a dirs
  paths=$(busctl --user --json=short get-property org.freedesktop.systemd1 /org/freedesktop/systemd1 \
    org.freedesktop.systemd1.Manager UnitPath 2>/dev/null | jq -r '.data[]' 2>/dev/null) && [[ -n $paths ]] || return 1
  mapfile -t dirs <<<"$paths"
  for dir in "${dirs[@]}"; do
    [[ ! -e $dir/openclaw-$1.service && ! -L $dir/openclaw-$1.service ]] || return 1
  done
}

# Whether the command the user manager runs for a service, drop-ins included and
# whichever file it was loaded from, is the runtime's.
manager_runs_runtime() {
  local argv
  argv=$(systemctl --user show -P ExecStart "openclaw-$1.service" 2>/dev/null | sed -n 's/.*argv\[\]=\([^;]*\) ;.*/\1/p' | head -1)
  [[ -n $argv ]] && runs_runtime <(printf 'ExecStart=%s\n' "$argv")
}

for role in gateway node; do
  if [[ -e $stopped_dir/$role ]]; then
    if ! unit_removed "$role"; then
      if ! systemctl --user is-active --quiet "openclaw-$role.service" && ! systemctl --user start "openclaw-$role.service"; then
        echo "Could not start the OpenClaw $role service again. Start it with: systemctl --user start openclaw-$role.service" >&2
        exit 1
      fi
      if [[ $role == gateway ]] && manager_runs_runtime gateway && ! settle_gateway; then
        exit 1
      fi
    fi
    rm -f "$stopped_dir/$role"
  fi
done

if ! on_path; then
  echo "$runtime_command is ready, but another openclaw is on PATH at $(foreign_openclaw), which Armbian could run instead." >&2
  echo "Remove it, then run omarchy-install-openclaw-cli --now again." >&2
  exit 1
fi
