#!/usr/bin/env bash
# terractl - helper for terraria@.service
#
# Layout:
#   /var/lib/terraria/dist/                   server zips (read-only source)
#       terraria-server-*.zip                 vanilla dedicated server zips from terraria.org
#       tModLoader*.zip                       tModLoader release zips (GitHub)
#   /var/lib/terraria/instances/<instance>/
#       instance.conf                         TYPE (vanilla|tmodloader), ZIP pin, EXTRA_ARGS
#       serverconfig.txt                      normal Terraria server config (port, world, ...)
#       server/                               unpacked server binaries (replaceable)
#       data/                                 worlds, mods, player data (kept across reinstalls)
#
# Managing other hosts (--host, remote, daemon, copy, move) is done by
# terractl-remote, in Python; see run_remote_helper.
set -euo pipefail

# Work from / so nothing depends on the caller's directory (which the 'terraria'
# user may not be allowed into, e.g. /root). Paths given on the command line are
# resolved against where terractl was called from, via abspath().
CALLER_PWD=$PWD
cd /

abspath() {
  if [[ $1 == /* ]]; then printf '%s\n' "$1"; else printf '%s\n' "$CALLER_PWD/$1"; fi
}

# Local overrides (see the comments in that file)
# shellcheck disable=SC1091
if [[ -r /etc/default/terractl ]]; then . /etc/default/terractl; fi

BASE_DIR="${TERRARIA_BASE:-/var/lib/terraria/instances}"
DIST_DIR="${TERRARIA_DIST:-/var/lib/terraria/dist}"
RUN_DIR="/run/terraria"
SVC_USER="${TERRARIA_USER:-terraria}"
STOP_TIMEOUT="${TERRARIA_STOP_TIMEOUT:-80}"   # keep below TimeoutStopSec
JOURNAL_NS=terraria                           # LogNamespace= in terraria@.service
TERRACTL_VERSION="@VERSION@"                  # filled in by 'make install' / 'make deb'
REMOTE_HELPER="@PKGLIBDIR@/terractl-remote"   # likewise

die() { echo "terractl: $*" >&2; exit 1; }
log() { echo "terractl: $*" >&2; }

# Is <user> (default: whoever runs this) in the terraria group?
in_group() {
  [[ " $(id -nG "${1:-$(id -un)}" 2>/dev/null) " == *" $SVC_USER "* ]]
}

# A terractl command to suggest: hint root|group <command> [args...]. 'root'
# commands need sudo; 'group' ones only for people outside the terraria group
# (start/stop/restart are allowed to the group by the polkit rule). Under sudo,
# it advises the person who ran sudo. When terractld runs a command for another
# host, TERRACTL_VIA says how that host reaches this one (e.g. "--host nas").
hint() {
  local need=$1 who=${SUDO_USER:-$(id -un)}; shift
  if [[ -n ${TERRACTL_VIA:-} ]]; then
    echo "terractl $TERRACTL_VIA $*"
  elif [[ $who == root ]] || { [[ $need == group ]] && in_group "$who"; }; then
    echo "terractl $*"
  else
    echo "sudo terractl $*"
  fi
}

usage() {
  cat <<EOF
Usage: terractl <command> <instance> [args]
       terractl --version

Admin commands:
  new <name> [vanilla|tmodloader] [port]   create an instance (run as root)
  list                                     show instances, type, port, boot, state
  info [<name>]                            paths and zips of this host, or details of an instance
  start|stop|restart <name>                start, stop (saves the world) or restart a server
  enable <name> [--now]                    start at boot (--now: also start it now)
  disable <name> [--now]                   don't start at boot (--now: also stop it now)
  attach <name>                            interactive console (Ctrl-D to detach)
  watch <name> [-n <lines>] [-q]           follow the server's output, read-only (Ctrl-C to quit)
  cmd <name> <console command...>          send a console command (e.g. save, say hi)
  reinstall <name> [--clean]               re-unpack server on next start
                                           (--clean also re-downloads tModLoader's .NET)
  mods <name> [list]                       show installed mods and whether they're enabled
  mods <name> add <file.tmod|folder>...    install mods (and ModConfigs) and enable them
  mods <name> enable|disable <mod>...      change which installed mods load
  mods <name> remove <mod>...              uninstall mods
  delete <name> [-y] [--no-backup]         stop, disable and remove an instance
  rename <name> <new name>                 rename an instance (restarted if it was running)
  export <name> [file.tar.gz] [--with-server]
                                           archive an instance (configs, worlds, mods)
  import <name> <file.tar.gz>              create instance <name> from an archive
                                           (worlds/configs archived to $BASE_DIR/.deleted/)

Other hosts:
  --host <remote> <command> [args]         run a command on another host (e.g. --host nas list)
  copy [<remote>:]<name> [<remote>:][<new name>] [--with-server]
                                           copy an instance, on this host or between hosts
  move [<remote>:]<name> [<remote>:][<new name>] [--with-server]
                                           move (or rename) an instance; stops it while moving
  remote add <name> <token>                add a host to manage (token from 'daemon trust add')
  remote add <name> <address> [<token>]    ... when the token's addresses don't work from here
  remote list | remote remove <name>       show or forget remotes
  daemon enable|disable|status             let other hosts manage this one (terractld.service)
  daemon trust add <client>                create a one-time token for a computer to join
  daemon trust list | trust remove <client>
                                           show or revoke computers that may manage this host

Used by terraria@.service:
  journal-access                           let the terraria group read the servers' journal
  prepare <name>                           create dirs/configs, unpack server if needed
  run <name>                               exec the server
  service-stop <name> <mainpid>            graceful shutdown via console "exit" (service only;
                                           to stop a server use: terractl stop <name>)
EOF
  exit "${1:-1}"
}

valid_name() {
  # Must not start with '.', so '.', '..' and hidden dirs can never be targeted
  [[ ${1:-} =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "invalid instance name '${1:-}'"
}

inst_dir() { echo "$BASE_DIR/$1"; }

load_conf() {
  local dir; dir=$(inst_dir "$1")
  TYPE=vanilla
  ZIP=""
  EXTRA_ARGS=()
  # shellcheck disable=SC1091
  [[ -f $dir/instance.conf ]] && source "$dir/instance.conf"
  case $TYPE in
    vanilla|tmodloader) ;;
    *) die "TYPE in $dir/instance.conf must be 'vanilla' or 'tmodloader' (got '$TYPE')" ;;
  esac
}

write_instance_conf() {
  local name=$1 type=$2 dir; dir=$(inst_dir "$name")
  cat > "$dir/instance.conf" <<EOF
# Settings for terraria@$name
# Changing TYPE or ZIP makes the next start re-unpack server/ (data/ is kept).

TYPE=$type          # vanilla | tmodloader

# Pin a specific zip from $DIST_DIR (filename or absolute path).
# Blank = newest matching zip at install time; it is NOT auto-upgraded later.
ZIP=

# Extra arguments passed to the server binary, as a bash array, e.g.
# EXTRA_ARGS=(-disableannouncementbox)
EXTRA_ARGS=()
EOF
}

next_free_port() {
  local max=7776 p f
  for f in "$BASE_DIR"/*/serverconfig.txt; do
    [[ -f $f ]] || continue
    p=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$f" | tail -n1)
    if [[ -n $p && $p -gt $max ]]; then max=$p; fi
  done
  echo $((max + 1))
}

write_server_config() {
  local name=$1 port=${2:-} dir; dir=$(inst_dir "$name")
  [[ -n $port ]] || port=$(next_free_port)
  cat > "$dir/serverconfig.txt" <<EOF
# Terraria server config for instance '$name'
# Edit freely, then: systemctl restart terraria@$name

world=$dir/data/Worlds/$name.wld
worldpath=$dir/data/Worlds
# Created automatically if the world file does not exist. 1=small 2=medium 3=large
autocreate=2
worldname=$name
# 0=classic 1=expert 2=master 3=journey
difficulty=0
#seed=

port=$port
maxplayers=8
password=
motd=Welcome to $name
secure=1
upnp=0
language=en-US
banlist=$dir/data/banlist.txt
EOF
  log "created $dir/serverconfig.txt (port $port)"
}

pick_zip() {
  local pattern f
  if [[ -n $ZIP ]]; then
    if [[ $ZIP == /* ]]; then echo "$ZIP"; else echo "$DIST_DIR/$ZIP"; fi
    return
  fi
  case $TYPE in
    vanilla)    pattern='terraria-server-*.zip' ;;
    tmodloader) pattern='tModLoader*.zip' ;;
  esac
  f=$(find "$DIST_DIR" -maxdepth 1 -type f -name "$pattern" -printf '%f\n' | sort -V | tail -n1)
  [[ -n $f ]] || die "no '$pattern' found in $DIST_DIR"
  echo "$DIST_DIR/$f"
}

# Global (not local) so the EXIT trap can still see them after functions return
EXTRACT_TMP=""
ATTACH_PID=""
cleanup() {
  if [[ -n $EXTRACT_TMP && -d $EXTRACT_TMP ]]; then rm -rf "$EXTRACT_TMP"; fi
  if [[ -n $ATTACH_PID ]]; then kill "$ATTACH_PID" 2>/dev/null || true; fi
  return 0
}
trap cleanup EXIT

install_server() {
  local name=$1 dir marker zip src have_type="" have_zip=""
  dir=$(inst_dir "$name")
  marker="$dir/server/.installed"

  # Already installed with the right type (and pinned zip, if any)? Nothing to do.
  if [[ -f $marker ]]; then
    read -r have_type have_zip < "$marker" || true
    if [[ $have_type == "$TYPE" ]] && [[ -z $ZIP || $have_zip == "$(basename "$ZIP")" ]]; then
      return 0
    fi
  fi

  command -v unzip >/dev/null || die "unzip is not installed"
  zip=$(pick_zip)
  [[ -r $zip ]] || die "cannot read $zip"
  log "installing $TYPE from $(basename "$zip") into $dir/server"

  EXTRACT_TMP=$(mktemp -d "$dir/.extract.XXXXXX")
  unzip -o -q "$zip" -d "$EXTRACT_TMP"

  case $TYPE in
    vanilla)
      # terraria.org zips contain <version>/{Linux,Windows,Mac}; we only want Linux
      src=$(find "$EXTRACT_TMP" -type f -name TerrariaServer.bin.x86_64 -path '*/Linux/*' -printf '%h\n' -quit)
      [[ -n $src ]] || die "Linux/TerrariaServer.bin.x86_64 not found in $zip"
      chmod +x "$src/TerrariaServer.bin.x86_64"
      [[ -f $src/TerrariaServer ]] && chmod +x "$src/TerrariaServer"
      ;;
    tmodloader)
      src=$(find "$EXTRACT_TMP" -type f -name start-tModLoaderServer.sh -printf '%h\n' -quit)
      [[ -n $src ]] || die "start-tModLoaderServer.sh not found in $zip"
      find "$src" -type f -name '*.sh' -exec chmod +x {} +
      ;;
  esac

  # tModLoader releases don't include .NET; it downloads it into server/dotnet on
  # first start. Carry that (and the logs) over so an upgrade or reinstall doesn't
  # download it again. tModLoader checks the .NET version itself on every start
  # and installs a different one if the new release needs it.
  if [[ $TYPE == tmodloader && -d $dir/server ]]; then
    local keep
    for keep in dotnet tModLoader-Logs; do
      if [[ -d $dir/server/$keep && ! -e $src/$keep ]]; then
        mv "$dir/server/$keep" "$src/$keep"
        log "kept existing server/$keep"
      fi
    done
  fi

  rm -rf "$dir/server"
  mv "$src" "$dir/server"
  echo "$TYPE $(basename "$zip")" > "$dir/server/.installed"
  cleanup
  log "install complete"
}

# One server (or prepare) per instance at a time. The lock is held on file
# descriptor 9. 'run' passes it on to the server process, so it's held for the
# server's whole lifetime and released by the kernel when the server exits,
# however it exits; it can never go stale.
take_lock() {
  local name=$1 dir state
  dir=$(inst_dir "$name")
  exec 9>"$dir/.lock"
  if ! flock -n 9; then
    state=$(systemctl show -p ActiveState --value "terraria@$name" 2>/dev/null || true)
    case $state in
      active|activating|deactivating|reloading)
        die "terraria@$name is $state; stop it first: $(hint group stop "$name")" ;;
      *)
        die "instance '$name' is already running outside systemd (e.g. 'terractl run' in another terminal); stop that first" ;;
    esac
  fi
}

# True while a server (or prepare) holds the instance lock. Only tests the lock:
# never creates or changes the file, so it's safe for root and for members of
# the 'terraria' group. If the lock can't be read, assumes nothing is running.
instance_running() {
  local lock rc=0
  lock="$(inst_dir "$1")/.lock"
  [[ -r $lock ]] || return 1
  flock -n -E 75 "$lock" true 2>/dev/null || rc=$?
  [[ $rc -eq 75 ]]
}

cmd_prepare() {
  local name=$1 dir; dir=$(inst_dir "$name")
  mkdir -p "$dir/data/Worlds"
  take_lock "$name"      # never re-unpack server/ under a running server
  [[ -f $dir/instance.conf ]]    || write_instance_conf "$name" vanilla
  load_conf "$name"
  if [[ $TYPE == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi
  [[ -f $dir/serverconfig.txt ]] || write_server_config "$name"
  install_server "$name"
}

cmd_run() {
  local name=$1 dir; dir=$(inst_dir "$name")
  [[ -f $dir/instance.conf ]] || die "no instance '$name'"
  take_lock "$name"      # held by the server process until it exits
  load_conf "$name"
  [[ -f $dir/server/.installed ]] || die "server not installed; run 'prepare' first"
  # Per-instance HOME so nothing is shared through ~/.local or ~/.dotnet
  export HOME="$dir"
  cd "$dir/server"
  case $TYPE in
    vanilla)
      exec ./TerrariaServer.bin.x86_64 -config "$dir/serverconfig.txt" "${EXTRA_ARGS[@]}"
      ;;
    tmodloader)
      # -nosteam skips the interactive "use steam server?" prompt.
      # -tmlsavedirectory keeps worlds, Mods/ and configs under data/.
      # First start downloads a matching .NET runtime into server/dotnet.
      exec ./start-tModLoaderServer.sh -nosteam \
        -config "$dir/serverconfig.txt" \
        -tmlsavedirectory "$dir/data" \
        "${EXTRA_ARGS[@]}"
      ;;
  esac
}

# ExecStop of terraria@.service
cmd_service_stop() {
  local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i
  # systemd sets INVOCATION_ID for every process it runs for a service
  if [[ -z ${INVOCATION_ID:-} ]]; then
    die "'service-stop' is only called by terraria@.service. To stop the server: $(hint group stop "$name")"
  fi
  if [[ -n $pid && ! $pid =~ ^[0-9]+$ ]]; then die "invalid process ID '$pid'"; fi
  if [[ -z $pid ]] || ! kill -0 "$pid" 2>/dev/null; then exit 0; fi
  if [[ -p $fifo ]]; then
    echo exit > "$fifo"
  else
    log "console FIFO missing; systemd will send SIGTERM"
    exit 0
  fi
  for ((i = 0; i < STOP_TIMEOUT; i++)); do
    kill -0 "$pid" 2>/dev/null || exit 0
    sleep 1
  done
  log "server did not exit within ${STOP_TIMEOUT}s; leaving it to systemd"
}

# start/stop/restart through systemd: root, or the terraria group through the
# polkit rule (50-terractl.rules)
cmd_svc() {
  local action=$1 name=$2 unit="terraria@$2" state; shift 2
  [[ $# -eq 0 ]] || die "usage: terractl $action <name>"
  # Only checkable with read access to the instances (root or the group)
  if [[ -x $BASE_DIR && ! -f $(inst_dir "$name")/instance.conf ]]; then
    die "no instance '$name' (create it with: $(hint root new "$name"))"
  fi
  if [[ $EUID -ne 0 ]] && ! in_group; then
    die "'$action' needs root or the '$SVC_USER' group: sudo terractl $action $name"
  fi
  state=$(systemctl is-active "$unit" 2>/dev/null || true)
  case $action in
    start)
      if [[ $state == active ]]; then echo "$unit is already running"; return 0; fi
      systemctl start "$unit"
      echo "$unit started" ;;
    stop)
      if [[ $state == inactive || $state == failed ]]; then
        if instance_running "$name"; then
          die "'$name' is running outside systemd (e.g. 'terractl run' in another terminal); type 'exit' in its console"
        fi
        echo "$unit isn't running"
        return 0
      fi
      echo "stopping $unit (saves the world first)..."
      systemctl stop "$unit"
      echo "$unit stopped" ;;
    restart)
      echo "restarting $unit (saves the world first)..."
      systemctl restart "$unit"
      echo "$unit restarted" ;;
  esac
}

# The servers log to their own journal (LogNamespace=terraria), which the
# 'terraria' group can read; see cmd_journal_access. It lives in
# <machine-id>.terraria under /var/log/journal (or /run/log/journal).
journal_dirs() {
  local mid base
  mid=$(cat /etc/machine-id 2>/dev/null) || return 0
  [[ -n $mid ]] || return 0
  for base in /var/log/journal /run/log/journal; do
    if [[ -d $base/$mid.$JOURNAL_NS ]]; then printf '%s\n' "$base/$mid.$JOURNAL_NS"; fi
  done
}

# Checks the actual permissions rather than guessing from group names. The
# folder itself is world-readable, so the journal file inside is what counts.
# No folder or file yet means nothing has been logged, so nothing to hide.
can_read_journal() {
  if [[ $EUID -eq 0 ]]; then return 0; fi
  local d
  while IFS= read -r d; do
    if [[ ! -r $d || ! -x $d ]]; then return 1; fi
    if [[ -e $d/system.journal && ! -r $d/system.journal ]]; then return 1; fi
  done < <(journal_dirs)
  return 0
}

journal_hint() {
  {
    echo "$1"
    if [[ " $(id -Gn) " == *" $SVC_USER "* ]]; then
      echo "You're in the '$SVC_USER' group, but the servers' journal isn't set up for it"
      echo "yet. That happens automatically when a server (re)starts, or run once:"
      echo "  sudo terractl journal-access"
    else
      echo "Reading it needs root or the '$SVC_USER' group. If you were just added to it,"
      echo "log out and back in. Or use sudo."
    fi
  } >&2
}

# Run as root before every server start (ExecStartPre=+ in terraria@.service).
# Creates the servers' journal folder so it can be given to the terraria group
# before journald writes to it, then applies the access rules from tmpfiles.d
# (which also repair files journald created earlier). Only creates the folder
# where journald already keeps journals: creating /var/log/journal itself would
# switch the whole system to persistent logging. Never blocks a server start.
cmd_journal_access() {
  [[ $EUID -eq 0 ]] || die "journal-access must run as root"
  local mid base
  mid=$(cat /etc/machine-id 2>/dev/null) || return 0
  [[ -n $mid ]] || return 0
  for base in /var/log/journal /run/log/journal; do
    if [[ -d $base && ! -d $base/$mid.$JOURNAL_NS ]]; then
      # inherits journald's group (systemd-journal) through the setgid parent
      mkdir -m 0755 "$base/$mid.$JOURNAL_NS" 2>/dev/null ||
        log "warning: could not create $base/$mid.$JOURNAL_NS"
    fi
  done
  systemd-tmpfiles --create --prefix=/var/log/journal --prefix=/run/log/journal \
    terractl.conf 2>/dev/null || log "warning: could not set journal access for group '$SVC_USER'"
}

cmd_cmd() {
  local name=$1; shift
  [[ $# -gt 0 ]] || die "no console command given"
  local fifo="$RUN_DIR/$name.stdin" unit="terraria@$name" cursor="" out="" prev=-1 quiet=0 i
  [[ -p $fifo ]] || die "$fifo not found - is terraria@$name running?"
  [[ -w $fifo ]] || die "no write access to $fifo (run with sudo or join group '$SVC_USER')"

  if ! can_read_journal; then
    printf '%s\n' "$*" > "$fifo"
    echo "sent."
    journal_hint "Note: commands are sent, but you can't see the server's output."
    return 0
  fi

  # Note the journal position first, so only the reply to this command is shown
  cursor=$(journalctl --namespace="$JOURNAL_NS" -u "$unit" -n 1 --show-cursor -q -o cat 2>/dev/null |
           sed -n 's/^-- cursor: //p') || true
  local -a after=(--since=-2s)
  if [[ -n $cursor ]]; then after=(--after-cursor="$cursor"); fi
  printf '%s\n' "$*" > "$fifo"

  # Show the server's output until it has been quiet for 1s (at most 5s)
  for ((i = 0; i < 50; i++)); do
    sleep 0.1
    out=$(journalctl --namespace="$JOURNAL_NS" -u "$unit" "${after[@]}" -q -o cat 2>/dev/null) || true
    if [[ -n $out && ${#out} -eq $prev ]]; then
      quiet=$((quiet + 1))
      if [[ $quiet -ge 10 ]]; then break; fi
    else
      quiet=0
    fi
    prev=${#out}
  done
  if [[ -n $out ]]; then
    printf '%s\n' "$out"
  else
    echo "sent; no output from the server within 5s (it may be busy or still starting)."
    echo "Watch it with: $(hint group attach "$name")"
  fi
}

cmd_attach() {
  local name=$1 fifo="$RUN_DIR/$1.stdin" line
  [[ -p $fifo ]] || die "$fifo not found - is terraria@$name running?"
  [[ -w $fifo ]] || die "no write access to $fifo (run with sudo or join group '$SVC_USER')"

  echo "Attached to terraria@$name. Ctrl-D or Ctrl-C detaches; the server keeps running."
  if can_read_journal; then
    # Server output comes from the journal; -o cat drops the timestamp prefixes.
    journalctl --namespace="$JOURNAL_NS" -u "terraria@$name" -f -n 30 -o cat &
    ATTACH_PID=$!    # killed by cleanup() however attach ends
  else
    journal_hint "Note: commands are sent, but you can't see the server's output."
  fi
  trap 'echo; exit 0' INT

  # -e gives line editing and history while typing commands
  while IFS= read -r -e line; do
    [[ -z $line ]] && continue
    if [[ $line == exit ]]; then
      echo "(not sent: 'exit' would shut the server down. Use Ctrl-D to detach," \
           "or '$(hint group stop "$name")' to stop it.)"
      continue
    fi
    printf '%s\n' "$line" > "$fifo"
  done
  echo
}

# Like attach, but only shows output: nothing typed is sent to the server, so
# it's safe to leave running. Works whether or not the server is running.
cmd_watch() {
  local name=$1 lines=30 quiet=0; shift
  while [[ $# -gt 0 ]]; do
    case $1 in
      -n|--lines)
        [[ ${2:-} =~ ^[0-9]+$ ]] || die "$1 needs a number of lines"
        lines=$2; shift ;;
      -q|--quiet) quiet=1 ;;
      *) die "unknown option '$1'" ;;
    esac
    shift
  done
  # Only checkable with read access to the instances (root or the group)
  if [[ -x $BASE_DIR && ! -f $(inst_dir "$name")/instance.conf ]]; then
    die "no instance '$name'"
  fi
  if ! can_read_journal; then
    journal_hint "Can't show the server's output: no read access to the servers' journal."
    exit 1
  fi
  if [[ $quiet -eq 0 ]]; then
    if ! systemctl is-active --quiet "terraria@$name" 2>/dev/null; then
      echo "terraria@$name isn't running; output will appear here once it starts."
    fi
    echo "Watching terraria@$name (read-only). Ctrl-C quits; the server keeps running."
  fi
  exec journalctl --namespace="$JOURNAL_NS" -u "terraria@$name" -f -n "$lines" -o cat
}

cmd_delete() {
  local name=$1; shift
  local assume_yes=0 backup=1 dir archive answer
  while [[ $# -gt 0 ]]; do
    case $1 in
      -y|--yes)    assume_yes=1 ;;
      --no-backup) backup=0 ;;
      *) die "unknown option '$1'" ;;
    esac
    shift
  done

  [[ $EUID -eq 0 ]] || die "delete must be run as root"
  dir=$(inst_dir "$name")
  [[ -d $dir ]] || die "$dir does not exist"
  # Refuse to remove anything that doesn't look like one of our instances
  [[ -f $dir/instance.conf ]] || die "$dir has no instance.conf; not deleting it"

  if [[ $assume_yes -ne 1 ]]; then
    [[ -t 0 ]] || die "not a terminal; use -y to delete without confirmation"
    echo "This will stop terraria@$name and remove $dir."
    if [[ $backup -eq 1 ]]; then
      echo "Worlds and configs will be archived to $BASE_DIR/.deleted/ first."
    else
      echo "NO BACKUP will be made. Worlds will be lost."
    fi
    read -r -p "Type the instance name to confirm: " answer
    [[ $answer == "$name" ]] || die "confirmation did not match; nothing deleted"
  fi

  if command -v systemctl >/dev/null; then
    systemctl disable --now "terraria@$name.service" 2>/dev/null || true
    systemctl stop "terraria@$name.socket" 2>/dev/null || true
    systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true
  fi
  if instance_running "$name"; then
    die "'$name' is still running outside systemd (e.g. 'terractl run' in another terminal); stop it, then run delete again"
  fi

  if [[ $backup -eq 1 ]]; then
    mkdir -p "$BASE_DIR/.deleted"
    chmod 700 "$BASE_DIR/.deleted"
    archive="$BASE_DIR/.deleted/$name-$(date +%Y%m%d-%H%M%S).tar.gz"
    # server/ is excluded: it's just the unpacked zip and can be recreated
    tar -czf "$archive" -C "$BASE_DIR" \
      --exclude="$name/server" --exclude="$name/.extract.*" "$name"
    echo "backup: $archive"
  fi

  rm -rf -- "$dir"
  echo "deleted instance '$name'"
}

# Gives an instance a new name: its folder, the paths in serverconfig.txt, and
# its service (stopped and started again, and still starting at boot if it did).
cmd_rename() {
  local name=$1 new=${2:-} dir newdir state enabled
  [[ $# -eq 2 ]] || die "usage: terractl rename <name> <new name>"
  valid_name "$new"
  [[ $EUID -eq 0 ]] || die "rename must be run as root: sudo terractl rename $name $new"
  dir=$(inst_dir "$name"); newdir=$(inst_dir "$new")
  [[ -f $dir/instance.conf ]] || die "no instance '$name'"
  [[ ! -e $newdir ]] || die "instance '$new' already exists"
  state=$(systemctl is-active "terraria@$name" 2>/dev/null || true)
  enabled=$(systemctl is-enabled "terraria@$name" 2>/dev/null || true)
  if [[ $state == inactive || $state == failed ]] && instance_running "$name"; then
    die "'$name' is running outside systemd (e.g. 'terractl run' in another terminal); stop it first"
  fi
  if [[ $state == active || $state == activating || $state == reloading ]]; then
    echo "stopping terraria@$name (saves the world first)..."
    systemctl stop "terraria@$name"
  fi
  if [[ $enabled == enabled ]]; then systemctl disable -q "terraria@$name"; fi
  systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true
  mv "$dir" "$newdir"
  if [[ -f $newdir/serverconfig.txt ]]; then
    sed -i -E "s#^(world|worldpath|banlist)=.*/data/#\1=$newdir/data/#" "$newdir/serverconfig.txt"
  fi
  echo "renamed '$name' to '$new'"
  if [[ $enabled == enabled ]]; then systemctl enable -q "terraria@$new"; echo "terraria@$new starts at boot"; fi
  if [[ $state == active || $state == activating || $state == reloading ]]; then
    systemctl start "terraria@$new"
    echo "terraria@$new started"
  fi
}

# ------------------------------------------------------- export/import ----
# Archive layout:  terractl-export.txt   (info about the export; optional)
#                  <instance>/...        (instance.conf, serverconfig.txt, data/, [server/])
# Backups made by 'delete' have the same layout without the info file, so they
# can be imported too.
EXPORT_INFO=terractl-export.txt

# Ask a server running under systemd to save, then wait until its world files
# have been written and stopped changing. Best effort: carries on after 60s.
save_and_wait() {
  local name=$1 dir marker newest last="" stable=0 i
  dir=$(inst_dir "$name")
  marker=$(mktemp)
  printf 'save\n' > "$RUN_DIR/$name.stdin"
  log "'$name' is running: asked it to save, waiting for the world files..."
  for ((i = 0; i < 60; i++)); do
    sleep 1
    newest=$(find "$dir/data/Worlds" -maxdepth 1 -type f -newer "$marker" -printf '%T@\n' 2>/dev/null |
             sort -n | tail -n 1) || true
    [[ -n $newest ]] || continue
    if [[ $newest == "$last" ]]; then
      stable=$((stable + 1))
      if [[ $stable -ge 3 ]]; then rm -f "$marker"; log "save finished"; return 0; fi
    else
      stable=0; last=$newest
    fi
  done
  rm -f "$marker"
  log "warning: no completed save seen within 60s; exporting the last saved state"
}

cmd_export() {
  local name=$1; shift
  local out="" with_server=0 arg dir installed_zip="" ver rc=0
  for arg in "$@"; do
    case $arg in
      --with-server) with_server=1 ;;
      -*) die "unknown option '$arg'" ;;
      *) [[ -z $out ]] || die "more than one output file given"; out=$arg ;;
    esac
  done
  dir=$(inst_dir "$name")
  [[ -f $dir/instance.conf ]] || die "no instance '$name'"
  load_conf "$name"

  [[ -n $out ]] || out="$name-$(date +%Y%m%d-%H%M%S).tar.gz"
  out=$(abspath "$out")
  [[ $out == *.tar.gz || $out == *.tgz ]] || out="$out.tar.gz"
  [[ ! -e $out ]] || die "$out already exists"
  [[ -d $(dirname "$out") ]] || die "folder $(dirname "$out") does not exist"

  if instance_running "$name"; then
    if [[ -p $RUN_DIR/$name.stdin ]]; then
      save_and_wait "$name"
    else
      die "'$name' is running outside systemd, so terractl can't ask it to save; stop it first (type 'exit' in its console)"
    fi
  fi

  if [[ -r $dir/server/.installed ]]; then read -r _ installed_zip < "$dir/server/.installed" || true; fi
  ver=$TERRACTL_VERSION; if [[ $ver == *@* ]]; then ver=development; fi
  EXTRACT_TMP=$(mktemp -d)      # removed by cleanup() on exit
  {
    echo "# Created by 'terractl export'. Import with: terractl import <name> <this file>"
    echo "format=1"
    echo "terractl=$ver"
    echo "instance=$name"
    echo "type=$TYPE"
    echo "server_zip=$installed_zip"
    echo "includes_server=$(if [[ $with_server -eq 1 ]]; then echo yes; else echo no; fi)"
    echo "exported=$(date -Iseconds)"
    echo "host=$(hostname 2>/dev/null || echo unknown)"
  } > "$EXTRACT_TMP/$EXPORT_INFO"

  local -a excludes=(--exclude="$name/.lock" --exclude="$name/.extract.*"
                     --exclude="$name/data/Mods/.enabled.json.tmp")
  if [[ $with_server -eq 0 ]]; then excludes+=(--exclude="$name/server"); fi

  # serverconfig.txt can hold the server password, so the archive is private (0600)
  (
    umask 077
    tar -czf "$out" --owner=0 --group=0 --numeric-owner "${excludes[@]}" \
      -C "$EXTRACT_TMP" "$EXPORT_INFO" -C "$BASE_DIR" "$name"
  ) || rc=$?
  if [[ $rc -ge 2 ]]; then rm -f "$out"; die "export failed (tar exit status $rc)"; fi
  if [[ $rc -eq 1 ]]; then
    log "warning: some files changed while being archived (an autosave?); consider exporting again"
  fi
  echo "exported '$name' to $out ($(du -h "$out" | cut -f1), readable only by $(id -un))"
  if [[ $with_server -eq 0 ]]; then
    echo "server/ not included; it's unpacked again from ${installed_zip:-the server zip} on first start after import"
  fi
}

cmd_import() {
  local name=$1; shift
  [[ $# -eq 1 ]] || die "usage: terractl import <new instance name> <file.tar.gz>"
  local file dir listing types m top="" meta_zip="" meta_from="" port newport p f clash=0 zip=""
  file=$(abspath "$1")
  [[ -f $file && -r $file ]] || die "cannot read $file"
  dir=$(inst_dir "$name")
  [[ ! -e $dir ]] || die "instance '$name' already exists; choose another name, or delete it first"

  # Check the archive before extracting anything
  listing=$(tar -tzf "$file" 2>/dev/null) || die "$file is not a readable .tar.gz archive"
  types=$(tar -tvzf "$file" 2>/dev/null | cut -c1 | sort -u | tr -d '\n')
  [[ $types =~ ^[-d]+$ ]] || die "archive contains links or special files; refusing to import it"
  local -A tops=()
  while IFS= read -r m; do
    case $m in
      /*|..|../*|*/../*|*/..) die "archive contains an unsafe path '$m'; refusing to import it" ;;
    esac
    m=${m#./}
    if [[ -z $m || $m == . || $m == "$EXPORT_INFO" ]]; then continue; fi
    tops[${m%%/*}]=1
  done <<< "$listing"
  if [[ ${#tops[@]} -ne 1 ]]; then
    local found="nothing"
    if [[ ${#tops[@]} -gt 0 ]]; then found="${!tops[*]}"; fi
    die "expected one instance folder in the archive (as 'terractl export' creates); found: $found"
  fi
  top=${!tops[*]}

  mkdir -p "$BASE_DIR"
  EXTRACT_TMP=$(mktemp -d "$BASE_DIR/.import.XXXXXX")     # removed by cleanup() on exit
  ( umask 027; tar -xzf "$file" -C "$EXTRACT_TMP" --no-same-owner --no-same-permissions ) ||
    die "extracting $file failed"
  [[ -f $EXTRACT_TMP/$top/instance.conf ]] || die "no instance.conf in '$top/'; this isn't a terractl instance archive"
  if [[ -f $EXTRACT_TMP/$EXPORT_INFO ]]; then
    meta_zip=$(sed -n 's/^server_zip=//p' "$EXTRACT_TMP/$EXPORT_INFO" | tail -n 1)
    meta_from=$(sed -n 's/^instance=//p' "$EXTRACT_TMP/$EXPORT_INFO" | tail -n 1)
  fi
  mv "$EXTRACT_TMP/$top" "$dir"
  rm -rf "$EXTRACT_TMP"; EXTRACT_TMP=""

  # serverconfig.txt holds absolute paths from where it came from; keep only
  # the part after /data/ (this also converts old /opt/terraria instances)
  if [[ -f $dir/serverconfig.txt ]]; then
    sed -i -E "s#^(world|worldpath|banlist)=.*/data/#\1=$dir/data/#" "$dir/serverconfig.txt"
    port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$dir/serverconfig.txt" | tail -n 1)
    if [[ -n $port ]]; then
      for f in "$BASE_DIR"/*/serverconfig.txt; do
        [[ -f $f && $f != "$dir/serverconfig.txt" ]] || continue
        p=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$f" | tail -n 1)
        if [[ $p == "$port" ]]; then clash=1; fi
      done
      if [[ $clash -eq 1 ]]; then
        newport=$(next_free_port)
        sed -i "s/^port=.*/port=$newport/" "$dir/serverconfig.txt"
        log "port $port is already used by another instance; '$name' now uses port $newport"
      fi
    fi
  fi

  # Keep the server version the world was exported with, unless one is pinned already
  load_conf "$name"
  # A pin to an absolute path from another host usually doesn't exist here: use
  # the zip of that name in this host's dist folder instead
  if [[ $ZIP == /* && ! -r $ZIP && $(basename "$ZIP") =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; then
    log "ZIP=$ZIP isn't on this host; now ZIP=$(basename "$ZIP") (from $DIST_DIR)"
    ZIP=$(basename "$ZIP")
    sed -i "s/^ZIP=.*/ZIP=$ZIP/" "$dir/instance.conf"
  fi
  if [[ -z $ZIP && $meta_zip =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; then
    sed -i "s/^ZIP=.*/ZIP=$meta_zip/" "$dir/instance.conf"
    ZIP=$meta_zip
    log "pinned ZIP=$meta_zip in instance.conf (the version it was exported with)"
  fi
  if [[ ! -f $dir/server/.installed ]]; then
    zip=$(pick_zip 2>/dev/null) || true
    if [[ -z $zip || ! -r $zip ]]; then
      log "warning: before starting, put ${ZIP:-a $TYPE server zip} in $DIST_DIR"
    fi
  fi

  if [[ $EUID -eq 0 ]]; then chown -R "$SVC_USER:$SVC_USER" "$dir"; fi
  if [[ -n $meta_from ]]; then
    echo "imported '$meta_from' as '$name' into $dir"
  else
    echo "imported '$name' into $dir"
  fi
  echo "Review $dir/serverconfig.txt, then: $(hint root enable "$name" --now)"
}

# ---------------------------------------------------------------- mods ----
# enabled.json is a plain JSON array of mod names, e.g. ["CalamityMod","MagicStorage"].
# Mod names are identifiers, so simple text handling is enough (no jq needed).

fix_owner() { if [[ $EUID -eq 0 ]]; then chown "$SVC_USER:$SVC_USER" "$@"; fi; }

read_enabled() {
  local f="$1/enabled.json"
  [[ -f $f ]] || return 0
  grep -o '"[^"]*"' "$f" | tr -d '"' || true
}

write_enabled() {   # write_enabled <mods dir> <name>...
  local mdir=$1; shift
  local out="[" sep="" n
  for n in "$@"; do out+="$sep"$'\n'"  \"$n\""; sep=","; done
  if [[ $# -gt 0 ]]; then out+=$'\n'; fi
  out+="]"
  printf '%s\n' "$out" > "$mdir/.enabled.json.tmp"
  mv "$mdir/.enabled.json.tmp" "$mdir/enabled.json"
  fix_owner "$mdir/enabled.json"
}

mods_restart_hint() {
  if systemctl is-active --quiet "terraria@$name" 2>/dev/null; then
    echo "Restart to apply: $(hint group restart "$name")"
  fi
}

mods_list() {
  local enabled f n st
  enabled=$(read_enabled "$mdir")
  local -a files=()
  for f in "$mdir"/*.tmod; do
    if [[ -f $f ]]; then files+=("$f"); fi
  done
  if [[ ${#files[@]} -eq 0 ]]; then
    echo "No mods installed in $mdir"
    echo "Add some with: $(hint root mods "$name" add "<folder or .tmod files>")"
    return 0
  fi
  printf '%-32s %s\n' MOD STATE
  for f in "${files[@]}"; do
    n=$(basename "$f" .tmod)
    if grep -qxF "$n" <<<"$enabled"; then st=enabled; else st=disabled; fi
    printf '%-32s %s\n' "$n" "$st"
  done
  for n in $enabled; do
    if [[ ! -f $mdir/$n.tmod ]]; then printf '%-32s %s\n' "$n" "enabled but MISSING (server will fail to load mods)"; fi
  done
}

mods_add() {
  [[ $# -gt 0 ]] || die "usage: terractl mods $name add <file.tmod|folder>..."
  local src f n found
  local -a files=() cfgs=()
  for src in "$@"; do
    src=$(abspath "$src")
    if [[ -f $src ]]; then
      [[ $src == *.tmod ]] || die "$src is not a .tmod file"
      files+=("$src")
    elif [[ -d $src ]]; then
      # Accepts a folder of .tmod files, or an export folder containing Mods/ and ModConfigs/
      found=0
      for f in "$src"/*.tmod "$src"/Mods/*.tmod; do
        if [[ -f $f ]]; then files+=("$f"); found=1; fi
      done
      [[ $found -eq 1 ]] || die "no .tmod files in $src or $src/Mods"
      for f in "$src"/ModConfigs/*.json; do
        if [[ -f $f ]]; then cfgs+=("$f"); fi
      done
    else
      die "$src not found"
    fi
  done

  local -A pick=()
  for f in "${files[@]}"; do
    n=$(basename "$f" .tmod)
    [[ $n =~ ^[A-Za-z0-9_]+$ ]] || die "unexpected mod file name '$n.tmod'"
    if [[ -n ${pick[$n]:-} ]]; then log "warning: $n given more than once; using $f"; fi
    pick[$n]=$f
  done

  mkdir -p "$mdir"
  fix_owner "$dir/data" "$mdir"
  for n in "${!pick[@]}"; do
    install -m 0640 "${pick[$n]}" "$mdir/$n.tmod"
    fix_owner "$mdir/$n.tmod"
    echo "installed $n"
  done

  if [[ ${#cfgs[@]} -gt 0 ]]; then
    mkdir -p "$dir/data/ModConfigs"
    fix_owner "$dir/data/ModConfigs"
    for f in "${cfgs[@]}"; do
      install -m 0640 "$f" "$dir/data/ModConfigs/$(basename "$f")"
      fix_owner "$dir/data/ModConfigs/$(basename "$f")"
    done
    echo "installed ${#cfgs[@]} mod config file(s)"
  fi

  local -a current=()
  mapfile -t current < <(read_enabled "$mdir")
  # shellcheck disable=SC2046
  write_enabled "$mdir" $(printf '%s\n' "${current[@]}" "${!pick[@]}" | sort -u)
  echo "enabled: ${!pick[*]}"
  echo "Note: dependencies are not checked here; if one is missing, the server log will say which."
  mods_restart_hint
}

mods_set() {   # mods_set 1|0 <name>...
  local on=$1; shift
  [[ $# -gt 0 ]] || die "no mod names given"
  local n list
  local -a current=()
  mapfile -t current < <(read_enabled "$mdir")
  if [[ $on -eq 1 ]]; then
    for n in "$@"; do
      [[ -f $mdir/$n.tmod ]] || die "$n is not installed (no $mdir/$n.tmod)"
    done
    list=$(printf '%s\n' "${current[@]}" "$@" | sort -u)
  else
    list=$(printf '%s\n' "${current[@]}" | grep -vxF -f <(printf '%s\n' "$@") || true)
  fi
  mkdir -p "$mdir"
  # shellcheck disable=SC2086
  write_enabled "$mdir" $list
}

mods_remove() {
  [[ $# -gt 0 ]] || die "no mod names given"
  local n
  for n in "$@"; do
    [[ $n =~ ^[A-Za-z0-9_]+$ ]] || die "invalid mod name '$n'"
    if [[ -f $mdir/$n.tmod ]]; then rm -f "$mdir/$n.tmod"; echo "removed $n"; else log "$n was not installed"; fi
  done
  mods_set 0 "$@"
}

cmd_mods() {
  local name=$1; shift
  local action=${1:-list}
  if [[ $# -gt 0 ]]; then shift; fi
  local dir mdir
  dir=$(inst_dir "$name")
  [[ -f $dir/instance.conf ]] || die "no instance '$name' (create it with: $(hint root new "$name" tmodloader))"
  load_conf "$name"
  [[ $TYPE == tmodloader ]] || die "'$name' is a $TYPE instance; mods need TYPE=tmodloader"
  mdir="$dir/data/Mods"
  case $action in
    list)    mods_list ;;
    add)     mods_add "$@" ;;
    enable)  mods_set 1 "$@"; echo "enabled: $*"; mods_restart_hint ;;
    disable) mods_set 0 "$@"; echo "disabled: $*"; mods_restart_hint ;;
    remove)  mods_remove "$@"; mods_restart_hint ;;
    *)       die "unknown mods action '$action' (list, add, enable, disable, remove)" ;;
  esac
}

cmd_new() {
  local name=$1 type=${2:-vanilla} port=${3:-} dir
  [[ $type == vanilla || $type == tmodloader ]] || die "type must be vanilla or tmodloader"
  [[ -z $port || $port =~ ^[0-9]+$ ]] || die "port must be a number"
  dir=$(inst_dir "$name")
  [[ -e $dir ]] && die "$dir already exists"
  mkdir -p "$dir/data/Worlds"
  if [[ $type == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi
  write_instance_conf "$name" "$type"
  write_server_config "$name" "$port"
  if [[ $EUID -eq 0 ]]; then chown -R "$SVC_USER:$SVC_USER" "$dir"; fi
  cat <<EOF
Created $type instance '$name' in $dir
Review $dir/serverconfig.txt, then:
EOF
  if [[ $type == tmodloader ]]; then
    echo "  $(hint root mods "$name" add "<folder or .tmod files>")   (before first start)"
  fi
  cat <<EOF
  $(hint root enable "$name" --now)
  $(hint group attach "$name")
EOF
}

# Whether the server starts at boot. Needs root: systemd doesn't let polkit
# rules limit enabling to our units, so the terraria group can't be allowed it.
cmd_enable() {
  local action=$1 name=$2 now=0 unit="terraria@$2"; shift 2
  while [[ $# -gt 0 ]]; do
    case $1 in
      --now) now=1 ;;
      *) die "unknown option '$1'" ;;
    esac
    shift
  done
  [[ $EUID -eq 0 ]] || die "'$action' needs root: sudo terractl $action $name$(if [[ $now -eq 1 ]]; then echo " --now"; fi)"
  if [[ $action == enable ]]; then
    # Enabling a name without an instance would create a default one at boot
    [[ -f $(inst_dir "$name")/instance.conf ]] ||
      die "no instance '$name' (create it with: $(hint root new "$name"))"
    systemctl enable -q "$unit"
    if [[ $now -eq 1 ]]; then
      systemctl start "$unit"
      echo "$unit started and will start at boot"
    elif systemctl is-active --quiet "$unit"; then
      echo "$unit will start at boot"
    else
      echo "$unit will start at boot. To start it now: $(hint group start "$name")"
    fi
  else
    systemctl disable -q "$unit"
    if [[ $now -eq 1 ]]; then
      echo "stopping $unit (saves the world first)..."
      systemctl stop "$unit"
      echo "$unit stopped and won't start at boot"
    elif systemctl is-active --quiet "$unit"; then
      echo "$unit won't start at boot; it is still running. To stop it: $(hint group stop "$name")"
    else
      echo "$unit won't start at boot"
    fi
  fi
}

cmd_list() {
  local d n port boot state
  printf '%-20s %-11s %-6s %-9s %s\n' INSTANCE TYPE PORT BOOT STATE
  for d in "$BASE_DIR"/*/; do
    [[ -f $d/instance.conf ]] || continue
    n=$(basename "$d")
    (
      load_conf "$n"
      port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$d/serverconfig.txt" 2>/dev/null | tail -n1)
      # enabled, disabled, or e.g. masked
      boot=$(systemctl is-enabled "terraria@$n" 2>/dev/null || true)
      state=$(systemctl is-active "terraria@$n" 2>/dev/null || true)
      if [[ $state != active ]] && instance_running "$n"; then state="running (outside systemd)"; fi
      printf '%-20s %-11s %-6s %-9s %s\n' "$n" "$TYPE" "${port:--}" "${boot:--}" "$state"
    ) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)"
  done
}

cmd_reinstall() {
  local name=$1 clean=0 dir; dir=$(inst_dir "$name")
  shift
  case ${1:-} in
    --clean) clean=1 ;;
    "") ;;
    *) die "unknown option '$1'" ;;
  esac
  [[ -f $dir/instance.conf ]] || die "no instance '$name'"
  if [[ $clean -eq 1 ]] && instance_running "$name"; then
    die "'$name' is running; --clean would delete .NET under it. Stop it first: $(hint group stop "$name")"
  fi
  rm -f "$dir/server/.installed"
  if [[ $clean -eq 1 ]]; then
    rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs"
    echo "removed the downloaded .NET runtime and logs; .NET will be downloaded again"
  fi
  echo "server/ will be re-unpacked on next start: $(hint group restart "$name")"
}

# key=value facts, one per line, for people and for terractl-remote (copy, move,
# tab completion). Without a name: this host. Repeated keys are lists.
cmd_info() {
  local name=${1:-} dir f ver installed="" port
  if [[ -z $name ]]; then
    ver=$TERRACTL_VERSION; if [[ $ver == *@* ]]; then ver=development; fi
    echo "version=$ver"
    echo "host=$(hostname 2>/dev/null || echo unknown)"
    echo "base_dir=$BASE_DIR"
    echo "dist_dir=$DIST_DIR"
    echo "run_dir=$RUN_DIR"
    for f in "$DIST_DIR"/*.zip; do
      if [[ -f $f ]]; then echo "dist_zip=$(basename "$f")"; fi
    done
    for f in "$BASE_DIR"/*/instance.conf; do
      if [[ -f $f ]]; then echo "instance=$(basename "$(dirname "$f")")"; fi
    done
    return 0
  fi
  dir=$(inst_dir "$name")
  echo "instance=$name"
  if [[ ! -f $dir/instance.conf ]]; then echo "exists=no"; return 0; fi
  load_conf "$name"
  if [[ -r $dir/server/.installed ]]; then read -r _ installed < "$dir/server/.installed" || true; fi
  port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$dir/serverconfig.txt" 2>/dev/null | tail -n1) || true
  echo "exists=yes"
  echo "dir=$dir"
  echo "type=$TYPE"
  echo "port=$port"
  echo "pinned_zip=$ZIP"
  echo "installed_zip=$installed"
  echo "state=$(systemctl is-active "terraria@$name" 2>/dev/null || true)"
  echo "boot=$(systemctl is-enabled "terraria@$name" 2>/dev/null || true)"
  echo "running=$(if instance_running "$name"; then echo yes; else echo no; fi)"
}

# Other hosts are handled by terractl-remote (Python). It runs this script for
# anything it does on this host, and passes on where it was called from.
run_remote_helper() {
  local helper=$REMOTE_HELPER ver=$TERRACTL_VERSION self
  self=$(readlink -f "$0")
  # A development copy uses the helper from its source tree
  if [[ $helper == *@* ]]; then helper="$(dirname "$self")/../lib/terractl-remote"; fi
  if [[ $ver == *@* ]]; then ver=development; fi
  command -v python3 >/dev/null || die "this needs python3 (sudo apt install python3)"
  TERRACTL_BIN=$self TERRACTL_VERSION=$ver TERRACTL_CALLER_PWD=$CALLER_PWD \
    TERRACTL_LISTEN=${TERRACTL_LISTEN:-} exec python3 "$helper" "$@"
}

[[ $# -ge 1 ]] || usage
case $1 in
  --host|--host=*|-H|remote|daemon|copy|move) run_remote_helper "$@" ;;
esac
command=$1; shift
case $command in
  -h|--help|help) usage 0 ;;
  -V|--version|version)
    # The build replaces the placeholder with a version number, which never contains '@'
    if [[ $TERRACTL_VERSION == *@* ]]; then echo "terractl (development copy)"
    else echo "terractl $TERRACTL_VERSION"; fi
    exit 0 ;;
  list) cmd_list; exit ;;
  info) if [[ $# -eq 0 ]]; then cmd_info; exit; fi ;;
  journal-access) cmd_journal_access; exit ;;
esac
[[ $# -ge 1 ]] || usage
valid_name "$1"

# prepare/run create and modify instance files. If invoked as root (e.g. while
# debugging), switch to the service user so nothing ends up owned by root.
if [[ $command == prepare || $command == run ]]; then
  if [[ $EUID -eq 0 ]]; then
    command -v runuser >/dev/null || die "runuser not found; run as '$SVC_USER' instead"
    exec runuser -u "$SVC_USER" -- env HOME="$(inst_dir "$1")" \
      "$(readlink -f "$0")" "$command" "$@"
  elif [[ $(id -un) != "$SVC_USER" ]]; then
    # Being in the group isn't enough: files would end up owned by the caller
    die "'$command' needs root (it switches to the '$SVC_USER' user by itself): sudo terractl $command $1"
  fi
fi

case $command in
  prepare)   cmd_prepare "$1" ;;
  run)       cmd_run "$1" ;;
  service-stop) cmd_service_stop "$@" ;;
  stop)
    # Before 0.0.11 the service stopped servers with 'terractl stop <name> <pid>';
    # a unit loaded before an upgrade may still do that
    if [[ -n ${INVOCATION_ID:-} && $# -eq 2 ]]; then cmd_service_stop "$@"; else cmd_svc stop "$@"; fi ;;
  start|restart) cmd_svc "$command" "$@" ;;
  info)      cmd_info "$1" ;;
  cmd)       cmd_cmd "$@" ;;
  attach)    cmd_attach "$1" ;;
  watch)     cmd_watch "$@" ;;
  enable|disable) cmd_enable "$command" "$@" ;;
  mods)      cmd_mods "$@" ;;
  new)       cmd_new "$@" ;;
  reinstall) cmd_reinstall "$@" ;;
  delete)    cmd_delete "$@" ;;
  rename)    cmd_rename "$@" ;;
  export)    cmd_export "$@" ;;
  import)    cmd_import "$@" ;;
  *)         usage ;;
esac
