#!/usr/bin/env bash
#
# Workshop Plan — Linux setup tool
# =================================
#
# The Linux counterpart of installer/WorkshopPlanSetupBootstrap.cs (the Windows
# WinForms bootstrapper). It installs/repairs/uninstalls the app, connects this
# PC to a shared workshop database, or turns this PC into the main shared-data
# host (PostgreSQL) — guided, with a clear summary of what it changes.
#
# It deliberately honours the SAME contracts the running app and the Windows
# installer use, so Windows and Linux machines interoperate on one database:
#   - DB config:  ~/Documents/plasma/workshop/database.properties
#                 keys: database.url / database.user / database.password
#                 url : jdbc:postgresql://HOST:5432/DBNAME   (SQLite if absent)
#   - defaults :  db=plasma_planner  user=plasma_app  password=workshopplan
#   - release  :  GET $BACKEND/api/v1/updates/latest?platform=linux&channel=stable&currentVersion=0.0.0
#                 -> JSON { version, url, sizeBytes, sha256, ... }
#   - artifact :  WorkshopPlan-linux.zip  ->  "Workshop Plan/bin/Workshop Plan"
#
# Run with no arguments for the interactive menu, or use the flags documented in
# usage() for scripted / non-interactive runs. Nothing privileged happens
# without an explicit confirmation (or --assume-yes).
#
# Tested actions on this machine come from --self-test and a sandboxed
# --install against a local zip (see scripts notes / docs).

set -uo pipefail

# --------------------------------------------------------------------------- #
# Constants (kept in lockstep with the Windows bootstrapper)
# --------------------------------------------------------------------------- #
APP_NAME="Workshop Plan"
DEFAULT_BACKEND_BASE_URL="https://api.workshopplan.co.uk"
DEFAULT_DB_NAME="plasma_planner"
DEFAULT_DB_USER="plasma_app"
DEFAULT_DB_PASSWORD="workshopplan"
RELEASE_PLATFORM="linux"
RELEASE_CHANNEL="stable"
RELEASE_ZIP_NAME="WorkshopPlan-linux.zip"
APP_IMAGE_DIRNAME="Workshop Plan"          # archive root inside the zip
LAUNCHER_RELPATH="bin/Workshop Plan"       # launcher inside the app-image
DESKTOP_ENTRY_NAME="workshop-plan.desktop"
PG_LAN_RANGES=("192.168.0.0/16" "10.0.0.0/8" "172.16.0.0/12")
BACKUP_KEEP=30

# --------------------------------------------------------------------------- #
# Environment-derived paths (HOME can be overridden for testing via --home)
# --------------------------------------------------------------------------- #
HOME_DIR="${WORKSHOP_PLAN_HOME:-$HOME}"
BACKEND_BASE_URL="${WORKSHOP_PLAN_BACKEND:-$DEFAULT_BACKEND_BASE_URL}"

# Install location is resolved per-action (user vs system).
INSTALL_MODE="user"           # user | system
INSTALL_DIR_OVERRIDE=""       # --dest

ASSUME_YES=0
NO_LAUNCH=0
SKIP_FIREWALL=0
LOCAL_SOURCE=""               # --source PATH to a local zip (skips download)
ASKPASS_GUI=0                 # set by --askpass-gui: use `sudo -A` (zenity prompt)

# Absolute path to this script, so the GUI can re-invoke verified backend actions.
SELF_PATH="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || printf '%s' "$0")"

# --------------------------------------------------------------------------- #
# Output helpers
# --------------------------------------------------------------------------- #
if [[ -t 1 ]]; then
    C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
    C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[34m'
else
    C_RESET=""; C_BOLD=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""
fi

log_file() { printf '%s/Documents/plasma/logs/workshop-plan-setup.log' "$HOME_DIR"; }

_log() {
    local line="$1"
    local lf; lf="$(log_file)"
    mkdir -p "$(dirname "$lf")" 2>/dev/null || true
    printf '%s  %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$line" >>"$lf" 2>/dev/null || true
}

info()  { printf '%s\n' "$*"; _log "INFO  $*"; }
step()  { printf '%s==>%s %s\n' "$C_BLUE" "$C_RESET" "$*"; _log "STEP  $*"; }
ok()    { printf '%s  ok%s %s\n' "$C_GREEN" "$C_RESET" "$*"; _log "OK    $*"; }
warn()  { printf '%s warn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; _log "WARN  $*"; }
err()   { printf '%serror%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; _log "ERROR $*"; }
die()   { err "$*"; exit 1; }

confirm() {
    # confirm "Question?"  -> 0 if yes
    local prompt="$1"
    if [[ "$ASSUME_YES" -eq 1 ]]; then
        info "$prompt  [auto-yes]"
        return 0
    fi
    local reply
    read -r -p "$prompt [y/N] " reply || return 1
    [[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
}

prompt_default() {
    # prompt_default "Label" "default" [hidden]
    local label="$1" def="$2" hidden="${3:-}" reply
    if [[ "$ASSUME_YES" -eq 1 ]]; then
        printf '%s' "$def"
        return 0
    fi
    if [[ -n "$hidden" ]]; then
        read -r -s -p "$label [$def]: " reply; echo >&2
    else
        read -r -p "$label [$def]: " reply
    fi
    printf '%s' "${reply:-$def}"
}

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

require_tools() {
    local missing=()
    local t
    for t in "$@"; do have "$t" || missing+=("$t"); done
    if [[ ${#missing[@]} -gt 0 ]]; then
        die "Missing required tool(s): ${missing[*]}"
    fi
}

# Validate a PostgreSQL identifier the same way the Windows installer does:
# letters/numbers/underscore, not starting with a digit. Prevents SQL injection
# via the db/user names we interpolate into admin SQL.
assert_safe_pg_identifier() {
    local value="$1" label="$2"
    if [[ ! "$value" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
        die "$label must only contain letters, numbers, and underscores, and must not start with a number."
    fi
}

# Single-quote-escape a SQL string literal (doubles ' -> '').
escape_sql_literal() { printf '%s' "${1//\'/\'\'}"; }

# Extract a flat field from a small JSON object without needing jq.
# json_field '<json>' version  -> value (string or number, unquoted)
json_field() {
    local json="$1" key="$2"
    if have python3; then
        WP_JSON="$json" python3 - "$key" <<'PY' 2>/dev/null
import json, os, sys
try:
    data = json.loads(os.environ.get("WP_JSON", ""))
except Exception:
    sys.exit(1)
val = data.get(sys.argv[1])
if val is None:
    sys.exit(1)
print(val)
PY
        return $?
    fi
    # Fallback: grep/sed for "key": "value"  or  "key": number
    local v
    v="$(printf '%s' "$json" \
        | grep -oE "\"$key\"[[:space:]]*:[[:space:]]*(\"[^\"]*\"|[0-9]+)" \
        | head -n1 \
        | sed -E "s/\"$key\"[[:space:]]*:[[:space:]]*//; s/^\"//; s/\"$//")"
    [[ -n "$v" ]] || return 1
    printf '%s' "$v"
}

# Best-effort primary LAN IPv4 of this host (mirrors GetPrimaryLanIp()).
primary_lan_ip() {
    local ip=""
    if have ip; then
        # IP used to reach a public address = the active LAN interface IP.
        ip="$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -n1)"
    fi
    if [[ -z "$ip" ]] && have hostname; then
        ip="$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^(10|192\.168|172\.(1[6-9]|2[0-9]|3[01]))\.' | head -n1)"
    fi
    [[ -n "$ip" ]] && printf '%s' "$ip" || printf 'localhost'
}

# TCP reachability check (used before a remote DB connect).
can_reach_tcp() {
    local host="$1" port="$2" timeout="${3:-3}"
    if have nc; then
        nc -z -w "$timeout" "$host" "$port" >/dev/null 2>&1 && return 0
        return 1
    fi
    # Pure-bash /dev/tcp fallback.
    timeout "$timeout" bash -c "exec 3<>/dev/tcp/$host/$port" >/dev/null 2>&1
}

# --------------------------------------------------------------------------- #
# Path resolvers
# --------------------------------------------------------------------------- #
workshop_dir()       { printf '%s/Documents/plasma/workshop' "$HOME_DIR"; }
db_config_path()     { printf '%s/database.properties' "$(workshop_dir)"; }
server_backups_dir() { printf '%s/Documents/plasma/backups/server' "$HOME_DIR"; }

install_dir() {
    if [[ -n "$INSTALL_DIR_OVERRIDE" ]]; then
        printf '%s' "$INSTALL_DIR_OVERRIDE"
        return
    fi
    if [[ "$INSTALL_MODE" == "system" ]]; then
        printf '/opt/workshop-plan'
    else
        printf '%s/.local/share/workshop-plan' "$HOME_DIR"
    fi
}

desktop_entry_path() {
    if [[ "$INSTALL_MODE" == "system" ]]; then
        printf '/usr/share/applications/%s' "$DESKTOP_ENTRY_NAME"
    else
        printf '%s/.local/share/applications/%s' "$HOME_DIR" "$DESKTOP_ENTRY_NAME"
    fi
}

launcher_path() { printf '%s/%s' "$(install_dir)" "$LAUNCHER_RELPATH"; }
app_icon_path() { printf '%s/lib/Workshop Plan.png' "$(install_dir)"; }

# Run a command as root when needed (system install / privileged config).
# In GUI mode SUDO_ASKPASS points at a zenity helper, so `sudo -A` pops a
# graphical password dialog (and caches the credential for later commands).
as_root() {
    if [[ "$(id -u)" -eq 0 ]]; then
        "$@"
    elif [[ "$ASKPASS_GUI" -eq 1 && -n "${SUDO_ASKPASS:-}" ]] && have sudo; then
        sudo -A "$@"
    elif have sudo; then
        sudo "$@"
    elif have pkexec; then
        pkexec "$@"
    else
        die "This step needs root privileges but neither sudo nor pkexec is available. Re-run as root."
    fi
}

# --------------------------------------------------------------------------- #
# Release lookup + download
# --------------------------------------------------------------------------- #
release_manifest_url() {
    printf '%s/api/v1/updates/latest?platform=%s&channel=%s&currentVersion=0.0.0' \
        "$BACKEND_BASE_URL" "$RELEASE_PLATFORM" "$RELEASE_CHANNEL"
}

fetch_release_manifest() {
    require_tools curl
    local url; url="$(release_manifest_url)"
    local json
    json="$(curl -fsSL --max-time 30 "$url" 2>/dev/null)" || {
        err "Could not reach the update server at $url"
        return 1
    }
    printf '%s' "$json"
}

# Download $1 -> $2, with a progress bar when interactive.
download_file() {
    local url="$1" dest="$2"
    require_tools curl
    if [[ -t 1 ]]; then
        curl -fL --max-time 1800 -o "$dest" "$url"
    else
        curl -fsSL --max-time 1800 -o "$dest" "$url"
    fi
}

verify_sha256() {
    local file="$1" expected="$2"
    [[ -n "$expected" ]] || { warn "No SHA-256 in manifest; skipping checksum verification."; return 0; }
    require_tools sha256sum
    local actual
    actual="$(sha256sum "$file" | awk '{print $1}')"
    if [[ "${actual,,}" != "${expected,,}" ]]; then
        die "Checksum mismatch for $(basename "$file"). Expected $expected but got $actual. The download may be corrupt; try again."
    fi
    ok "Checksum verified (SHA-256)."
}

# --------------------------------------------------------------------------- #
# Action: install / repair the app
# --------------------------------------------------------------------------- #
do_install() {
    step "Install or repair $APP_NAME ($INSTALL_MODE install)"
    require_tools unzip

    local tmp; tmp="$(mktemp -d "${TMPDIR:-/tmp}/workshopplan-setup.XXXXXX")"
    trap '[[ -n "${tmp:-}" ]] && rm -rf "$tmp"' RETURN

    local zip_path version
    if [[ -n "$LOCAL_SOURCE" ]]; then
        [[ -f "$LOCAL_SOURCE" ]] || die "Source zip not found: $LOCAL_SOURCE"
        zip_path="$LOCAL_SOURCE"
        version="(local)"
        info "Installing from local archive: $zip_path"
    else
        step "Checking for the latest Linux release"
        local manifest; manifest="$(fetch_release_manifest)" || return 1
        version="$(json_field "$manifest" version || true)"
        local dl_url; dl_url="$(json_field "$manifest" url || true)"
        local sha; sha="$(json_field "$manifest" sha256 || true)"
        local size; size="$(json_field "$manifest" sizeBytes || true)"
        [[ -n "$dl_url" ]] || die "The update server did not return a download URL for platform=$RELEASE_PLATFORM."
        info "Latest version: ${version:-unknown}${size:+  (~$((size/1024/1024)) MB)}"
        zip_path="$tmp/$RELEASE_ZIP_NAME"
        step "Downloading $RELEASE_ZIP_NAME"
        download_file "$dl_url" "$zip_path" || die "Download failed."
        verify_sha256 "$zip_path" "$sha"
    fi

    step "Extracting"
    local extract="$tmp/extract"
    mkdir -p "$extract"
    unzip -q "$zip_path" -d "$extract" || die "Could not extract the archive."

    # Locate the app-image root (zip root is "Workshop Plan/").
    local src="$extract/$APP_IMAGE_DIRNAME"
    if [[ ! -d "$src" ]]; then
        # Fallback: a single top-level dir containing the launcher.
        local candidate
        candidate="$(find "$extract" -maxdepth 2 -type f -path "*/$LAUNCHER_RELPATH" -print -quit)"
        [[ -n "$candidate" ]] || die "The downloaded release did not contain '$LAUNCHER_RELPATH'."
        src="$(dirname "$(dirname "$candidate")")"
    fi
    [[ -f "$src/$LAUNCHER_RELPATH" ]] || die "The downloaded release did not contain '$LAUNCHER_RELPATH'."
    ok "Verified app-image layout."

    local dest; dest="$(install_dir)"
    step "Installing to $dest"
    # priv runs a command as root only for a system install; directly otherwise.
    priv() { if [[ "$INSTALL_MODE" == "system" ]]; then as_root "$@"; else "$@"; fi; }
    priv mkdir -p "$(dirname "$dest")"
    # Swap atomically-ish: move the old install aside, copy the new one in, then drop the old.
    if [[ -d "$dest" ]]; then
        priv rm -rf "$dest.old"
        priv mv "$dest" "$dest.old"
    fi
    priv cp -a "$src" "$dest"
    priv rm -rf "$dest.old" 2>/dev/null || true

    # Ensure the native launcher is executable.
    local launcher="$dest/$LAUNCHER_RELPATH"
    priv chmod +x "$launcher"
    ok "Installed launcher: $launcher"

    write_desktop_entry
    ok "$APP_NAME ${version:+$version }installed."

    if [[ "$NO_LAUNCH" -eq 0 ]] && [[ -t 0 ]] && confirm "Launch $APP_NAME now?"; then
        ( "$launcher" >/dev/null 2>&1 & )
        ok "Launched."
    fi
}

write_desktop_entry() {
    local path; path="$(desktop_entry_path)"
    local launcher; launcher="$(launcher_path)"
    local icon; icon="$(app_icon_path)"
    local content
    content="[Desktop Entry]
Type=Application
Name=$APP_NAME
Comment=Workshop operations planner
Exec=\"$launcher\"
Icon=$icon
Terminal=false
Categories=Office;Utility;
StartupWMClass=com.example.plasmaplanner.app.Launcher
"
    step "Creating desktop entry: $path"
    if [[ "$INSTALL_MODE" == "system" ]]; then
        printf '%s' "$content" | as_root tee "$path" >/dev/null
        as_root chmod 644 "$path"
    else
        mkdir -p "$(dirname "$path")"
        printf '%s' "$content" >"$path"
        chmod 644 "$path"
        have update-desktop-database && update-desktop-database "$(dirname "$path")" >/dev/null 2>&1 || true
    fi
    ok "Desktop entry written."
}

# --------------------------------------------------------------------------- #
# Action: uninstall (app files only; never touches Documents/plasma data)
# --------------------------------------------------------------------------- #
do_uninstall() {
    local dest; dest="$(install_dir)"
    local desk; desk="$(desktop_entry_path)"
    step "Uninstall $APP_NAME ($INSTALL_MODE)"
    info "This removes:"
    info "  - app files:     $dest"
    info "  - desktop entry: $desk"
    info "Your workshop data in $HOME_DIR/Documents/plasma is NOT touched."
    confirm "Remove these app files now?" || { info "Cancelled."; return 0; }
    if [[ "$INSTALL_MODE" == "system" ]]; then
        as_root rm -rf "$dest"; as_root rm -f "$desk"
    else
        rm -rf "$dest"; rm -f "$desk"
    fi
    ok "Uninstalled (workshop data preserved)."
}

# --------------------------------------------------------------------------- #
# Action: connect this PC to an existing shared database
# --------------------------------------------------------------------------- #
write_db_config() {
    local host="$1" db="$2" user="$3" pass="$4"
    local path; path="$(db_config_path)"
    mkdir -p "$(dirname "$path")"
    {
        printf '# %s database configuration\n' "$APP_NAME"
        printf 'database.url=jdbc:postgresql://%s:5432/%s\n' "$host" "$db"
        printf 'database.user=%s\n' "$user"
        printf 'database.password=%s\n' "$pass"
    } >"$path"
    chmod 600 "$path" 2>/dev/null || true
    ok "Saved connection: $path"
}

test_db_connection() {
    local host="$1" db="$2" user="$3" pass="$4"
    if ! can_reach_tcp "$host" 5432 3; then
        warn "Could not reach $host:5432. Check the main PC is on, PostgreSQL is running, and its firewall allows port 5432."
        return 1
    fi
    if ! have psql; then
        warn "psql not found, so the login could not be fully verified (the port is reachable). Install 'postgresql-client' for a full test."
        return 0
    fi
    local result
    result="$(PGPASSWORD="$pass" psql -h "$host" -p 5432 -U "$user" -d "$db" -tAc 'SELECT 1;' 2>/dev/null | tr -d '[:space:]')"
    if [[ "$result" == "1" ]]; then
        ok "Connected to $db on $host as $user."
        return 0
    fi
    warn "The server responded but the Workshop Plan login could not be confirmed. Check the database name, user, and password."
    return 1
}

do_connect() {
    step "Connect this PC to shared workshop data"
    local host db user pass
    host="${OPT_HOST:-$(prompt_default "Main data PC (hostname or IP)" "")}"
    [[ -n "$host" ]] || die "A host is required to connect."
    db="${OPT_DB:-$(prompt_default "Data name" "$DEFAULT_DB_NAME")}"
    user="${OPT_USER:-$(prompt_default "App user" "$DEFAULT_DB_USER")}"
    pass="${OPT_PASSWORD:-$(prompt_default "App password" "$DEFAULT_DB_PASSWORD" hidden)}"

    step "Testing connection to $host"
    if test_db_connection "$host" "$db" "$user" "$pass"; then
        write_db_config "$host" "$db" "$user" "$pass"
    else
        if confirm "Save this connection anyway (you can fix it later)?"; then
            write_db_config "$host" "$db" "$user" "$pass"
        else
            info "Connection not saved."
            return 1
        fi
    fi
}

# --------------------------------------------------------------------------- #
# Action: use local-only mode (SQLite) — remove the shared-data config
# --------------------------------------------------------------------------- #
do_local_only() {
    step "Switch this PC to local-only mode"
    local path; path="$(db_config_path)"
    if [[ -f "$path" ]]; then
        confirm "Remove the shared-data connection and use this PC's local data only?" || { info "Cancelled."; return 0; }
        rm -f "$path"
    fi
    ok "Local-only mode. $APP_NAME will use its on-disk SQLite data ($(workshop_dir)/plasma-planner.db)."
}

# --------------------------------------------------------------------------- #
# Action: make this PC the main shared-data host (PostgreSQL)
# --------------------------------------------------------------------------- #
psql_admin() {
    # Run admin SQL as the postgres superuser (peer auth, no password on Linux).
    as_root -u postgres psql -v ON_ERROR_STOP=1 -tAc "$1"
}
psql_admin_db() {
    local db="$1"; shift
    as_root -u postgres psql -v ON_ERROR_STOP=1 -d "$db" -tAc "$1"
}

ensure_postgres_installed() {
    if have psql && (have pg_ctl || systemctl_has postgresql); then
        return 0
    fi
    # Detect a server package even if client tools are partially present.
    if have psql && pg_lsclusters >/dev/null 2>&1; then
        return 0
    fi
    warn "PostgreSQL does not appear to be installed on this PC."
    if ! have apt-get; then
        die "Automatic install is only supported on Debian/Ubuntu (apt). Install PostgreSQL manually (server + client), then re-run this step."
    fi
    info "Setup can install it with: sudo apt-get update && sudo apt-get install -y postgresql postgresql-client"
    confirm "Install PostgreSQL now (downloads packages and starts a background service)?" \
        || die "PostgreSQL is required to host shared data. Install it, then re-run."
    step "Installing PostgreSQL (this can take a few minutes)"
    as_root apt-get update
    as_root apt-get install -y postgresql postgresql-client || die "PostgreSQL install failed. Check the internet connection and try again."
    ok "PostgreSQL installed."
}

systemctl_has() { have systemctl && systemctl list-unit-files "$1.service" >/dev/null 2>&1 \
    && systemctl list-unit-files "$1.service" 2>/dev/null | grep -q "$1.service"; }

restart_postgres() {
    step "Restarting PostgreSQL"
    if have systemctl; then
        as_root systemctl restart postgresql || warn "Could not restart postgresql via systemctl; restart it manually if clients cannot connect."
    elif have service; then
        as_root service postgresql restart || warn "Could not restart postgresql; restart it manually."
    else
        warn "No systemctl/service found. Restart PostgreSQL manually to apply LAN settings."
    fi
}

configure_pg_lan_access() {
    local db="$1" user="$2"
    step "Allowing other workshop PCs to connect"
    local conf hba
    conf="$(psql_admin 'SHOW config_file;' 2>/dev/null | tr -d '[:space:]')"
    hba="$(psql_admin 'SHOW hba_file;' 2>/dev/null | tr -d '[:space:]')"
    [[ -n "$conf" && -n "$hba" ]] || { warn "Could not locate postgresql.conf / pg_hba.conf; skipping LAN config. Set listen_addresses='*' and add pg_hba host lines manually."; return 1; }
    info "postgresql.conf: $conf"
    info "pg_hba.conf:     $hba"

    # listen_addresses = '*'
    if as_root grep -Eq "^[[:space:]]*#?[[:space:]]*listen_addresses[[:space:]]*=" "$conf"; then
        as_root sed -i -E "s|^[[:space:]]*#?[[:space:]]*listen_addresses[[:space:]]*=.*$|listen_addresses = '*'|" "$conf"
    else
        printf "listen_addresses = '*'\n" | as_root tee -a "$conf" >/dev/null
    fi

    # pg_hba LAN ranges (scram-sha-256), only if missing.
    local appended="" range pattern
    for range in "${PG_LAN_RANGES[@]}"; do
        pattern="^[[:space:]]*host[[:space:]]+${db}[[:space:]]+${user}[[:space:]]+$(sed_escape "$range")[[:space:]]+"
        if ! as_root grep -Eq "$pattern" "$hba"; then
            appended+="host    ${db}    ${user}    ${range}    scram-sha-256"$'\n'
        fi
    done
    if [[ -n "$appended" ]]; then
        printf '\n# Workshop Plan LAN access\n%s' "$appended" | as_root tee -a "$hba" >/dev/null
        ok "Added LAN access rules to pg_hba.conf."
    else
        ok "LAN access rules already present."
    fi
    restart_postgres
}

sed_escape() { printf '%s' "$1" | sed -e 's/[.[\*^$/]/\\&/g'; }

configure_firewall() {
    [[ "$SKIP_FIREWALL" -eq 1 ]] && { info "Skipping firewall configuration (--no-firewall)."; return 0; }
    if have ufw; then
        if as_root ufw status 2>/dev/null | grep -qi "Status: active"; then
            step "Opening firewall port 5432/tcp (ufw)"
            as_root ufw allow 5432/tcp >/dev/null 2>&1 && ok "Firewall rule added." \
                || warn "Could not add the ufw rule; add it manually: sudo ufw allow 5432/tcp"
        else
            info "ufw is installed but inactive; no firewall rule needed."
        fi
    else
        info "No ufw firewall detected. If a firewall is active, allow TCP port 5432 manually."
    fi
}

install_daily_backup() {
    local db="$1" user="$2"
    step "Installing a daily database backup"
    local bdir; bdir="$(server_backups_dir)"
    mkdir -p "$bdir"
    local script="$bdir/workshopplan-postgres-backup.sh"
    local cfg; cfg="$(db_config_path)"
    cat >"$script" <<EOF
#!/usr/bin/env bash
# Daily Workshop Plan PostgreSQL backup (installed by workshop-plan-setup.sh).
set -euo pipefail
cfg="$cfg"
if [[ -f "\$cfg" ]]; then
    pass="\$(sed -n 's/^database\\.password=//p' "\$cfg" | head -n1)"
    [[ -n "\$pass" ]] && export PGPASSWORD="\$pass"
fi
bdir="$bdir"
mkdir -p "\$bdir"
out="\$bdir/workshopplan-postgres-\$(date '+%Y-%m-%d-%H%M%S').backup"
pg_dump -h localhost -p 5432 -U "$user" -d "$db" -F c -f "\$out"
# Keep only the most recent $BACKUP_KEEP backups.
ls -1t "\$bdir"/workshopplan-postgres-*.backup 2>/dev/null | tail -n +$((BACKUP_KEEP+1)) | xargs -r rm -f
EOF
    chmod +x "$script"

    # Prefer a systemd *user* timer (no root needed); fall back to cron guidance.
    if have systemctl && systemctl --user show-environment >/dev/null 2>&1; then
        local udir="$HOME_DIR/.config/systemd/user"
        mkdir -p "$udir"
        cat >"$udir/workshopplan-backup.service" <<EOF
[Unit]
Description=Workshop Plan daily PostgreSQL backup

[Service]
Type=oneshot
ExecStart=$script
EOF
        cat >"$udir/workshopplan-backup.timer" <<EOF
[Unit]
Description=Run Workshop Plan backup daily at 12:00

[Timer]
OnCalendar=*-*-* 12:00:00
Persistent=true

[Install]
WantedBy=timers.target
EOF
        systemctl --user daemon-reload >/dev/null 2>&1 || true
        if systemctl --user enable --now workshopplan-backup.timer >/dev/null 2>&1; then
            ok "Daily backup timer installed (systemd user timer, 12:00)."
            info "Tip: 'loginctl enable-linger $USER' keeps the timer running when you are logged out."
            return 0
        fi
        warn "Could not enable the systemd user timer."
    fi
    warn "Automatic scheduling unavailable. Add this cron line manually to back up daily at 12:00:"
    info "  0 12 * * * $script"
}

do_host_setup() {
    step "Make this PC the main shared-data host"
    local db user pass
    db="${OPT_DB:-$(prompt_default "Data name" "$DEFAULT_DB_NAME")}"
    user="${OPT_USER:-$(prompt_default "App user" "$DEFAULT_DB_USER")}"
    pass="${OPT_PASSWORD:-$(prompt_default "App password" "$DEFAULT_DB_PASSWORD" hidden)}"
    assert_safe_pg_identifier "$db" "Data name"
    assert_safe_pg_identifier "$user" "App user"

    info ""
    info "This will, on THIS PC:"
    info "  - install PostgreSQL if it is missing (with your confirmation)"
    info "  - create database '$db' and login role '$user'"
    info "  - allow LAN PCs to connect (postgresql.conf + pg_hba.conf, restart)"
    info "  - open firewall port 5432 (ufw) and install a daily backup"
    info "  - save this PC's connection using its LAN IP"
    confirm "Proceed?" || { info "Cancelled."; return 0; }

    ensure_postgres_installed
    configure_firewall
    configure_pg_lan_access "$db" "$user"

    step "Creating shared workshop data"
    local esc; esc="$(escape_sql_literal "$pass")"
    psql_admin "DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$user') THEN CREATE ROLE $user LOGIN PASSWORD '$esc'; ELSE ALTER ROLE $user WITH PASSWORD '$esc'; END IF; END \$\$;"
    local exists; exists="$(psql_admin "SELECT 1 FROM pg_database WHERE datname = '$db';" | tr -d '[:space:]')"
    if [[ "$exists" != "1" ]]; then
        as_root -u postgres createdb -O "$user" "$db"
        ok "Created database '$db'."
    else
        ok "Database '$db' already exists."
    fi
    psql_admin "GRANT ALL PRIVILEGES ON DATABASE $db TO $user;"
    psql_admin_db "$db" "GRANT USAGE, CREATE ON SCHEMA public TO $user; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $user; GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $user; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $user; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $user;"
    ok "Granted privileges to '$user'."

    install_daily_backup "$db" "$user"

    local ip; ip="$(primary_lan_ip)"
    write_db_config "$ip" "$db" "$user" "$pass"
    info ""
    ok "This PC is now the main data host."
    info "Other PCs should connect to:  ${C_BOLD}$ip${C_RESET}  (data '$db', user '$user')."
    [[ "$ip" == "localhost" ]] && warn "Could not detect a LAN IP; other PCs need this PC's real network address."
}

# --------------------------------------------------------------------------- #
# Self-test (no network / no root) — validates the pure logic
# --------------------------------------------------------------------------- #
do_self_test() {
    local fails=0
    _t_ok()   { printf '%s  PASS%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
    _t_fail() { printf '%s  FAIL%s %s\n' "$C_RED" "$C_RESET" "$*"; fails=$((fails+1)); }

    echo "== Self-test: identifier validation =="
    ( assert_safe_pg_identifier "plasma_planner" "x" ) >/dev/null 2>&1 && _t_ok "valid identifier accepted" || _t_fail "valid identifier rejected"
    ( assert_safe_pg_identifier "1bad" "x" )          >/dev/null 2>&1 && _t_fail "leading digit accepted" || _t_ok "leading digit rejected"
    ( assert_safe_pg_identifier "drop;table" "x" )    >/dev/null 2>&1 && _t_fail "punctuation accepted" || _t_ok "punctuation rejected"

    echo "== Self-test: SQL literal escaping =="
    [[ "$(escape_sql_literal "a'b")" == "a''b" ]] && _t_ok "single quote escaped" || _t_fail "single quote not escaped"

    echo "== Self-test: JSON field extraction =="
    local j='{"version":"1.4.5","url":"https://x/y.zip","sizeBytes":149000000,"sha256":"abc123"}'
    [[ "$(json_field "$j" version)" == "1.4.5" ]]        && _t_ok "version parsed"  || _t_fail "version parse"
    [[ "$(json_field "$j" url)" == "https://x/y.zip" ]]  && _t_ok "url parsed"      || _t_fail "url parse"
    [[ "$(json_field "$j" sizeBytes)" == "149000000" ]]  && _t_ok "size parsed"     || _t_fail "size parse"
    [[ "$(json_field "$j" sha256)" == "abc123" ]]        && _t_ok "sha parsed"      || _t_fail "sha parse"

    echo "== Self-test: database.properties writing =="
    local sandbox; sandbox="$(mktemp -d)"
    local saved_home="$HOME_DIR"; HOME_DIR="$sandbox"
    write_db_config "192.168.1.50" "plasma_planner" "plasma_app" "secret" >/dev/null
    local cfg; cfg="$(db_config_path)"
    grep -q '^database.url=jdbc:postgresql://192.168.1.50:5432/plasma_planner$' "$cfg" && _t_ok "url line correct" || _t_fail "url line"
    grep -q '^database.user=plasma_app$' "$cfg" && _t_ok "user line correct" || _t_fail "user line"
    grep -q '^database.password=secret$' "$cfg" && _t_ok "password line correct" || _t_fail "password line"

    echo "== Self-test: desktop entry generation =="
    INSTALL_MODE="user"; INSTALL_DIR_OVERRIDE="$sandbox/app"
    write_desktop_entry >/dev/null
    local desk; desk="$(desktop_entry_path)"
    grep -q "^Name=$APP_NAME$" "$desk" && _t_ok "desktop Name correct" || _t_fail "desktop Name"
    grep -q "^Exec=\"$sandbox/app/$LAUNCHER_RELPATH\"$" "$desk" && _t_ok "desktop Exec correct" || _t_fail "desktop Exec"

    echo "== Self-test: release manifest URL =="
    [[ "$(release_manifest_url)" == "$BACKEND_BASE_URL/api/v1/updates/latest?platform=linux&channel=stable&currentVersion=0.0.0" ]] \
        && _t_ok "manifest URL correct" || _t_fail "manifest URL"

    HOME_DIR="$saved_home"; INSTALL_DIR_OVERRIDE=""
    rm -rf "$sandbox"
    echo
    if [[ "$fails" -eq 0 ]]; then ok "All self-tests passed."; return 0; fi
    err "$fails self-test(s) failed."; return 1
}

# --------------------------------------------------------------------------- #
# Graphical front-end (Zenity / GTK)
#
# The GUI gathers input with native dialogs, then runs the SAME verified backend
# actions by re-invoking this script with flags. Privileged steps elevate once
# via `sudo -A` using a zenity askpass helper. Falls back to the terminal menu
# when zenity is unavailable.
# --------------------------------------------------------------------------- #
GUI_BASE_ARGS=()      # flags forwarded to every backend re-invocation
GUI_ASKPASS=""        # path to the temp zenity askpass helper
GUI_LAST_LOG=""       # captured output of the last gui_run

gui_available() { have zenity && [[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; }

zen() { zenity "$@" 2>/dev/null; }

gui_init() {
    GUI_BASE_ARGS=(--home "$HOME_DIR")
    [[ "$BACKEND_BASE_URL" != "$DEFAULT_BACKEND_BASE_URL" ]] && GUI_BASE_ARGS+=(--backend "$BACKEND_BASE_URL")
    if [[ "$INSTALL_MODE" == "system" ]]; then GUI_BASE_ARGS+=(--system); else GUI_BASE_ARGS+=(--user); fi
    [[ -n "$INSTALL_DIR_OVERRIDE" ]] && GUI_BASE_ARGS+=(--dest "$INSTALL_DIR_OVERRIDE")

    # A zenity-based SUDO_ASKPASS so privileged steps prompt graphically.
    GUI_ASKPASS="$(mktemp "${TMPDIR:-/tmp}/wp-askpass.XXXXXX")"
    cat >"$GUI_ASKPASS" <<'EOF'
#!/usr/bin/env bash
exec zenity --password --title="Administrator password" 2>/dev/null
EOF
    chmod +x "$GUI_ASKPASS"
    export SUDO_ASKPASS="$GUI_ASKPASS"
    trap 'rm -f "$GUI_ASKPASS"' EXIT
}

# gui_run "Title" cmd...  — run a backend command behind a pulsating progress
# dialog, streaming its output as status text. Returns the command's exit code
# and leaves its output in GUI_LAST_LOG. Shows an error dialog on failure.
gui_run() {
    local title="$1"; shift
    local logf rcf; logf="$(mktemp)"; rcf="$(mktemp)"
    ( "$@" >"$logf" 2>&1; printf '%s' "$?" >"$rcf" ) &
    local pid=$!
    # Feed each output line (ANSI stripped, '#'-prefixed) to the progress dialog.
    ( tail -n +1 -f --pid="$pid" "$logf" 2>/dev/null \
        | sed -u -E 's/\x1b\[[0-9;]*m//g; s/^/# /' ) \
        | zen --progress --pulsate --auto-close --no-cancel --width=480 --title="$title" --text="Working…"
    wait "$pid" 2>/dev/null
    local rc; rc="$(cat "$rcf" 2>/dev/null || echo 1)"
    GUI_LAST_LOG="$(cat "$logf" 2>/dev/null)"
    rm -f "$logf" "$rcf"
    if [[ "$rc" != "0" ]]; then
        local tail_txt; tail_txt="$(printf '%s' "$GUI_LAST_LOG" | tail -n 12 | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')"
        zen --error --width=540 --title="$title" --text="<b>Something went wrong.</b>\n\n<tt>$tail_txt</tt>"
    fi
    return "$rc"
}

gui_status_text() {
    local line; line="$(current_status_line)"
    printf 'Workshop Plan setup for Linux.\n\n%s\n\nChoose what you want to do:' "$line"
}

# Read four pipe-separated fields, applying DB defaults to blank entries.
gui_db_form() {
    local title="$1" out
    out="$(zen --forms --title="$title" --width=440 \
        --text="Leave a field blank to use its default." \
        --add-entry="Data name (default: $DEFAULT_DB_NAME)" \
        --add-entry="App user (default: $DEFAULT_DB_USER)" \
        --add-password="App password (default: $DEFAULT_DB_PASSWORD)" \
        --separator='|')" || return 1
    printf '%s' "$out"
}

gui_install() {
    zen --question --width=460 --title="Install Workshop Plan" \
        --text="Download the latest Linux version and install it?\n\nLocation: <tt>$(install_dir)</tt>" || return 0
    if gui_run "Installing Workshop Plan" "$SELF_PATH" "${GUI_BASE_ARGS[@]}" --install --assume-yes --no-launch; then
        if zen --question --width=420 --title="Workshop Plan" \
            --text="Workshop Plan is installed.\n\nLaunch it now?"; then
            ( "$(launcher_path)" >/dev/null 2>&1 & )
        fi
    fi
}

gui_connect() {
    local host
    host="$(zen --entry --title="Connect to shared data" --width=440 \
        --text="Enter the main data PC (hostname or IP):")" || return 0
    [[ -n "$host" ]] || { zen --error --text="A host name or IP is required."; return 0; }
    local out; out="$(gui_db_form "Connect to shared data")" || return 0
    local db user pass; IFS='|' read -r db user pass <<<"$out"
    db="${db:-$DEFAULT_DB_NAME}"; user="${user:-$DEFAULT_DB_USER}"; pass="${pass:-$DEFAULT_DB_PASSWORD}"
    gui_run "Connecting to $host" "$SELF_PATH" "${GUI_BASE_ARGS[@]}" \
        --connect --host "$host" --db "$db" --user-name "$user" --password "$pass" --assume-yes
    if printf '%s' "$GUI_LAST_LOG" | grep -qiE 'could not (reach|confirm)|responded but'; then
        zen --warning --width=480 --title="Saved with a warning" \
            --text="The connection was saved, but the database could not be fully reached yet.\n\nCheck the main PC is on and PostgreSQL allows LAN access, then use 'Re-test the saved connection'."
    else
        zen --info --width=420 --title="Connected" --text="This PC is now connected to shared data on <b>$host</b>."
    fi
}

gui_host() {
    zen --question --width=520 --title="Make this PC the main data host" \
        --text="This will set up <b>shared workshop data (PostgreSQL)</b> on THIS PC:\n\n• install PostgreSQL if missing\n• create the shared database and app login\n• allow other workshop PCs on the network to connect\n• open firewall port 5432 and schedule a daily backup\n\nYou will be asked for your administrator password.\n\nContinue?" || return 0
    local out; out="$(gui_db_form "Main data host")" || return 0
    local db user pass; IFS='|' read -r db user pass <<<"$out"
    db="${db:-$DEFAULT_DB_NAME}"; user="${user:-$DEFAULT_DB_USER}"; pass="${pass:-$DEFAULT_DB_PASSWORD}"
    if gui_run "Setting up the main data host" "$SELF_PATH" "${GUI_BASE_ARGS[@]}" --askpass-gui \
        --host-setup --db "$db" --user-name "$user" --password "$pass" --assume-yes; then
        local ip; ip="$(printf '%s' "$GUI_LAST_LOG" | sed -n 's/.*connect to:[[:space:]]*\([0-9.]*\).*/\1/p' | head -n1)"
        zen --info --width=480 --title="Main data host ready" \
            --text="This PC is now the main data host.\n\nOther PCs should connect to:\n<b>${ip:-this PC on the LAN}</b>  (data '$db', user '$user')."
    fi
}

gui_local() {
    zen --question --width=460 --title="Local-only mode" \
        --text="Use this PC's own local data only (no shared database)?" || return 0
    gui_run "Switching to local-only" "$SELF_PATH" "${GUI_BASE_ARGS[@]}" --local-only --assume-yes \
        && zen --info --width=420 --text="This PC now uses local-only data."
}

gui_uninstall() {
    zen --question --width=480 --title="Uninstall Workshop Plan" \
        --text="Remove Workshop Plan app files?\n\n<tt>$(install_dir)</tt>\n\nYour workshop data is kept." || return 0
    gui_run "Uninstalling" "$SELF_PATH" "${GUI_BASE_ARGS[@]}" --uninstall --assume-yes \
        && zen --info --width=420 --text="Workshop Plan was removed. Your data was kept."
}

gui_retest() {
    local cfg; cfg="$(db_config_path)"
    if [[ ! -f "$cfg" ]]; then
        zen --info --width=420 --text="No shared-data connection is saved (local-only mode)."
        return 0
    fi
    local h d u p
    h="$(sed -n 's#^database.url=jdbc:postgresql://\([^:/]*\).*#\1#p' "$cfg" | head -n1)"
    d="$(sed -n 's#^database.url=jdbc:postgresql://[^/]*/\([^?]*\).*#\1#p' "$cfg" | head -n1)"
    u="$(sed -n 's/^database.user=//p' "$cfg" | head -n1)"
    p="$(sed -n 's/^database.password=//p' "$cfg" | head -n1)"
    gui_run "Testing connection to $h" "$SELF_PATH" "${GUI_BASE_ARGS[@]}" \
        --connect --host "$h" --db "$d" --user-name "$u" --password "$p" --assume-yes
    if printf '%s' "$GUI_LAST_LOG" | grep -qiE 'could not (reach|confirm)|responded but'; then
        zen --warning --width=480 --text="Could not fully reach the shared database. Check the main PC and its firewall."
    else
        zen --info --width=420 --text="Connection to <b>$h</b> is working."
    fi
}

gui_main() {
    gui_init
    while true; do
        local choice
        choice="$(zen --list --radiolist --title="Workshop Plan Setup" \
            --text="$(gui_status_text)" --width=480 --height=380 \
            --column="" --column="Action" --column="id" --hide-column=3 --print-column=3 \
            TRUE  "Install or repair Workshop Plan"  install \
            FALSE "Connect this PC to shared data"   connect \
            FALSE "Make this PC the main data host"  host \
            FALSE "Use local-only mode"              local \
            FALSE "Re-test the saved connection"     retest \
            FALSE "Uninstall (keep my data)"         uninstall)" || break
        case "$choice" in
            install)   gui_install ;;
            connect)   gui_connect ;;
            host)      gui_host ;;
            local)     gui_local ;;
            retest)    gui_retest ;;
            uninstall) gui_uninstall ;;
            *) break ;;
        esac
    done
}

# --------------------------------------------------------------------------- #
# Interactive menu
# --------------------------------------------------------------------------- #
current_status_line() {
    local cfg; cfg="$(db_config_path)"
    if [[ -f "$cfg" ]]; then
        local url; url="$(sed -n 's/^database.url=//p' "$cfg" | head -n1)"
        printf 'Shared data: %s' "${url:-configured}"
    else
        printf 'Data mode: local-only (SQLite)'
    fi
}

menu() {
    while true; do
        echo
        printf '%s%s — Linux setup%s\n' "$C_BOLD" "$APP_NAME" "$C_RESET"
        printf '%s%s%s\n' "$C_DIM" "$(current_status_line)" "$C_RESET"
        echo "  1) Install or repair $APP_NAME"
        echo "  2) Connect this PC to shared data"
        echo "  3) Make this PC the main data host"
        echo "  4) Use local-only mode"
        echo "  5) Re-test the saved connection"
        echo "  6) Uninstall (keep my data)"
        echo "  7) View setup log"
        echo "  q) Quit"
        local choice; read -r -p "Choose: " choice || break
        case "$choice" in
            1) do_install ;;
            2) do_connect ;;
            3) do_host_setup ;;
            4) do_local_only ;;
            5)
                local cfg; cfg="$(db_config_path)"
                if [[ -f "$cfg" ]]; then
                    local h d u p
                    h="$(sed -n 's#^database.url=jdbc:postgresql://\([^:/]*\).*#\1#p' "$cfg" | head -n1)"
                    d="$(sed -n 's#^database.url=jdbc:postgresql://[^/]*/\([^?]*\).*#\1#p' "$cfg" | head -n1)"
                    u="$(sed -n 's/^database.user=//p' "$cfg" | head -n1)"
                    p="$(sed -n 's/^database.password=//p' "$cfg" | head -n1)"
                    test_db_connection "$h" "$d" "$u" "$p"
                else
                    info "No shared-data connection saved (local-only mode)."
                fi
                ;;
            6) do_uninstall ;;
            7) local lf; lf="$(log_file)"; [[ -f "$lf" ]] && tail -n 40 "$lf" || info "No log yet." ;;
            q|Q) break ;;
            *) warn "Unknown choice: $choice" ;;
        esac
    done
}

# --------------------------------------------------------------------------- #
# Usage / argument parsing
# --------------------------------------------------------------------------- #
usage() {
    cat <<EOF
$APP_NAME — Linux setup tool

Usage:
  workshop-plan-setup.sh                 Graphical setup if a desktop is detected,
                                         otherwise the interactive terminal menu
  workshop-plan-setup.sh --gui           Force the graphical (Zenity) setup
  workshop-plan-setup.sh --install       Download + install (or repair) the app
  workshop-plan-setup.sh --connect       Connect this PC to shared data
  workshop-plan-setup.sh --host-setup    Make this PC the main data host
  workshop-plan-setup.sh --local-only    Use local SQLite data only
  workshop-plan-setup.sh --uninstall     Remove app files (keeps workshop data)
  workshop-plan-setup.sh --self-test     Run built-in checks (no network/root)

Options:
  --user                 User install (default): ~/.local/share/workshop-plan
  --system               System install: /opt/workshop-plan (needs root)
  --dest DIR             Override the install directory
  --source ZIP           Install from a local WorkshopPlan-linux.zip (skips download)
  --host H               (connect) main data PC hostname/IP
  --db NAME              Database name        (default: $DEFAULT_DB_NAME)
  --user-name NAME       App database user    (default: $DEFAULT_DB_USER)
  --password PASS        App database password
  --no-firewall          (host-setup) skip the firewall rule
  --no-launch            (install) do not offer to launch afterwards
  --assume-yes, -y       Answer yes to prompts (non-interactive)
  --home DIR             Treat DIR as the user home (testing)
  --backend URL          Override backend base URL (default: $DEFAULT_BACKEND_BASE_URL)
  -h, --help             Show this help

Contracts honoured (shared with the Windows installer and the app):
  config  ~/Documents/plasma/workshop/database.properties
  release GET $DEFAULT_BACKEND_BASE_URL/api/v1/updates/latest?platform=linux&channel=stable
EOF
}

main() {
    local action=""
    OPT_HOST=""; OPT_DB=""; OPT_USER=""; OPT_PASSWORD=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --install)    action="install" ;;
            --connect)    action="connect" ;;
            --host-setup) action="host" ;;
            --local-only) action="local" ;;
            --uninstall)  action="uninstall" ;;
            --self-test)  action="selftest" ;;
            --gui)        action="gui" ;;
            --askpass-gui) ASKPASS_GUI=1 ;;
            --user)       INSTALL_MODE="user" ;;
            --system)     INSTALL_MODE="system" ;;
            --dest)       INSTALL_DIR_OVERRIDE="${2:?--dest needs a path}"; shift ;;
            --source)     LOCAL_SOURCE="${2:?--source needs a path}"; shift ;;
            --host)       OPT_HOST="${2:?--host needs a value}"; shift ;;
            --db)         OPT_DB="${2:?--db needs a value}"; shift ;;
            --user-name)  OPT_USER="${2:?--user-name needs a value}"; shift ;;
            --password)   OPT_PASSWORD="${2:?--password needs a value}"; shift ;;
            --no-firewall) SKIP_FIREWALL=1 ;;
            --no-launch)  NO_LAUNCH=1 ;;
            --assume-yes|-y) ASSUME_YES=1 ;;
            --home)       HOME_DIR="${2:?--home needs a path}"; shift ;;
            --backend)    BACKEND_BASE_URL="${2:?--backend needs a URL}"; shift ;;
            -h|--help)    usage; exit 0 ;;
            *) err "Unknown option: $1"; echo; usage; exit 2 ;;
        esac
        shift
    done

    case "$action" in
        install)   do_install ;;
        connect)   do_connect ;;
        host)      do_host_setup ;;
        local)     do_local_only ;;
        uninstall) do_uninstall ;;
        selftest)  do_self_test ;;
        gui)       gui_available || die "The graphical setup needs 'zenity' and a desktop session. Run without --gui for the terminal menu."; gui_main ;;
        "")
            # Launched from a file manager / desktop (no terminal) -> prefer the
            # GUI; from a terminal, use the text menu unless --gui was passed.
            if [[ ! -t 0 ]] && gui_available; then gui_main; else menu; fi
            ;;
    esac
}

# WORKSHOP_PLAN_NO_MAIN lets tests source this file to exercise functions
# without launching an action or the menu.
if [[ "${WORKSHOP_PLAN_NO_MAIN:-0}" != "1" ]]; then
    main "$@"
fi
