#!/bin/bash
# Last-Updated: 2026-08-05 17:00 EDT
# Created: 2026-07 (tlb-2.0+ packaging / installer)
# Authors: Paul Aidukas KN2R, Logan Crook K8LRC
#
# install-tlbweb.sh - automated setup for LinkBox public monitor + tlb-admin
#
# Package download URL (IP first — some networks block crabdance.com DDNS):
#   http://45.32.193.131/tlb-2+/
#   Alternate: http://linkbox.k8lrc.crabdance.com/tlb-2+/
#
# Preferred one-package install (Paul-style tgz — same idea as supermonASL_fresh_install):
#   wget 'http://45.32.193.131/install-tlbweb-fresh'
#   chmod +x install-tlbweb-fresh
#   sudo ./install-tlbweb-fresh
#
# Or download this installer only (package files fetch automatically):
#   wget 'http://45.32.193.131/tlb-2+/install-tlbweb.sh'
#   chmod +x install-tlbweb.sh
#   sudo ./install-tlbweb.sh
#
# install-tlbweb.sh copies the public monitor and tlb-admin into /var/www/html
# (WEB_ROOT). Run from anywhere; templates may be downloaded from the package URL.
#
# Installed paths (Paul KN2R naming):
#   /var/www/html/exp.php                - public monitor (official)
#   /var/www/html/tlb-common.inc
#   /var/www/html/tlb-admin/index.php    - admin tool
#   /var/www/html/tlb-admin/.htaccess
#
# Non-interactive examples:
#   sudo SITE_TYPE=irlp SERVER_IP=203.0.113.10 REFLECTOR_NAME=exp0092 \
#        DOMAIN_OR_IP=linkbox.example.com ALLSTAR_NODE=41001 ./install-tlbweb.sh --yes
#   sudo SITE_TYPE=echolink SERVER_IP=203.0.113.10 REFLECTOR_NAME='*CONF*' \
#        USES_IRLP=0 ./install-tlbweb.sh --yes
#   sudo SITE_TYPE=link SERVER_IP=203.0.113.10 REFLECTOR_NAME=K8LRC-L \
#        USES_IRLP=0 ./install-tlbweb.sh --yes
#   sudo SITE_TYPE=repeater SERVER_IP=203.0.113.10 REFLECTOR_NAME=K8LRC-R \
#        USES_IRLP=0 ./install-tlbweb.sh --yes
#
# Set INSTALL_TLBEVENT=1 to also install EventScripts (see SETUP-INSTRUCTIONS.txt Step 6).
#   TLBEVENT_POLICY=conference  -> activate tlbevent-conference.sh (WITH policy)
#   TLBEVENT_POLICY=nopolicy    -> activate tlbevent-nopolicy.sh (NO policy)
#   TLBEVENT_POLICY=plain       -> activate tlbevent.sh (legacy NO policy name)
# Scripts are installed only if missing (never overwrite existing).
# EventScript= in tlb.conf is set only if not already present.
# Restart TLB: installs restart scripts only if missing; asks which button uses.
#
# Update path (update-tlbweb.sh) does NOT touch /home/thelinkbox/scripts,
# tlb.conf EventScript=, or an existing tlb-admin/.htaccess — leave those to the operator.
# Updates also preserve an existing $tlbrestartscript in tlb-admin (never auto-switch).

INSTALLER_VERSION=34

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_TLB_PACKAGE_URL_PRESET="${PACKAGE_URL-}"
PACKAGE_URL="${PACKAGE_URL:-http://45.32.193.131/tlb-2+}"
# Remember if caller exported WEB_ROOT before we apply the default
_TLB_WEB_ROOT_PRESET="${WEB_ROOT-}"
WEB_ROOT="${WEB_ROOT:-/var/www/html}"
TLBEVENT_DEST="${TLBEVENT_DEST:-/home/thelinkbox/scripts/tlbevent-conference.sh}"
TLBEVENT_PLAIN_DEST="${TLBEVENT_PLAIN_DEST:-/home/thelinkbox/scripts/tlbevent.sh}"
TLBEVENT_NOPOLICY_DEST="${TLBEVENT_NOPOLICY_DEST:-/home/thelinkbox/scripts/tlbevent-nopolicy.sh}"
RESTART_TLB_DEST="${RESTART_TLB_DEST:-/home/thelinkbox/scripts/restart-tlb}"
RESTART_TLB_NOPOLICY_DEST="${RESTART_TLB_NOPOLICY_DEST:-/home/thelinkbox/scripts/restart-tlb-nopolicy}"
RESTART_TLB_CONFERENCE_DEST="${RESTART_TLB_CONFERENCE_DEST:-/home/thelinkbox/scripts/restart-tlb-conference}"
ASSUME_YES=0
DO_DOWNLOAD=0

usage() {
    cat <<'EOF'
Usage: install-tlbweb.sh [options]

Automates deployment to /var/www/html (Apache web root):
  - Public monitor: /var/www/html/exp.php   (official; Paul KN2R)
  - Shared include: /var/www/html/tlb-common.inc
  - Admin tool:     /var/www/html/tlb-admin/index.php + .htaccess

Options:
  --download         Download package files from PACKAGE_URL into this directory
  -y, --yes          Use defaults / env vars without prompting (where possible)
  -h, --help         Show this help

Preferred one-package (tgz) install:
  wget 'http://45.32.193.131/install-tlbweb-fresh'
  chmod +x install-tlbweb-fresh
  sudo ./install-tlbweb-fresh

Or download this installer only:
  wget 'http://45.32.193.131/tlb-2+/install-tlbweb.sh'
  chmod +x install-tlbweb.sh
  sudo ./install-tlbweb.sh
  (missing package files are downloaded automatically before install)

Or fetch package files only:
  ./install-tlbweb.sh --download

tlb.ini (optional):
  - Installer ASKS for your site info (IP, name, Supermon, …) and can write
    /var/www/html/tlb.ini for you.
  - Or copy tlb.ini.template → tlb.ini, fill YOUR_* values, then install.
  - tlb.ini.sample is a filled example. Env vars still win over tlb.ini.

Environment (optional, used with -y or as defaults when prompting):
  PACKAGE_URL        Package base URL (default: http://45.32.193.131/tlb-2+)
                     Alternate: http://linkbox.k8lrc.crabdance.com/tlb-2+
  WEB_ROOT           Apache document root (default: /var/www/html) - public monitor and tlb-admin are copied here
  SERVER_IP          Public IP shown on monitor/admin pages
  SITE_TYPE          irlp      = IRLP experimental reflector (exp####)
                     echolink  = EchoLink conference (*CONF* / *OHIO*)
                     link      = EchoLink -L Link station (CALL-L)
                     repeater  = EchoLink -R Repeater station (CALL-R)
  REFLECTOR_NAME     exp0092 | *CONF* | K8LRC-L | K8LRC-R
  USES_IRLP          1/0 or yes/no — this box uses IRLP
  USES_ECHOLINK      1/0 or yes/no — this box uses EchoLink
  DOMAIN_OR_IP       Hostname fallback for admin redirects
  SUPERMON_URL       Supermon site URL (http or https); blank = omit AllStar link
                     e.g. http://41001.nodes.allstarlink.org
                          https://42121.nodes.allstarlink.org
  ALLSTAR_NODE       Legacy: hub node number only (builds http://NODE.nodes... URL)
  TLBCMD_PORT        tlbcmd port number only (e.g. 6198) or full -p6198
  TLB_LOG_PATH       Full path to tlb.log (admin log panel)
  TLB_MESSAGES_PATH  Full path to messages log (admin log panel)
  AUTH_MODE          dedicated | supermon
  SUPERMON_HTPASSWD  Path when AUTH_MODE=supermon
  PUBLIC_MONITOR_SCRIPT  Always exp.php (public monitor). Admin is tlb-admin/index.php.
  INSTALL_TLBEVENT   1 to install both EventScripts (conference + plain)
  TLBEVENT_POLICY    conference | plain  (or 1 | 2) — which one EventScript= uses
  TLBEVENT_DEST      Destination for WITH-policy script
                     (default /home/thelinkbox/scripts/tlbevent-conference.sh)
  TLBEVENT_PLAIN_DEST Destination for NO-policy script
                     (default /home/thelinkbox/scripts/tlbevent.sh)
  TLB_CONF           Path to tlb.conf (default: auto-detect /home/thelinkbox/tlb.conf)
  RESTART_TLB_POLICY conference | nopolicy | plain (or 1 | 2 | 3) — which restart script tlb-admin uses
  RESTART_TLB_DEST   legacy NO-policy restart path (default .../restart-tlb)
  RESTART_TLB_NOPOLICY_DEST preferred NO-policy restart path (.../restart-tlb-nopolicy)
  RESTART_TLB_CONFERENCE_DEST WITH-policy restart path (default .../restart-tlb-conference)

See SETUP-INSTRUCTIONS.txt for manual setup and Apache/permission notes.
EOF
}

log() {
    printf 'install-tlbweb: %s\n' "$*"
}

print_install_disclaimer() {
    cat <<'EOF'

========================================================================
  LinkBox Web Monitor + Admin  (IRLP / EchoLink / -L / -R)
========================================================================

  READ SETUP-INSTRUCTIONS.txt BEFORE CONTINUING.

  Installer asks what THIS TheLinkBox is, then which networks it uses:

    Role (page title):
      1) IRLP experimental reflector — exp0092
         => "IRLP exp0092 Reflector"
      2) EchoLink conference         — *OHIO* / *CONF* / *HAM*
         => "EchoLink *OHIO* Conference"
      3) EchoLink -L Link station    — CALL-L
         => "EchoLink CALL-L Link"
      4) EchoLink -R Repeater        — CALL-R
         => "EchoLink CALL-R Repeater"

    Networks (can be both, IRLP only, or EchoLink only / no IRLP):
      - IRLP
      - EchoLink

  Not for standard IRLP node-only installs without a TLB reflector/conference.

  You must password-protect tlb-admin/ (Apache .htaccess included).

========================================================================
EOF
}

confirm_read_instructions() {
    local answer=""

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        return 0
    fi

    prompt answer "Have you read SETUP-INSTRUCTIONS.txt (or README-FIRST.txt)? (y/N)" "N"
    [[ "${answer}" =~ ^[Yy] ]] || die "Install cancelled - read SETUP-INSTRUCTIONS.txt first"
}

die() {
    printf 'install-tlbweb: ERROR: %s\n' "$*" >&2
    exit 1
}

escape_sed_replacement() {
    # Escape sed replacement specials when using | as delimiter.
    # Do NOT turn "/" into "&" — in sed replacements "&" means the whole match.
    printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'
}

# Safely set AuthUserFile in tlb-admin/.htaccess (no sed "&" pitfalls).
# Rejects already-corrupted values (contain "AuthUserFile" or whitespace).
set_htaccess_authuserfile() {
    local htaccess="$1"
    local path="$2"
    [[ -f "${htaccess}" ]] || return 1
    path="$(trim_value "${path}")"
    if [[ -z "${path}" || "${path}" != /* || "${path}" == *" "* || "${path}" == *AuthUserFile* ]]; then
        return 1
    fi
    awk -v p="${path}" '
        BEGIN { done = 0 }
        /^AuthUserFile[[:space:]]/ {
            if (!done) { print "AuthUserFile " p; done = 1 }
            next
        }
        { print }
        END { if (!done) print "AuthUserFile " p }
    ' "${htaccess}" > "${htaccess}.tmp.$$" && mv "${htaccess}.tmp.$$" "${htaccess}"
}

require_file() {
    local path="$1"
    [[ -f "${path}" ]] || die "Missing required file: ${path}"
}

package_file_list() {
    printf '%s\n' \
        "README-FIRST.txt" \
        "SETUP-INSTRUCTIONS.txt" \
        "CHANGELOG.txt" \
        "index.template" \
        "tlb-common.inc" \
        "tlbevent-conference.template" \
        "tlbevent-conference.sh" \
        "tlbevent.template" \
        "tlbevent.sh" \
        "tlbevent-nopolicy.template" \
        "tlbevent-nopolicy.sh" \
        "admin-index.template" \
        "admin-htaccess" \
        "net-schedule.inc" \
        "net-schedule.json.sample" \
        "tlb_net_schedule" \
        "restart-tlb.sample" \
        "restart-tlb" \
        "restart-tlb-nopolicy.sample" \
        "restart-tlb-nopolicy" \
        "restart-tlb-conference.sample" \
        "restart-tlb-conference"
}

resolve_pkg_file() {
    local primary="$1"
    shift
    local alt=""

    if [[ -f "${SCRIPT_DIR}/${primary}" ]]; then
        printf '%s' "${SCRIPT_DIR}/${primary}"
        return 0
    fi

    for alt in "$@"; do
        if [[ -f "${SCRIPT_DIR}/${alt}" ]]; then
            printf '%s' "${SCRIPT_DIR}/${alt}"
            return 0
        fi
    done

    return 1
}

package_is_complete() {
    resolve_pkg_file "index.template" "index.php.bak" >/dev/null || return 1
    resolve_pkg_file "tlb-common.inc" >/dev/null || return 1
    resolve_pkg_file "admin-index.template" "admin-index.php.bak" >/dev/null || return 1
    resolve_pkg_file "admin-htaccess" >/dev/null || return 1
    resolve_pkg_file "tlbevent-conference.template" "tlbevent-conference.sh" >/dev/null || return 1
    resolve_pkg_file "tlbevent.template" "tlbevent.sh" >/dev/null || return 1
    resolve_pkg_file "tlbevent-nopolicy.template" "tlbevent-nopolicy.sh" >/dev/null || return 1
    resolve_pkg_file "SETUP-INSTRUCTIONS.txt" >/dev/null || return 1
    return 0
}

missing_package_files() {
    local f
    for f in $(package_file_list); do
        case "${f}" in
            index.template)
                resolve_pkg_file "index.template" "index.php.bak" >/dev/null || printf '%s\n' "${f}"
                ;;
            admin-index.template)
                resolve_pkg_file "admin-index.template" "admin-index.php.bak" >/dev/null || printf '%s\n' "${f}"
                ;;
            tlbevent-conference.template)
                resolve_pkg_file "tlbevent-conference.template" "tlbevent-conference.sh" >/dev/null || printf '%s\n' "${f}"
                ;;
            tlbevent.template)
                resolve_pkg_file "tlbevent.template" "tlbevent.sh" >/dev/null || printf '%s\n' "${f}"
                ;;
            tlbevent-nopolicy.template)
                resolve_pkg_file "tlbevent-nopolicy.template" "tlbevent-nopolicy.sh" >/dev/null || printf '%s\n' "${f}"
                ;;
            *)
                [[ -f "${SCRIPT_DIR}/${f}" ]] || printf '%s\n' "${f}"
                ;;
        esac
    done
}

download_one_file() {
    local dest="$1"
    local base="$2"
    local local_name="$3"
    shift 3
    local url_name out tmp ok=1

    out="${dest}/${local_name}"
    tmp="${out}.wgettmp"
    rm -f "${tmp}"

    for url_name in "$@"; do
        log "  ${local_name} (try ${url_name})"
        if wget --show-progress -O "${tmp}" "${base}/${url_name}"; then
            mv -f "${tmp}" "${out}"
            ok=0
            break
        fi
        rm -f "${tmp}"
    done

    if [[ "${ok}" -ne 0 ]]; then
        die "wget failed for ${local_name} from ${base}/
Tried: $*
Upload the full tlb-web package to ${base}/"
    fi
}

download_package() {
    local dest="$1"
    local base="${PACKAGE_URL%/}"

    if ! command -v wget >/dev/null 2>&1; then
        die "wget is required to download the package (install wget first)"
    fi

    mkdir -p "${dest}"
    log "Downloading package files from ${base}/"

    download_one_file "${dest}" "${base}" "README-FIRST.txt" "README-FIRST.txt"
    download_one_file "${dest}" "${base}" "SETUP-INSTRUCTIONS.txt" "SETUP-INSTRUCTIONS.txt"
    download_one_file "${dest}" "${base}" "CHANGELOG.txt" "CHANGELOG.txt"
    download_one_file "${dest}" "${base}" "index.template" "index.template" "index.php.bak"
    download_one_file "${dest}" "${base}" "tlb-common.inc" "tlb-common.inc"
    download_one_file "${dest}" "${base}" "tlbevent-conference.template" "tlbevent-conference.template" "tlbevent-conference.sh"
    download_one_file "${dest}" "${base}" "tlbevent-conference.sh" "tlbevent-conference.sh" "tlbevent-conference.template"
    download_one_file "${dest}" "${base}" "tlbevent.template" "tlbevent.template" "tlbevent.sh"
    download_one_file "${dest}" "${base}" "tlbevent.sh" "tlbevent.sh" "tlbevent.template"
    download_one_file "${dest}" "${base}" "tlbevent-nopolicy.template" "tlbevent-nopolicy.template" "tlbevent-nopolicy.sh"
    download_one_file "${dest}" "${base}" "tlbevent-nopolicy.sh" "tlbevent-nopolicy.sh" "tlbevent-nopolicy.template"
    download_one_file "${dest}" "${base}" "admin-index.template" "admin-index.template" "admin-index.php.bak"
    download_one_file "${dest}" "${base}" "admin-htaccess" "admin-htaccess"
    download_one_file "${dest}" "${base}" "net-schedule.inc" "net-schedule.inc"
    download_one_file "${dest}" "${base}" "net-schedule.json.sample" "net-schedule.json.sample"
    download_one_file "${dest}" "${base}" "tlb_net_schedule" "tlb_net_schedule"
    download_one_file "${dest}" "${base}" "tlb.ini.template" "tlb.ini.template"
    download_one_file "${dest}" "${base}" "tlb.ini.sample" "tlb.ini.sample"
    download_one_file "${dest}" "${base}" "restart-tlb.sample" "restart-tlb.sample" "restart-tlb"
    download_one_file "${dest}" "${base}" "restart-tlb" "restart-tlb" "restart-tlb.sample"
    download_one_file "${dest}" "${base}" "restart-tlb-nopolicy.sample" "restart-tlb-nopolicy.sample" "restart-tlb-nopolicy"
    download_one_file "${dest}" "${base}" "restart-tlb-nopolicy" "restart-tlb-nopolicy" "restart-tlb-nopolicy.sample"
    download_one_file "${dest}" "${base}" "restart-tlb-conference.sample" "restart-tlb-conference.sample" "restart-tlb-conference"
    download_one_file "${dest}" "${base}" "restart-tlb-conference" "restart-tlb-conference" "restart-tlb-conference.sample"

    if wget -q -O "${dest}/update-tlbweb.sh.tmp" "${base}/update-tlbweb.sh"; then
        mv -f "${dest}/update-tlbweb.sh.tmp" "${dest}/update-tlbweb.sh"
        chmod +x "${dest}/update-tlbweb.sh" 2>/dev/null || true
        log "  update-tlbweb.sh"
    else
        rm -f "${dest}/update-tlbweb.sh.tmp"
    fi

    chmod +x "${dest}/install-tlbweb.sh" 2>/dev/null || true
    log "Package ready in ${dest}"
}

refresh_update_package() {
    local dest="${SCRIPT_DIR}"
    local base="${PACKAGE_URL%/}"

    if ! command -v wget >/dev/null 2>&1; then
        log "wget not available - using existing templates in ${dest}"
        return 0
    fi

    verify_package_url

    log "Refreshing deploy templates from ${base}/"
    # Web templates only — do NOT refresh EventScripts / restart scripts here.
    # Update leaves /home/thelinkbox/scripts and tlb.conf alone (operator-managed).
    download_one_file "${dest}" "${base}" "index.template" "index.template" "index.php.bak"
    download_one_file "${dest}" "${base}" "admin-index.template" "admin-index.template" "admin-index.php.bak"
    download_one_file "${dest}" "${base}" "tlb-common.inc" "tlb-common.inc"
    download_one_file "${dest}" "${base}" "admin-htaccess" "admin-htaccess"
    download_one_file "${dest}" "${base}" "net-schedule.inc" "net-schedule.inc"
    download_one_file "${dest}" "${base}" "net-schedule.json.sample" "net-schedule.json.sample"
    download_one_file "${dest}" "${base}" "tlb_net_schedule" "tlb_net_schedule"
}

validate_public_monitor_template() {
    local tpl=""

    tpl="$(resolve_pkg_file "index.template" "index.php.bak")" \
        || die "Missing public monitor template (index.template)"
    # Public monitor should link to tlb-admin/ (logs live inside admin now).
    if ! grep -q 'href="tlb-admin/"' "${tpl}" 2>/dev/null; then
        die "index.template in ${SCRIPT_DIR} is outdated (must link to tlb-admin/).
Upload the latest index.template to ${PACKAGE_URL%/}/ and re-run update."
    fi
}

verify_package_url() {
    local base="${PACKAGE_URL%/}"
    local tmp=""

    if ! command -v wget >/dev/null 2>&1; then
        return 0
    fi

    tmp="$(mktemp)"
    if wget -q -O "${tmp}" "${base}/index.template" 2>/dev/null && [[ -s "${tmp}" ]]; then
        rm -f "${tmp}"
        return 0
    fi
    rm -f "${tmp}"

    die "Cannot download package from ${base}/

Use the full path (tlb- prefix, not /2.0+/):
  http://45.32.193.131/tlb-2+/

Test:
  wget -O /dev/null 'http://45.32.193.131/tlb-2+/index.template'

If you set PACKAGE_URL, fix or unset it:
  unset PACKAGE_URL"
}

ensure_package() {
    if package_is_complete; then
        return 0
    fi

    echo "Package files are not present in ${SCRIPT_DIR}:"
    missing_package_files | sed 's/^/  - /'
    echo

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        download_package "${SCRIPT_DIR}"
        return 0
    fi

    local answer=""
    prompt answer "Download package from ${PACKAGE_URL}/ now? (Y/n)" "Y"
    if [[ "${answer}" =~ ^[Nn] ]]; then
        die "Cannot continue without package files. Run: ./install-tlbweb.sh --download"
    fi

    download_package "${SCRIPT_DIR}"
}

prompt() {
    local var_name="$1"
    local prompt_text="$2"
    local default_value="${3:-}"
    local input=""

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        printf -v "${var_name}" '%s' "${default_value}"
        return 0
    fi

    if [[ -n "${default_value}" ]]; then
        read -r -p "${prompt_text} [${default_value}]: " input
        input="${input:-${default_value}}"
    else
        read -r -p "${prompt_text}: " input
    fi
    printf -v "${var_name}" '%s' "${input}"
}

prompt_secret() {
    local var_name="$1"
    local prompt_text="$2"
    local input=""

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        printf -v "${var_name}" '%s' ""
        return 0
    fi

    read -r -s -p "${prompt_text}: " input
    echo
    printf -v "${var_name}" '%s' "${input}"
}

detect_public_ip() {
    local ip=""

    if command -v curl >/dev/null 2>&1; then
        ip="$(curl -4 -fsS --max-time 5 ifconfig.me 2>/dev/null || true)"
    fi
    if [[ -z "${ip}" ]] && command -v hostname >/dev/null 2>&1; then
        ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
    fi
    if [[ -z "${ip}" ]] && command -v ip >/dev/null 2>&1; then
        ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')"
    fi
    if [[ "${ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
        printf '%s' "${ip}"
    fi
}

is_valid_host_or_ip() {
    local value="$1"

    [[ -n "${value}" ]] || return 1
    [[ "${value}" != *"/"* ]] || return 1
    [[ "${value}" != *" "* ]] || return 1
    [[ "${value}" != *"install-tlbweb"* ]] || return 1
    if [[ "${value}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
        return 0
    fi
    if [[ "${value}" =~ ^[A-Za-z0-9.*_-]+$ ]]; then
        return 0
    fi
    return 1
}

prompt_host_or_ip() {
    local var_name="$1"
    local prompt_text="$2"
    local default_value="${3:-}"
    local input=""
    local tries=0

    if [[ -z "${default_value}" ]] || ! is_valid_host_or_ip "${default_value}"; then
        default_value="$(detect_public_ip)"
    fi

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        if is_valid_host_or_ip "${default_value}"; then
            printf -v "${var_name}" '%s' "${default_value}"
            return 0
        fi
        die "${prompt_text}: set SERVER_IP or DOMAIN_OR_IP to a valid IP/hostname for --yes"
    fi

    while [[ "${tries}" -lt 5 ]]; do
        if [[ -n "${default_value}" ]]; then
            read -r -p "${prompt_text} [${default_value}]: " input
            input="${input:-${default_value}}"
        else
            read -r -p "${prompt_text}: " input
        fi
        if is_valid_host_or_ip "${input}"; then
            printf -v "${var_name}" '%s' "${input}"
            return 0
        fi
        echo "Please enter a public IP address or hostname (not a file path or command)."
        tries=$((tries + 1))
        default_value=""
    done

    die "Could not get a valid value for ${prompt_text}"
}

normalize_tlbcmd_port() {
    local raw="$1"
    [[ -z "${raw}" ]] && return 0
    if [[ "${raw}" == -p* ]]; then
        printf '%s' "${raw}"
    else
        printf -- '-p%s' "${raw}"
    fi
}

trim_value() {
    local v="$1"
    v="${v#"${v%%[![:space:]]*}"}"
    v="${v%"${v##*[![:space:]]}"}"
    printf '%s' "${v}"
}


# Load tlb.ini (same idea as Supermon allmon.ini). Only fills unset/empty vars
# so environment variables and -y exports still win. Looks for:
#   ./tlb.ini  ${SCRIPT_DIR}/tlb.ini  ${WEB_ROOT}/tlb.ini
load_tlb_ini() {
    local ini="" line key val section=""
    local cand
    for cand in "${PWD}/tlb.ini" "${SCRIPT_DIR}/tlb.ini" "${WEB_ROOT}/tlb.ini"; do
        if [[ -f "${cand}" ]]; then
            ini="${cand}"
            break
        fi
    done
    [[ -n "${ini}" ]] || return 0

    log "Loading settings from ${ini}"

    ini_set_if_empty() {
        local var="$1" value="$2"
        [[ -n "${value}" ]] || return 0
        if [[ -z "${!var:-}" ]]; then
            printf -v "${var}" '%s' "${value}"
        fi
    }

    while IFS= read -r line || [[ -n "${line}" ]]; do
        line="${line%$'\r'}"
        # trim
        line="${line#"${line%%[![:space:]]*}"}"
        line="${line%"${line##*[![:space:]]}"}"
        [[ -z "${line}" ]] && continue
        [[ "${line}" == \;* || "${line}" == \#* ]] && continue
        if [[ "${line}" == \[* ]]; then
            section="${line#"["}"
            section="${section%"]"}"
            section="$(printf '%s' "${section}" | tr '[:upper:]' '[:lower:]')"
            continue
        fi
        [[ "${line}" == *=* ]] || continue
        key="$(trim_value "${line%%=*}")"
        val="$(trim_value "${line#*=}")"
        # strip trailing inline comment
        if [[ "${val}" == *\;* ]]; then
            val="$(trim_value "${val%%;*}")"
        fi
        # strip matching quotes
        if [[ "${val}" == \"*\" && "${val}" == *\" ]]; then
            val="${val#\"}"
            val="${val%\"}"
        elif [[ "${val}" == \'*\' && "${val}" == *\' ]]; then
            val="${val#\'}"
            val="${val%\'}"
        fi
        key="$(printf '%s' "${key}" | tr '[:upper:]' '[:lower:]')"

        case "${key}" in
            site_type) ini_set_if_empty SITE_TYPE "${val}" ;;
            reflector_name) ini_set_if_empty REFLECTOR_NAME "${val}" ;;
            server_ip) ini_set_if_empty SERVER_IP "${val}" ;;
            domain_or_ip) ini_set_if_empty DOMAIN_OR_IP "${val}" ;;
            supermon_url) ini_set_if_empty SUPERMON_URL "${val}" ;;
            allstar_node) ini_set_if_empty ALLSTAR_NODE "${val}" ;;
            tlbcmd_port) ini_set_if_empty TLBCMD_PORT "${val}" ;;
            tlb_log_path) ini_set_if_empty TLB_LOG_PATH "${val}" ;;
            tlb_messages_path) ini_set_if_empty TLB_MESSAGES_PATH "${val}" ;;
            public_monitor|public_monitor_script)
                ini_set_if_empty PUBLIC_MONITOR_SCRIPT "${val}"
                ;;
            uses_irlp) ini_set_if_empty USES_IRLP "${val}" ;;
            uses_echolink) ini_set_if_empty USES_ECHOLINK "${val}" ;;
            auth_mode) ini_set_if_empty AUTH_MODE "${val}" ;;
            supermon_htpasswd) ini_set_if_empty SUPERMON_HTPASSWD "${val}" ;;
            package_url)
                if [[ -n "${val}" && -z "${_TLB_PACKAGE_URL_PRESET}" ]]; then
                    PACKAGE_URL="${val}"
                fi
                ;;
            web_root)
                # Only override default when caller did not export WEB_ROOT
                if [[ -n "${val}" && -z "${_TLB_WEB_ROOT_PRESET}" ]]; then
                    WEB_ROOT="${val}"
                fi
                ;;
        esac
    done < "${ini}"
}

# Write answered site settings to WEB_ROOT/tlb.ini (like Supermon allmon.ini).
# Interactive install asks; --yes writes only when tlb.ini is missing.
write_tlb_ini() {
    local dest="${WEB_ROOT%/}/tlb.ini"
    local answer="Y"
    local uses_irlp_val="${USES_IRLP:-}"
    local uses_el_val="${USES_ECHOLINK:-}"

    ini_quote() {
        local v="$1"
        # Quote if empty, has spaces, or special chars (*, ;, #, =, ')
        if [[ -z "${v}" || "${v}" =~ [[:space:]\*\;#=\'\"] ]]; then
            v="${v//\\/\\\\}"
            v="${v//\"/\\\"}"
            printf '"%s"' "${v}"
        else
            printf '%s' "${v}"
        fi
    }

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        if [[ -f "${dest}" ]]; then
            log "Keeping existing ${dest} (--yes; not overwriting)"
            return 0
        fi
        answer="Y"
    else
        echo
        echo "Save these answers to ${dest} for next install/update?"
        echo "  (site type, name, IP, Supermon URL, networks, log paths)"
        if [[ -f "${dest}" ]]; then
            prompt answer "Overwrite existing tlb.ini? (y/N)" "N"
            [[ "${answer}" =~ ^[Yy] ]] || {
                log "Keeping existing ${dest}"
                return 0
            }
        else
            prompt answer "Write ${dest}? (Y/n)" "Y"
            [[ "${answer}" =~ ^[Nn] ]] && {
                log "Skipped writing tlb.ini (you can still cp tlb.ini.template tlb.ini later)"
                return 0
            }
        fi
    fi

    [[ -d "${WEB_ROOT}" ]] || die "WEB_ROOT does not exist: ${WEB_ROOT}"

    cat > "${dest}" << EOF
; ============================================================================
; tlb.ini - site settings for LinkBox web monitor + tlb-admin
; Written by install-tlbweb.sh / update-tlbweb.sh on $(date '+%Y-%m-%d %H:%M %Z')
; Edit this file or re-run the installer to change values.
; ============================================================================

[site]
site_type = $(ini_quote "${SITE_TYPE:-}")
reflector_name = $(ini_quote "${REFLECTOR_NAME:-}")
server_ip = $(ini_quote "${SERVER_IP:-}")
domain_or_ip = $(ini_quote "${DOMAIN_OR_IP:-${SERVER_IP:-}}")
supermon_url = $(ini_quote "${SUPERMON_URL:-}")
allstar_node = $(ini_quote "${ALLSTAR_NODE:-}")
tlbcmd_port = $(ini_quote "${TLBCMD_PORT:-}")
tlb_log_path = $(ini_quote "${TLB_LOG_PATH:-}")
tlb_messages_path = $(ini_quote "${TLB_MESSAGES_PATH:-}")
web_root = $(ini_quote "${WEB_ROOT:-/var/www/html}")
public_monitor = $(ini_quote "${PUBLIC_MONITOR_SCRIPT:-exp.php}")
uses_irlp = $(ini_quote "${uses_irlp_val}")
uses_echolink = $(ini_quote "${uses_el_val}")

[auth]
auth_mode = $(ini_quote "${AUTH_MODE:-dedicated}")
; supermon_htpasswd = $(ini_quote "${SUPERMON_HTPASSWD:-}")

[package]
; package_url = http://45.32.193.131
EOF

    chmod 0644 "${dest}" 2>/dev/null || true
    chown root:root "${dest}" 2>/dev/null || true
    log "Wrote site answers to ${dest}"
}

# Pull AllStar hub node from a monitor/admin HTML/PHP file (several URL shapes).
extract_allstar_node_from_file() {
    local file="$1"
    local tmp=""

    [[ -f "${file}" ]] || return 1

    tmp="$(sed -n "s|.*allstarlink.org/supermon/link.php?nodes=\\([0-9A-Za-z][0-9A-Za-z]*\\).*|\\1|p" "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    if [[ -n "${tmp}" ]]; then
        printf '%s' "${tmp}"
        return 0
    fi

    tmp="$(sed -n "s|.*https\\?://\\([0-9A-Za-z][0-9A-Za-z]*\\)\\.nodes\\.allstarlink\\.org.*|\\1|p" "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    if [[ -n "${tmp}" ]]; then
        printf '%s' "${tmp}"
        return 0
    fi

    # Comment / placeholder leftovers: "4. 41001" or "AllStar hub node 41001"
    tmp="$(sed -n "s/.*AllStar[^0-9]*\\([0-9][0-9]*\\).*/\\1/p" "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    if [[ -n "${tmp}" ]]; then
        printf '%s' "${tmp}"
        return 0
    fi

    return 1
}

# Prefer live monitors, then recent backups (update can wipe the link if node was unknown).

extract_supermon_url_from_file() {
    local file="$1"
    local tmp=""

    [[ -f "${file}" ]] || return 1
    tmp="$(sed -n 's|.*href="\(https\?://[^"]*nodes\.allstarlink\.org[^"]*\)".*|\1|p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] || return 1
    printf '%s' "${tmp}"
}

normalize_supermon_url() {
    local url="$1"
    local node=""

    url="$(trim_value "${url}")"
    [[ -n "${url}" ]] || { printf ''; return 0; }

    if [[ "${url}" =~ ^[0-9A-Za-z]+$ ]]; then
        node="${url}"
        ALLSTAR_NODE="${node}"
        printf 'http://%s.nodes.allstarlink.org/supermon/link.php?nodes=%s' "${node}" "${node}"
        return 0
    fi

    url="${url%/}"

    if [[ "${url}" == *"/supermon/link.php"* ]]; then
        node="$(sed -n 's|.*[?&]nodes=\([0-9A-Za-z][0-9A-Za-z]*\).*|\1|p' <<< "${url}" | head -1)"
        [[ -n "${node}" ]] && ALLSTAR_NODE="${node}"
        printf '%s' "${url}"
        return 0
    fi

    if [[ "${url}" =~ ^https?:// ]]; then
        node="$(sed -n 's|^https\?://\([0-9A-Za-z][0-9A-Za-z]*\)\.nodes\.allstarlink\.org.*|\1|p' <<< "${url}" | head -1)"
        if [[ -n "${node}" ]]; then
            ALLSTAR_NODE="${node}"
            url="$(sed 's|/supermon$||' <<< "${url}")"
            printf '%s/supermon/link.php?nodes=%s' "${url}" "${node}"
            return 0
        fi
        printf '%s' "${url}"
        return 0
    fi

    printf '%s' "${url}"
}

prompt_supermon_url() {
    local default_url=""

    # Prefer already-detected / env value as the editable default
    if [[ -n "${SUPERMON_URL:-}" ]]; then
        default_url="$(normalize_supermon_url "${SUPERMON_URL}")"
        # Show base host as the typed default when possible
        default_url="$(sed -n 's|^\(https\?://[^/]*\.nodes\.allstarlink\.org\).*|\1|p' <<< "${default_url}")"
        [[ -n "${default_url}" ]] || default_url="${SUPERMON_URL}"
    fi
    if [[ -z "${default_url}" ]]; then
        default_url="$(extract_supermon_url_from_file "${WEB_ROOT}/exp.php" 2>/dev/null || true)"
    fi
    if [[ -z "${default_url}" ]]; then
        default_url="$(extract_supermon_url_from_file "${WEB_ROOT}/index.php" 2>/dev/null || true)"
    fi
    if [[ -z "${default_url}" && -n "${ALLSTAR_NODE:-}" ]]; then
        default_url="http://${ALLSTAR_NODE}.nodes.allstarlink.org"
    fi
    # Prefer short base URL in the prompt
    if [[ "${default_url}" == *"/supermon/link.php"* ]]; then
        default_url="$(sed -n 's|^\(https\?://[^/]*\.nodes\.allstarlink\.org\).*|\1|p' <<< "${default_url}")"
    fi

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        if [[ -n "${SUPERMON_URL:-}" ]]; then
            SUPERMON_URL="$(normalize_supermon_url "${SUPERMON_URL}")"
        elif [[ -n "${default_url}" ]]; then
            SUPERMON_URL="$(normalize_supermon_url "${default_url}")"
        elif [[ -n "${ALLSTAR_NODE:-}" ]]; then
            SUPERMON_URL="$(normalize_supermon_url "${ALLSTAR_NODE}")"
        else
            SUPERMON_URL=""
        fi
        log "Supermon site URL: ${SUPERMON_URL:-<omit>}"
        return 0
    fi

    echo
    echo "Supermon / AllStar hub site (type the URL you use):"
    echo "  Examples:"
    echo "    http://41001.nodes.allstarlink.org          (ASL1 / http)"
    echo "    https://42121.nodes.allstarlink.org         (ASL3 / https)"
    echo "    http://41001.nodes.allstarlink.org/supermon/link.php?nodes=41001"
    echo "  Leave blank to omit the AllStar/Supermon link on the public monitor."
    prompt SUPERMON_URL "Supermon site URL" "${default_url}"
    SUPERMON_URL="$(normalize_supermon_url "${SUPERMON_URL}")"
    if [[ -n "${SUPERMON_URL}" ]]; then
        log "Supermon site URL: ${SUPERMON_URL}"
    else
        log "No Supermon site URL — AllStar hub link will be omitted"
    fi
}

find_allstar_node_from_webroot() {
    local f tmp=""
    local candidates=()

    for f in index.php exp.php; do
        [[ -f "${WEB_ROOT}/${f}" ]] && candidates+=("${WEB_ROOT}/${f}")
    done
    # Newest backups first
    while IFS= read -r f; do
        [[ -n "${f}" ]] && candidates+=("${f}")
    done < <(ls -1t "${WEB_ROOT}"/index.php.bak.* "${WEB_ROOT}"/exp.php.bak.* 2>/dev/null | head -20 || true)

    for f in "${candidates[@]}"; do
        tmp="$(extract_allstar_node_from_file "${f}" || true)"
        if [[ -n "${tmp}" ]]; then
            printf '%s' "${tmp}"
            return 0
        fi
    done
    return 1
}

read_php_string_var() {
    local file="$1"
    local var="$2"
    local line=""

    [[ -f "${file}" ]] || return 0
    line="$(grep -E "^[[:space:]]*\\$${var}[[:space:]]*=" "${file}" 2>/dev/null | head -1 || true)"
    [[ -n "${line}" ]] || return 0
    sed -n 's/.*= "\([^"]*\)".*/\1/p' <<< "${line}" | head -1
}

read_reflector_name_from_file() {
    local file="$1"
    local tmp=""

    [[ -f "${file}" ]] || return 1

    # Titles: IRLP Reflector / EchoLink Conference / EchoLink Link / EchoLink Repeater
    tmp="$(sed -n 's/.*LinkBox Monitor 2+ IRLP \([^< ]*\) Reflector.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    tmp="$(sed -n 's/.*LinkBox Manager 2+ IRLP \([^< ]*\) Reflector.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    tmp="$(sed -n 's/.*LinkBox Monitor 2+ EchoLink \([^< ]*\) Conference.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    tmp="$(sed -n 's/.*LinkBox Manager 2+ EchoLink \([^< ]*\) Conference.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    tmp="$(sed -n 's/.*LinkBox Monitor 2+ EchoLink \([^< ]*\) Link.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    tmp="$(sed -n 's/.*LinkBox Manager 2+ EchoLink \([^< ]*\) Link.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    tmp="$(sed -n 's/.*LinkBox Monitor 2+ EchoLink \([^< ]*\) Repeater.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    tmp="$(sed -n 's/.*LinkBox Manager 2+ EchoLink \([^< ]*\) Repeater.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    # Older titles
    tmp="$(sed -n 's/.*IRLP \([^< ]*\) Reflector.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    tmp="$(sed -n 's/.*\(exp[0-9][0-9]*\) Monitor.*/\1/p' "${file}" | head -1)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    tmp="$(grep -oE 'exp[0-9]{4}' "${file}" 2>/dev/null | head -1 || true)"
    tmp="$(trim_value "${tmp}")"
    [[ -n "${tmp}" ]] && REFLECTOR_NAME="${tmp}" && return 0

    return 1
}

read_server_ip_from_file() {
    local file="$1"
    local tmp=""

    [[ -f "${file}" ]] || return 1

    tmp="$(sed -n 's/.*IP Address:&nbsp; \([^<]*\).*/\1/p' "${file}" | head -1)"
    SERVER_IP="$(trim_value "${tmp}")"
    is_valid_host_or_ip "${SERVER_IP}" && return 0

    tmp="$(sed -n 's/.*IP Address:[[:space:]]*\([^<]*\).*/\1/p' "${file}" | head -1)"
    SERVER_IP="$(trim_value "${tmp}")"
    is_valid_host_or_ip "${SERVER_IP}" && return 0

    SERVER_IP=""
    return 1
}

detect_existing_public_monitor_script() {
    local f

    if [[ -n "${PUBLIC_MONITOR_SCRIPT:-}" ]]; then
        return 0
    fi

    for f in exp.php index.php; do
        if [[ -f "${WEB_ROOT}/${f}" ]]; then
            PUBLIC_MONITOR_SCRIPT="${f}"
            log "Detected existing public monitor: ${f}"
            return 0
        fi
    done

    return 1
}

load_settings_from_existing_files() {
    local monitor=""
    local f

    for f in exp.php index.php; do
        if [[ -f "${WEB_ROOT}/${f}" ]]; then
            monitor="${WEB_ROOT}/${f}"
            break
        fi
    done
    [[ -n "${monitor}" ]] || return 0

    log "Reading settings from existing ${monitor} (if present)..."

    [[ -z "${REFLECTOR_NAME:-}" ]] && read_reflector_name_from_file "${monitor}" || true
    [[ -z "${SERVER_IP:-}" ]] && read_server_ip_from_file "${monitor}" || true
    if [[ -z "${ALLSTAR_NODE:-}" ]]; then
        ALLSTAR_NODE="$(extract_allstar_node_from_file "${monitor}" || true)"
        ALLSTAR_NODE="$(trim_value "${ALLSTAR_NODE:-}")"
    fi
    if [[ -z "${ALLSTAR_NODE:-}" ]]; then
        ALLSTAR_NODE="$(find_allstar_node_from_webroot || true)"
        ALLSTAR_NODE="$(trim_value "${ALLSTAR_NODE:-}")"
    fi
    [[ -z "${TLBCMD_PORT:-}" ]] && TLBCMD_PORT="$(read_php_string_var "${monitor}" "tlbcmdport")"
    [[ -z "${TLB_LOG_PATH:-}" ]] && TLB_LOG_PATH="$(read_php_string_var "${monitor}" "tlblogpath")"
    [[ -z "${TLB_MESSAGES_PATH:-}" ]] && TLB_MESSAGES_PATH="$(read_php_string_var "${monitor}" "tlbmessagespath")"

    detect_existing_public_monitor_script || true
}

# Collapse leftover stamped backups (path.bak.*, path.bak2, …) into one path.bak.
# Keeps a single backup; removes the pile. Used for web files, tlb.conf, EventScripts.
collapse_backups_to_one() {
    local path="$1"
    local bak="${path}.bak"
    local dir base
    local -a extras=()
    local f removed=0

    dir="$(dirname "${path}")"
    base="$(basename "${path}")"
    [[ -d "${dir}" ]] || return 0

    shopt -s nullglob
    extras=( "${dir}/${base}".bak* )
    shopt -u nullglob

    # Promote first extra to path.bak if needed (skip exact path.bak in the loop).
    if [[ ! -e "${bak}" ]]; then
        for f in "${extras[@]}"; do
            [[ -e "${f}" ]] || continue
            [[ "${f}" == "${bak}" ]] && continue
            cp -a "${f}" "${bak}"
            log "Kept one backup as ${bak}"
            break
        done
    fi

    for f in "${extras[@]}"; do
        [[ -e "${f}" ]] || continue
        [[ "${f}" == "${bak}" ]] && continue
        rm -f "${f}"
        removed=$((removed + 1))
    done
    if [[ "${removed}" -gt 0 ]]; then
        log "Removed ${removed} extra backup(s) for ${base} (one .bak only)"
    fi
}

# Collapse bak piles for operator-managed files (never overwrite the live files).
# tlb.conf + EventScripts + restart scripts — one .bak each.
collapse_operator_managed_backups() {
    local conf=""
    local p

    if declare -F find_tlb_conf >/dev/null 2>&1; then
        conf="$(find_tlb_conf 2>/dev/null || true)"
    fi
    for p in \
        "${conf}" \
        /home/thelinkbox/tlb.conf \
        /home/tlb/tlb.conf \
        "${TLBEVENT_DEST:-/home/thelinkbox/scripts/tlbevent-conference.sh}" \
        "${TLBEVENT_NOPOLICY_DEST:-/home/thelinkbox/scripts/tlbevent-nopolicy.sh}" \
        "${TLBEVENT_PLAIN_DEST:-/home/thelinkbox/scripts/tlbevent.sh}" \
        "${RESTART_TLB_CONFERENCE_DEST:-/home/thelinkbox/scripts/restart-tlb-conference}" \
        "${RESTART_TLB_NOPOLICY_DEST:-/home/thelinkbox/scripts/restart-tlb-nopolicy}" \
        "${RESTART_TLB_DEST:-/home/thelinkbox/scripts/restart-tlb}"
    do
        [[ -n "${p}" ]] || continue
        # Collapse even if live file is missing (leftover .bak.* piles).
        collapse_backups_to_one "${p}"
    done
}

# Backup once only — never pile up .bak.TIMESTAMP files on every update.
# Creates path.bak if missing; if any path.bak* already exists, skip.
backup_if_exists() {
    local path="$1"
    local bak="${path}.bak"
    local dir base
    local -a existing=()

    [[ -e "${path}" ]] || return 0

    collapse_backups_to_one "${path}"

    if [[ -e "${bak}" ]]; then
        log "Backup already exists — keeping ${bak} (not creating another)"
        return 0
    fi

    dir="$(dirname "${path}")"
    base="$(basename "${path}")"
    shopt -s nullglob
    existing=( "${dir}/${base}".bak* )
    shopt -u nullglob
    if [[ ${#existing[@]} -gt 0 ]]; then
        log "Backup already exists — keeping ${existing[0]} (not creating another)"
        return 0
    fi

    cp -a "${path}" "${bak}"
    log "Backed up ${path} -> ${bak}"
}

# Remove legacy web-root public monitor index.php (Paul: public is permanently exp.php).
# Never touches tlb-admin/index.php or index.html.
remove_legacy_webroot_public_monitor() {
    local path="${WEB_ROOT}/index.php"

    [[ -f "${path}" ]] || return 0

    if ! grep -qE 'exp_render_live_panel|tlb-common\.inc|LinkBox Monitor|LinkBox Manager|tlb-admin/|tlbcmdport' "${path}" 2>/dev/null; then
        log "Leaving ${path} in place (not a LinkBox public monitor)"
        return 0
    fi

    backup_if_exists "${path}"
    rm -f "${path}"
    log "Removed legacy public monitor ${path} (public monitor is permanently exp.php)"
}

# Normalize SITE_TYPE into LINK_TYPE + SITE_KIND for page titles.
set_site_type_labels() {
    case "${SITE_TYPE:-}" in
        irlp|IRLP|1)
            SITE_TYPE="irlp"
            LINK_TYPE="IRLP"
            SITE_KIND="Reflector"
            ;;
        echolink|EchoLink|echo|ECHO|conference|2)
            SITE_TYPE="echolink"
            LINK_TYPE="EchoLink"
            SITE_KIND="Conference"
            ;;
        link|LINK|echolink-l|el-l|3|-L)
            SITE_TYPE="link"
            LINK_TYPE="EchoLink"
            SITE_KIND="Link"
            ;;
        repeater|REPEATER|echolink-r|el-r|4|-R)
            SITE_TYPE="repeater"
            LINK_TYPE="EchoLink"
            SITE_KIND="Repeater"
            ;;
        *)
            die "SITE_TYPE must be irlp, echolink, link (-L), or repeater (-R). Got: ${SITE_TYPE:-<empty>}"
            ;;
    esac
}

# Normalize yes/no/1/0 env answers into 1 or 0.
normalize_yes_no() {
    case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
        1|y|yes|true|on) printf '1' ;;
        0|n|no|false|off) printf '0' ;;
        *) printf '' ;;
    esac
}

# Authoritative type from the site NAME only.
#   exp0092     -> irlp experimental reflector
#   *OHIO*      -> EchoLink conference (never IRLP Reflector)
#   CALL-L      -> EchoLink Link
#   CALL-R      -> EchoLink Repeater
# Does NOT look at /home/irlp — having IRLP software must not retitle a conference.
site_type_from_name() {
    local name
    name="$(trim_value "${1:-${REFLECTOR_NAME:-}}")"
    name="${name//\"/}"
    name="${name//\'/}"

    if [[ "${name}" =~ ^[Ee][Xx][Pp][0-9]{3,5}$ ]]; then
        printf 'irlp'
        return 0
    fi
    # Conference-style name starting with * (e.g. *OHIO*, *CONF*, *HAM*)
    case "${name}" in
        \**)
            printf 'echolink'
            return 0
            ;;
    esac
    if [[ -n "${name}" && "${name:0:1}" == '*' ]]; then
        printf 'echolink'
        return 0
    fi
    # EchoLink -L / -R station callsigns
    if [[ "${name}" =~ ^[A-Za-z0-9]{3,}-[Ll]$ ]]; then
        printf 'link'
        return 0
    fi
    if [[ "${name}" =~ ^[A-Za-z0-9]{3,}-[Rr]$ ]]; then
        printf 'repeater'
        return 0
    fi
    printf ''
}

# Force SITE_TYPE / LINK_TYPE / SITE_KIND from REFLECTOR_NAME.
# Always safe to call before writing pages or printing the Done summary.
force_site_type_from_name() {
    local from_name prev
    REFLECTOR_NAME="$(trim_value "${REFLECTOR_NAME:-}")"
    from_name="$(site_type_from_name "${REFLECTOR_NAME}")"
    [[ -n "${from_name}" ]] || return 1

    prev="${SITE_TYPE:-}"
    SITE_TYPE="${from_name}"
    if [[ -n "${prev}" && "${prev}" != "${SITE_TYPE}" ]]; then
        log "Correcting site type from '${prev}' to '${SITE_TYPE}' (name ${REFLECTOR_NAME} is authoritative)"
        # Stale USES_* from a wrong role (e.g. leftover tlb.ini with uses_irlp=1)
        # must not stick — re-prompt / re-default networks for the corrected type.
        USES_IRLP=""
        USES_ECHOLINK=""
        log "Cleared network flags so IRLP/EchoLink can match ${SITE_TYPE}"
    fi
    set_site_type_labels
    return 0
}

# Force SITE_TYPE to match the name. Fixes pages that were wrongly saved as
# "IRLP *OHIO* Reflector" when the site is an EchoLink conference.
reconcile_site_type_with_name() {
    force_site_type_from_name || true
}

read_tlb_callsign() {
    local call=""
    if [[ -f /home/thelinkbox/tlb.conf ]]; then
        call="$(awk -F'=' '/^[[:space:]]*Callsign[[:space:]]*=/{gsub(/[[:space:]"]/,"",$2); print $2; exit}' /home/thelinkbox/tlb.conf 2>/dev/null || true)"
    fi
    if [[ -z "${call}" && -f /home/tlb/tlb.conf ]]; then
        call="$(awk -F'=' '/^[[:space:]]*Callsign[[:space:]]*=/{gsub(/[[:space:]"]/,"",$2); print $2; exit}' /home/tlb/tlb.conf 2>/dev/null || true)"
    fi
    # ConferenceCall is often the public conference/*name* when Callsign differs
    if [[ -z "${call}" && -f /home/thelinkbox/tlb.conf ]]; then
        call="$(awk -F'=' '/^[[:space:]]*ConferenceCall[[:space:]]*=/{gsub(/[[:space:]"]/,"",$2); print $2; exit}' /home/thelinkbox/tlb.conf 2>/dev/null || true)"
    fi
    printf '%s' "${call}"
}

# Guess only when name is unknown. Never use /home/irlp as proof of exp reflector.
detect_site_type_guess() {
    local call=""
    local name="${REFLECTOR_NAME:-}"
    local from_name=""

    from_name="$(site_type_from_name "${name}")"
    if [[ -n "${from_name}" ]]; then
        printf '%s' "${from_name}"
        return 0
    fi

    call="$(read_tlb_callsign)"
    from_name="$(site_type_from_name "${call}")"
    if [[ -n "${from_name}" ]]; then
        printf '%s' "${from_name}"
        return 0
    fi

    # Ambiguous — leave empty so the operator chooses.
    printf ''
}

# Guess whether IRLP software / experimental env is present (hint only).
detect_irlp_present() {
    if [[ -d /home/irlp || -d /home/irlp/scripts ]]; then
        printf '1'
        return 0
    fi
    if [[ -f /home/irlp/custom/environment ]] || [[ -f /home/irlp/environment ]]; then
        printf '1'
        return 0
    fi
    printf '0'
}

read_site_type_from_file() {
    local file="$1"
    [[ -f "${file}" ]] || return 1

    if grep -qE 'The LinkBox (Monitor|Manager) 2\+ EchoLink .* Repeater' "${file}" 2>/dev/null; then
        SITE_TYPE="repeater"
        return 0
    fi
    if grep -qE 'The LinkBox (Monitor|Manager) 2\+ EchoLink .* Link' "${file}" 2>/dev/null; then
        SITE_TYPE="link"
        return 0
    fi
    if grep -qE 'The LinkBox (Monitor|Manager) 2\+ EchoLink ' "${file}" 2>/dev/null; then
        SITE_TYPE="echolink"
        return 0
    fi
    if grep -qE 'The LinkBox (Monitor|Manager) 2\+ IRLP ' "${file}" 2>/dev/null; then
        SITE_TYPE="irlp"
        return 0
    fi
    return 1
}

# Ask which networks this TLB uses (both / IRLP only / EchoLink only).
prompt_network_usage() {
    local choice default_num irlp_guess

    USES_IRLP="$(normalize_yes_no "${USES_IRLP:-}")"
    USES_ECHOLINK="$(normalize_yes_no "${USES_ECHOLINK:-}")"

    if [[ -n "${USES_IRLP}" && -n "${USES_ECHOLINK}" ]]; then
        log "Networks: IRLP=$([ "${USES_IRLP}" = 1 ] && echo yes || echo no), EchoLink=$([ "${USES_ECHOLINK}" = 1 ] && echo yes || echo no)"
        return 0
    fi

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        # Sensible --yes defaults from role when unset.
        case "${SITE_TYPE:-}" in
            irlp)
                [[ -n "${USES_IRLP}" ]] || USES_IRLP="1"
                [[ -n "${USES_ECHOLINK}" ]] || USES_ECHOLINK="1"
                ;;
            echolink|link|repeater)
                [[ -n "${USES_ECHOLINK}" ]] || USES_ECHOLINK="1"
                [[ -n "${USES_IRLP}" ]] || USES_IRLP="0"
                ;;
            *)
                [[ -n "${USES_IRLP}" ]] || USES_IRLP="$(detect_irlp_present)"
                [[ -n "${USES_ECHOLINK}" ]] || USES_ECHOLINK="1"
                ;;
        esac
        if [[ "${USES_IRLP}" != "1" && "${USES_ECHOLINK}" != "1" ]]; then
            die "With --yes set USES_IRLP and/or USES_ECHOLINK (at least one must be yes)"
        fi
        log "Networks: IRLP=$([ "${USES_IRLP}" = 1 ] && echo yes || echo no), EchoLink=$([ "${USES_ECHOLINK}" = 1 ] && echo yes || echo no)"
        return 0
    fi

    irlp_guess="$(detect_irlp_present)"
    echo
    echo "Which networks does THIS TheLinkBox use?"
    echo "  1) Both IRLP and EchoLink"
    echo "  2) IRLP only"
    echo "  3) EchoLink only (no IRLP)"
    echo
    # *HOTMOVE2* / *OHIO* conferences default to EchoLink-only — do NOT assume
    # IRLP just because /home/irlp exists on an ASL/DVSwitch box.
    default_num=""
    if [[ "${SITE_TYPE}" == "irlp" ]]; then
        default_num="1"
    elif [[ "${SITE_TYPE}" =~ ^(echolink|link|repeater)$ ]]; then
        default_num="3"
    elif [[ "${irlp_guess}" == "1" ]]; then
        default_num="1"
    else
        default_num="3"
    fi
    prompt choice "Enter 1, 2, or 3" "${default_num}"
    case "${choice}" in
        1|both|BOTH)
            USES_IRLP="1"
            USES_ECHOLINK="1"
            ;;
        2|irlp|IRLP)
            USES_IRLP="1"
            USES_ECHOLINK="0"
            ;;
        3|echolink|EchoLink|echo|EL)
            USES_IRLP="0"
            USES_ECHOLINK="1"
            ;;
        *)
            die "Invalid network choice: ${choice}"
            ;;
    esac

    if [[ "${SITE_TYPE}" == "irlp" && "${USES_IRLP}" != "1" ]]; then
        log "Note: IRLP experimental reflector role usually uses IRLP — leaving USES_IRLP=0 as answered"
    fi
    if [[ "${SITE_TYPE}" =~ ^(echolink|link|repeater)$ && "${USES_ECHOLINK}" != "1" ]]; then
        log "Note: EchoLink role usually uses EchoLink — leaving USES_ECHOLINK=0 as answered"
    fi

    log "Networks: IRLP=$([ "${USES_IRLP}" = 1 ] && echo yes || echo no), EchoLink=$([ "${USES_ECHOLINK}" = 1 ] && echo yes || echo no)"
}

prompt_site_type_and_name() {
    local guess choice default_num name_prompt name_default
    local name_implies=""

    name_implies="$(site_type_from_name "${REFLECTOR_NAME:-}")"

    # If name already clearly implies type (*HOTMOVE2*, exp0092, CALL-L), use it.
    if [[ -n "${name_implies}" ]]; then
        reconcile_site_type_with_name
    elif [[ "${ASSUME_YES}" -eq 1 ]]; then
        guess="$(detect_site_type_guess)"
        [[ -n "${guess}" ]] || guess="${SITE_TYPE:-}"
        [[ -n "${guess}" ]] || die "SITE_TYPE required with --yes (irlp|echolink|link|repeater). Example: SITE_TYPE=echolink REFLECTOR_NAME='*OHIO*' USES_IRLP=0"
        SITE_TYPE="${guess}"
        set_site_type_labels
    else
        # Interactive: ALWAYS ask role. Do not trust a packaged tlb.ini SITE_TYPE=irlp
        # (that poisoned *HOTMOVE2* installs with IRLP defaults / wrong name prompt).
        guess="$(detect_site_type_guess)"
        [[ -n "${guess}" ]] || guess="${SITE_TYPE:-}"
        echo
        echo "What is THIS TheLinkBox primarily?"
        echo "  1) IRLP experimental reflector — name like exp0092"
        echo "     Page title:  IRLP exp0092 Reflector"
        echo "  2) EchoLink conference — name like *OHIO* / *CONF* / *HOTMOVE2*"
        echo "     Page title:  EchoLink *OHIO* Conference"
        echo "     (Use even if IRLP also exists; *name* is never titled IRLP Reflector.)"
        echo "  3) EchoLink -L Link station — callsign like K8LRC-L"
        echo "     Page title:  EchoLink K8LRC-L Link"
        echo "  4) EchoLink -R Repeater station — callsign like K8LRC-R"
        echo "     Page title:  EchoLink K8LRC-R Repeater"
        echo
        default_num=""
        case "${guess}" in
            irlp) default_num="1" ;;
            echolink) default_num="2" ;;
            link) default_num="3" ;;
            repeater) default_num="4" ;;
        esac
        prompt choice "Enter 1, 2, 3, or 4" "${default_num}"
        case "${choice}" in
            1|irlp|IRLP) SITE_TYPE="irlp" ;;
            2|echolink|EchoLink|echo|conference) SITE_TYPE="echolink" ;;
            3|link|LINK|-L|L) SITE_TYPE="link" ;;
            4|repeater|REPEATER|-R|R) SITE_TYPE="repeater" ;;
            *) die "Invalid site type choice: ${choice}" ;;
        esac
        set_site_type_labels
    fi

    # Name default: for EchoLink conference, prefer tlb.conf callsign over a stale
    # exp0092 left in tlb.ini / previous defaults.
    name_default="${REFLECTOR_NAME:-}"
    case "${SITE_TYPE}" in
        irlp)
            name_prompt="IRLP experimental reflector name (e.g. exp0092)"
            if [[ -z "${name_default}" || "${name_default:0:1}" == '*' ]]; then
                name_default="exp0092"
            fi
            ;;
        echolink)
            name_prompt="EchoLink conference name (e.g. *OHIO* or *HOTMOVE2*)"
            if [[ -z "${name_default}" || "${name_default}" =~ ^[Ee][Xx][Pp][0-9] ]]; then
                name_default="$(read_tlb_callsign)"
                [[ -n "${name_default}" && "${name_default:0:1}" == '*' ]] || name_default="*CONF*"
            fi
            ;;
        link)
            name_prompt="EchoLink -L Link callsign (e.g. K8LRC-L)"
            if [[ -z "${name_default}" || "${name_default:0:1}" == '*' || "${name_default}" =~ ^[Ee][Xx][Pp] ]]; then
                name_default="$(read_tlb_callsign)"
                [[ -n "${name_default}" ]] || name_default="CALL-L"
            fi
            ;;
        repeater)
            name_prompt="EchoLink -R Repeater callsign (e.g. K8LRC-R)"
            if [[ -z "${name_default}" || "${name_default:0:1}" == '*' || "${name_default}" =~ ^[Ee][Xx][Pp] ]]; then
                name_default="$(read_tlb_callsign)"
                [[ -n "${name_default}" ]] || name_default="CALL-R"
            fi
            ;;
    esac

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        [[ -n "${REFLECTOR_NAME:-}" ]] || REFLECTOR_NAME="${name_default}"
    else
        prompt REFLECTOR_NAME "${name_prompt}" "${name_default}"
    fi
    REFLECTOR_NAME="$(trim_value "${REFLECTOR_NAME:-}")"
    [[ -n "${REFLECTOR_NAME}" ]] || die "REFLECTOR_NAME is required"

    # Name always wins (fixes wrong titles when name and SITE_TYPE disagree).
    if ! force_site_type_from_name; then
        die "Could not determine site type from name '${REFLECTOR_NAME}'.
Use exp0092 (IRLP), *OHIO*/*CONF* (EchoLink conference), CALL-L (-L), or CALL-R (-R)."
    fi

    case "${SITE_TYPE}" in
        irlp)
            if [[ ! "${REFLECTOR_NAME}" =~ ^[Ee][Xx][Pp][0-9]{3,5}$ ]]; then
                die "IRLP experimental reflector name must look like exp0092 (got: ${REFLECTOR_NAME})."
            fi
            ;;
        echolink)
            if [[ "${REFLECTOR_NAME:0:1}" != '*' ]]; then
                die "EchoLink conference name must start with * (got: ${REFLECTOR_NAME}).
Example: *OHIO* or *CONF*. For -L/-R use CALL-L / CALL-R (SITE_TYPE=link|repeater)."
            fi
            ;;
        link)
            if [[ ! "${REFLECTOR_NAME}" =~ ^[A-Za-z0-9]{3,}-[Ll]$ ]]; then
                die "EchoLink -L Link callsign must end with -L (got: ${REFLECTOR_NAME}). Example: K8LRC-L"
            fi
            ;;
        repeater)
            if [[ ! "${REFLECTOR_NAME}" =~ ^[A-Za-z0-9]{3,}-[Rr]$ ]]; then
                die "EchoLink -R Repeater callsign must end with -R (got: ${REFLECTOR_NAME}). Example: K8LRC-R"
            fi
            ;;
    esac

    prompt_network_usage

    log "Site type: ${LINK_TYPE} ${REFLECTOR_NAME} ${SITE_KIND}"
}

apply_placeholders() {
    local src="$1"
    local dst="$2"

    local ip_esc name_esc domain_esc supermon_esc monitor_esc link_esc kind_esc

    # Hard rule at write time — *OHIO* can never become IRLP Reflector.
    if ! force_site_type_from_name; then
        [[ -n "${LINK_TYPE:-}" && -n "${SITE_KIND:-}" ]] || set_site_type_labels
    fi

    # Prefer typed SUPERMON_URL; fall back to classic node-number URL
    if [[ -z "${SUPERMON_URL:-}" && -n "${ALLSTAR_NODE:-}" ]]; then
        SUPERMON_URL="$(normalize_supermon_url "${ALLSTAR_NODE}")"
    fi
    SUPERMON_URL="${SUPERMON_URL:-}"

    ip_esc="$(escape_sed_replacement "${SERVER_IP}")"
    name_esc="$(escape_sed_replacement "${REFLECTOR_NAME}")"
    domain_esc="$(escape_sed_replacement "${DOMAIN_OR_IP}")"
    supermon_esc="$(escape_sed_replacement "${SUPERMON_URL}")"
    monitor_esc="$(escape_sed_replacement "${PUBLIC_MONITOR_SCRIPT}")"
    link_esc="$(escape_sed_replacement "${LINK_TYPE}")"
    kind_esc="$(escape_sed_replacement "${SITE_KIND}")"

    sed -e "s|YOUR_SERVER_IP|${ip_esc}|g" \
        -e "s|YOUR_LINK_TYPE|${link_esc}|g" \
        -e "s|YOUR_REFLECTOR_NAME|${name_esc}|g" \
        -e "s|YOUR_SITE_KIND|${kind_esc}|g" \
        -e "s|YOUR_DOMAIN_OR_IP|${domain_esc}|g" \
        -e "s|YOUR_SUPERMON_URL|${supermon_esc}|g" \
        -e "s|YOUR_PUBLIC_MONITOR_SCRIPT|${monitor_esc}|g" \
        "${src}" > "${dst}"

    # Show IRLP experimental environment hint only for IRLP exp reflectors that use IRLP.
    # EchoLink-only / -L / -R / conference-without-IRLP: remove the hint block.
    if [[ "${SITE_TYPE}" == "irlp" && "${USES_IRLP:-1}" == "1" ]]; then
        sed -i '/<!--IRLP_HINT_START-->/d;/<!--IRLP_HINT_END-->/d' "${dst}"
    else
        sed -i '/<!--IRLP_HINT_START-->/,/<!--IRLP_HINT_END-->/d' "${dst}"
    fi

    if [[ -n "${TLBCMD_PORT_ARG}" ]]; then
        local port_esc
        port_esc="$(escape_sed_replacement "${TLBCMD_PORT_ARG}")"
        sed -i "s|\$tlbcmdport = \"\";|\$tlbcmdport = \"${port_esc}\";|" "${dst}"
    fi

    # Only when fresh-install explicitly chose an active restart script.
    # Never fall back to RESTART_TLB_DEST — that default was resetting operators
    # on every update (Paul: do not auto-replace their choice).
    if [[ -n "${RESTART_TLB_ACTIVE:-}" ]]; then
        local restart_esc
        restart_esc="$(escape_sed_replacement "${RESTART_TLB_ACTIVE}")"
        sed -i "s|\$tlbrestartscript = \".*\";|\$tlbrestartscript = \"${restart_esc}\";|" "${dst}"
    fi
}

# Read $tlbrestartscript from a deployed tlb-admin/index.php (empty if unset).
extract_tlbrestartscript_from_file() {
    local path="$1"
    [[ -f "${path}" ]] || { printf ''; return 0; }
    sed -n 's/.*\$tlbrestartscript[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "${path}" 2>/dev/null | head -1 || true
}

set_tlbrestartscript_in_file() {
    local path="$1"
    local value="$2"
    local restart_esc
    [[ -f "${path}" ]] || return 1
    [[ -n "${value}" ]] || return 1
    restart_esc="$(escape_sed_replacement "${value}")"
    if grep -qE '\$tlbrestartscript[[:space:]]*=' "${path}" 2>/dev/null; then
        sed -i "s|\$tlbrestartscript = \".*\";|\$tlbrestartscript = \"${restart_esc}\";|" "${path}"
    else
        # Older admin without the variable — insert after $tlbmessagesfull line.
        sed -i "/\$tlbmessagesfull/a\\
	\$tlbrestartscript = \"${restart_esc}\";  // Restart TLB button (operator-managed)
" "${path}"
    fi
}

apply_admin_log_paths() {
    local dst="$1"

    if [[ -n "${TLB_LOG_PATH}" ]]; then
        local log_esc
        log_esc="$(escape_sed_replacement "${TLB_LOG_PATH}")"
        sed -i "s|\$tlblogpath = \"\";|\$tlblogpath = \"${log_esc}\";|" "${dst}"
    fi
    if [[ -n "${TLB_MESSAGES_PATH}" ]]; then
        local msg_esc
        msg_esc="$(escape_sed_replacement "${TLB_MESSAGES_PATH}")"
        sed -i "s|\$tlbmessagespath = \"\";|\$tlbmessagespath = \"${msg_esc}\";|" "${dst}"
    fi
}

prompt_public_monitor_script() {
    # Permanent: public monitor is always exp.php (Paul KN2R).
    # Web-root index.php is NOT a public monitor. Admin is tlb-admin/index.php.
    # Package no longer ships index.html (do not overwrite operators' web root).
    if [[ -n "${PUBLIC_MONITOR_SCRIPT:-}" && "${PUBLIC_MONITOR_SCRIPT}" != "exp.php" ]]; then
        log "Ignoring PUBLIC_MONITOR_SCRIPT=${PUBLIC_MONITOR_SCRIPT} — public monitor is permanently exp.php"
    fi
    PUBLIC_MONITOR_SCRIPT="exp.php"
    log "Public monitor filename: exp.php (permanent)"
}

install_web_files() {
    local admin_dir="${WEB_ROOT}/tlb-admin"
    local monitor_template admin_template
    local monitor_path=""
    local monitors=()
    local script
    local preserved_restart=""

    monitor_template="$(resolve_pkg_file "index.template" "index.php.bak")" \
        || die "Missing monitor template (index.template)"
    admin_template="$(resolve_pkg_file "admin-index.template" "admin-index.php.bak")" \
        || die "Missing admin template (admin-index.template)"
    resolve_pkg_file "tlb-common.inc" >/dev/null || die "Missing tlb-common.inc"
    resolve_pkg_file "admin-htaccess" >/dev/null || die "Missing admin-htaccess"

    mkdir -p "${admin_dir}"

    # Collapse operator-managed bak piles (tlb.conf / EventScripts / restart) — never overwrite those files.
    # Skip on web update: do not touch anything under /home/thelinkbox during update.
    if [[ "${SKIP_OPERATOR_BACKUP_COLLAPSE:-0}" != "1" ]]; then
        collapse_operator_managed_backups
    fi

    # Preserve operator's Restart TLB button choice across template rewrite.
    if [[ -z "${RESTART_TLB_ACTIVE:-}" && -f "${admin_dir}/index.php" ]]; then
        preserved_restart="$(extract_tlbrestartscript_from_file "${admin_dir}/index.php")"
        preserved_restart="$(trim_value "${preserved_restart:-}")"
    fi

    # Backup only files we are about to replace (not .htaccess — never overwritten).
    # One .bak each for exp.php / tlb-common.inc / admin (no timestamp piles).
    # Never backup/touch web-root index.html (Apache DirectoryIndex; operator landing page).
    backup_if_exists "${WEB_ROOT}/index.php"
    backup_if_exists "${WEB_ROOT}/exp.php"
    backup_if_exists "${WEB_ROOT}/tlb-common.inc"
    backup_if_exists "${admin_dir}/index.php"

    # Public monitor is permanently exp.php only (never web-root index.php).
    PUBLIC_MONITOR_SCRIPT="exp.php"
    monitors=("exp.php")

    for script in "${monitors[@]}"; do
        monitor_path="${WEB_ROOT}/${script}"
        PUBLIC_MONITOR_SCRIPT="${script}"

        # Keep AllStar hub across updates even if detection failed once before.
        if [[ -z "${ALLSTAR_NODE:-}" ]]; then
            ALLSTAR_NODE="$(extract_allstar_node_from_file "${monitor_path}" || true)"
            ALLSTAR_NODE="$(trim_value "${ALLSTAR_NODE:-}")"
        fi
        if [[ -z "${ALLSTAR_NODE:-}" ]]; then
            ALLSTAR_NODE="$(find_allstar_node_from_webroot || true)"
            ALLSTAR_NODE="$(trim_value "${ALLSTAR_NODE:-}")"
        fi

        apply_placeholders "${monitor_template}" "${monitor_path}"
        apply_admin_log_paths "${monitor_path}"
        if [[ -z "${SUPERMON_URL:-}" ]]; then
            sed -i '/YOUR_SUPERMON_URL\|Show AllStar HUB activity/d' "${monitor_path}"
            # also drop empty href leftovers
            sed -i '/href=""/{N;d;}' "${monitor_path}" 2>/dev/null || true
            sed -i '/allstarlink\.org/d' "${monitor_path}"
            log "Removed AllStar/Supermon link (no SUPERMON_URL) from ${script}"
        else
            log "Supermon/AllStar hub link: ${SUPERMON_URL} in ${script}"
        fi
        log "Updated public monitor: ${monitor_path}"
    done

    cp -a "${SCRIPT_DIR}/tlb-common.inc" "${WEB_ROOT}/tlb-common.inc"

    # Site config templates in WEB_ROOT so operators can find them after
    # install-tlbweb-fresh (package extract under /tmp is deleted).
    # Never overwrite a filled-in tlb.ini.
    if [[ -f "${SCRIPT_DIR}/tlb.ini.template" ]]; then
        cp -a "${SCRIPT_DIR}/tlb.ini.template" "${WEB_ROOT}/tlb.ini.template"
        log "Installed ${WEB_ROOT}/tlb.ini.template (fill in YOUR info → tlb.ini)"
    fi
    if [[ -f "${SCRIPT_DIR}/tlb.ini.sample" ]]; then
        cp -a "${SCRIPT_DIR}/tlb.ini.sample" "${WEB_ROOT}/tlb.ini.sample"
        log "Installed ${WEB_ROOT}/tlb.ini.sample (filled example)"
    fi
    if [[ -f "${WEB_ROOT}/tlb.ini" ]]; then
        log "Keeping existing ${WEB_ROOT}/tlb.ini (not overwriting)"
    else
        log "No ${WEB_ROOT}/tlb.ini yet — optional: cp tlb.ini.template tlb.ini && edit"
    fi

    apply_placeholders "${admin_template}" "${admin_dir}/index.php"
    apply_admin_log_paths "${admin_dir}/index.php"

    # Restore operator $tlbrestartscript unless fresh-install set RESTART_TLB_ACTIVE.
    if [[ -n "${RESTART_TLB_ACTIVE:-}" ]]; then
        set_tlbrestartscript_in_file "${admin_dir}/index.php" "${RESTART_TLB_ACTIVE}" || true
        log "Active Restart TLB button: ${RESTART_TLB_ACTIVE}"
    elif [[ -n "${preserved_restart}" ]]; then
        set_tlbrestartscript_in_file "${admin_dir}/index.php" "${preserved_restart}" || true
        log "Kept existing \$tlbrestartscript = ${preserved_restart} (not auto-changing)"
    fi

    # .htaccess: install only if missing. Never overwrite an existing one
    # (Paul/Dave custom AuthUserFile / FilesMatch — operator-managed).
    # Never touch .htpasswd here. Never create .htaccess.bak.* piles.
    if [[ -f "${admin_dir}/.htaccess" ]]; then
        log "Keeping existing ${admin_dir}/.htaccess (not overwriting)"
    else
        cp -a "${SCRIPT_DIR}/admin-htaccess" "${admin_dir}/.htaccess"
        log "Installed ${admin_dir}/.htaccess from admin-htaccess (was missing)"
    fi

    # Remove leftover .htaccess.bak.* from old updater that backed up every run.
    local bak_count=0
    shopt -s nullglob
    for bak in "${admin_dir}"/.htaccess.bak.*; do
        rm -f "${bak}"
        bak_count=$((bak_count + 1))
    done
    shopt -u nullglob
    if [[ "${bak_count}" -gt 0 ]]; then
        log "Removed ${bak_count} leftover ${admin_dir}/.htaccess.bak.* file(s) (no longer needed)"
    fi

    # Delete legacy web-root LinkBox public monitor (kept as .bak.* backup)
    remove_legacy_webroot_public_monitor

    chown root:root "${WEB_ROOT}/tlb-common.inc" \
        "${admin_dir}/index.php" 2>/dev/null || true
    [[ -f "${WEB_ROOT}/tlb.ini.template" ]] && chown root:root "${WEB_ROOT}/tlb.ini.template" 2>/dev/null || true
    [[ -f "${WEB_ROOT}/tlb.ini.sample" ]] && chown root:root "${WEB_ROOT}/tlb.ini.sample" 2>/dev/null || true
    [[ -f "${admin_dir}/.htaccess" ]] && chown root:root "${admin_dir}/.htaccess" 2>/dev/null || true
    for script in "${monitors[@]}"; do
        chown root:root "${WEB_ROOT}/${script}" 2>/dev/null || true
        chmod 0644 "${WEB_ROOT}/${script}" 2>/dev/null || true
    done
    chmod 0644 "${WEB_ROOT}/tlb-common.inc" "${admin_dir}/index.php"
    [[ -f "${WEB_ROOT}/tlb.ini.template" ]] && chmod 0644 "${WEB_ROOT}/tlb.ini.template"
    [[ -f "${WEB_ROOT}/tlb.ini.sample" ]] && chmod 0644 "${WEB_ROOT}/tlb.ini.sample"
    [[ -f "${admin_dir}/.htaccess" ]] && chmod 0644 "${admin_dir}/.htaccess"

    log "Installed web files under ${WEB_ROOT}:"
    for script in "${monitors[@]}"; do
        log "  ${WEB_ROOT}/${script}            (public monitor)"
    done
    log "  ${WEB_ROOT}/tlb-common.inc"
    [[ -f "${WEB_ROOT}/tlb.ini.template" ]] && log "  ${WEB_ROOT}/tlb.ini.template   (copy to tlb.ini + edit)"
    [[ -f "${WEB_ROOT}/tlb.ini.sample" ]] && log "  ${WEB_ROOT}/tlb.ini.sample     (filled example)"
    log "  ${admin_dir}/index.php          (tlb-admin)"
    log "  ${admin_dir}/.htaccess          (kept if already present)"

    install_net_schedule
}


# Deploy Net Schedule helpers + minute cron (outbound auto .connect / .disconnect).
install_net_schedule() {
    local admin_dir="${WEB_ROOT}/tlb-admin"
    local inc_src cron_file runner_src runner_dest www_user www_group

    mkdir -p "${admin_dir}"

    if [[ -f "${SCRIPT_DIR}/net-schedule.inc" ]]; then
        cp -a "${SCRIPT_DIR}/net-schedule.inc" "${admin_dir}/net-schedule.inc"
        chmod 0644 "${admin_dir}/net-schedule.inc"
        log "Installed ${admin_dir}/net-schedule.inc"
    else
        die "Missing net-schedule.inc in package (SCRIPT_DIR) — update aborted to avoid breaking tlb-admin"
    fi

    if [[ ! -f "${admin_dir}/net-schedule.json" ]]; then
        if [[ -f "${SCRIPT_DIR}/net-schedule.json.sample" ]]; then
            cp -a "${SCRIPT_DIR}/net-schedule.json.sample" "${admin_dir}/net-schedule.json"
        else
            printf '%s\n' '{' '  "entries": []' '}' > "${admin_dir}/net-schedule.json"
        fi
        log "Created ${admin_dir}/net-schedule.json"
    else
        log "Keeping existing ${admin_dir}/net-schedule.json"
    fi

    www_user="www-data"
    www_group="www-data"
    if ! id www-data >/dev/null 2>&1; then
        www_user="apache"
        www_group="apache"
    fi
    chown "${www_user}:${www_group}" "${admin_dir}/net-schedule.json" 2>/dev/null || true
    chmod 0664 "${admin_dir}/net-schedule.json" 2>/dev/null || true

    runner_src="${SCRIPT_DIR}/tlb_net_schedule"
    runner_dest="/usr/local/sbin/tlb_net_schedule"
    if [[ -f "${runner_src}" ]]; then
        cp -a "${runner_src}" "${runner_dest}"
        chmod 0755 "${runner_dest}"
        log "Installed ${runner_dest}"
    fi

    touch /var/log/tlb-net-schedule.log
    chmod 0644 /var/log/tlb-net-schedule.log

    cron_file="/etc/cron.d/tlb-net-schedule"
    cat > "${cron_file}" <<CRON
# LinkBox Net Schedule — auto .connect at start / .disconnect at end
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
* * * * * root WEB_ROOT=${WEB_ROOT} /usr/local/sbin/tlb_net_schedule >/dev/null 2>&1
CRON
    chmod 0644 "${cron_file}"
    log "Installed ${cron_file} (runs every minute)"
}

update_web_files() {
    UPDATE_PRESERVE_AUTH=1
    install_web_files
}

setup_apache_auth() {
    local admin_dir="${WEB_ROOT}/tlb-admin"
    local htpasswd_path="${admin_dir}/.htpasswd"
    local supermon_default="/var/www/html/supermon/.htpasswd"

    AUTH_MODE="${AUTH_MODE:-}"
    SUPERMON_HTPASSWD="${SUPERMON_HTPASSWD:-${supermon_default}}"

    if [[ -z "${AUTH_MODE}" ]]; then
        if [[ "${ASSUME_YES}" -eq 1 ]]; then
            AUTH_MODE="dedicated"
        else
            echo
            echo "Apache auth for tlb-admin/:"
            echo "  1) Create a new password file in tlb-admin/.htpasswd"
            echo "  2) Reuse Supermon's .htpasswd"
            prompt AUTH_CHOICE "Choose 1 or 2" "1"
            if [[ "${AUTH_CHOICE}" == "2" ]]; then
                AUTH_MODE="supermon"
            else
                AUTH_MODE="dedicated"
            fi
        fi
    fi

    if [[ "${AUTH_MODE}" == "supermon" ]]; then
        prompt SUPERMON_HTPASSWD "Path to Supermon .htpasswd" "${SUPERMON_HTPASSWD}"
        [[ -f "${SUPERMON_HTPASSWD}" ]] || die "Supermon .htpasswd not found: ${SUPERMON_HTPASSWD}"
        set_htaccess_authuserfile "${admin_dir}/.htaccess" "${SUPERMON_HTPASSWD}" \
            || die "Failed to set AuthUserFile in ${admin_dir}/.htaccess"
        log "Configured tlb-admin auth to use ${SUPERMON_HTPASSWD}"
        return 0
    fi

    if [[ ! -x "$(command -v htpasswd 2>/dev/null || true)" ]]; then
        log "htpasswd not found - create ${htpasswd_path} manually (see SETUP-INSTRUCTIONS.txt Step 3)"
        return 0
    fi

    if [[ -f "${htpasswd_path}" ]]; then
        log "Leaving existing ${htpasswd_path} in place"
        return 0
    fi

    local admin_user admin_pass admin_pass2
    prompt admin_user "Admin username for tlb-admin" "tlbadmin"
    if [[ -z "${admin_user}" ]]; then
        log "No username entered - skip .htpasswd creation"
        return 0
    fi

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        log "Skipping .htpasswd creation in --yes mode (create manually)"
        return 0
    fi

    while true; do
        prompt_secret admin_pass "Password for ${admin_user}"
        prompt_secret admin_pass2 "Confirm password"
        [[ "${admin_pass}" == "${admin_pass2}" ]] && break
        echo "Passwords do not match, try again."
    done

    htpasswd -cbB "${htpasswd_path}" "${admin_user}" "${admin_pass}"
    chmod 0640 "${htpasswd_path}"
    chown root:www-data "${htpasswd_path}" 2>/dev/null || chmod 0640 "${htpasswd_path}"
    log "Created ${htpasswd_path} for user ${admin_user} (bcrypt -B)"
}

find_tlb_conf() {
    local conf="${TLB_CONF:-}"
    if [[ -n "${conf}" && -f "${conf}" ]]; then
        printf '%s' "${conf}"
        return 0
    fi
    for conf in /home/thelinkbox/tlb.conf /home/tlb/tlb.conf; do
        if [[ -f "${conf}" ]]; then
            printf '%s' "${conf}"
            return 0
        fi
    done
    printf ''
}

# Return conference | nopolicy | plain based on current EventScript= (empty if unknown).
read_current_eventscript_policy() {
    local conf path
    conf="$(find_tlb_conf)"
    [[ -n "${conf}" ]] || return 0
    path="$(awk -F'=' '/^[[:space:]]*EventScript[[:space:]]*=/{gsub(/[[:space:]"]/,"",$2); print $2; exit}' "${conf}" 2>/dev/null || true)"
    case "${path}" in
        *tlbevent-conference.sh|*tlbevent.conference.sh) printf 'conference' ;;
        *tlbevent-nopolicy.sh) printf 'nopolicy' ;;
        *tlbevent.sh|*tlbevent-testing.sh) printf 'plain' ;;
        *) printf '' ;;
    esac
}

normalize_eventscript_policy() {
    case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
        1|conference|policy|with|with-policy|conf) printf 'conference' ;;
        2|nopolicy|no-policy|none|no) printf 'nopolicy' ;;
        3|plain|legacy|testing) printf 'plain' ;;
        *) printf '' ;;
    esac
}

# Install a script only if the destination does not already exist.
install_script_if_missing() {
    local src="$1"
    local dest="$2"
    local label="$3"

    mkdir -p "$(dirname "${dest}")"
    if [[ -e "${dest}" ]]; then
        log "Keeping existing ${dest} (not overwriting — ${label})"
        return 0
    fi
    cp -a "${src}" "${dest}"
    chmod 0755 "${dest}"
    log "Installed ${dest} (${label})"
}

# Write EventScript= only when tlb.conf has no active EventScript= line.
apply_eventscript_to_tlb_conf() {
    local chosen_path="$1"
    local conf
    conf="$(find_tlb_conf)"
    if [[ -z "${conf}" ]]; then
        log "WARNING: tlb.conf not found — set EventScript manually if desired:"
        log "  EventScript = ${chosen_path}"
        return 1
    fi

    if grep -qE '^[[:space:]]*EventScript[[:space:]]*=' "${conf}" 2>/dev/null; then
        log "tlb.conf already has EventScript= — left unchanged (edit manually if you want ${chosen_path})"
        return 0
    fi

    backup_if_exists "${conf}"
    printf '\nEventScript = %s\n' "${chosen_path}" >> "${conf}"
    log "Set EventScript in ${conf} (was unset):"
    log "  EventScript = ${chosen_path}"
    return 0
}

prompt_eventscript_policy() {
    local choice default_num current=""

    TLBEVENT_POLICY="$(normalize_eventscript_policy "${TLBEVENT_POLICY:-}")"
    if [[ -n "${TLBEVENT_POLICY}" ]]; then
        return 0
    fi

    current="$(read_current_eventscript_policy)"
    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        if [[ -n "${current}" ]]; then
            TLBEVENT_POLICY="${current}"
        elif [[ "${SITE_TYPE:-}" == "irlp" || "${SITE_TYPE:-}" == "echolink" ]]; then
            TLBEVENT_POLICY="conference"
        else
            TLBEVENT_POLICY="nopolicy"
        fi
        return 0
    fi

    echo
    echo "Choose ONE EventScript for tlb.conf (files installed only if missing):"
    echo "  1) WITH conference policy  — tlbevent-conference.sh"
    echo "     (rapid-reconnect / multi-conference monitoring — normal production)"
    echo "  2) NO policy               — tlbevent-nopolicy.sh"
    echo "     (linkit + IRLP bump only — preferred NO-policy name)"
    echo "  3) NO policy (legacy name) — tlbevent.sh"
    echo "     (same as 2; keep if your tlb.conf already points here)"
    echo
    default_num="1"
    [[ "${current}" == "nopolicy" ]] && default_num="2"
    [[ "${current}" == "plain" ]] && default_num="3"
    [[ "${current}" == "conference" ]] && default_num="1"
    if [[ -z "${current}" && "${SITE_TYPE:-}" =~ ^(link|repeater)$ ]]; then
        default_num="2"
    fi
    prompt choice "Enter 1, 2, or 3" "${default_num}"
    case "${choice}" in
        1|conference|policy) TLBEVENT_POLICY="conference" ;;
        2|nopolicy|no-policy|none|no) TLBEVENT_POLICY="nopolicy" ;;
        3|plain|legacy) TLBEVENT_POLICY="plain" ;;
        *) die "Invalid EventScript choice: ${choice}" ;;
    esac
}

install_tlbevent_optional() {
    local conf="" has_eventscript=0
    local conference_template plain_template nopolicy_template chosen_path

    INSTALL_TLBEVENT="${INSTALL_TLBEVENT:-0}"
    if [[ "${INSTALL_TLBEVENT}" != "1" ]]; then
        if [[ "${ASSUME_YES}" -eq 1 ]]; then
            return 0
        fi
        local answer=""
        local default_ev="N"
        # IRLP users usually want EventScripts (clean .bump / linkit).
        if [[ "${USES_IRLP:-0}" == "1" || "${SITE_TYPE}" == "irlp" ]]; then
            default_ev="Y"
        fi
        echo
        if [[ "${USES_IRLP:-0}" == "1" ]]; then
            echo "EventScripts recommended when using IRLP (clean .bump via TCP 15425)."
        elif [[ "${USES_ECHOLINK:-0}" == "1" ]]; then
            echo "EventScripts optional for EchoLink / -L / -R (policy or no-policy)."
        fi
        echo "Will install these three only if missing (never overwrite):"
        echo "  tlbevent-conference.sh   — WITH conference policy"
        echo "  tlbevent-nopolicy.sh     — NO policy (preferred name)"
        echo "  tlbevent.sh              — NO policy (legacy name)"
        echo "If tlb.conf already has EventScript=, that line is left alone."
        echo "You pick/switch via tlb.conf — installer will not auto-replace your choice."
        prompt answer "Install missing EventScripts now? (Y/n)" "${default_ev}"
        [[ "${answer}" =~ ^[Yy] ]] || return 0
    fi

    conference_template="$(resolve_pkg_file "tlbevent-conference.template" "tlbevent-conference.sh")" \
        || die "Missing tlbevent-conference.template"
    plain_template="$(resolve_pkg_file "tlbevent.template" "tlbevent.sh")" \
        || die "Missing tlbevent.template"
    nopolicy_template="$(resolve_pkg_file "tlbevent-nopolicy.template" "tlbevent-nopolicy.sh")" \
        || die "Missing tlbevent-nopolicy.template"

    echo
    echo "Installing EventScripts (skip any that already exist):"
    install_script_if_missing "${conference_template}" "${TLBEVENT_DEST}" "tlbevent-conference.sh — WITH policy"
    install_script_if_missing "${nopolicy_template}" "${TLBEVENT_NOPOLICY_DEST}" "tlbevent-nopolicy.sh — NO policy"
    install_script_if_missing "${plain_template}" "${TLBEVENT_PLAIN_DEST}" "tlbevent.sh — NO policy (legacy)"

    # Paul: provide new scripts under new names; do not auto-enable/replace.
    conf="$(find_tlb_conf)"
    if [[ -n "${conf}" ]] && grep -qE '^[[:space:]]*EventScript[[:space:]]*=' "${conf}" 2>/dev/null; then
        has_eventscript=1
    fi
    if [[ "${has_eventscript}" -eq 1 ]]; then
        log "tlb.conf already has EventScript= — left unchanged (edit manually to switch)"
        log "  Example: EventScript = ${TLBEVENT_NOPOLICY_DEST}"
        return 0
    fi

    # Fresh only: EventScript unset — ask which path to write.
    prompt_eventscript_policy

    if [[ "${TLBEVENT_POLICY}" == "conference" ]]; then
        chosen_path="${TLBEVENT_DEST}"
        log "Active choice: WITH conference policy"
    elif [[ "${TLBEVENT_POLICY}" == "plain" ]]; then
        chosen_path="${TLBEVENT_PLAIN_DEST}"
        log "Active choice: NO policy (legacy tlbevent.sh)"
    else
        chosen_path="${TLBEVENT_NOPOLICY_DEST}"
        log "Active choice: NO policy (tlbevent-nopolicy.sh)"
    fi

    apply_eventscript_to_tlb_conf "${chosen_path}" || true
    log "If you set EventScript, restart TheLinkBox for it to take effect."
}

prompt_restart_tlb_policy() {
    local choice default_num

    case "$(printf '%s' "${RESTART_TLB_POLICY:-}" | tr '[:upper:]' '[:lower:]')" in
        1|conference|policy|with|with-policy|conf) RESTART_TLB_POLICY="conference" ;;
        2|nopolicy|no-policy|none|no) RESTART_TLB_POLICY="nopolicy" ;;
        3|plain|legacy) RESTART_TLB_POLICY="plain" ;;
        "") ;;
        *) RESTART_TLB_POLICY="$(normalize_eventscript_policy "${RESTART_TLB_POLICY:-}")" ;;
    esac
    if [[ -n "${RESTART_TLB_POLICY}" ]]; then
        return 0
    fi

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        case "${TLBEVENT_POLICY:-}" in
            conference) RESTART_TLB_POLICY="conference" ;;
            plain) RESTART_TLB_POLICY="plain" ;;
            *) RESTART_TLB_POLICY="nopolicy" ;;
        esac
        return 0
    fi

    echo
    echo "Choose which Restart TLB script the admin button should use:"
    echo "  1) WITH conference policy  — restart-tlb-conference"
    echo "     (pair with EventScript tlbevent-conference.sh)"
    echo "  2) NO policy               — restart-tlb-nopolicy"
    echo "     (pair with EventScript tlbevent-nopolicy.sh — preferred)"
    echo "  3) NO policy (legacy name) — restart-tlb"
    echo "     (pair with legacy tlbevent.sh)"
    echo
    echo "Existing scripts under /home/thelinkbox/scripts are never overwritten."
    default_num="2"
    [[ "${TLBEVENT_POLICY:-}" == "conference" ]] && default_num="1"
    [[ "${TLBEVENT_POLICY:-}" == "plain" ]] && default_num="3"
    [[ "${TLBEVENT_POLICY:-}" == "nopolicy" ]] && default_num="2"
    prompt choice "Enter 1, 2, or 3" "${default_num}"
    case "${choice}" in
        1|conference|policy) RESTART_TLB_POLICY="conference" ;;
        2|nopolicy|no-policy|none|no) RESTART_TLB_POLICY="nopolicy" ;;
        3|plain|legacy) RESTART_TLB_POLICY="plain" ;;
        *) die "Invalid restart script choice: ${choice}" ;;
    esac
}

# Install ALL restart scripts only if missing — NEVER overwrite
# restart-tlb, restart-tlb-nopolicy, or restart-tlb-conference.
# Activate one for tlb-admin only when $tlbrestartscript is unset (fresh).
# sudoers: create only if missing.
install_restart_tlb() {
    local plain_dest="${RESTART_TLB_DEST:-/home/thelinkbox/scripts/restart-tlb}"
    local nopolicy_dest="${RESTART_TLB_NOPOLICY_DEST:-/home/thelinkbox/scripts/restart-tlb-nopolicy}"
    local conf_dest="${RESTART_TLB_CONFERENCE_DEST:-/home/thelinkbox/scripts/restart-tlb-conference}"
    local sudoers_file="/etc/sudoers.d/tlb-restart-web"
    local plain_sample="" nopolicy_sample="" conf_sample="" active_dest=""
    local admin="${WEB_ROOT}/tlb-admin/index.php"
    local existing_restart=""

    # Keep destinations distinct if env was overridden oddly.
    if [[ "${plain_dest}" == "${conf_dest}" ]] || [[ "${plain_dest}" == *restart-tlb-conference* ]] || [[ "${plain_dest}" == *restart-tlb-nopolicy* ]]; then
        plain_dest="/home/thelinkbox/scripts/restart-tlb"
    fi
    if [[ "${nopolicy_dest}" != *restart-tlb-nopolicy* ]]; then
        nopolicy_dest="/home/thelinkbox/scripts/restart-tlb-nopolicy"
    fi
    if [[ "${conf_dest}" != *restart-tlb-conference* ]]; then
        conf_dest="/home/thelinkbox/scripts/restart-tlb-conference"
    fi

    if [[ -e "${plain_dest}" || -e "${nopolicy_dest}" || -e "${conf_dest}" ]]; then
        log "Existing restart-tlb* scripts are never overwritten"
    fi

    plain_sample="$(resolve_pkg_file "restart-tlb.sample" "restart-tlb" 2>/dev/null || true)"
    nopolicy_sample="$(resolve_pkg_file "restart-tlb-nopolicy.sample" "restart-tlb-nopolicy" 2>/dev/null || true)"
    conf_sample="$(resolve_pkg_file "restart-tlb-conference.sample" "restart-tlb-conference" 2>/dev/null || true)"

    if [[ -n "${plain_sample}" && -f "${plain_sample}" ]]; then
        install_script_if_missing "${plain_sample}" "${plain_dest}" "restart-tlb (legacy) — NEVER overwrite if present"
    elif [[ -e "${plain_dest}" ]]; then
        log "Keeping existing ${plain_dest} (restart-tlb — not overwriting)"
    else
        die "Missing restart-tlb.sample (and ${plain_dest} not found).
Upload restart-tlb.sample to ${PACKAGE_URL%/}/ or place the script at ${plain_dest}."
    fi

    if [[ -n "${nopolicy_sample}" && -f "${nopolicy_sample}" ]]; then
        install_script_if_missing "${nopolicy_sample}" "${nopolicy_dest}" "restart-tlb-nopolicy — NEVER overwrite if present"
    elif [[ -e "${nopolicy_dest}" ]]; then
        log "Keeping existing ${nopolicy_dest} (restart-tlb-nopolicy — not overwriting)"
    else
        die "Missing restart-tlb-nopolicy.sample (and ${nopolicy_dest} not found).
Upload restart-tlb-nopolicy.sample to ${PACKAGE_URL%/}/."
    fi

    if [[ -n "${conf_sample}" && -f "${conf_sample}" ]]; then
        install_script_if_missing "${conf_sample}" "${conf_dest}" "restart-tlb-conference — NEVER overwrite if present"
    elif [[ -e "${conf_dest}" ]]; then
        log "Keeping existing ${conf_dest} (restart-tlb-conference — not overwriting)"
    else
        die "Missing restart-tlb-conference.sample (and ${conf_dest} not found).
Upload restart-tlb-conference.sample to ${PACKAGE_URL%/}/."
    fi

    # Paul: do not auto-replace the operator's admin button choice.
    if [[ -f "${admin}" ]]; then
        existing_restart="$(extract_tlbrestartscript_from_file "${admin}")"
        existing_restart="$(trim_value "${existing_restart:-}")"
    fi
    if [[ -n "${existing_restart}" ]]; then
        active_dest="${existing_restart}"
        RESTART_TLB_ACTIVE="${active_dest}"
        log "Keeping existing \$tlbrestartscript = ${active_dest} (not auto-changing)"
    else
        prompt_restart_tlb_policy
        if [[ "${RESTART_TLB_POLICY}" == "conference" ]]; then
            active_dest="${conf_dest}"
            log "Active Restart TLB button: WITH policy (${active_dest})"
        elif [[ "${RESTART_TLB_POLICY}" == "plain" ]]; then
            active_dest="${plain_dest}"
            log "Active Restart TLB button: NO policy legacy (${active_dest})"
        else
            active_dest="${nopolicy_dest}"
            log "Active Restart TLB button: NO policy (${active_dest})"
        fi
        RESTART_TLB_ACTIVE="${active_dest}"
        if [[ -f "${admin}" ]]; then
            set_tlbrestartscript_in_file "${admin}" "${active_dest}" || true
            log "Set \$tlbrestartscript in ${admin}"
        fi
    fi

    if [[ -f "${sudoers_file}" ]]; then
        log "Keeping existing ${sudoers_file} (not overwriting)"
    else
        cat > "${sudoers_file}" <<EOF
# Allow Apache (www-data) to run restart scripts from tlb-admin without a password.
# Used by tlb_restart_service() in tlb-common.inc. Managed by install-tlbweb.sh.
# Active choice: ${active_dest}
www-data ALL=(root) NOPASSWD: ${plain_dest}
www-data ALL=(root) NOPASSWD: ${nopolicy_dest}
www-data ALL=(root) NOPASSWD: ${conf_dest}
EOF
        chmod 440 "${sudoers_file}"
        if ! visudo -cf "${sudoers_file}" >/dev/null 2>&1; then
            rm -f "${sudoers_file}"
            die "Invalid sudoers written for restart scripts - removed ${sudoers_file}"
        fi
        log "Configured ${sudoers_file} (chmod 440) for all restart scripts"
    fi
    log "  Active for tlb-admin: ${active_dest}"
}

fix_log_permissions_optional() {
    local tlb_log="${TLB_LOG_PATH:-/home/tlb.log}"
    local tlb_messages="${TLB_MESSAGES_PATH:-/home/tlb/log/messages}"

    if [[ "${ASSUME_YES}" -eq 1 ]]; then
        return 0
    fi

    local answer=""
    prompt answer "Try to chmod log files for Apache www-data read access? (y/N)" "N"
    [[ "${answer}" =~ ^[Yy] ]] || return 0

    [[ -f "${tlb_log}" ]] && chmod 0644 "${tlb_log}" && log "chmod 0644 ${tlb_log}"
    [[ -f "${tlb_messages}" ]] && chmod 0644 "${tlb_messages}" && log "chmod 0644 ${tlb_messages}"
    [[ -d "$(dirname "${tlb_messages}")" ]] && chmod 0755 "$(dirname "${tlb_messages}")" 2>/dev/null || true
    [[ -d /home/tlb ]] && chmod o+x /home/tlb 2>/dev/null || true
}

print_summary() {
    local pub_url="${DOMAIN_OR_IP:-${SERVER_IP}}"
    local monitor_url="http://${pub_url}/"
    local monitor_path="${WEB_ROOT}/${PUBLIC_MONITOR_SCRIPT}"

    if [[ "${PUBLIC_MONITOR_SCRIPT}" == "exp.php" ]]; then
        monitor_url="http://${pub_url}/exp.php"
    fi

    # Final correction so the Done: line cannot show IRLP *OHIO* Reflector.
    force_site_type_from_name || true

    cat <<EOF

Done. Web files installed under ${WEB_ROOT}:

  Site type:       ${LINK_TYPE:-?} ${REFLECTOR_NAME:-?} ${SITE_KIND:-?}
  Networks:        IRLP=$([ "${USES_IRLP:-0}" = 1 ] && echo yes || echo no), EchoLink=$([ "${USES_ECHOLINK:-0}" = 1 ] && echo yes || echo no)
  EventScript:     $([ "${TLBEVENT_POLICY:-}" = conference ] && echo "WITH policy (${TLBEVENT_DEST})" || { [ "${TLBEVENT_POLICY:-}" = plain ] && echo "NO policy (${TLBEVENT_PLAIN_DEST})" || echo "<not set this run>"; })
  Restart script:  $(case "${RESTART_TLB_POLICY:-}" in conference) echo "WITH policy (${RESTART_TLB_ACTIVE:-${RESTART_TLB_CONFERENCE_DEST}})" ;; nopolicy) echo "NO policy (${RESTART_TLB_ACTIVE:-${RESTART_TLB_NOPOLICY_DEST}})" ;; plain) echo "NO policy legacy (${RESTART_TLB_ACTIVE:-${RESTART_TLB_DEST}})" ;; *) echo "<default / ask on install>" ;; esac)
  Public monitor:  ${monitor_path}
                   ${monitor_url}
  Admin tool:      ${WEB_ROOT}/tlb-admin/index.php
                   http://${pub_url}/tlb-admin/

Next checks (see SETUP-INSTRUCTIONS.txt if needed):
  - Apache AllowOverride All for ${WEB_ROOT}
  - auth_basic / authn_file modules enabled
  - tlb-admin login prompt in a private browser window
  - admin log panels readable (Step 5)
  - IRLP experimental: ALLOW_EXPERIMENTAL_NODES=YES (not needed for EchoLink-only / -L / -R)

EOF
}

main() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --download) DO_DOWNLOAD=1; shift ;;
            -y|--yes) ASSUME_YES=1; shift ;;
            -h|--help) usage; exit 0 ;;
            *) die "Unknown option: $1 (use --help)" ;;
        esac
    done

    if [[ "${DO_DOWNLOAD}" -eq 1 ]]; then
        download_package "${SCRIPT_DIR}"
        echo
        log "Next: sudo ./install-tlbweb.sh"
        exit 0
    fi

    if [[ "$(id -u)" -ne 0 ]]; then
        die "Run as root (sudo ./install-tlbweb.sh)"
    fi

    [[ -d "${WEB_ROOT}" ]] || die "WEB_ROOT does not exist: ${WEB_ROOT}"

    echo "LinkBox web installer v${INSTALLER_VERSION} (package: ${SCRIPT_DIR})"
    print_install_disclaimer
    confirm_read_instructions
    echo

    ensure_package
    echo

    SERVER_IP="${SERVER_IP:-}"
    SITE_TYPE="${SITE_TYPE:-}"
    REFLECTOR_NAME="${REFLECTOR_NAME:-}"
    USES_IRLP="${USES_IRLP:-}"
    USES_ECHOLINK="${USES_ECHOLINK:-}"
    DOMAIN_OR_IP="${DOMAIN_OR_IP:-}"
    ALLSTAR_NODE="${ALLSTAR_NODE:-}"
    SUPERMON_URL="${SUPERMON_URL:-}"
    TLBCMD_PORT="${TLBCMD_PORT:-}"
    TLB_LOG_PATH="${TLB_LOG_PATH:-}"
    TLB_MESSAGES_PATH="${TLB_MESSAGES_PATH:-}"
    PUBLIC_MONITOR_SCRIPT="${PUBLIC_MONITOR_SCRIPT:-}"
    AUTH_MODE="${AUTH_MODE:-}"
    SUPERMON_HTPASSWD="${SUPERMON_HTPASSWD:-}"

    load_tlb_ini
    load_settings_from_existing_files

    # Prefer site type already written into an existing monitor/admin page.
    if [[ -z "${SITE_TYPE:-}" ]]; then
        read_site_type_from_file "${WEB_ROOT}/${PUBLIC_MONITOR_SCRIPT:-exp.php}" 2>/dev/null || true
    fi
    if [[ -z "${SITE_TYPE:-}" ]]; then
        read_site_type_from_file "${WEB_ROOT}/tlb-admin/index.php" 2>/dev/null || true
    fi

    echo
    echo "============================================================"
    echo " Site information (saved to tlb.ini if you choose)"
    echo "============================================================"
    echo " Answer these for YOUR system — used on the monitor pages"
    echo " and optional ${WEB_ROOT}/tlb.ini (like Supermon allmon.ini)."
    echo

    prompt_host_or_ip SERVER_IP "Server public IP address" "${SERVER_IP}"
    prompt_site_type_and_name
    prompt_host_or_ip DOMAIN_OR_IP "Domain or IP for admin redirects" "${DOMAIN_OR_IP:-${SERVER_IP}}"
    prompt_supermon_url
    prompt TLBCMD_PORT "tlbcmd port if non-default (blank = default)" "${TLBCMD_PORT}"
    prompt TLB_LOG_PATH "tlb.log path (blank = /home/tlb.log)" "${TLB_LOG_PATH}"
    prompt TLB_MESSAGES_PATH "messages log path (blank = /home/tlb/log/messages)" "${TLB_MESSAGES_PATH}"
    if ! is_valid_host_or_ip "${SERVER_IP}"; then
        die "SERVER_IP is invalid: ${SERVER_IP}"
    fi
    [[ -n "${DOMAIN_OR_IP}" ]] || DOMAIN_OR_IP="${SERVER_IP}"
    if ! is_valid_host_or_ip "${DOMAIN_OR_IP}"; then
        die "DOMAIN_OR_IP is invalid: ${DOMAIN_OR_IP}"
    fi

    TLBCMD_PORT_ARG="$(normalize_tlbcmd_port "${TLBCMD_PORT}")"

    prompt_public_monitor_script
    log "Public monitor will be installed as ${PUBLIC_MONITOR_SCRIPT}"

    write_tlb_ini

    install_web_files
    setup_apache_auth
    install_tlbevent_optional
    install_restart_tlb
    fix_log_permissions_optional
    print_summary
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
