#!/usr/bin/env bash
# bezgelor-linux.sh: install, start and check the WildStar client on Linux (Proton-GE through umu-launcher)
# against a Bezgelor server. Run it with --help for the commands:
#
#   bezgelor-linux.sh install     set everything up (packages, umu-launcher, prefix, launcher, menu entry)
#   bezgelor-wildstar play        start the game (the installed copy of this script)
#   bezgelor-wildstar doctor      check the setup and say how to fix what is wrong
#   bezgelor-wildstar uninstall   remove what install created
#
# The client files are not part of this project and are not downloaded: use your own copy.
# Source of truth: tools/linux/bezgelor-linux.sh in the repository. The portal serves this file with the
# server's own address on the BAKED_SERVER line.
set -euo pipefail

BEZGELOR_LINUX_VERSION="1"
BAKED_SERVER="bezgelor.com"
# The address this script is served from; the portal fills it in. Used to install the launcher when the
# script runs from a pipe (curl ... | bash) and so has no file to copy.
BEZGELOR_INSTALLER_URL="https://bezgelor.com/downloads/bezgelor-linux.sh"

AUTH_PORT=6600
REALM_PORT=23115
WORLD_PORT=24000
PORTAL_PORT=4000
INSTALL_REL='drive_c/Program Files (x86)/NCSOFT/WildStar'
UMU_REPO="Open-Wine-Components/umu-launcher"

DRY_RUN=0
ASSUME_YES=0
STEP_N=0
STEP_TOTAL=8
PROBLEMS=0

# ---------------------------------------------------------------------------------------------
# Output

set_colours() {
  if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
    C_OK=$'\e[32m'
    C_WARN=$'\e[33m'
    C_BAD=$'\e[31m'
    C_BOLD=$'\e[1m'
    C_OFF=$'\e[0m'
  else
    C_OK="" C_WARN="" C_BAD="" C_BOLD="" C_OFF=""
  fi
}

say() { printf '%s\n' "$*"; }
note() { printf '      %s\n' "$*"; }
step() {
  STEP_N=$((STEP_N + 1))
  printf '%s[%s/%s]%s %s\n' "$C_BOLD" "$STEP_N" "$STEP_TOTAL" "$C_OFF" "$*"
}
problem() {
  PROBLEMS=$((PROBLEMS + 1))
  printf '      %sProblem:%s %s\n' "$C_BAD" "$C_OFF" "$*" >&2
}
die() {
  printf '%sError:%s %s\n' "$C_BAD" "$C_OFF" "$*" >&2
  exit 1
}

quote_cmd() { printf '%q ' "$@" | sed 's/ $//'; }

# Run a command, or only print it with --dry-run.
run() {
  if [[ $DRY_RUN -eq 1 ]]; then
    printf '      + %s\n' "$(quote_cmd "$@")"
  else
    "$@" </dev/null
  fi
}

have() { command -v "$1" >/dev/null 2>&1; }

# ---------------------------------------------------------------------------------------------
# Paths and configuration

init_paths() {
  CONF_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
  DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
  CONF_DIR=$CONF_HOME/bezgelor
  CONF_FILE=$CONF_DIR/linux.conf
  STATE_DIR=$DATA_HOME/bezgelor
  LOG_DIR=${WILDSTAR_LOG_DIR:-$STATE_DIR/logs}
  BIN_DIR=$HOME/.local/bin
  LAUNCHER=$BIN_DIR/bezgelor-wildstar
  DESKTOP_FILE=$DATA_HOME/applications/bezgelor-wildstar.desktop
  OS_RELEASE=${BEZGELOR_OS_RELEASE:-/etc/os-release}
  LIBDIRS32=${BEZGELOR_LIBDIRS32:-"/usr/lib32 /lib32 /usr/lib/i386-linux-gnu /usr/lib/i686-linux-gnu /usr/lib"}
  LIBDIRS64=${BEZGELOR_LIBDIRS64:-"/usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu /lib/x86_64-linux-gnu"}
  LD32_PATHS=${BEZGELOR_LD32:-"/lib/ld-linux.so.2 /lib32/ld-linux.so.2 /usr/lib/ld-linux.so.2 /usr/lib32/ld-linux.so.2 /usr/lib/i386-linux-gnu/ld-linux.so.2"}
  PACMAN_CONF=${BEZGELOR_PACMAN_CONF:-/etc/pacman.conf}
  if [[ ${EUID:-$(id -u)} -eq 0 ]]; then SUDO=""; else SUDO="sudo "; fi
}

# config_get FILE KEY: the value of KEY="value" in FILE (the file is read, never sourced).
config_get() {
  local line
  line=$(grep -E "^$2=\"" "$1" 2>/dev/null | tail -n 1) || true
  line=${line#"$2=\""}
  line=${line%\"}
  printf '%s' "$line"
}

# config_text SERVER CLIENT_DIR PREFIX: the text of the configuration file.
config_text() {
  printf '# Written by bezgelor-wildstar install. Change a value and run: bezgelor-wildstar doctor\n'
  printf 'SERVER="%s"\nCLIENT_DIR="%s"\nPREFIX="%s"\n' "$1" "$2" "$3"
}

config_write() {
  if [[ $DRY_RUN -eq 1 ]]; then
    note "+ write $CONF_FILE"
    config_text "$1" "$2" "$3" | sed 's/^/        /'
    return 0
  fi
  mkdir -p "$CONF_DIR"
  config_text "$1" "$2" "$3" >"$CONF_FILE"
  CONFIG_PRESENT=1
}

# Settings, by precedence: command line (already in SERVER/CLIENT_DIR/PREFIX), environment,
# the configuration file, defaults.
load_settings() {
  local cfg_server="" cfg_client="" cfg_prefix=""
  if [[ -r $CONF_FILE ]]; then
    cfg_server=$(config_get "$CONF_FILE" SERVER)
    cfg_client=$(config_get "$CONF_FILE" CLIENT_DIR)
    cfg_prefix=$(config_get "$CONF_FILE" PREFIX)
    CONFIG_PRESENT=1
  else
    CONFIG_PRESENT=0
  fi
  SERVER=${SERVER:-${BEZGELOR_SERVER:-$cfg_server}}
  SERVER=${SERVER:-$BAKED_SERVER}
  CLIENT_DIR=${CLIENT_DIR:-${WILDSTAR_CLIENT_DIR:-$cfg_client}}
  CLIENT_DIR=${CLIENT_DIR:-$STATE_DIR/client}
  PREFIX=${PREFIX:-${WILDSTAR_PREFIX:-$cfg_prefix}}
  PREFIX=${PREFIX:-$STATE_DIR/prefix}
}

valid_host() { [[ $1 =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]]; }

# ---------------------------------------------------------------------------------------------
# Questions

# ask PROMPT DEFAULT: sets REPLY.
ask() {
  local prompt=$1 default=${2:-} input=/dev/stdin
  REPLY=$default
  [[ -t 0 ]] || input=/dev/tty
  if ! { [[ -t 0 ]] || { : </dev/tty; } 2>/dev/null; }; then return 1; fi
  if [[ -n $default ]]; then
    printf '      %s [%s]: ' "$prompt" "$default" >&2
  else
    printf '      %s: ' "$prompt" >&2
  fi
  local answer=""
  read -r answer <"$input" || true
  REPLY=${answer:-$default}
}

# confirm QUESTION [y|n]: yes with --yes or --dry-run, otherwise asks.
confirm() {
  local q=$1 def=${2:-y} hint
  if [[ $ASSUME_YES -eq 1 || $DRY_RUN -eq 1 ]]; then
    if [[ $ASSUME_YES -eq 1 ]]; then note "$q yes (--yes)"; else note "$q yes (dry run)"; fi
    return 0
  fi
  [[ $def == y ]] && hint="Y/n" || hint="y/N"
  ask "$q ($hint)" "$def" || die "$q Answer needed, but there is no terminal: rerun with --yes."
  [[ $REPLY =~ ^[Yy] ]]
}

# ---------------------------------------------------------------------------------------------
# Distribution and GPU

os_field() {
  local v
  v=$(grep -E "^$2=" "$1" 2>/dev/null | head -n 1 | cut -d= -f2-) || true
  v=${v#\"}
  v=${v%\"}
  v=${v#\'}
  v=${v%\'}
  printf '%s' "$v"
}

# detect_family OS_RELEASE_FILE: arch, steamos, debian, fedora, suse or unknown.
detect_family() {
  local f=$1 id like
  [[ -r $f ]] || {
    echo unknown
    return 0
  }
  id=$(os_field "$f" ID)
  like=$(os_field "$f" ID_LIKE)
  case " $id $like " in
    *" steamos "*) echo steamos ;;
    *" arch "* | *" archlinux "* | *" manjaro "* | *" endeavouros "* | *" omarchy "* | *" cachyos "* | *" garuda "*) echo arch ;;
    *" debian "* | *" ubuntu "* | *" linuxmint "* | *" pop "*) echo debian ;;
    *" fedora "* | *" nobara "*) echo fedora ;;
    *" suse "* | *" opensuse "* | *" opensuse-tumbleweed "* | *" opensuse-leap "* | *" sles "*) echo suse ;;
    *) echo unknown ;;
  esac
}

family_label() {
  case $1 in
    arch) echo "Arch family" ;;
    steamos) echo "SteamOS" ;;
    debian) echo "Debian family" ;;
    fedora) echo "Fedora family" ;;
    suse) echo "openSUSE family" ;;
    *) echo "not handled automatically" ;;
  esac
}

# gpu_vendors: reads "lspci -nn" text on standard input, prints the vendors found (intel, amd, nvidia).
gpu_vendors() {
  local out="" line v
  while IFS= read -r line; do
    case $line in
      *[Vv][Gg][Aa]* | *"3D controller"* | *"Display controller"*) ;;
      *) continue ;;
    esac
    case $line in
      *"[8086:"* | *Intel*) v=intel ;;
      *"[1002:"* | *AMD* | *ATI*) v=amd ;;
      *"[10de:"* | *NVIDIA* | *Nvidia*) v=nvidia ;;
      *) continue ;;
    esac
    [[ " $out " == *" $v "* ]] || out+="${out:+ }$v"
  done
  printf '%s' "$out"
}

# detect_gpus: lspci, then /sys/class/drm, then vulkaninfo.
detect_gpus() {
  local out="" f id
  if have lspci; then out=$(lspci -nn 2>/dev/null | gpu_vendors) || true; fi
  if [[ -z $out ]]; then
    for f in "${BEZGELOR_SYS_DRM:-/sys/class/drm}"/card*/device/vendor; do
      [[ -r $f ]] || continue
      id=$(cat "$f")
      case $id in
        0x8086) [[ " $out " == *" intel "* ]] || out+="${out:+ }intel" ;;
        0x1002) [[ " $out " == *" amd "* ]] || out+="${out:+ }amd" ;;
        0x10de) [[ " $out " == *" nvidia "* ]] || out+="${out:+ }nvidia" ;;
        *) ;;
      esac
    done
  fi
  if [[ -z $out ]] && have vulkaninfo; then
    out=$(vulkaninfo --summary 2>/dev/null | sed -n 's/.*deviceName *= *//p' | gpu_vendors) || true
  fi
  printf '%s' "$out"
}

gpu_names() {
  if have lspci; then
    lspci 2>/dev/null | grep -Ei 'vga|3d controller|display controller' | sed -E 's/^[^ ]* [^:]*: //; s/ \(rev [0-9a-f]+\)//' | paste -sd';' - | sed 's/;/; /g'
  fi
}

# pkg_list FAMILY "VENDORS": the 32-bit packages the client needs (space-separated).
pkg_list() {
  local fam=$1 vendors=$2 v out=""
  case $fam in
    arch) out="lib32-glibc lib32-mesa lib32-vulkan-icd-loader vulkan-icd-loader" ;;
    debian) out="libc6:i386 libvulkan1:i386 mesa-vulkan-drivers:i386" ;;
    fedora) out="glibc.i686 vulkan-loader.i686 mesa-vulkan-drivers.i686" ;;
    suse) out="glibc-32bit libvulkan1-32bit" ;;
    *) return 0 ;;
  esac
  for v in $vendors; do
    case $fam:$v in
      arch:intel) out+=" lib32-vulkan-intel vulkan-intel" ;;
      arch:amd) out+=" lib32-vulkan-radeon vulkan-radeon" ;;
      arch:nvidia) out+=" lib32-nvidia-utils" ;;
      debian:nvidia) out+=" $(debian_nvidia_pkg)" ;;
      fedora:nvidia) out+=" xorg-x11-drv-nvidia-libs.i686" ;;
      suse:intel) out+=" libvulkan_intel-32bit" ;;
      suse:amd) out+=" libvulkan_radeon-32bit" ;;
      *) ;;
    esac
  done
  printf '%s' "$out"
}

debian_nvidia_pkg() {
  local ver
  if [[ $(os_field "$OS_RELEASE" ID) == debian ]]; then
    echo "nvidia-driver-libs-i386"
    return 0
  fi
  ver=$(dpkg-query -W -f='${Package}\n' 'libnvidia-gl-*' 2>/dev/null | head -n 1) || true
  if [[ -n $ver ]]; then echo "${ver}:i386"; else echo "libnvidia-gl-NNN:i386"; fi
}

umu_pkg_for() {
  case $1 in
    arch | fedora) echo "umu-launcher" ;;
    *) ;;
  esac
}

# umu_pkg_available FAMILY: whether the distribution repositories carry umu-launcher.
umu_pkg_available() {
  case $1 in
    arch) pacman -Si umu-launcher >/dev/null 2>&1 ;;
    fedora) dnf -q list --available umu-launcher >/dev/null 2>&1 ;;
    *) return 1 ;;
  esac
}

pkg_is_installed() {
  case $1 in
    arch) have pacman && pacman -Q "$2" >/dev/null 2>&1 ;;
    debian) have dpkg-query && dpkg-query -W -f='${Status}' "$2" 2>/dev/null | grep -q 'install ok installed' ;;
    fedora | suse) have rpm && rpm -q "$2" >/dev/null 2>&1 ;;
    *) return 1 ;;
  esac
}

# pkg_missing FAMILY PKG...: the packages that are not installed.
pkg_missing() {
  local fam=$1 p out=""
  shift
  for p in "$@"; do
    pkg_is_installed "$fam" "$p" || out+="${out:+ }$p"
  done
  printf '%s' "$out"
}

# pkg_commands FAMILY PKG...: the commands that install them, one per line.
pkg_commands() {
  local fam=$1 pk
  shift
  pk="$*"
  case $fam in
    arch) echo "${SUDO}pacman -S --needed --noconfirm $pk" ;;
    debian)
      if ! { have dpkg && dpkg --print-foreign-architectures 2>/dev/null | grep -qx i386; }; then
        echo "${SUDO}dpkg --add-architecture i386"
        echo "${SUDO}apt-get update"
      fi
      echo "${SUDO}apt-get install -y $pk"
      ;;
    fedora) echo "${SUDO}dnf install -y $pk" ;;
    suse) echo "${SUDO}zypper --non-interactive install $pk" ;;
    *) ;;
  esac
}

join_lines() {
  local out="" line
  while IFS= read -r line; do out+="${out:+ && }$line"; done
  printf '%s' "$out"
}

# ---------------------------------------------------------------------------------------------
# Libraries, umu, client

# elf_class FILE: 1 for a 32-bit ELF file, 2 for a 64-bit one.
elf_class() { od -An -tu1 -j4 -N1 "$1" 2>/dev/null | tr -d ' \n'; }

# find_lib CLASS GLOB DIRS: the first ELF file of that class matching GLOB in DIRS.
find_lib() {
  local class=$1 glob=$2 dirs=$3 d f
  for d in $dirs; do
    # shellcheck disable=SC2231
    for f in "$d"/$glob; do
      [[ -e $f ]] || continue
      if [[ $(elf_class "$f") == "$class" ]]; then
        printf '%s' "$f"
        return 0
      fi
    done
  done
  return 1
}

has_ld32() {
  local p
  for p in $LD32_PATHS; do [[ -e $p ]] && return 0; done
  return 1
}

# driver_glob VENDOR: the Vulkan driver library of a GPU vendor.
driver_glob() {
  case $1 in
    intel) echo "libvulkan_intel.so*" ;;
    amd) echo "libvulkan_radeon.so*" ;;
    nvidia) echo "libGLX_nvidia.so*" ;;
    *) echo "" ;;
  esac
}

find_umu() {
  if [[ -n ${UMU_RUN:-} && -x ${UMU_RUN} ]]; then
    printf '%s' "$UMU_RUN"
  elif have umu-run; then
    command -v umu-run
  elif [[ -x $BIN_DIR/umu-run ]]; then
    printf '%s' "$BIN_DIR/umu-run"
  else
    return 1
  fi
}

umu_version() {
  local u=$1
  "$u" --version 2>/dev/null | head -n 1 | sed 's/ (.*//' || true
}

find_proton() {
  local d p
  for d in "$DATA_HOME/Steam/compatibilitytools.d" "$HOME/.steam/root/compatibilitytools.d" "$DATA_HOME/umu/compatibilitytools.d"; do
    for p in "$d"/GE-Proton*; do
      if [[ -d $p ]]; then
        printf '%s' "$p"
        return 0
      fi
    done
  done
  return 1
}

client_ok() { [[ -f $1/Client64/WildStar64.exe ]]; }

# normalize_client PATH: the physical client folder (the one that holds Client64), or failure.
normalize_client() {
  local p=${1/#\~/$HOME}
  [[ -d $p ]] || return 1
  p=$(cd "$p" && pwd -P)
  if ! client_ok "$p" && [[ $(basename "$p") == Client64 && -f $p/WildStar64.exe ]]; then
    p=$(dirname "$p")
  fi
  client_ok "$p" || return 1
  printf '%s' "$p"
}

install_dir() { printf '%s/%s' "$PREFIX" "$INSTALL_REL"; }

# ---------------------------------------------------------------------------------------------
# The starter program (tools/linux/mkstart.py, embedded so this script is a single file)

emit_mkstart() {
  cat <<'MKSTART_PY'
#!/usr/bin/env python3
"""Write a tiny Windows program that starts a program with an exact command line.

    mkstart.py OUT.exe PROGRAM COMMAND_LINE

The program calls CreateProcessW(PROGRAM, COMMAND_LINE, ...) and waits for it.  The command line is
passed exactly as given, WITHOUT the program name in front.  WildStar64.exe needs that: it reads the
first word of its command line as "/auth" (the retail launcher starts it that way), and any other
first word makes it start the 32-bit launcher instead.  Windows' own `start` and Wine's `start`
always put the program name first, so neither can do it.

No compiler is needed; the program is assembled by hand (about 100 bytes of code, x86-64, imports
only CreateProcessW, WaitForSingleObject and ExitProcess from KERNEL32.dll).
"""
import struct
import sys

if len(sys.argv) != 4:
    sys.exit(__doc__)
out, program, cmdline = sys.argv[1:]

SEC_RVA, SEC_SIZE, FILE_ALIGN = 0x1000, 0x1000, 0x200
O_IMPORT, O_ILT, O_IAT, O_NAMES = 0x100, 0x140, 0x160, 0x180
O_APP, O_CMD, O_SI, O_PI = 0x200, 0x400, 0xC00, 0xC80
APP_MAX, CMD_MAX = 0x200, 0x800

sec = bytearray(SEC_SIZE)


def put(off, data):
    sec[off:off + len(data)] = data


def wide(s, limit):
    b = s.encode("utf-16le") + b"\0\0"
    if len(b) > limit:
        sys.exit("string too long: " + s[:40])
    return b


put(O_APP, wide(program, APP_MAX))
put(O_CMD, wide(cmdline, CMD_MAX))
put(O_SI, struct.pack("<I", 0x68))

# imports: hint/name entries then the DLL name
names = [b"CreateProcessW", b"WaitForSingleObject", b"ExitProcess"]
pos = O_NAMES
hn = []
for n in names:
    put(pos, struct.pack("<H", 0) + n + b"\0")
    hn.append(SEC_RVA + pos)
    pos += (2 + len(n) + 1 + 1) & ~1
dll_off = pos
put(dll_off, b"KERNEL32.dll\0")
for i, rva in enumerate(hn):
    put(O_ILT + 8 * i, struct.pack("<Q", rva))
    put(O_IAT + 8 * i, struct.pack("<Q", rva))
put(O_IMPORT, struct.pack("<IIIII", SEC_RVA + O_ILT, 0, 0, SEC_RVA + dll_off, SEC_RVA + O_IAT))

code = bytearray()


def emit(b):
    code.extend(b)


def rel(target):
    # rip-relative displacement from the end of the 4-byte field being written
    return struct.pack("<i", target - (len(code) + 4))


emit(b"\x48\x83\xEC\x58")                       # sub rsp,0x58
emit(b"\x48\x8D\x0D"); emit(rel(O_APP))         # lea rcx,[app]
emit(b"\x48\x8D\x15"); emit(rel(O_CMD))         # lea rdx,[cmd]
emit(b"\x45\x31\xC0")                           # xor r8d,r8d
emit(b"\x45\x31\xC9")                           # xor r9d,r9d
for off in (0x20, 0x28, 0x30, 0x38):
    emit(b"\x48\x83\x64\x24" + bytes([off, 0])) # and qword [rsp+off],0
emit(b"\x48\x8D\x05"); emit(rel(O_SI))          # lea rax,[si]
emit(b"\x48\x89\x44\x24\x40")                   # mov [rsp+0x40],rax
emit(b"\x48\x8D\x05"); emit(rel(O_PI))          # lea rax,[pi]
emit(b"\x48\x89\x44\x24\x48")                   # mov [rsp+0x48],rax
emit(b"\xFF\x15"); emit(rel(O_IAT + 0))         # call [CreateProcessW]
emit(b"\x48\x8B\x0D"); emit(rel(O_PI))          # mov rcx,[pi.hProcess]
emit(b"\xBA\xFF\xFF\xFF\xFF")                   # mov edx,-1
emit(b"\xFF\x15"); emit(rel(O_IAT + 8))         # call [WaitForSingleObject]
emit(b"\x31\xC9")                               # xor ecx,ecx
emit(b"\xFF\x15"); emit(rel(O_IAT + 16))        # call [ExitProcess]
assert len(code) < 0x100
put(0, bytes(code))

# PE headers
dos = bytearray(0x40)
dos[0:2] = b"MZ"
struct.pack_into("<I", dos, 0x3C, 0x40)
coff = struct.pack("<HHIIIHH", 0x8664, 1, 0, 0, 0, 0xF0, 0x0022)
opt = struct.pack(
    "<HBBIIIIIQIIHHHHHHIIIIHHQQQQII",
    0x20B, 14, 0, 0x200, 0x200, 0, SEC_RVA, SEC_RVA,
    0x140000000, 0x1000, FILE_ALIGN, 6, 0, 0, 0, 6, 0,
    0, SEC_RVA + SEC_SIZE, 0x200, 0, 3, 0x8160,
    0x100000, 0x1000, 0x100000, 0x1000, 0, 16)
dirs = bytearray(16 * 8)
struct.pack_into("<II", dirs, 8, SEC_RVA + O_IMPORT, 40)       # import table
struct.pack_into("<II", dirs, 12 * 8, SEC_RVA + O_IAT, 24)      # IAT
section = struct.pack("<8sIIIIIIHHI", b".text", SEC_SIZE, SEC_RVA, SEC_SIZE, 0x200, 0, 0, 0, 0, 0xE0000060)
hdr = bytes(dos) + b"PE\0\0" + coff + opt + bytes(dirs) + section
hdr = hdr.ljust(0x200, b"\0")
with open(out, "wb") as f:
    f.write(hdr + bytes(sec))
MKSTART_PY
}

write_starter() {
  local out=$1 cmdline=$2 tmp
  tmp=$(mktemp "${TMPDIR:-/tmp}/bezgelor-mkstart.XXXXXX")
  emit_mkstart >"$tmp"
  python3 -I "$tmp" "$out" 'C:\Program Files (x86)\NCSOFT\WildStar\Client64\WildStar64.exe' "$cmdline"
  rm -f "$tmp"
}

client_cmdline() {
  printf '/auth %s /authNc %s /lang en /patcher %s /SettingsKey WildStar /realmDataCenterId 9' "$1" "$1" "$1"
}

# ensure_link: the install folder inside the prefix is a link to the client folder.
ensure_link() {
  local dir parent real
  dir=$(install_dir)
  parent=$(dirname "$dir")
  real=$(cd "$CLIENT_DIR" && pwd -P)
  if [[ -L $dir ]]; then
    if [[ $(readlink -f "$dir") == "$real" ]]; then
      note "install folder link already in place"
      return 0
    fi
    run ln -sfn "$real" "$dir"
  elif [[ -e $dir ]]; then
    return 2
  else
    run mkdir -p "$parent"
    run ln -s "$real" "$dir"
  fi
}

# ---------------------------------------------------------------------------------------------
# Progress for long steps

# with_progress LABEL LOGFILE COMMAND...: runs the command with a spinner, the elapsed time and the
# last line of its output; the full output goes to LOGFILE.
with_progress() {
  local label=$1 log=$2 pid start rc=0 i=0 last next=15 frames='|/-\'
  shift 2
  if [[ $DRY_RUN -eq 1 ]]; then
    printf '      + %s\n' "$(quote_cmd "$@")"
    return 0
  fi
  mkdir -p "$(dirname "$log")"
  "$@" >"$log" 2>&1 </dev/null &
  pid=$!
  start=$SECONDS
  while kill -0 "$pid" 2>/dev/null; do
    if [[ -t 1 ]]; then
      last=$(tail -n 1 "$log" 2>/dev/null | tr -d '\r' | cut -c1-50) || true
      printf '\r      %s %s  %ss  %s\033[K' "${frames:i%4:1}" "$label" "$((SECONDS - start))" "$last"
      i=$((i + 1))
      sleep 0.2
    else
      if ((SECONDS - start >= next)); then
        printf '      %s: still working (%ss)\n' "$label" "$((SECONDS - start))"
        next=$((next + 15))
      fi
      sleep 1
    fi
  done
  wait "$pid" || rc=$?
  [[ -t 1 ]] && printf '\r\033[K'
  if [[ $rc -ne 0 ]]; then
    printf '      %s failed (exit %s). Last lines of %s:\n' "$label" "$rc" "$log" >&2
    tail -n 8 "$log" | sed 's/^/        /' >&2
    return "$rc"
  fi
  note "$label done in $((SECONDS - start))s"
}

# ---------------------------------------------------------------------------------------------
# install

usage_install() {
  cat <<'EOF'
Usage: bezgelor-linux.sh install [options]

Sets up the WildStar client on Linux: the 32-bit system libraries and Vulkan driver for your graphics
card, umu-launcher with Proton-GE, a Wine prefix, the install folder link, the starter program, a
launcher command (bezgelor-wildstar) and an application menu entry (Bezgelor WildStar). Safe to run again.

Options:
  --server HOST       the Bezgelor server address (default: the address this script was downloaded from)
  --client-dir DIR    the folder that holds Client64 (your own copy of the client; it is asked for if missing)
  --prefix DIR        the Wine prefix (default: ~/.local/share/bezgelor/prefix)
  --gpu VENDOR        intel, amd or nvidia, when the graphics card is not detected
  --yes               do not ask for confirmation (the commands are still printed)
  --no-packages       print the package commands but do not run them
  --no-warmup         do not download Proton and create the prefix now (the first play does it)
  --dry-run           print every command that would run, change nothing
  -h, --help          this text

The administrator password is asked for once, by sudo, when system packages are installed.
The client files are not downloaded by this script.
EOF
}

NO_PACKAGES=0
NO_WARMUP=0
GPU_OVERRIDE=""

cmd_install() {
  SERVER="" CLIENT_DIR="" PREFIX=""
  while [[ $# -gt 0 ]]; do
    case $1 in
      --server) SERVER=${2:?--server needs a host}; shift 2 ;;
      --client-dir) CLIENT_DIR=${2:?--client-dir needs a folder}; shift 2 ;;
      --prefix) PREFIX=${2:?--prefix needs a folder}; shift 2 ;;
      --gpu) GPU_OVERRIDE=${2:?--gpu needs intel, amd or nvidia}; shift 2 ;;
      --yes | -y) ASSUME_YES=1; shift ;;
      --no-packages) NO_PACKAGES=1; shift ;;
      --no-warmup) NO_WARMUP=1; shift ;;
      --dry-run) DRY_RUN=1; shift ;;
      -h | --help) usage_install; return 0 ;;
      *) usage_install >&2; die "unknown option: $1" ;;
    esac
  done
  load_settings
  local own_prefix="$PREFIX"
  [[ $DRY_RUN -eq 0 ]] || say "Dry run: nothing is changed; the commands that would run are printed with a leading +."
  say "${C_BOLD}Bezgelor WildStar installer for Linux${C_OFF} (version $BEZGELOR_LINUX_VERSION)"

  # 1. system
  step "Checking the system"
  local family vendors
  family=$(detect_family "$OS_RELEASE")
  vendors=${GPU_OVERRIDE:-$(detect_gpus)}
  note "System:   $(os_field "$OS_RELEASE" PRETTY_NAME) - $(family_label "$family")"
  note "Graphics: ${vendors:-not detected} $(gpu_names | sed 's/^/(/;s/$/)/;s/()//')"
  have python3 || die "python3 is required (the installer builds the starter program with it)."
  have curl || note "curl is not installed; it is only needed to download umu-launcher when no package exists."
  if [[ -z $SERVER ]]; then
    if [[ -n $BAKED_SERVER ]]; then SERVER=$BAKED_SERVER; fi
  fi
  if [[ -z $SERVER ]]; then
    ask "Bezgelor server address" "" || die "no server address: pass --server HOST."
    SERVER=$REPLY
  fi
  valid_host "$SERVER" || die "'$SERVER' is not a valid server address (a host name or an IP address, without a port)."
  note "Server:   $SERVER"

  # 2. client folder
  step "Finding the WildStar client folder"
  local found="" cand
  for cand in "$CLIENT_DIR" "${WILDSTAR_CLIENT_DIR:-}" "$STATE_DIR/client" "$HOME/WildStar" "$HOME/Games/WildStar"; do
    [[ -n $cand ]] || continue
    if found=$(normalize_client "$cand"); then break; fi
    found=""
  done
  if [[ -n $CLIENT_DIR && -z $found ]]; then
    die "'$CLIENT_DIR' is not a WildStar client folder: it must contain Client64/WildStar64.exe."
  fi
  local tries=0
  while [[ -z $found ]]; do
    tries=$((tries + 1))
    [[ $tries -le 3 ]] || die "no client folder given. Pass --client-dir DIR (the folder that holds Client64)."
    ask "Folder that holds Client64 (your own WildStar client)" "" || die "client folder not found. Pass --client-dir DIR."
    found=$(normalize_client "$REPLY") || {
      note "Not a client folder: Client64/WildStar64.exe is missing in '$REPLY'."
      found=""
    }
  done
  CLIENT_DIR=$found
  note "Client:   $CLIENT_DIR"
  [[ -f $CLIENT_DIR/Patch/ClientData.archive ]] || note "Warning: Patch/ClientData.archive is missing; the client folder looks incomplete."
  PREFIX=${own_prefix:-$PREFIX}
  note "Prefix:   $PREFIX"

  # 3. system packages
  step "System libraries (32-bit)"
  UMU_VIA_PKG=0
  install_system_packages "$family" "$vendors"

  # 4. umu-launcher
  step "umu-launcher"
  install_umu

  # 5. Proton and prefix
  step "Proton-GE and the Wine prefix"
  warm_prefix

  # 6. link and starter
  step "Install folder link and starter program"
  local rc=0
  ensure_link || rc=$?
  if [[ $rc -eq 2 ]]; then
    local dir
    dir=$(install_dir)
    note "$dir is a real folder (a copy of the client), not a link."
    if confirm "Remove that copy so it can become a link to your client folder? Your client folder is not touched." y; then
      run rm -rf -- "$dir"
      ensure_link
    else
      problem "the install folder is a copy; the launcher would copy the client again (about 14 GB)."
    fi
  fi
  if [[ $DRY_RUN -eq 1 ]]; then
    note "+ write $STATE_DIR/wildstar-start.exe (starts WildStar64.exe with: $(client_cmdline "$SERVER"))"
  else
    mkdir -p "$STATE_DIR"
    write_starter "$STATE_DIR/wildstar-start.exe" "$(client_cmdline "$SERVER")"
    note "starter written: $STATE_DIR/wildstar-start.exe"
  fi

  # 7. launcher and menu entry
  step "Launcher command and application menu entry"
  config_write "$SERVER" "$CLIENT_DIR" "$PREFIX"
  install_launcher

  # 8. check
  step "Checking the result"
  if [[ $DRY_RUN -eq 1 ]]; then
    note "Dry run: doctor is not run."
    return 0
  fi
  local worst=0
  say ""
  doctor_run || worst=$?
  say ""
  if [[ $worst -eq 0 && $PROBLEMS -eq 0 ]]; then
    local shown=bezgelor-wildstar
    have bezgelor-wildstar || shown=$LAUNCHER
    if find_proton >/dev/null; then
      say "${C_OK}Installed.${C_OFF}"
    else
      say "${C_OK}Installed.${C_OFF} The first start downloads Proton-GE (about 700 MB) and takes a few minutes."
    fi
    say "Run: $shown play"
  else
    say "${C_WARN}The installation finished with problems.${C_OFF} Fix the lines marked FAIL above, then run this installer again."
    return 1
  fi
}

install_system_packages() {
  local family=$1 vendors=$2 pkgs missing cmds umu_pkg=""
  case $family in
    steamos)
      note "SteamOS keeps its system files read-only and already includes the 32-bit libraries. Nothing to install."
      return 0
      ;;
    unknown)
      note "This distribution is not handled automatically. Install, from your distribution:"
      note "  - the 32-bit C library (glibc, 32-bit / i386 / i686 build)"
      note "  - the 32-bit Vulkan loader (libvulkan, 32-bit)"
      note "  - the 32-bit Vulkan driver for your graphics card (Mesa Vulkan intel / radeon, or the 32-bit NVIDIA driver)"
      note "  - umu-launcher (or let this installer download it)"
      note "Then run this installer again."
      return 0
      ;;
    *) ;;
  esac
  if [[ -z $vendors ]]; then
    note "The graphics card was not detected: only the common 32-bit libraries are installed."
    note "Run with --gpu intel, --gpu amd or --gpu nvidia to add the 32-bit driver."
  fi
  if [[ $family == arch ]] && ! grep -q '^\[multilib\]' "$PACMAN_CONF" 2>/dev/null; then
    problem "the [multilib] repository is not enabled. In $PACMAN_CONF remove the # in front of [multilib] and the Include line under it, run: ${SUDO}pacman -Sy, then run this installer again."
    return 0
  fi
  pkgs=$(pkg_list "$family" "$vendors")
  if ! find_umu >/dev/null; then
    umu_pkg=$(umu_pkg_for "$family")
    if [[ -n $umu_pkg ]] && umu_pkg_available "$family"; then
      pkgs+=" $umu_pkg"
      UMU_VIA_PKG=1
    fi
  fi
  # shellcheck disable=SC2086
  missing=$(pkg_missing "$family" $pkgs)
  if [[ -z $missing ]]; then
    note "All required packages are already installed."
    return 0
  fi
  # shellcheck disable=SC2086
  cmds=$(pkg_commands "$family" $missing)
  note "Packages to install: $missing"
  note "Commands:"
  while IFS= read -r line; do note "  $line"; done <<<"$cmds"
  if [[ $NO_PACKAGES -eq 1 ]]; then
    note "Skipped (--no-packages). Run the commands above yourself."
    return 0
  fi
  if [[ $DRY_RUN -eq 0 && -n $SUDO ]] && ! have sudo; then
    problem "sudo is not installed. Run the commands above as the administrator, then run this installer again."
    return 0
  fi
  if ! confirm "Run these commands now? Your administrator password is asked once." y; then
    note "Not run. Install the packages yourself and run this installer again."
    UMU_VIA_PKG=0
    return 0
  fi
  local line words
  while IFS= read -r line; do
    read -ra words <<<"$line"
    run "${words[@]}" || {
      problem "'$line' failed."
      UMU_VIA_PKG=0
      return 0
    }
  done <<<"$cmds"
}

install_umu() {
  local umu tag url tmp opt
  if umu=$(find_umu); then
    note "umu-run found: $umu ($(umu_version "$umu"))"
    return 0
  fi
  if [[ $UMU_VIA_PKG -eq 1 && $DRY_RUN -eq 1 ]]; then
    note "umu-launcher is installed by the package command above."
    return 0
  fi
  have curl || {
    problem "umu-run is missing and curl is not installed to download it. Install umu-launcher from your distribution."
    return 0
  }
  note "No umu-launcher package: downloading the official release into $BIN_DIR."
  if [[ $DRY_RUN -eq 1 ]]; then
    note "+ download the latest zipapp from https://github.com/$UMU_REPO/releases and link it as $BIN_DIR/umu-run"
    return 0
  fi
  tmp=$(mktemp -d "${TMPDIR:-/tmp}/bezgelor-umu.XXXXXX")
  url=$(curl -fsSL "https://api.github.com/repos/$UMU_REPO/releases/latest" |
    python3 -I -c 'import json,sys
d=json.load(sys.stdin)
for a in d.get("assets",[]):
    if a["name"].endswith("zipapp.tar"):
        print(d["tag_name"], a["browser_download_url"]); break') || url=""
  if [[ -z $url ]]; then
    rm -rf "$tmp"
    problem "could not find the umu-launcher release. Install umu-launcher from https://github.com/$UMU_REPO"
    return 0
  fi
  tag=${url%% *}
  url=${url#* }
  with_progress "downloading umu-launcher $tag" "$LOG_DIR/umu-download.log" curl -fL --retry 3 -o "$tmp/umu.tar" "$url" || {
    rm -rf "$tmp"
    problem "the umu-launcher download failed."
    return 0
  }
  opt=$HOME/.local/opt/umu-$tag
  mkdir -p "$opt" "$BIN_DIR"
  tar -xf "$tmp/umu.tar" -C "$opt" --strip-components=0
  rm -rf "$tmp"
  local bin
  bin=$(find "$opt" -name umu-run -type f | head -n 1)
  if [[ -z $bin ]]; then
    problem "umu-run was not found in the downloaded archive."
    return 0
  fi
  chmod +x "$bin"
  ln -sfn "$bin" "$BIN_DIR/umu-run"
  note "umu-run installed: $BIN_DIR/umu-run ($(umu_version "$BIN_DIR/umu-run"))"
}

warm_prefix() {
  local umu proton
  umu=$(find_umu) || {
    if [[ $DRY_RUN -eq 1 ]]; then
      note "(after umu-launcher is installed) + umu-run wineboot -u, with WINEPREFIX=$PREFIX"
      return 0
    fi
    problem "umu-run is not available, so Proton cannot be prepared."
    return 0
  }
  if [[ -d $PREFIX/drive_c ]] && proton=$(find_proton); then
    note "Proton-GE ($(basename "$proton")) and the prefix are already in place."
    return 0
  fi
  if [[ $NO_WARMUP -eq 1 ]]; then
    note "Skipped (--no-warmup): the first 'bezgelor-wildstar play' downloads Proton-GE (about 700 MB) and creates the prefix."
    return 0
  fi
  note "The first run downloads Proton-GE and the Steam Linux Runtime (about 700 MB). This takes a few minutes."
  run mkdir -p "$PREFIX" "$LOG_DIR"
  with_progress "preparing Proton and the prefix" "$LOG_DIR/prefix-setup.log" \
    env WINEPREFIX="$PREFIX" GAMEID=umu-default PROTONPATH=GE-Proton "$umu" wineboot -u || problem "the Proton setup failed; see $LOG_DIR/prefix-setup.log. Check the network connection and run this installer again."
}

# fetch_launcher: no script file (the script came through a pipe): download the installer from the
# address baked into it, check that it is this script, and install it as the launcher.
fetch_launcher() {
  local tmp
  [[ -n $BEZGELOR_INSTALLER_URL ]] || {
    problem "this script has no file and no download address; save it to a file and run it from there."
    return 0
  }
  if [[ $DRY_RUN -eq 1 ]]; then
    note "+ download $BEZGELOR_INSTALLER_URL to $LAUNCHER"
    note "+ write $DESKTOP_FILE"
    return 0
  fi
  have curl || {
    problem "curl is needed to download the launcher."
    return 0
  }
  mkdir -p "$BIN_DIR" "$(dirname "$DESKTOP_FILE")"
  tmp=$(mktemp "${TMPDIR:-/tmp}/bezgelor-launcher.XXXXXX")
  if ! curl -fsSL --retry 3 -o "$tmp" "$BEZGELOR_INSTALLER_URL" </dev/null; then
    rm -f "$tmp"
    problem "the launcher download from $BEZGELOR_INSTALLER_URL failed."
    return 0
  fi
  if ! is_installer_script "$tmp"; then
    rm -f "$tmp"
    problem "$BEZGELOR_INSTALLER_URL did not return this installer (wrong version or incomplete download)."
    return 0
  fi
  install -m 755 "$tmp" "$LAUNCHER"
  rm -f "$tmp"
  note "launcher downloaded: $LAUNCHER"
  write_desktop_entry
}

# is_installer_script FILE: a complete bash script of this installer's version.
is_installer_script() {
  head -n 1 "$1" | grep -q '^#!/usr/bin/env bash' &&
    grep -qx "BEZGELOR_LINUX_VERSION=\"$BEZGELOR_LINUX_VERSION\"" "$1" &&
    [[ $(tail -n 1 "$1") == 'bezgelor_entry "$@"' ]] &&
    bash -n "$1" 2>/dev/null
}

write_desktop_entry() {
  local exec_path=$LAUNCHER
  [[ $exec_path != *" "* ]] || exec_path="\"$exec_path\""
  desktop_text "$exec_path" >"$DESKTOP_FILE"
  have update-desktop-database && update-desktop-database "$(dirname "$DESKTOP_FILE")" >/dev/null 2>&1 || true
  note "menu entry: Bezgelor WildStar ($DESKTOP_FILE)"
  path_hint
}

path_hint() {
  case ":$PATH:" in
    *":$BIN_DIR:"*) ;;
    *)
      note "Warning: $BIN_DIR is not in your PATH, so 'bezgelor-wildstar' alone will not be found."
      note "Add it with: echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.profile   (then log in again)"
      ;;
  esac
}

install_launcher() {
  local self dest_state="installed"
  self=$(readlink -f "${BASH_SOURCE[0]:-}" 2>/dev/null || true)
  if [[ -z $self || ! -f $self ]]; then
    fetch_launcher
    return 0
  fi
  if [[ $DRY_RUN -eq 1 ]]; then
    note "+ copy $self to $LAUNCHER"
    note "+ write $DESKTOP_FILE"
    return 0
  fi
  mkdir -p "$BIN_DIR" "$(dirname "$DESKTOP_FILE")"
  if [[ $(readlink -f "$LAUNCHER" 2>/dev/null || true) != "$self" ]]; then
    cp "$self" "$LAUNCHER"
  else
    dest_state="already in place"
  fi
  chmod 755 "$LAUNCHER"
  note "launcher $dest_state: $LAUNCHER"
  write_desktop_entry
}

desktop_text() {
  cat <<EOF
[Desktop Entry]
Type=Application
Name=Bezgelor WildStar
Comment=Play WildStar on a Bezgelor server
Exec=$1 play
Terminal=false
Categories=Game;
Icon=applications-games
EOF
}

# ---------------------------------------------------------------------------------------------
# play

usage_play() {
  cat <<'EOF'
Usage: bezgelor-wildstar play [options] [-- extra client arguments]

Starts the WildStar client through Proton-GE and connects it to the Bezgelor server from the
configuration (~/.config/bezgelor/linux.conf). The first start asks for a display language and shows
the game's own user agreement: read it and accept it yourself.

Options:
  --server HOST       use another server address for this start
  --client-dir DIR    use another client folder for this start
  --prefix DIR        use another Wine prefix for this start
  --dry-run           print what would run
  -h, --help          this text

Environment: DXVK_HUD=fps,devinfo shows the frame rate; PROTONPATH selects another Proton.
Arguments after -- are passed on to the client.
EOF
}

cmd_play() {
  SERVER="" CLIENT_DIR="" PREFIX=""
  local extra=()
  while [[ $# -gt 0 ]]; do
    case $1 in
      --server) SERVER=${2:?--server needs a host}; shift 2 ;;
      --client-dir) CLIENT_DIR=${2:?--client-dir needs a folder}; shift 2 ;;
      --prefix) PREFIX=${2:?--prefix needs a folder}; shift 2 ;;
      --dry-run) DRY_RUN=1; shift ;;
      -h | --help) usage_play; return 0 ;;
      --) shift; extra+=("$@"); break ;;
      *) extra+=("$1"); shift ;;
    esac
  done
  load_settings
  [[ -n $SERVER ]] || die "no server address. Run the installer, or pass --server HOST."
  valid_host "$SERVER" || die "'$SERVER' is not a valid server address."
  if [[ ! -t 2 && $DRY_RUN -eq 0 ]]; then
    mkdir -p "$LOG_DIR"
    exec >>"$LOG_DIR/launcher.log" 2>&1
    printf '\n--- %s ---\n' "$(date '+%F %T')"
  fi
  local umu
  umu=$(find_umu) || die "umu-run not found. Run: bezgelor-wildstar doctor"
  client_ok "$CLIENT_DIR" || die "WildStar client not found in $CLIENT_DIR (Client64/WildStar64.exe is missing). Run: bezgelor-wildstar doctor"
  if ! has_ld32 && [[ -z ${WILDSTAR_SKIP_32BIT_CHECK:-} ]]; then
    die "no 32-bit loader (ld-linux.so.2): the game is a 32-bit program and would close after the user agreement. Run: bezgelor-wildstar doctor"
  fi
  local rc=0
  mkdir -p "$PREFIX" "$LOG_DIR" "$STATE_DIR"
  ensure_link || rc=$?
  [[ $rc -ne 2 ]] || die "$(install_dir) is a copy of the client, not a link. Remove it and run the installer again: bezgelor-wildstar doctor"

  local cmdline
  cmdline=$(client_cmdline "$SERVER")
  [[ ${#extra[@]} -eq 0 ]] || cmdline="$cmdline ${extra[*]}"
  export WINEPREFIX=$PREFIX GAMEID=${GAMEID:-umu-default} PROTONPATH=${PROTONPATH:-GE-Proton}
  export PROTON_LOG=${PROTON_LOG:-0} PROTON_LOG_DIR=$LOG_DIR
  [[ -z ${DXVK_HUD:-} ]] || export DXVK_HUD
  if [[ $DRY_RUN -eq 1 ]]; then
    say "WINEPREFIX=$PREFIX GAMEID=$GAMEID PROTONPATH=$PROTONPATH"
    say "starter command line: $cmdline"
    say "+ cd $CLIENT_DIR/Client64 && $umu $STATE_DIR/wildstar-start.exe"
    return 0
  fi
  write_starter "$STATE_DIR/wildstar-start.exe" "$cmdline"
  cd "$CLIENT_DIR/Client64"
  exec "$umu" "$STATE_DIR/wildstar-start.exe"
}

# ---------------------------------------------------------------------------------------------
# doctor

usage_doctor() {
  cat <<'EOF'
Usage: bezgelor-wildstar doctor [--report]

Checks the installation without changing anything. Every line is PASS, WARN or FAIL; a line that is not
PASS says how to fix it. The exit status is 1 when there is a FAIL.

  --report    print a block for a bug report: the same checks with the home folder, the user name,
              the computer name and every IP address except the server's removed
EOF
}

D_PASS=0
D_WARN=0
D_FAIL=0

# result LEVEL LABEL DETAIL [FIX]
result() {
  local lvl=$1 label=$2 detail=$3 fix=${4:-} colour=""
  case $lvl in
    PASS) D_PASS=$((D_PASS + 1)); colour=$C_OK ;;
    WARN) D_WARN=$((D_WARN + 1)); colour=$C_WARN ;;
    *) D_FAIL=$((D_FAIL + 1)); colour=$C_BAD ;;
  esac
  printf '%s%-4s%s  %-22s %s\n' "$colour" "$lvl" "$C_OFF" "$label" "$detail"
  [[ -z $fix ]] || printf '      Fix: %s\n' "$fix"
}

tcp_open() { timeout 3 bash -c 'exec 3<>"/dev/tcp/$0/$1"' "$1" "$2" 2>/dev/null; }

# human_age SECONDS
human_age() {
  local s=$1
  if ((s < 3600)); then
    echo "$((s / 60)) min ago"
  elif ((s < 172800)); then
    echo "$((s / 3600)) h ago"
  else
    echo "$((s / 86400)) days ago"
  fi
}

doctor_system() {
  local family vendors pretty libs_fix pkgs
  family=$(detect_family "$OS_RELEASE")
  pretty=$(os_field "$OS_RELEASE" PRETTY_NAME)
  if [[ $family == unknown ]]; then
    result WARN "Distribution" "${pretty:-unknown}, kernel $(uname -r): not handled by the installer" \
      "install the 32-bit glibc, Vulkan loader and Mesa/NVIDIA Vulkan driver yourself (see the Linux client guide)"
  else
    result PASS "Distribution" "$pretty ($(family_label "$family")), kernel $(uname -r)"
  fi

  vendors=$(detect_gpus)
  pkgs=$(pkg_list "$family" "$vendors")
  # shellcheck disable=SC2086
  libs_fix=$(pkg_commands "$family" $pkgs | join_lines)
  [[ -n $libs_fix ]] || libs_fix="install the 32-bit glibc, Vulkan loader and Vulkan driver for your graphics card"
  if [[ $family == steamos ]]; then libs_fix="SteamOS includes them; update SteamOS"; fi

  if [[ -n $vendors ]]; then
    result PASS "Graphics card" "$vendors ($(gpu_names))"
  else
    result WARN "Graphics card" "not detected" "install pciutils (lspci), or run: bezgelor-linux.sh install --gpu intel|amd|nvidia"
  fi

  if has_ld32; then
    result PASS "32-bit loader" "ld-linux.so.2 present"
  else
    result FAIL "32-bit loader" "ld-linux.so.2 is missing; the game closes after the user agreement" "$libs_fix"
  fi

  local f
  if f=$(find_lib 2 "libvulkan.so.1" "$LIBDIRS64"); then
    result PASS "Vulkan loader (64)" "$f"
  else
    result FAIL "Vulkan loader (64)" "libvulkan.so.1 not found" "$libs_fix"
  fi
  if f=$(find_lib 1 "libvulkan.so.1" "$LIBDIRS32"); then
    result PASS "Vulkan loader (32)" "$f"
  else
    result FAIL "Vulkan loader (32)" "32-bit libvulkan.so.1 not found" "$libs_fix"
  fi

  local v glob
  for v in $vendors; do
    glob=$(driver_glob "$v")
    if f=$(find_lib 2 "$glob" "$LIBDIRS64"); then
      result PASS "Vulkan driver (64) $v" "$f"
    else
      result WARN "Vulkan driver (64) $v" "not found" "$libs_fix"
    fi
    if f=$(find_lib 1 "$glob" "$LIBDIRS32"); then
      result PASS "Vulkan driver (32) $v" "$f"
    else
      result FAIL "Vulkan driver (32) $v" "32-bit $v Vulkan driver not found; DXVK cannot start" "$libs_fix"
    fi
  done

  if have vulkaninfo; then
    local devs
    devs=$(vulkaninfo --summary 2>/dev/null | sed -n 's/^ *deviceName *= *//p' | paste -sd';' - | sed 's/;/; /g') || devs=""
    if [[ -z $devs ]]; then
      result FAIL "vulkaninfo" "lists no Vulkan device" "check the graphics driver; run: vulkaninfo --summary"
    elif [[ $devs == *llvmpipe* && $devs != *\;* ]]; then
      result WARN "vulkaninfo" "only a software device: $devs" "install and select the Vulkan driver for your graphics card"
    else
      result PASS "vulkaninfo" "$devs"
    fi
  else
    result WARN "vulkaninfo" "not installed (optional)" "install vulkan-tools (Debian: vulkan-tools, Fedora: vulkan-tools) to list the Vulkan devices"
  fi
}

doctor_umu() {
  local umu proton
  if umu=$(find_umu); then
    result PASS "umu-launcher" "$umu ($(umu_version "$umu"))"
  else
    result FAIL "umu-launcher" "umu-run not found" "run the installer again: bezgelor-linux.sh install"
  fi
  if proton=$(find_proton); then
    result PASS "GE-Proton" "$(basename "$proton")"
  else
    result WARN "GE-Proton" "not downloaded yet (about 700 MB, downloaded by the installer or the first start)" "run: bezgelor-wildstar play"
  fi
}

doctor_files() {
  local dir real
  if [[ $CONFIG_PRESENT -eq 1 ]]; then
    result PASS "Configuration" "$CONF_FILE (server $SERVER)"
  else
    result WARN "Configuration" "$CONF_FILE not found; defaults in use" "run the installer: bezgelor-linux.sh install"
  fi
  if [[ -d $PREFIX/drive_c ]]; then
    result PASS "Prefix" "$PREFIX"
  else
    result WARN "Prefix" "$PREFIX does not exist yet" "run the installer, or start the game once: bezgelor-wildstar play"
  fi
  if client_ok "$CLIENT_DIR" && [[ -f $CLIENT_DIR/Patch/ClientData.archive ]]; then
    result PASS "Client folder" "$CLIENT_DIR (Client64/WildStar64.exe, Patch/ClientData.archive)"
  elif client_ok "$CLIENT_DIR"; then
    result FAIL "Client folder" "$CLIENT_DIR has no Patch/ClientData.archive: the client is incomplete" "copy the complete client folder (about 30 GB)"
  else
    result FAIL "Client folder" "$CLIENT_DIR has no Client64/WildStar64.exe" "run the installer with your client folder: bezgelor-linux.sh install --client-dir DIR"
  fi
  dir=$(install_dir)
  if [[ -L $dir ]]; then
    real=$(readlink -f "$dir" 2>/dev/null || true)
    if [[ ! -d $real ]]; then
      result FAIL "Install folder link" "the link points to a folder that does not exist" "run the installer again: bezgelor-linux.sh install"
    elif ! client_ok "$real"; then
      result FAIL "Install folder link" "the link target has no Client64/WildStar64.exe" "run the installer again: bezgelor-linux.sh install --client-dir DIR"
    elif [[ $real != "$(cd "$CLIENT_DIR" 2>/dev/null && pwd -P || true)" ]]; then
      result WARN "Install folder link" "points to $real, not to the configured client folder" "run the installer again: bezgelor-linux.sh install"
    else
      result PASS "Install folder link" "-> $real"
    fi
  elif [[ -e $dir ]]; then
    result FAIL "Install folder link" "$INSTALL_REL is a real folder (a copy); the launcher would copy 14 GB" "remove it and run the installer again: rm -rf '$dir' && bezgelor-linux.sh install"
  else
    result FAIL "Install folder link" "missing; the client would not find its files" "run the installer again: bezgelor-linux.sh install"
  fi
  if [[ -s $STATE_DIR/wildstar-start.exe ]]; then
    result PASS "Starter program" "$STATE_DIR/wildstar-start.exe"
  else
    result WARN "Starter program" "wildstar-start.exe is missing (written at each start)" "run: bezgelor-wildstar play"
  fi
  local probe=$PREFIX avail
  while [[ ! -d $probe && $probe != / ]]; do probe=$(dirname "$probe"); done
  avail=$(df -Pk "$probe" 2>/dev/null | awk 'NR==2 {print int($4/1048576)}') || avail=""
  if [[ -z $avail ]]; then
    result WARN "Free disk space" "could not be read"
  elif ((avail < 2)); then
    result FAIL "Free disk space" "${avail} GB free where the prefix lives" "free at least 5 GB"
  elif ((avail < 10)); then
    result WARN "Free disk space" "${avail} GB free where the prefix lives" "free more space; Proton and the prefix need about 5 GB"
  else
    result PASS "Free disk space" "${avail} GB free where the prefix lives"
  fi
}

doctor_session() {
  local type=${XDG_SESSION_TYPE:-}
  if [[ -n ${WAYLAND_DISPLAY:-} || $type == wayland ]]; then
    if [[ -n ${DISPLAY:-} ]]; then
      result PASS "Session" "Wayland with XWayland (DISPLAY=${DISPLAY})"
    else
      result WARN "Session" "Wayland without XWayland" "enable XWayland in your compositor (Xwayland package)"
    fi
  elif [[ -n ${DISPLAY:-} ]]; then
    result PASS "Session" "X11 (DISPLAY=${DISPLAY})"
  else
    result WARN "Session" "no graphical session in this terminal" "start the game from the desktop, not over ssh"
  fi
}

doctor_server() {
  local tmp name port spec host=$SERVER
  if [[ -z $host ]]; then
    result FAIL "Server" "no server address configured" "run the installer: bezgelor-linux.sh install --server HOST"
    return 0
  fi
  if ! [[ $host =~ ^[0-9.]+$ ]] && ! getent hosts "$host" >/dev/null 2>&1; then
    result FAIL "Server" "$host does not resolve" "check the address and your network connection"
    return 0
  fi
  tmp=$(mktemp -d "${TMPDIR:-/tmp}/bezgelor-doctor.XXXXXX")
  for spec in "Auth:$AUTH_PORT" "Realm:$REALM_PORT" "World:$WORLD_PORT" "Portal:$PORTAL_PORT"; do
    name=${spec%%:*}
    port=${spec##*:}
    (if tcp_open "$host" "$port"; then echo ok >"$tmp/$name"; else echo no >"$tmp/$name"; fi) &
  done
  wait
  for spec in "Auth:$AUTH_PORT" "Realm:$REALM_PORT" "World:$WORLD_PORT" "Portal:$PORTAL_PORT"; do
    name=${spec%%:*}
    port=${spec##*:}
    if [[ $(cat "$tmp/$name" 2>/dev/null) == ok ]]; then
      result PASS "Server $name port $port" "$host reachable"
    elif [[ $name == Portal ]]; then
      result WARN "Server $name port $port" "$host does not answer on $port within 3 s" "only the portal is affected; the game does not use this port"
    else
      result FAIL "Server $name port $port" "$host does not answer on $port within 3 s" "check that the server is running and that a firewall or router does not block port $port"
    fi
  done
  rm -rf "$tmp"
}

doctor_crashes() {
  local dir=$CLIENT_DIR/Errors newest ts age name fatal base log
  if [[ -d $dir ]]; then
    newest=$(find "$dir" -maxdepth 1 -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n 1) || true
  else
    newest=""
  fi
  if [[ -z $newest ]]; then
    result PASS "Client crash reports" "none in $dir"
  else
    ts=${newest%%.*}
    name=${newest#* }
    age=$(($(date +%s) - ts))
    base=${name%.*}
    fatal=$(tr -d '\r' <"$base.txt" 2>/dev/null | grep -v '^//' | sed 's/^ *//' | grep -m1 . | cut -c1-120) || true
    if ((age < 14 * 86400)); then
      result WARN "Client crash reports" "newest: $(basename "$name") ($(human_age "$age"))${fatal:+; $fatal}" "see Troubleshooting in the Linux client guide"
    else
      result PASS "Client crash reports" "newest is $(basename "$name") ($(human_age "$age"))${fatal:+; $fatal}"
    fi
  fi
  log=$CLIENT_DIR/NcPlatform.log
  if [[ -r $log ]]; then
    fatal=$(grep -i -A2 'Fatal error' "$log" | tail -n 3 | tr '\n' ' ' | sed 's/  */ /g') || fatal=""
    if [[ -n $fatal ]]; then
      result WARN "NcPlatform.log" "last fatal error: ${fatal:0:140}" "if the game has run since, ignore it; otherwise see Troubleshooting in the Linux client guide"
    else
      result PASS "NcPlatform.log" "no fatal error"
    fi
  else
    result PASS "NcPlatform.log" "not present (the game has not run here yet)"
  fi
}

# doctor_run: all checks; returns 1 when any check failed.
doctor_run() {
  D_PASS=0 D_WARN=0 D_FAIL=0
  doctor_system
  doctor_umu
  doctor_files
  doctor_session
  doctor_server
  doctor_crashes
  printf '\n%s passed, %s warnings, %s failures\n' "$D_PASS" "$D_WARN" "$D_FAIL"
  [[ $D_FAIL -eq 0 ]]
}

# report_filter: removes what identifies the person or the network from a report (standard input to output).
report_filter() {
  local user=${USER:-$(id -un)} host
  host=$(uname -n)
  local server=${SERVER:-} text
  text=$(cat)
  text=${text//"$HOME"/\~}
  text=${text//$'\r'/}
  if [[ -n $server ]]; then text=${text//"$server"/@SERVER@}; fi
  if [[ ${#user} -ge 2 ]]; then text=$(printf '%s' "$text" | sed -E "s/(^|[^A-Za-z0-9])$(printf '%s' "$user" | sed 's/[][\.*^$/]/\\&/g')([^A-Za-z0-9]|$)/\1user\2/g"); fi
  if [[ ${#host} -ge 2 ]]; then text=$(printf '%s' "$text" | sed -E "s/$(printf '%s' "$host" | sed 's/[][\.*^$/]/\\&/g')/computer/g"); fi
  text=$(printf '%s' "$text" | sed -E \
    -e 's|/home/[^/ ]+|~|g' \
    -e 's|/run/user/[0-9]+|/run/user/UID|g' \
    -e 's/\.[A-Za-z0-9_-]+\.([0-9]{6}\.[0-9]{6}\.(dmp|log|txt))/.COMPUTER.\1/g' \
    -e 's/\b([0-9]{1,3}\.){3}[0-9]{1,3}\b/x.x.x.x/g')
  text=${text//@SERVER@/$server}
  printf '%s\n' "$text"
}

cmd_doctor() {
  local report=0
  SERVER="" CLIENT_DIR="" PREFIX=""
  while [[ $# -gt 0 ]]; do
    case $1 in
      --report) report=1; shift ;;
      -h | --help) usage_doctor; return 0 ;;
      *) usage_doctor >&2; die "unknown option: $1" ;;
    esac
  done
  load_settings
  if [[ $report -eq 1 ]]; then
    C_OK="" C_WARN="" C_BAD="" C_BOLD="" C_OFF=""
    local out rc=0
    out=$(doctor_run) || rc=$?
    {
      printf 'Bezgelor Linux report (installer version %s, %s)\n' "$BEZGELOR_LINUX_VERSION" "$(date -u '+%F %T UTC')"
      printf 'Server: %s\n\n' "${SERVER:-not set}"
      printf '%s\n' "$out"
    } | report_filter | sed '1i ```' | sed '$a ```'
    return "$rc"
  fi
  say "Bezgelor Linux doctor (installer version $BEZGELOR_LINUX_VERSION)"
  say ""
  doctor_run
}

# ---------------------------------------------------------------------------------------------
# uninstall

usage_uninstall() {
  cat <<'EOF'
Usage: bezgelor-wildstar uninstall [--remove-prefix] [--yes] [--dry-run]

Removes what install created: the launcher command, the application menu entry, the configuration and
the starter program. The Wine prefix (several GB, with your game settings) is kept unless you answer yes
or pass --remove-prefix. Your WildStar client folder is never removed. System packages and umu-launcher
are left in place.
EOF
}

cmd_uninstall() {
  local rm_prefix=0
  SERVER="" CLIENT_DIR="" PREFIX=""
  while [[ $# -gt 0 ]]; do
    case $1 in
      --remove-prefix) rm_prefix=1; shift ;;
      --yes | -y) ASSUME_YES=1; shift ;;
      --dry-run) DRY_RUN=1; shift ;;
      -h | --help) usage_uninstall; return 0 ;;
      *) usage_uninstall >&2; die "unknown option: $1" ;;
    esac
  done
  load_settings
  confirm "Remove the Bezgelor WildStar launcher, menu entry and configuration? Your client folder is not touched." y || {
    say "Nothing removed."
    return 0
  }
  local f verb=removed
  [[ $DRY_RUN -eq 0 ]] || verb="would remove"
  for f in "$LAUNCHER" "$DESKTOP_FILE" "$CONF_FILE" "$STATE_DIR/wildstar-start.exe"; do
    if [[ -e $f || -L $f ]]; then
      run rm -f -- "$f"
      note "$verb $f"
    fi
  done
  [[ $DRY_RUN -eq 1 ]] || rmdir "$CONF_DIR" 2>/dev/null || true
  local link
  link=$(install_dir)
  if [[ -L $link ]]; then run rm -f -- "$link"; fi
  if [[ -d $PREFIX/drive_c ]]; then
    if [[ $rm_prefix -eq 0 && $ASSUME_YES -eq 0 && $DRY_RUN -eq 0 ]]; then
      confirm "Also remove the Wine prefix $PREFIX (several GB)?" n && rm_prefix=1
    fi
    if [[ $rm_prefix -eq 1 ]]; then
      [[ $PREFIX != / && $PREFIX != "$HOME" && -d $PREFIX/drive_c ]] || die "refusing to remove '$PREFIX'."
      run rm -rf -- "$PREFIX"
      note "$verb $PREFIX"
    else
      note "kept the prefix: $PREFIX"
    fi
  fi
  say "Done. The client folder ($CLIENT_DIR) was not touched."
}

# ---------------------------------------------------------------------------------------------

usage() {
  cat <<'EOF'
bezgelor-linux.sh: install, start and check the WildStar client on Linux.

Usage:
  bezgelor-linux.sh install [--server HOST] [--client-dir DIR] [--yes]   set everything up
  bezgelor-wildstar play [-- client arguments]                           start the game
  bezgelor-wildstar doctor [--report]                                    check the setup
  bezgelor-wildstar uninstall                                            remove what install created
  <command> --help                                                       options of a command

`bezgelor-wildstar` is the installed copy of this script (~/.local/bin/bezgelor-wildstar).
The client files are your own copy; they are not downloaded.
EOF
}

main() {
  set_colours
  init_paths
  local cmd=${1:-}
  [[ $# -eq 0 ]] || shift
  case $cmd in
    install) cmd_install "$@" ;;
    play) cmd_play "$@" ;;
    doctor) cmd_doctor "$@" ;;
    uninstall) cmd_uninstall "$@" ;;
    version | --version) echo "bezgelor-linux.sh version $BEZGELOR_LINUX_VERSION" ;;
    "" | -h | --help | help) usage ;;
    *) usage >&2; die "unknown command: $cmd" ;;
  esac
}

# Run main unless BEZGELOR_SOURCE_ONLY is set (the tests source the file). Everything above is function and
# variable definitions, so a script cut short by a broken pipe never reaches this last call.
bezgelor_entry() {
  [[ -n ${BEZGELOR_SOURCE_ONLY:-} ]] || main "$@"
}
bezgelor_entry "$@"
