#!/bin/bash

# omarchy:summary=Install Hermes Desktop for the default agent; its runtime provides the hermes command
# omarchy:args=<--check|--now|--retire-mise>
# omarchy:examples=omarchy install hermes cli --now | omarchy install hermes cli --check
# omarchy:requires-sudo=true

# There is one Hermes on a machine, and it is the desktop app's. hermes-desktop
# ships upstream's installer and the release it was built from, and that
# installer makes the only Hermes that can update itself: a checkout under
# ~/.hermes with its own venv, which `hermes update` fast-forwards. The mise
# build this replaced had no checkout, so nothing an update could move.
# Choosing Hermes as the default agent therefore installs the app, the way
# Install > AI does, short of opening its window; see omarchy-install-ai-hermes.
# Hermes is only ever installed through the app: --check asks whether the
# app's Hermes is in and its command is what omarchy-agent would run, and
# --now installs the app whatever else answered to hermes before, saving
# whatever held the command's name aside before upstream's installer takes it.
#
# The wrapper the retired installer wrote, and the mise environment it built,
# are known here and nowhere else: --retire-mise is what the migration runs,
# and --now runs the same removal once it has replaced the wrapper.
#
# Each mode is named outright. The app's launcher used to call this with no
# arguments to reconcile a mise copy, and a default of --now would turn every
# launch of the app into an install.

set -euo pipefail

mode=${1:-}

# Keep the runtime at the root even when invoked from a Hermes profile.
HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
home_parent=$(dirname -- "$HERMES_HOME")
if [[ ${home_parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then
  HERMES_HOME=$(dirname -- "$home_parent")
fi
export HERMES_HOME

runtime="$HERMES_HOME/hermes-agent"
native_app="$runtime/apps/desktop/release/linux-unpacked"
command_path="$HOME/.local/bin/hermes"

# The wrapper the retired mise-backed installer wrote, matched whole so a
# wrapper that merely mentions it is not mistaken for it, and the tool it
# built. Running the wrapper built Hermes through mise, so it is never run to
# find out whether Hermes is there.
legacy_marker='# Written by omarchy-install-hermes-cli.'
legacy_tool='pipx:hermes-agent[extras=all]'

legacy_stub() {
  [[ -f $command_path ]] && grep -qxF "$legacy_marker" "$command_path"
}

# Only the wrapper Omarchy wrote proves the environment is Omarchy's to remove:
# the regular file at its path, or the copy a run of this installer saved aside
# before upstream's installer took the name, since a user who chose Hermes
# before the migration ran has it there and the environment still requested.
# A link is someone else's arrangement, even when it lands on the wrapper, and
# so is a linked backup directory: the installer makes real ones.
saved_wrapper() {
  [[ ! -L ${1%/hermes} && -f $1 && ! -L $1 ]] && grep -qxF "$legacy_marker" "$1"
}

legacy_owned() {
  local saved
  if legacy_stub && [[ ! -L $command_path ]]; then
    return 0
  fi
  for saved in "$HOME/.local/bin"/.hermes-before-desktop.*/hermes; do
    if saved_wrapper "$saved"; then
      return 0
    fi
  done
  return 1
}

# Gone means neither installed nor still asked for in the global config, where
# `mise up` would build it again. `mise rm -g` exits 0 whether or not it removed
# anything, so the listing is read instead, and a listing that cannot be read
# -- mise broken, or not there to read it -- is not an answer. The key carries
# the backend and name, with or without the options.
mise_retired() {
  local requested
  requested=$(mise ls -g --json 2>/dev/null) || return 1
  ! mise where "$legacy_tool" >/dev/null 2>&1 && ! grep -qE '"pipx:hermes-agent(\[|")' <<<"$requested"
}

# Removes the environment the retired wrapper built, judged by what is left
# rather than by what the commands claimed. Only for a caller that has proved
# it Omarchy's.
retire_mise() {
  if mise_retired; then
    return 0
  fi
  mise rm -g "$legacy_tool" >/dev/null 2>&1 || true
  mise uninstall --all "$legacy_tool" >/dev/null 2>&1 || true
  if mise_retired; then
    return 0
  fi
  echo "Could not remove the Hermes that mise built. Finish by hand:" >&2
  if omarchy-cmd-missing mise; then
    echo "  omarchy pkg add mise" >&2
  fi
  echo "  mise rm -g '$legacy_tool'" >&2
  echo "  mise uninstall --all '$legacy_tool'" >&2
  return 1
}

# Once the environment is gone the proof has done its work. Left behind it
# would keep --check answering no for an install that is finished, and claim
# an environment the user builds later as Omarchy's. The copies are Omarchy's
# own wrapper, so removing them takes nothing of the user's; a backup directory
# that held nothing else goes with them.
forget_legacy() {
  local saved
  if legacy_stub && [[ ! -L $command_path ]]; then
    rm -f "$command_path"
  fi
  for saved in "$HOME/.local/bin"/.hermes-before-desktop.*/hermes; do
    if saved_wrapper "$saved"; then
      rm -f "$saved"
      rmdir "${saved%/hermes}" 2>/dev/null || true
    fi
  done
}

# A hermes at that path is usable when it is a command that runs: a regular
# executable whose --version answers. The executable bit alone proves little -- a
# directory passes -x on search permission, and a wrapper whose interpreter or
# target is gone passes it too. The desktop app applies the same probe with the
# same 15 second budget, so what passes here is what it will use.
hermes_runs() {
  [[ -f $command_path && -x $command_path ]] &&
    timeout 15 "$command_path" --version >/dev/null 2>&1
}

# A flag counts only when the help defines it, not whenever it is mentioned:
# closing its short form, followed by its metavar, or padded to a description
# column. Matched against the help text rather than by parsing an actual
# invocation on purpose: a release that ignores unknown arguments would turn
# the probe into a live session.
help_defines_flag() {
  grep -qE -- "$1(]|[[:space:]][[:upper:]]|[[:space:]]{2}|$)" <<<"$2"
}

# Probed for the flags omarchy-agent actually passes -- --query to seed the
# session and --tui to keep it interactive -- rather than a marker standing in
# for them.
hermes_prompt_ready() {
  local help
  hermes_runs &&
    help=$(timeout 15 "$command_path" chat --help 2>/dev/null) &&
    help_defines_flag '--tui' "$help" &&
    help_defines_flag '--query' "$help"
}

# The command upstream's installer writes execs into the runtime. Anything else
# at that path -- an official install elsewhere, a hand-rolled wrapper, even a
# dangling link -- is the user's. Matched as a plain string with its trailing
# slash, because the path carries a dot and a bare prefix would also claim a
# wrapper pointing at ~/.hermes-old.
ours() {
  [[ -f $command_path && ! -L $command_path ]] && grep -qF "$HERMES_HOME/" "$command_path"
}

# What omarchy-agent runs is whichever hermes is first on PATH, and Omarchy puts
# mise's shims ahead of ~/.local/bin. So the command the probe vets has to be
# the one PATH finds, or the agent is on a different Hermes than the one set
# up. A PATH that finds none is not in the way: the theme unit asks from a
# service whose PATH has no ~/.local/bin, and runs the command by its path.
on_path() {
  local found
  found=$(type -P hermes) || return 0
  [[ $(realpath -m -- "$found") == "$(realpath -m -- "$command_path")" ]]
}

shadowing_hermes() {
  type -P hermes || true
}

# The installer writes the marker last, so it is the one thing that says a
# runtime finished installing rather than merely started; the venv appears
# several stages earlier. And a marker left behind by an install whose venv has
# since gone answers for nothing, so the command has to run.
runtime_ready() {
  [[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] &&
    timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1
}

native_app_complete() {
  [[ -f $native_app/Hermes && -x $native_app/Hermes && -f $native_app/resources/app.asar && -f $native_app/resources/install-stamp.json ]]
}

# What --check answers, and what --now has nothing left to do about: the app
# is installed and the Hermes on the machine is its own -- the command
# upstream's installer wrote, the runtime's completion marker and the seeded
# packaged app, because the app only runs against a runtime it prepared and
# the terminal has to be on the same one -- and that command is the one PATH
# finds, with nothing of the retired wrapper's left to remove. The two agree
# on purpose: choosing Hermes from the menu asks first and opens a terminal
# only on a no, so a yes here has to mean --now would take no minutes-long
# step where nobody can watch it.
installed() {
  on_path || return 1
  if legacy_owned; then
    return 1
  fi
  omarchy-pkg-present hermes-desktop && ours && [[ -f $runtime/.hermes-bootstrap-complete ]] && native_app_complete && hermes_prompt_ready
}

# Once a Hermes answers at the command's path, the environment the retired
# wrapper built goes with whatever proof is left of it, and then PATH has to
# agree with the probe: a mise shim, or anything else ahead of ~/.local/bin, is
# what the default agent would run instead.
settle_path() {
  if legacy_owned; then
    echo "Removing the Hermes that mise built; Hermes now updates itself..."
    retire_mise || return 1
    forget_legacy
  fi
  if ! on_path; then
    echo "$command_path is ready, but 'hermes' on PATH is $(shadowing_hermes), which is what the default agent runs." >&2
    echo "Remove or reorder it, then run omarchy-install-hermes-cli --now again." >&2
    return 1
  fi
}

# The updater switches to main before checking for changes, so main has to
# start at the packaged release. Whatever main pointed at first is kept under
# another name rather than judged: upstream rewrites its history often enough
# that a clone's main sits off origin/main with no local commit on it, and a
# user's own commits are exactly what must not be lost either way.
keep_main() {
  local main_commit kept
  main_commit=$(git -C "$runtime" rev-parse --verify refs/heads/main 2>/dev/null || true)
  if [[ -n $main_commit && $main_commit != "$release_commit" && $main_commit != "$(git -C "$runtime" rev-parse --verify refs/remotes/origin/main 2>/dev/null)" ]]; then
    # A run that stopped after this point keeps it again on the next try; one
    # branch at that commit is enough.
    if [[ -z $(git -C "$runtime" for-each-ref --points-at "$main_commit" 'refs/heads/main-before-omarchy-*') ]]; then
      kept="main-before-omarchy-$(date +%s)"
      git -C "$runtime" branch "$kept" "$main_commit"
      echo "Kept what Hermes main pointed at as the branch $kept; main now starts at the packaged release."
    fi
  fi
}

# The repository a process's GIT_DIR names, canonical: relative to its working
# directory when relative, and with no trailing slash or dot segments either
# way. Read NUL-delimited as the kernel writes it, never through a pipe: a
# path may carry a newline, and grep stopping at a match would fail a pipe
# under pipefail and call a busy git idle.
git_dir_of() {
  local pid=$1 entry dir="" cwd
  while IFS= read -r -d '' entry; do
    if [[ $entry == GIT_DIR=* ]]; then
      dir=${entry#GIT_DIR=}
      break
    fi
  done <"/proc/$pid/environ" 2>/dev/null || return 1
  [[ -n $dir ]] || return 1
  if [[ $dir != /* ]]; then
    cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || return 1
    dir="$cwd/$dir"
  fi
  realpath -m -- "$dir"
}

# A git of this user working in the runtime: a fetch Hermes started to work
# out its --version, or its updater. Found by working directory, or by a
# GIT_DIR in its environment, rather than by command line, since the remote
# helpers git spawns carry no repository path on theirs. Compared canonical,
# because /proc resolves every link and the runtime path keeps them.
git_busy_in_runtime() {
  local pid cwd dir real
  real=$(realpath -e -- "$runtime" 2>/dev/null) || return 1
  for pid in $(pgrep -u "$(id -u)" -f '^([^ ]*/)?git(-remote-[a-z]+)?( |$)' 2>/dev/null); do
    cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
    if [[ $cwd == "$real" || $cwd == "$real/"* ]]; then
      return 0
    fi
    if dir=$(git_dir_of "$pid") && [[ $dir == "$real/.git" ]]; then
      return 0
    fi
  done
  return 1
}

# Hermes works out its --version against origin, and the probe's 15 second
# budget can kill it mid-fetch, which leaves git's shallow.lock behind; a fetch
# of ours that a network blip interrupted leaves the same. Git refuses to clear
# a lock it did not take, and a file's age alone cannot say whether anything
# still holds it, so a live git in the runtime is waited for first, and only a
# lock nobody holds and nothing has touched for a minute is cleared. The fetch
# itself gets three tries.
unshallow_runtime() {
  local attempt waited lock="$runtime/.git/shallow.lock"
  for attempt in 1 2 3; do
    waited=0
    while git_busy_in_runtime && (( waited < 60 )); do
      if (( waited == 0 )); then
        echo "Waiting for Hermes to finish working in $runtime..."
      fi
      sleep 2
      waited=$(( waited + 2 ))
    done
    if git_busy_in_runtime; then
      echo "Hermes is still working in $runtime. Let it finish, then run omarchy-install-hermes-cli --now again." >&2
      return 1
    fi
    if [[ -e $lock && -n $(find "$lock" -mmin +1 2>/dev/null) ]]; then
      rm -f "$lock"
    fi
    if git -C "$runtime" fetch --unshallow origin main; then
      return 0
    fi
    if (( attempt < 3 )); then
      sleep 2
    fi
  done
  echo "Could not fetch the Hermes history from origin. Check the network, then run omarchy-install-hermes-cli --now again." >&2
  return 1
}

case "$mode" in
--check)
  if installed; then exit 0; else exit 1; fi
  ;;
--retire-mise)
  # The migration's whole job. The environment goes before the wrapper, since
  # once the wrapper is gone nothing marks the environment as Omarchy's and a
  # rerun could not finish what a failed removal left behind.
  if legacy_owned; then
    retire_mise || exit 1
    forget_legacy
  fi
  exit 0
  ;;
--now) ;;
*)
  echo "Usage: omarchy-install-hermes-cli <--check|--now|--retire-mise>" >&2
  exit 1
  ;;
esac

if (( EUID == 0 )); then
  echo "Run this command as your desktop user, without sudo." >&2
  exit 1
fi

if installed; then
  exit 0
fi

if ! omarchy-pkg-present hermes-desktop; then
  echo "Installing Hermes Desktop..."
  omarchy-pkg-add hermes-desktop
fi

if [[ ! -r /usr/share/hermes-desktop/install.sh || ! -r /usr/share/hermes-desktop/runtime.patch ]] ||
  ! release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json 2>/dev/null); then
  echo "The installed Hermes package cannot prepare in-app updates. Run 'omarchy update', then try again." >&2
  exit 1
fi

runtime_present=false
if runtime_ready; then
  runtime_present=true
fi

# A seeded app has to be whole, whatever state the runtime beside it is in.
refuse_unless_app_whole() {
  if [[ -e $native_app || -L $native_app ]] && ! native_app_complete; then
    echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2
    return 1
  fi
}

# The packaged app can be seeded only beside a runtime at the release it was
# built from, with its desktop sources untouched and the Linux runtime patch
# still able to land. Asked before a command is replaced, so that a refusal
# leaves the user's Hermes exactly as it was, and again before seeding.
# Asked through ||, so nothing in here stops on its own: every git answer is
# checked, and one that cannot be read refuses rather than reading as clean.
refuse_unless_seedable() {
  local runtime_commit changes
  refuse_unless_app_whole || return 1
  if [[ -e $native_app || -L $native_app ]]; then
    return 0
  fi
  if ! runtime_commit=$(git -C "$runtime" rev-parse HEAD 2>/dev/null) || [[ -z $runtime_commit ]]; then
    echo "Could not read the Hermes runtime's git state at $runtime. Repair that installation before trying again; existing files have been kept." >&2
    return 1
  fi
  if [[ $runtime_commit != "$release_commit" ]]; then
    echo "The Hermes runtime has moved beyond the packaged desktop release. Run 'hermes desktop --build-only', then try again." >&2
    return 1
  fi
  if ! changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json 2>/dev/null); then
    echo "Could not read the Hermes runtime's git state at $runtime. Repair that installation before trying again; existing files have been kept." >&2
    return 1
  fi
  if [[ -n $changes ]]; then
    echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
    return 1
  fi
  if ! git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1 &&
    ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
    echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2
    return 1
  fi
}

if [[ $runtime_present == "true" ]]; then
  refuse_unless_seedable || exit 1
elif [[ -e $runtime || -L $runtime ]]; then
  # The upstream installer can reset an existing checkout. Do not pin a newer
  # or modified runtime back to the package release while repairing setup,
  # and a git state that cannot be read is not a clean one.
  refuse_unless_app_whole || exit 1
  if ! runtime_commit=$(git -C "$runtime" rev-parse HEAD 2>/dev/null) || [[ $runtime_commit != "$release_commit" ]] ||
    ! changes=$(git -C "$runtime" status --porcelain --untracked-files=all 2>/dev/null) || [[ -n $changes ]]; then
    echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2
    exit 1
  fi
fi

# Whether this run gave Hermes something new to show the theme to: a runtime
# it set up, or the app it seeded. Putting a command back is neither.
set_up=false

# The command is the runtime's own and runs; anything else at the path gets
# replaced below. The retired wrapper is never run to find out.
command_ready=false
if ! legacy_stub && ours && hermes_runs; then
  command_ready=true
fi

# Upstream replaces these commands, including foreign files and symlinks,
# whether it sets the runtime up or only writes the commands again. Keep their
# original bytes/links before handing the names to the runtime.
if [[ $runtime_present == "false" || $command_ready == "false" ]]; then
  command_backup=""
  for command in hermes hermes-agent hermes-acp; do
    existing="$HOME/.local/bin/$command"
    if [[ -e $existing || -L $existing ]]; then
      if [[ ! -f $existing && ! -L $existing ]]; then
        echo "Cannot replace $existing: move it aside before installing Hermes." >&2
        exit 1
      fi
      if [[ -z $command_backup ]]; then
        command_backup=$(mktemp -d "$HOME/.local/bin/.hermes-before-desktop.XXXXXX")
        echo "Saving existing Hermes commands in $command_backup"
      fi
      cp -a -- "$existing" "$command_backup/"
    fi
  done
fi

if [[ $runtime_present == "false" ]]; then
  echo "Setting up the Hermes runtime..."
  # npx stops to ask before fetching Playwright for the browser tools, and in
  # the terminal the menu opens for this nobody is there to say yes.
  npm_config_yes=true bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$HERMES_HOME"
  if ! runtime_ready; then
    echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2
    exit 1
  fi
  set_up=true
elif [[ $command_ready == "false" ]]; then
  # The runtime is in place but the command is not its own, or does not run:
  # gone, the retired wrapper, one the user put there, or the runtime's own
  # with its mode bits stripped. Upstream's path stage writes its commands
  # without touching the checkout.
  echo "Restoring the Hermes commands..."
  bash /usr/share/hermes-desktop/install.sh --stage path --dir "$runtime" --hermes-home "$HERMES_HOME"
fi

runtime_commit=$(git -C "$runtime" rev-parse HEAD)
if [[ $runtime_commit == "$release_commit" ]]; then
  # Start main at the packaged release, with enough history for its first
  # fast-forward.
  keep_main
  if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then
    unshallow_runtime
  fi
  git -C "$runtime" switch -C main "$release_commit"

  if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
    git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch
  elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
    echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2
    exit 1
  fi
fi

refuse_unless_seedable || exit 1
if [[ ! -e $native_app && ! -L $native_app ]]; then
  mkdir -p -- "${native_app%/*}"
  staging=$(mktemp -d "${native_app%/*}/.linux-unpacked.XXXXXX")
  trap 'rm -rf -- "$staging"' EXIT
  cp -a /opt/hermes-desktop/. "$staging/"
  chmod 0755 "$staging/chrome-sandbox"
  mv -T --no-clobber -- "$staging" "$native_app"
  if [[ -e $staging ]]; then
    echo "A Hermes desktop app appeared during setup. It has been kept; please try again." >&2
    exit 1
  fi
  trap - EXIT

  # Record this matching prebuilt app using the CLI's own content hash, so
  # subsequent menu launches do not rebuild an app that is already current.
  env -u PYTHONPATH -u PYTHONHOME "$runtime/venv/bin/python" - "$runtime" <<'PY'
import sys
from pathlib import Path

sys.path.insert(0, sys.argv[1])
if Path(sys.argv[1], "hermes_cli/main_desktop.py").is_file():
    from hermes_cli.main_desktop import _write_desktop_build_stamp
else:
    from hermes_cli.main import _write_desktop_build_stamp

_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
PY
  set_up=true
fi

# What omarchy-agent runs is the command, not the venv, so the command is what
# has to answer for the seeded sessions.
if ! hermes_prompt_ready; then
  echo "Hermes is installed at $runtime, but $command_path does not run the interactive seeded sessions Omarchy needs." >&2
  echo "Update Hermes with 'hermes update', then run omarchy-install-hermes-cli --now again." >&2
  exit 1
fi

settle_path || exit 1

# Only a running Hermes can be told which skin to show; a unit outlives this
# terminal and reports to the journal.
if [[ $set_up == "true" ]]; then
  echo "Matching Hermes to the current theme once it is set up..."
  systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
  systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait
fi
