#!/usr/bin/env sh
set -eu

SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)"
SELF=$SCRIPT_DIR/$(basename -- "$0")
STATE_FILE="${OMAIRC_VERIFY_STATE:-${TMPDIR:-/tmp}/omairc-verify-$USER/state}"
EVIDENCE_DIR="${OMAIRC_VERIFY_EVIDENCE:-$ROOT/test-artifacts/verify}"
# Jump, nick-jump, and composer focus need a pause after the chord before
# type. The chord's own pause is not always enough on this Xvfb.
JUMP_FIELD_DELAY=0.4
# import can return a flat Xvfb frame before Qt has painted, or fail and
# leave the previous PNG in place. Drop that destination before each grab
# and retry. Near-solid means a non-finite or tiny standard deviation, or
# very few colors. File size is not the test: a sparse painted window can
# be a small PNG.
SCREENSHOT_GRAB_ATTEMPTS=5
SCREENSHOT_GRAB_DELAY=0.3
SCREENSHOT_FLAT_STDDEV=0.005
SCREENSHOT_FLAT_COLORS=16

usage() {
  cat <<'EOF'
control-omairc - isolated driver for the compiled Omairc desktop app

Usage:
  control-omairc launch [--demo-server]
  control-omairc doctor
  control-omairc title
  control-omairc wait-title --exact "<title>"
  control-omairc click --x <px> --y <px>
  control-omairc click-conversation --name <#channel|nick>
  control-omairc click-member --name <nick>
  control-omairc click-people [--hidden]
  control-omairc click-network
  control-omairc click-edit
  control-omairc click-version
  control-omairc click-send
  control-omairc focus-composer
  control-omairc composer
  control-omairc jump --query "<text>"
  control-omairc walk --down|--up [--times N]
  control-omairc unread
  control-omairc toggle-members
  control-omairc focus-members
  control-omairc nick-jump --query "<text>"
  control-omairc status
  control-omairc connect
  control-omairc type --text "<text>"
  control-omairc key --key <xdotool-key>
  control-omairc send --text "<text>"
  control-omairc screenshot --feature <id> --name <stem>
  control-omairc compare --before <path> --after <path>
  control-omairc run <feature> [--dry-run]
  control-omairc qml-suite
  control-omairc doctor-qml
  control-omairc cleanup

Coordinates assume the isolated window is the default 1180x760 at textScale 1.0.
Never point this helper at the user's interactive display.
EOF
}

die() {
  echo "control-omairc: $*" >&2
  exit 1
}

require_tools() {
  missing=
  for command_name in Xvfb xauth xdotool import magick; do
    if ! command -v "$command_name" >/dev/null 2>&1; then
      missing="$missing $command_name"
    fi
  done
  if [ -n "$missing" ]; then
    die "missing desktop tools:$missing. On Arch/Omarchy install: xorg-server-xvfb xorg-xauth xdotool imagemagick"
  fi
}

load_state() {
  [ -f "$STATE_FILE" ] || die "no active instance. Run: $SCRIPT_DIR/control-omairc launch [--demo-server]"
  # shellcheck disable=SC1090
  . "$STATE_FILE"
  [ -n "${DISPLAY:-}" ] || die "state file is missing DISPLAY"
  [ -n "${XAUTHORITY:-}" ] || die "state file is missing XAUTHORITY"
  [ -n "${APP_PID:-}" ] || die "state file is missing APP_PID"
  [ -n "${XVFB_PID:-}" ] || die "state file is missing XVFB_PID"
  [ -n "${WINDOW:-}" ] || die "state file is missing WINDOW"
  export DISPLAY XAUTHORITY
}

write_state() {
  mkdir -p "$(dirname -- "$STATE_FILE")"
  cat > "$STATE_FILE" <<EOF
ROOT=$ROOT
STATE_DIR=$STATE_DIR
EVIDENCE_DIR=$EVIDENCE_DIR
DISPLAY=$DISPLAY
XAUTHORITY=$XAUTHORITY
XVFB_PID=$XVFB_PID
APP_PID=$APP_PID
WINDOW=$WINDOW
BINARY=$BINARY
DEMO=$DEMO
EOF
}

process_alive() {
  kill -0 "$1" 2>/dev/null
}

stop_pid() {
  pid=$1
  if [ -n "$pid" ] && process_alive "$pid"; then
    kill "$pid"
    wait "$pid" 2>/dev/null || true
  fi
}

conversation_y() {
  case $1 in
    "#desktop") echo 124 ;;
    "#help") echo 160 ;;
    "#omarchy") echo 196 ;;
    "#ricing") echo 232 ;;
    anna) echo 276 ;;
    dax) echo 312 ;;
    *) die "unknown conversation '$1'. Use #desktop, #help, #omarchy, #ricing, anna, or dax." ;;
  esac
}

member_y() {
  # Rows are ordered by server PREFIX rank, highest first, then nick
  # (IrcEventReducer::orderedMembers). Seeded #omarchy settles as:
  # ~fred, &anna, @dax, @mira, %kai, +teo, ivy, lena, max, nora, sam, sol.
  # sol and nora are lateJoin and arrive via the transcript; wait for the
  # transcript to settle before clicking the plain nicks.
  case $1 in
    fred) echo 71 ;;
    anna) echo 114 ;;
    dax) echo 157 ;;
    mira) echo 200 ;;
    kai) echo 243 ;;
    teo) echo 286 ;;
    ivy) echo 329 ;;
    lena) echo 372 ;;
    max) echo 415 ;;
    nora) echo 458 ;;
    sam) echo 501 ;;
    sol) echo 544 ;;
    *) die "unknown member '$1'." ;;
  esac
}

window_title() {
  xdotool getwindowname "$WINDOW"
}

click_xy() {
  x=$1
  y=$2
  xdotool windowfocus --sync "$WINDOW"
  xdotool mousemove --window "$WINDOW" "$x" "$y" click 1
}

cmd_launch() {
  DEMO=
  while [ $# -gt 0 ]; do
    case $1 in
      --demo-server) DEMO=1; shift ;;
      *) die "unknown launch argument: $1. Use launch or launch --demo-server." ;;
    esac
  done

  require_tools
  if [ -f "$STATE_FILE" ]; then
    if "$SELF" doctor >/dev/null 2>&1; then
      die "an isolated instance is already running. Run cleanup first, or doctor to inspect it."
    fi
    echo "control-omairc: clearing stale run state" >&2
    cmd_cleanup >/dev/null 2>&1 || true
  fi

  "$ROOT/bin/build"

  STATE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/omairc-verify.XXXXXX")"
  BINARY="$ROOT/build/omairc"
  [ -x "$BINARY" ] || die "missing $BINARY after bin/build"

  display_number=110
  while [ -e "/tmp/.X11-unix/X$display_number" ]; do
    display_number=$((display_number + 1))
  done
  DISPLAY=":$display_number"
  export DISPLAY
  XAUTHORITY="$STATE_DIR/Xauthority"
  export XAUTHORITY
  cookie="$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
  xauth -f "$XAUTHORITY" add "$DISPLAY" . "$cookie"

  mkdir -p "$EVIDENCE_DIR" "$STATE_DIR/logs"
  Xvfb "$DISPLAY" -screen 0 1280x800x24 -nolisten tcp -auth "$XAUTHORITY" \
    >"$STATE_DIR/logs/xvfb.log" 2>&1 &
  XVFB_PID=$!

  attempt=0
  while ! xdotool getmouselocation >/dev/null 2>&1; do
    attempt=$((attempt + 1))
    if [ "$attempt" -ge 50 ] || ! process_alive "$XVFB_PID"; then
      die "isolated X server did not start; see $STATE_DIR/logs/xvfb.log"
    fi
    sleep 0.1
  done

  export XDG_CONFIG_HOME="$STATE_DIR/config"
  export XDG_CACHE_HOME="$STATE_DIR/cache"
  export XDG_DATA_HOME="$STATE_DIR/data"
  export XDG_STATE_HOME="$STATE_DIR/state"
  export DBUS_SESSION_BUS_ADDRESS="unix:path=$STATE_DIR/no-session-bus"
  export QT_QPA_PLATFORM=xcb
  export OMAIRC_ALLOW_MULTI=1
  if [ "$DEMO" = 1 ]; then
    "$BINARY" --demo-server >"$STATE_DIR/logs/omairc.log" 2>&1 &
  else
    "$BINARY" >"$STATE_DIR/logs/omairc.log" 2>&1 &
  fi
  APP_PID=$!

  attempt=0
  WINDOW=
  while [ -z "$WINDOW" ]; do
    WINDOW="$(xdotool search --onlyvisible --pid "$APP_PID" 2>/dev/null | head -1 || true)"
    attempt=$((attempt + 1))
    if [ "$attempt" -ge 50 ] || ! process_alive "$APP_PID"; then
      die "Omairc did not open; see $STATE_DIR/logs/omairc.log"
    fi
    sleep 0.1
  done

  write_state
  xdotool windowfocus --sync "$WINDOW"
  echo "launched isolated Omairc"
  if [ "$DEMO" = 1 ]; then
    echo "demo=yes"
  else
    echo "demo=no"
  fi
  echo "display=$DISPLAY"
  echo "pid=$APP_PID"
  echo "window=$WINDOW"
  echo "title=$(window_title)"
  echo "state=$STATE_FILE"
}

cmd_doctor() {
  load_state
  process_alive "$XVFB_PID" || die "Xvfb pid $XVFB_PID is not running"
  process_alive "$APP_PID" || die "Omairc pid $APP_PID is not running"
  [ -x "$BINARY" ] || die "binary missing: $BINARY"
  [ -e "/tmp/.X11-unix/X${DISPLAY#:}" ] || die "display $DISPLAY is gone"
  found="$(xdotool search --onlyvisible --pid "$APP_PID" 2>/dev/null | head -1 || true)"
  [ "$found" = "$WINDOW" ] || die "window $WINDOW is not the Omairc window for pid $APP_PID"
  title="$(window_title)"
  case $title in
    *" - Omairc"|*" Status"|Status) ;;
    *) die "unexpected window title '$title'" ;;
  esac
  echo "ok isolated Omairc"
  echo "binary=$BINARY"
  echo "display=$DISPLAY"
  echo "app_pid=$APP_PID"
  echo "xvfb_pid=$XVFB_PID"
  echo "window=$WINDOW"
  echo "title=$title"
  echo "xdg=$STATE_DIR"
  echo "evidence=$EVIDENCE_DIR"
  if [ "${DEMO:-}" = 1 ]; then
    echo "demo=yes"
  else
    echo "demo=no"
  fi
}

cmd_title() {
  load_state
  window_title
}

cmd_wait_title() {
  exact=
  while [ $# -gt 0 ]; do
    case $1 in
      --exact) exact=$2; shift 2 ;;
      *) die "unknown wait-title argument: $1" ;;
    esac
  done
  [ -n "$exact" ] || die "wait-title requires --exact"
  load_state
  attempt=0
  while [ "$(window_title)" != "$exact" ]; do
    attempt=$((attempt + 1))
    if [ "$attempt" -ge 30 ]; then
      die "expected title '$exact', got '$(window_title)'"
    fi
    sleep 0.1
  done
  window_title
}

cmd_click() {
  x=
  y=
  while [ $# -gt 0 ]; do
    case $1 in
      --x) x=$2; shift 2 ;;
      --y) y=$2; shift 2 ;;
      *) die "unknown click argument: $1" ;;
    esac
  done
  [ -n "$x" ] && [ -n "$y" ] || die "click requires --x and --y"
  load_state
  click_xy "$x" "$y"
}

cmd_click_conversation() {
  name=
  while [ $# -gt 0 ]; do
    case $1 in
      --name) name=$2; shift 2 ;;
      *) die "unknown click-conversation argument: $1" ;;
    esac
  done
  [ -n "$name" ] || die "click-conversation requires --name"
  load_state
  click_xy 120 "$(conversation_y "$name")"
}

cmd_click_member() {
  name=
  while [ $# -gt 0 ]; do
    case $1 in
      --name) name=$2; shift 2 ;;
      *) die "unknown click-member argument: $1" ;;
    esac
  done
  [ -n "$name" ] || die "click-member requires --name"
  load_state
  click_xy 1070 "$(member_y "$name")"
}

cmd_click_people() {
  hidden=
  while [ $# -gt 0 ]; do
    case $1 in
      --hidden) hidden=1; shift ;;
      *) die "unknown click-people argument: $1. Use click-people or click-people --hidden." ;;
    esac
  done
  load_state
  # The people control is right-anchored on the conversation header. The
  # member column is 216 CSS px, so the control sits at 908 when that
  # column is open and 1124 after it hides.
  if [ "$hidden" = 1 ]; then
    click_xy 1124 36
  else
    click_xy 908 36
  fi
}

cmd_click_network() {
  load_state
  click_xy 120 36
}

cmd_click_edit() {
  load_state
  click_xy 220 36
}

cmd_click_version() {
  load_state
  # Footer is 66 CSS px at the bottom of the 244 CSS px sidebar. The version
  # sits on the presence baseline, right-padded 17 CSS px.
  click_xy 214 734
}

cmd_click_send() {
  load_state
  click_xy 909 717
}

cmd_focus_composer() {
  require_no_args focus-composer "$@"
  cmd_key --key ctrl+l
  # Same settle jump uses before type, so a later type is not racing focus.
  sleep "$JUMP_FIELD_DELAY"
}

cmd_type() {
  text=
  while [ $# -gt 0 ]; do
    case $1 in
      --text) text=$2; shift 2 ;;
      *) die "unknown type argument: $1" ;;
    esac
  done
  [ -n "$text" ] || die "type requires --text"
  load_state
  xdotool windowfocus --sync "$WINDOW"
  xdotool type --window "$WINDOW" --delay 20 -- "$text"
}

cmd_key() {
  key=
  while [ $# -gt 0 ]; do
    case $1 in
      --key) key=$2; shift 2 ;;
      *) die "unknown key argument: $1" ;;
    esac
  done
  [ -n "$key" ] || die "key requires --key"
  # xdotool's ctrl+* / ctrl+shift+* tokens do not reach Qt ApplicationShortcuts
  # on this isolated Xvfb. The explicit modifier keysyms do.
  case $key in
    ctrl+slash) key=Control_L+slash ;;
    ctrl+k) key=Control_L+k ;;
    ctrl+l) key=Control_L+l ;;
    ctrl+grave) key=Control_L+grave ;;
    ctrl+comma) key=Control_L+comma ;;
    ctrl+shift+k) key=Control_L+Shift_L+k ;;
    ctrl+shift+m) key=Control_L+Shift_L+m ;;
    ctrl+shift+p) key=Control_L+Shift_L+p ;;
    ctrl+shift+s) key=Control_L+Shift_L+s ;;
    alt+shift+a) key=Alt_L+Shift_L+a ;;
    alt+shift+Left) key=Alt_L+Shift_L+Left ;;
    alt+shift+Right) key=Alt_L+Shift_L+Right ;;
    alt+shift+Up) key=Alt_L+Shift_L+Up ;;
    alt+shift+Down) key=Alt_L+Shift_L+Down ;;
    ctrl+alt+shift+Left) key=Control_L+Alt_L+Shift_L+Left ;;
    ctrl+alt+shift+Right) key=Control_L+Alt_L+Shift_L+Right ;;
  esac
  load_state
  xdotool windowfocus --sync "$WINDOW"
  xdotool key --window "$WINDOW" "$key"
  sleep 0.2
}

cmd_send() {
  text=
  while [ $# -gt 0 ]; do
    case $1 in
      --text) text=$2; shift 2 ;;
      *) die "unknown send argument: $1" ;;
    esac
  done
  [ -n "$text" ] || die "send requires --text"
  cmd_key --key ctrl+l
  sleep "$JUMP_FIELD_DELAY"
  cmd_type --text "$text"
  cmd_key --key Return
}

require_no_args() {
  verb=$1
  shift
  [ $# -eq 0 ] || die "$verb does not take arguments"
}

cmd_composer() {
  require_no_args composer "$@"
  cmd_key --key ctrl+l
  # Same settle jump uses before type, so a later type is not racing focus.
  sleep "$JUMP_FIELD_DELAY"
}

cmd_query_chord() {
  verb=$1
  chord=$2
  shift 2
  query=
  while [ $# -gt 0 ]; do
    case $1 in
      --query)
        [ $# -ge 2 ] || die "$verb requires --query"
        query=$2
        shift 2
        ;;
      *) die "unknown $verb argument: $1" ;;
    esac
  done
  [ -n "$query" ] || die "$verb requires --query"
  cmd_key --key "$chord"
  sleep "$JUMP_FIELD_DELAY"
  cmd_type --text "$query"
  cmd_key --key Return
}

cmd_jump() {
  cmd_query_chord jump ctrl+k "$@"
}

cmd_nick_jump() {
  cmd_query_chord nick-jump ctrl+shift+k "$@"
}

cmd_walk() {
  direction=
  times=1
  while [ $# -gt 0 ]; do
    case $1 in
      --down|--up)
        [ -z "$direction" ] || die "walk accepts only one of --down or --up"
        direction=${1#--}
        shift
        ;;
      --times)
        [ $# -ge 2 ] || die "walk requires --times N"
        times=$2
        shift 2
        ;;
      *) die "unknown walk argument: $1" ;;
    esac
  done
  [ -n "$direction" ] || die "walk requires --down or --up"
  case $times in
    *[!0-9]*|"") die "walk --times requires a positive integer" ;;
  esac
  times=$(printf '%s' "$times" | sed 's/^0*//')
  [ -n "$times" ] || die "walk --times requires a positive integer"
  if [ "$direction" = down ]; then
    key=alt+Down
  else
    key=alt+Up
  fi
  step=0
  while [ "$step" -lt "$times" ]; do
    cmd_key --key "$key"
    step=$((step + 1))
  done
}

cmd_unread() {
  require_no_args unread "$@"
  cmd_key --key alt+a
}

cmd_toggle_members() {
  require_no_args toggle-members "$@"
  cmd_key --key ctrl+shift+m
}

cmd_focus_members() {
  require_no_args focus-members "$@"
  cmd_key --key ctrl+shift+p
}

cmd_status() {
  require_no_args status "$@"
  cmd_key --key ctrl+grave
}

cmd_connect() {
  require_no_args connect "$@"
  cmd_key --key ctrl+comma
}

# Print "stddev colors" or nothing when magick cannot read the file.
screenshot_metrics() {
  magick "$1" -format '%[fx:standard_deviation] %k' info: 2>/dev/null || true
}

# Missing, empty, unreadable, or near-solid. ImageMagick 6 prints "-nan"
# for a low-variance frame. mawk treats that token as ordered, so match
# nan/inf as text before the numeric test. A flat frame is also one color,
# a handful of colors, or a field whose spread is ~0.
screenshot_is_near_solid() {
  _path=$1
  [ -s "$_path" ] || return 0
  _stats=$(screenshot_metrics "$_path")
  [ -n "$_stats" ] || return 0
  _std=$(printf '%s\n' "$_stats" | awk '{ print $1; exit }')
  _colors=$(printf '%s\n' "$_stats" | awk '{ print $2; exit }')
  awk -v std="$_std" -v colors="$_colors" \
    -v max_std="$SCREENSHOT_FLAT_STDDEV" -v max_colors="$SCREENSHOT_FLAT_COLORS" \
    'BEGIN {
      token = tolower(std "")
      sub(/^[+-]/, "", token)
      if (token ~ /^(nan|inf)/)
        exit 0
      std += 0
      colors += 0
      max_std += 0
      max_colors += 0
      if (std != std || std < max_std || colors < max_colors)
        exit 0
      exit 1
    }'
}

cmd_screenshot() {
  feature=
  name=
  while [ $# -gt 0 ]; do
    case $1 in
      --feature) feature=$2; shift 2 ;;
      --name) name=$2; shift 2 ;;
      *) die "unknown screenshot argument: $1" ;;
    esac
  done
  [ -n "$feature" ] && [ -n "$name" ] || die "screenshot requires --feature and --name"
  load_state
  dest="$EVIDENCE_DIR/$feature"
  mkdir -p "$dest"
  path="$dest/$name.png"
  _attempt=1
  while :; do
    # A failed import must not classify the previous PNG as this grab.
    rm -f -- "$path"
    if import -display "$DISPLAY" -window "$WINDOW" "$path"; then
      if ! screenshot_is_near_solid "$path"; then
        echo "$path"
        return 0
      fi
    else
      rm -f -- "$path"
    fi
    [ "$_attempt" -lt "$SCREENSHOT_GRAB_ATTEMPTS" ] || break
    _attempt=$((_attempt + 1))
    sleep "$SCREENSHOT_GRAB_DELAY"
  done
  # Latest grab failed, or it succeeded and is still near-solid.
  _stats=$(screenshot_metrics "$path")
  die "screenshot is missing, empty, or still a flat frame: $path${_stats:+ ($_stats)}"
}

cmd_compare() {
  before=
  after=
  while [ $# -gt 0 ]; do
    case $1 in
      --before) before=$2; shift 2 ;;
      --after) after=$2; shift 2 ;;
      *) die "unknown compare argument: $1" ;;
    esac
  done
  [ -n "$before" ] && [ -n "$after" ] || die "compare requires --before and --after"
  [ -s "$before" ] || die "missing before image: $before"
  [ -s "$after" ] || die "missing after image: $after"
  difference="$(magick compare -metric AE "$before" "$after" null: 2>&1 || true)"
  ae="$(printf '%s\n' "$difference" | awk '{ print $1; exit }')"
  if ! awk -v ae="$ae" 'BEGIN { exit !(ae + 0 > 100) }'; then
    die "expected a visible UI change between $before and $after (AE=$difference)"
  fi
  echo "changed AE=$ae"
}

cmd_doctor_qml() {
  [ -x "$ROOT/bin/build" ] || die "missing $ROOT/bin/build"
  [ -x "$ROOT/bin/test" ] || die "missing $ROOT/bin/test"
  [ -f "$ROOT/tests/ui/tst_omairc.qml" ] || die "missing $ROOT/tests/ui/tst_omairc.qml"
  [ -f "$ROOT/tests/ui/seeded-qml.pro" ] || die "missing $ROOT/tests/ui/seeded-qml.pro"
  echo "ok qml suite"
  echo "host=$ROOT/tests/ui/seeded-qml.pro"
  echo "suite=$ROOT/tests/ui/tst_omairc.qml"
  echo "evidence=$EVIDENCE_DIR"
  if [ -x "$ROOT/build/omairc" ]; then
    echo "binary=$ROOT/build/omairc"
  else
    echo "binary=missing (bin/test will build)"
  fi
}

cmd_qml_suite() {
  cmd_doctor_qml >/dev/null
  "$ROOT/bin/test"
  mkdir -p \
    "$EVIDENCE_DIR/connect" \
    "$EVIDENCE_DIR/switch-conversation" \
    "$EVIDENCE_DIR/send-message" \
    "$EVIDENCE_DIR/toggle-members" \
    "$EVIDENCE_DIR/open-direct-message" \
    "$EVIDENCE_DIR/status-console" \
    "$EVIDENCE_DIR/member-presence" \
    "$EVIDENCE_DIR/identity-footer" \
    "$EVIDENCE_DIR/about" \
    "$EVIDENCE_DIR/keyboard" \
    "$EVIDENCE_DIR/typing"
  for pair in \
    "connection-sheet.png:connect/first-run.png" \
    "switch-channel.png:switch-conversation/after-desktop.png" \
    "send-message.png:send-message/after-send.png" \
    "toggle-members.png:toggle-members/members-hidden.png" \
    "open-direct-message.png:open-direct-message/mira-dm.png" \
    "jump-to-nick.png:keyboard/jump-to-nick.png" \
    "server-list-collapsed.png:keyboard/server-list-collapsed.png" \
    "server-list-restored.png:keyboard/server-list-restored.png" \
    "status-console.png:status-console/after-open.png" \
    "switch-channel.png:member-presence/after-desktop.png" \
    "member-rank-order.png:member-presence/rank-order.png" \
    "member-rank-order-demoted.png:member-presence/rank-order-demoted.png" \
    "switch-channel.png:identity-footer/mock-fred.png" \
    "about-sheet.png:about/about-sheet.png" \
    "typing-member-glyph.png:typing/member-glyph.png" \
    "typing-dm-overlay.png:typing/dm-overlay.png"
  do
    src="$ROOT/test-artifacts/${pair%%:*}"
    dest="$EVIDENCE_DIR/${pair#*:}"
    [ -s "$src" ] || die "qml-suite expected $src"
    cp -f -- "$src" "$dest"
  done
  echo "qml suite evidence copied to $EVIDENCE_DIR"
}

cmd_cleanup() {
  if [ -f "$STATE_FILE" ]; then
    # shellcheck disable=SC1090
    . "$STATE_FILE"
    stop_pid "${APP_PID:-}"
    stop_pid "${XVFB_PID:-}"
    if [ -n "${STATE_DIR:-}" ] && [ -d "$STATE_DIR" ]; then
      rm -rf -- "$STATE_DIR"
    fi
    rm -f -- "$STATE_FILE"
  fi
  echo "cleaned isolated instance"
  echo "evidence kept at $EVIDENCE_DIR"
}

# Print one eval-safe word. Single quotes keep "#desktop" from becoming a comment.
shell_quote_word() {
  _word=$1
  _escaped=$(printf '%s' "$_word" | sed "s/'/'\\\\''/g")
  printf "'%s'" "$_escaped"
}

# Print the recipe line as eval-safe words for `eval "set -- ..."`.
# Unbalanced quotes exit 1 after a message. A trailing unquoted # is a comment.
shell_quote_line() {
  _rest=$1
  _built=
  while :; do
    while :; do
      case $_rest in
        "") break 2 ;;
        " "*) _rest=${_rest# } ;;
        "	"*) _rest=${_rest#	} ;;
        *) break ;;
      esac
    done
    case $_rest in
      \#*) break ;;
    esac
    _token=
    _mode=none
    _skip_token=0
    while [ -n "$_rest" ]; do
      _char=${_rest%"${_rest#?}"}
      _rest=${_rest#?}
      case $_mode in
        none)
          case $_char in
            " "|"	") break ;;
            "'") _mode=single ;;
            '"') _mode=double ;;
            \#)
              if [ -z "$_token" ]; then
                _skip_token=1
                _rest=
              else
                _token=$_token$_char
              fi
              ;;
            *) _token=$_token$_char ;;
          esac
          ;;
        single)
          case $_char in
            "'") _mode=none ;;
            *) _token=$_token$_char ;;
          esac
          ;;
        double)
          case $_char in
            '"') _mode=none ;;
            *) _token=$_token$_char ;;
          esac
          ;;
      esac
    done
    if [ "$_mode" != none ]; then
      die "unbalanced quotes in desktop recipe"
    fi
    if [ "$_skip_token" -eq 1 ]; then
      break
    fi
    _built="$_built $(shell_quote_word "$_token")"
  done
  printf '%s' "$_built"
}

trim_line() {
  printf '%s' "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}

read_desktop_recipe() {
  _feature_file=$1
  awk '
    in_fence && /^```[[:space:]]*$/ { in_fence = 0; closed = 1; next }
    in_fence { print; next }
    /^## Driving it with control-omairc[[:space:]]*$/ { section = 1; next }
    /^## / { if (section) section = 0; next }
    section && /^```desktop-recipe[[:space:]]*$/ { in_fence = 1; found = 1; next }
    END {
      if (!found || !closed)
        exit 3
    }
  ' "$_feature_file"
}

reject_offscreen_recipe_line() {
  case $1 in
    qml-suite|qml-suite[[:space:]]*|doctor-qml|doctor-qml[[:space:]]*)
      die "desktop recipe cannot run '$1': qml-suite and doctor-qml are not compiled-window proof"
      ;;
  esac
}

load_recipe_commands() {
  _feature=$1
  case $_feature in
    *[!A-Za-z0-9_-]*|"") die "invalid feature id '$_feature'" ;;
  esac
  _file=$SCRIPT_DIR/features/$_feature.md
  [ -f "$_file" ] || die "no feature file for '$_feature'"
  if ! _recipe=$(read_desktop_recipe "$_file"); then
    die "no desktop-recipe fence in $_file. Put it under ## Driving it with control-omairc, before ## Gotchas."
  fi
  _commands=$(mktemp)
  _raw=$(mktemp)
  printf '%s\n' "$_recipe" > "$_raw"
  while IFS= read -r _line || [ -n "$_line" ]; do
    _trimmed=$(trim_line "$_line")
    [ -n "$_trimmed" ] || continue
    case $_trimmed in
      \#*) continue ;;
    esac
    reject_offscreen_recipe_line "$_trimmed"
    if ! _quoted=$(shell_quote_line "$_trimmed"); then
      rm -f "$_commands" "$_raw"
      exit 1
    fi
    eval "set -- $_quoted"
    [ $# -gt 0 ] || die "empty command in desktop recipe: $_trimmed"
    case $1 in
      qml-suite|doctor-qml)
        die "desktop recipe cannot run '$1': it is not compiled-window proof"
        ;;
    esac
    printf '%s\n' "$_trimmed" >> "$_commands"
  done < "$_raw"
  rm -f "$_raw"
  if [ ! -s "$_commands" ]; then
    rm -f "$_commands"
    die "desktop-recipe fence is empty in $_file"
  fi
  RECIPE_COMMANDS=$_commands
  RECIPE_FILE=$_file
}

# Skip launch when doctor is already healthy and DEMO matches.
# Exit non-zero on a healthy instance in the other mode. Do not clean it up.
# Return 0 to skip, 1 to launch.
launch_should_skip() {
  _want_demo=
  shift
  while [ $# -gt 0 ]; do
    case $1 in
      --demo-server) _want_demo=1; shift ;;
      *) die "unknown launch argument: $1. Use launch or launch --demo-server." ;;
    esac
  done
  if ! _doctor=$("$SELF" doctor 2>/dev/null); then
    return 1
  fi
  _running=$(printf '%s\n' "$_doctor" | awk -F= '/^demo=/{ print $2; exit }')
  if [ "$_want_demo" = 1 ]; then
    if [ "$_running" != yes ]; then
      die "isolated instance is already running without --demo-server. Refusing to cleanup a healthy instance."
    fi
  else
    if [ "$_running" != no ]; then
      die "isolated instance is already running with --demo-server. Refusing to cleanup a healthy instance."
    fi
  fi
  echo "launch skipped; isolated instance already matches" >&2
  return 0
}

cmd_run() {
  _feature=
  _dry=0
  while [ $# -gt 0 ]; do
    case $1 in
      --dry-run) _dry=1; shift ;;
      -*) die "unknown run argument: $1" ;;
      *)
        [ -z "$_feature" ] || die "unexpected argument: $1"
        _feature=$1
        shift
        ;;
    esac
  done
  [ -n "$_feature" ] || die "run requires a feature id"
  load_recipe_commands "$_feature"
  if [ "$_dry" -eq 1 ]; then
    cat "$RECIPE_COMMANDS"
    rm -f "$RECIPE_COMMANDS"
    return 0
  fi

  cd "$ROOT"
  _shots=$(mktemp)
  while IFS= read -r _line || [ -n "$_line" ]; do
    if ! _quoted=$(shell_quote_line "$_line"); then
      exit 1
    fi
    eval "set -- $_quoted"
    [ $# -gt 0 ] || die "empty command in desktop recipe: $_line"
    if [ "$1" = launch ] && launch_should_skip "$@"; then
      continue
    fi
    if [ "$1" = screenshot ]; then
      set +e
      _path=$("$SELF" "$@")
      _status=$?
      set -e
      [ "$_status" -eq 0 ] || exit "$_status"
      [ -n "$_path" ] || die "screenshot printed an empty path"
      [ -s "$_path" ] || die "screenshot missing or empty: $_path"
      printf '%s\n' "$_path"
      printf '%s\n' "$_path" >> "$_shots"
    else
      "$SELF" "$@"
    fi
  done < "$RECIPE_COMMANDS"

  while IFS= read -r _path || [ -n "$_path" ]; do
    [ -n "$_path" ] || die "screenshot path is empty"
    [ -s "$_path" ] || die "screenshot missing or empty: $_path"
  done < "$_shots"
  rm -f "$RECIPE_COMMANDS" "$_shots"
}

cmd=${1:-}
if [ -n "$cmd" ]; then
  shift
fi

case $cmd in
  launch) cmd_launch "$@" ;;
  doctor) cmd_doctor ;;
  title) cmd_title ;;
  wait-title) cmd_wait_title "$@" ;;
  click) cmd_click "$@" ;;
  click-conversation) cmd_click_conversation "$@" ;;
  click-member) cmd_click_member "$@" ;;
  click-people) cmd_click_people "$@" ;;
  click-network) cmd_click_network ;;
  click-edit) cmd_click_edit ;;
  click-version) cmd_click_version ;;
  click-send) cmd_click_send ;;
  focus-composer) cmd_focus_composer ;;
  composer) cmd_composer "$@" ;;
  jump) cmd_jump "$@" ;;
  walk) cmd_walk "$@" ;;
  unread) cmd_unread "$@" ;;
  toggle-members) cmd_toggle_members "$@" ;;
  focus-members) cmd_focus_members "$@" ;;
  nick-jump) cmd_nick_jump "$@" ;;
  status) cmd_status "$@" ;;
  connect) cmd_connect "$@" ;;
  type) cmd_type "$@" ;;
  key) cmd_key "$@" ;;
  send) cmd_send "$@" ;;
  screenshot) cmd_screenshot "$@" ;;
  compare) cmd_compare "$@" ;;
  run) cmd_run "$@" ;;
  qml-suite) cmd_qml_suite ;;
  doctor-qml) cmd_doctor_qml ;;
  cleanup) cmd_cleanup ;;
  -h|--help|help|"") usage ;;
  *) die "unknown command '$cmd'. Try --help." ;;
esac
