#!/bin/bash
#
# Copyright Rimuhosting.com

# target for installation. remove any trailing /s
DEFAULTINSTALLTARGET="/usr/local/tomcat"
INSTALLTARGET=${INSTALLTARGET-}
INITSCRIPT="/etc/init.d/tomcat"
SYSTEMDUNIT="/etc/systemd/system/tomcat.service"

DEBUG_WORLD=${DEBUG_WORLD:-false} # used by prepserver, or you can define as an export :)
DEBUG_INSTALLTOMCAT=${DEBUG_INSTALLTOMCAT:-$DEBUG_WORLD} # used to reveal extra debugging message

# default values
ERRORMSG=
MVDIRSTAMP="old.$(date +%s)"
MVDIRNAME=
MAJORTOMCATVERSION=9
SETUPTYPE=${SETUPTYPE:-standard}
if [ -n "${ISPROMPT+x}" ]; then
  ISPROMPTCONFIGURED=true
else
  ISPROMPTCONFIGURED=false
fi
ISPROMPT=${ISPROMPT-}
ISNOPROMPTEXPLICIT=false
ISMIGRATEORIGINALWEBAPPS=${ISMIGRATEORIGINALWEBAPPS-}
ISREPLACEEXISTINGINSTALL=${ISREPLACEEXISTINGINSTALL-}
ISMIGRATECONFIGURATION=${ISMIGRATECONFIGURATION-}
ISMIGRATEONLY=${ISMIGRATEONLY:-false}
MIGRATIONSOURCE=${MIGRATIONSOURCE-}
ISLOCALDEBUGGERENABLED=${ISLOCALDEBUGGERENABLED-}
ISDEBUGGERSUSPEND=${ISDEBUGGERSUSPEND:-false}
DEBUGGERPORT=${DEBUGGERPORT-}
INSTANCENUMBER=${INSTANCENUMBER:-0}
SHUTDOWNPORT=
AJPPORT=
HTTPPORT=
HTTPSPORT=
ISPREVIEWENABLED=${ISPREVIEWENABLED-}
if [ -n "${ISNATIVEACCESSENABLED+x}" ]; then
  ISNATIVEACCESSCONFIGURED=true
else
  ISNATIVEACCESSCONFIGURED=false
fi
ISNATIVEACCESSENABLED=${ISNATIVEACCESSENABLED:-false}
ISSHOWHELP=false
INSTALLJAVA=${INSTALLJAVA:-true}
REMOVEEXAMPLEWEBAPPS=${REMOVEEXAMPLEWEBAPPS-}
ISUNPACKWARS=${ISUNPACKWARS-}
ISREDUCESPARETHREADS=${ISREDUCESPARETHREADS-}
REMOVEMANAGERWEBAPPS=${REMOVEMANAGERWEBAPPS-}
ISRUNONSTARTUP=${ISRUNONSTARTUP-}
ISURIUTF8=${ISURIUTF8-}
ISBINDHTTPPUBLIC=${ISBINDHTTPPUBLIC:-false}
ISMAXPARAMETERCOUNTUNLIMITED=${ISMAXPARAMETERCOUNTUNLIMITED-}
ISAPRREQUIRED=${ISAPRREQUIRED:-false}
ISUSERINSTALLPERMITTED=${ISUSERINSTALLPERMITTED-}
ISDRYRUN=${ISDRYRUN:-false}
ISRUNPOSTINSTALLTESTS=${ISRUNPOSTINSTALLTESTS:-true}
ISINSTALLTOMCATCTL=${ISINSTALLTOMCATCTL:-true}
ISROLLBACKONFAILURE=${ISROLLBACKONFAILURE-}
ISQUIET=${ISQUIET:-false}
COLOROUTPUT=${COLOROUTPUT:-auto}
OUTPUTFORMAT=${OUTPUTFORMAT:-text}
TOMCATVERSION=${TOMCATVERSION-}
TOMCATCTLURL=${TOMCATCTLURL:-https://public.rimuhosting.com/tomcatctl}
DEFAULTMEMSIZEMB=${DEFAULTMEMSIZEMB-}
TOMCATUSER=tomcat
ISSYSTEMD=false
XARGNORUNIFEMPTY="--no-run-if-empty"
DOWNLOADTMPFILES=
INSTALLSTAGE=
ISINSTALLACTIVATED=false
ISOLDTARGETPRESENT=false
ISOLDSERVICERUNNING=false
ISOLDSYSTEMDUNITPRESENT=false
ISOLDINITSCRIPTPRESENT=false
ISUSEROWNEDINSTALL=false
LOGROTATEFILE=
LOGROTATEBACKUP=
TOMCATCTLFILE=
TOMCATCTLCOMPLETIONFILE=
TOMCATCTLLINK=
TOMCATCTLCOMPLETIONLINK=
TOMCATCTLPATHCONFIG=
TOMCATCTLPROFILEFILES=

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

OUTPUTCOLOR=false
COLOR_RESET=
COLOR_BLUE=
COLOR_GREEN=
COLOR_YELLOW=
COLOR_RED=
COLOR_BOLD=

# Enable restrained colour only for terminals by default. NO_COLOR always wins.
function initialize_output() {
  case "$OUTPUTFORMAT" in
    text) ;;
    json) COLOROUTPUT=never ;;
    *) ERRORMSG="OUTPUTFORMAT must be text or json"; return 1 ;;
  esac
  [ -n "${NO_COLOR:-}" ] && COLOROUTPUT=never
  case "$COLOROUTPUT" in
    always) OUTPUTCOLOR=true ;;
    never) OUTPUTCOLOR=false ;;
    auto) [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ] && OUTPUTCOLOR=true ;;
    *) ERRORMSG="COLOROUTPUT must be auto, always, or never"; return 1 ;;
  esac
  if $OUTPUTCOLOR; then
    COLOR_RESET=$'\033[0m'
    COLOR_BLUE=$'\033[34m'
    COLOR_GREEN=$'\033[32m'
    COLOR_YELLOW=$'\033[33m'
    COLOR_RED=$'\033[31m'
    COLOR_BOLD=$'\033[1m'
  fi
}

function json_escape() {
  local value="$*"
  value=${value//\\/\\\\}
  value=${value//\"/\\\"}
  value=${value//$'\n'/\\n}
  value=${value//$'\r'/\\r}
  value=${value//$'\t'/\\t}
  printf '%s' "$value"
}

function json_event() {
  local level="$1"
  shift
  printf '{"level":"%s","message":"%s"}\n' "$level" "$(json_escape "$*")"
}

function section() {
  $ISQUIET && return 0
  [ "$OUTPUTFORMAT" = json ] && { json_event phase "$*"; return 0; }
  printf '%s%s==>%s %s\n' "$COLOR_BOLD" "$COLOR_BLUE" "$COLOR_RESET" "$*"
}

function detail() {
  $ISQUIET && return 0
  [ "$OUTPUTFORMAT" = json ] && { json_event info "$*"; return 0; }
  printf '    %s\n' "$*"
}

function success() {
  [ "$OUTPUTFORMAT" = json ] && { json_event success "$*"; return 0; }
  printf '%s%sOK%s %s\n' "$COLOR_BOLD" "$COLOR_GREEN" "$COLOR_RESET" "$*"
}

function warn() {
  [ "$OUTPUTFORMAT" = json ] && { json_event warning "$*"; return 0; }
  printf '%sWARNING:%s %s\n' "$COLOR_YELLOW" "$COLOR_RESET" "$*" >&2
}

function error() {
  [ "$OUTPUTFORMAT" = json ] && { json_event error "$*"; return 0; }
  printf '%s%sERROR:%s %s\n' "$COLOR_BOLD" "$COLOR_RED" "$COLOR_RESET" "$*" >&2
}

# Convert legacy y/n values and common boolean spellings to shell commands.
function normalize_boolean_value() {
  case "$1" in
    y|Y|yes|YES|Yes|true|TRUE|True|1) echo true ;;
    n|N|no|NO|No|false|FALSE|False|0) echo false ;;
    *) return 1 ;;
  esac
}

# Normalize a named configuration variable while allowing an empty value to
# mean "ask interactively" for custom installations.
function normalize_boolean_variable() {
  local name="$1"
  local value="${!name}"
  local normalized
  [ -z "$value" ] && return 0
  normalized=$(normalize_boolean_value "$value") || {
    ERRORMSG="$name must be true or false (legacy y/n is also accepted)"
    return 1
  }
  printf -v "$name" '%s' "$normalized"
}

# Describe an explicit boolean value only when it differs from the documented
# standard default. "Override" covers both environment and command-line input.
function output_usage_boolean_annotation() {
  local name="$1"
  local default_value="$2"
  local configured_value="${!name}"
  local normalized_value
  normalized_value=$(normalize_boolean_value "$configured_value" 2>/dev/null) || normalized_value="$configured_value"
  if [ -n "$normalized_value" ] && [ "$normalized_value" != "$default_value" ]; then
    printf '(default: %s; override: %s)' "$default_value" "$normalized_value"
  else
    printf '(default: %s)' "$default_value"
  fi
}

function output_usage_value_annotation() {
  local current_value="$1"
  local default_value="$2"
  if [ "$current_value" = "$default_value" ]; then
    printf '(default: %s)' "$default_value"
  else
    printf '(default: %s; override: %s)' "$default_value" "$current_value"
  fi
}

function output_install_target_annotation() {
  if [ "$INSTALLTARGET" = "$DEFAULTINSTALLTARGET" ]; then
    printf '(resolved: %s)' "$INSTALLTARGET"
  else
    printf '(system default: %s; resolved: %s)' "$DEFAULTINSTALLTARGET" "$INSTALLTARGET"
  fi
}

# List immediate deployment entries without descending into exploded webapps.
function output_webapp_entry_names() {
  local target="$1"
  local item name

  for item in "$target"/webapps/*; do
    [ -d "$item" ] || [[ "$item" == *.war && -f "$item" ]] || continue
    name=$(basename "$item")
    echo "$name"
  done | sort -u
}

function output_context_names() {
  local target="$1"
  local item
  for item in "$target"/conf/Catalina/localhost/*.xml; do
    [ -f "$item" ] || continue
    basename "$item"
  done | sort -u
}

# List immediate certificate, private-key, and Java-keystore files. These are
# security-sensitive configuration artifacts, so migration never overwrites one.
function output_security_file_names() {
  local target="$1"
  local item name
  for item in "$target"/conf/*; do
    [ -f "$item" ] || continue
    name=$(basename "$item")
    case "$name" in
      *.pem|*.crt|*.cer|*.key|*.p12|*.pfx|*.jks|*.keystore) echo "$name" ;;
    esac
  done | sort -u
}

function output_shared_library_names() {
  local target="$1"
  local item
  for item in "$target"/lib/*; do
    [ -f "$item" ] || continue
    basename "$item"
  done | sort -u
}

# Turn a newline-delimited inventory into a bounded, readable annotation.
function output_bounded_name_list() {
  awk '
    NR <= 12 { names = names (names ? ", " : "") $0 }
    END {
      if (NR == 0) print "none detected"
      else if (NR <= 12) print names
      else print names ", and " NR - 12 " more"
    }
  '
}

# Compare bounded, immediate inventories. This reports deployment/configuration
# shape without recursively hashing potentially enormous exploded applications.
function report_migration_inventory() {
  local label="$1"
  local inventory_function="$2"
  local source="$3"
  local target="$4"
  local source_only target_only
  if diff -q <("$inventory_function" "$source") <("$inventory_function" "$target") >/dev/null 2>&1; then
    detail "$label inventory: same"
    return 0
  fi
  source_only=$(comm -23 <("$inventory_function" "$source") <("$inventory_function" "$target") | output_bounded_name_list)
  target_only=$(comm -13 <("$inventory_function" "$source") <("$inventory_function" "$target") | output_bounded_name_list)
  warn "$label inventory: different"
  detail "Only in source: $source_only"
  detail "Only in target: $target_only"
}

# Compare the contents of common immediate files. Directories are deliberately
# skipped; recursively auditing exploded applications is an opt-in/manual task.
function report_migration_file_contents() {
  local label="$1"
  local inventory_function="$2"
  local source="$3"
  local target="$4"
  local relative_directory="$5"
  local name identical=0 different=0 source_only=0
  while IFS= read -r name; do
    [ -f "$source/$relative_directory/$name" ] || continue
    if [ ! -f "$target/$relative_directory/$name" ]; then
      source_only=$((source_only + 1))
    elif cmp -s "$source/$relative_directory/$name" "$target/$relative_directory/$name"; then
      identical=$((identical + 1))
    else
      different=$((different + 1))
    fi
  done < <("$inventory_function" "$source")
  detail "$label file contents: $identical identical, $different different, $source_only missing from target"
}

# Describe deployable webapps and named contexts without recursively traversing
# application trees, which can contain very large numbers of files.
function output_detected_webapps_annotation() {
  local target="${1:-${INSTALLTARGET:-$DEFAULTINSTALLTARGET}}"

  if [ ! -x "$target/bin/catalina.sh" ] && [ ! -f "$target/conf/server.xml" ]; then
    echo "N/A: Tomcat is not installed at $target"
    return 0
  fi

  {
    output_webapp_entry_names "$target" | sed 's/\.war$//'
    output_context_names "$target" | sed 's/\.xml$//'
  } | sort -u | output_bounded_name_list
}

# Report optional XML tooling without making --help depend on package managers
# or network access. xmlstarlet can query/edit XML; xmllint validates and queries.
function output_xml_tool_annotation() {
  if is_command_available xmlstarlet; then
    echo "xmlstarlet detected (structured XML inspection available)"
  elif is_command_available xmllint; then
    echo "xmllint detected (XML validation available; xmlstarlet enables structured editing)"
  elif is_command_available sw_vers; then
    echo "not detected; optional: brew install xmlstarlet"
  elif [ -e /etc/debian_version ]; then
    echo "not detected; optional: apt-get install xmlstarlet libxml2-utils"
  elif [ -e /etc/redhat-release ]; then
    echo "not detected; optional: dnf install xmlstarlet libxml2"
  else
    echo "not detected; install xmlstarlet (and libxml2/xmllint) with the system package manager"
  fi
}

# Validate XML with the best available local tool. Absence of optional tooling
# is not itself an installation failure; focused textual checks remain later.
function is_xml_file_valid() {
  if is_command_available xmlstarlet; then
    xmlstarlet val --err --well-formed "$1" >/dev/null 2>&1
  elif is_command_available xmllint; then
    xmllint --noout "$1" >/dev/null 2>&1
  else
    return 0
  fi
}

# Print a redacted diff because server.xml may contain connector or realm secrets.
function print_redacted_xml_diff() {
  local baseline="$1"
  local current="$2"
  local diff_output
  diff_output=$(diff -u \
    <(sed -E 's/(password|Password|keystorePass|KeystorePassword|truststorePass|TruststorePassword|secret|Secret)="[^"]*"/\1="[REDACTED]"/g' "$baseline") \
    <(sed -E 's/(password|Password|keystorePass|KeystorePassword|truststorePass|TruststorePassword|secret|Secret)="[^"]*"/\1="[REDACTED]"/g' "$current") || true)
  [ -n "$diff_output" ] || return 0
  if [ "$OUTPUTFORMAT" = json ]; then
    json_event info "$diff_output"
  else
    printf '%s\n' "$diff_output"
  fi
}

# Inspect configuration differences before downloads. Newer installations have
# a post-install baseline; legacy installations fall back to the pristine file.
function report_existing_configuration_changes() {
  local target="${1:-${INSTALLTARGET:-$DEFAULTINSTALLTARGET}}"
  local is_strict="${2:-true}"
  local current="$target/conf/server.xml"
  local baseline host_count
  [ -f "$current" ] || return 0

  if is_command_available xmlstarlet; then
    host_count=$(xmlstarlet sel -t -v 'count(//*[local-name()="Host"])' "$current" 2>/dev/null) || {
      ERRORMSG="could not inspect Host elements in the existing server.xml"
      return 1
    }
  else
    # Fallback counts Host start tags rather than lines, avoiding a false count
    # when XML formatting places multiple elements on the same line.
    host_count=$(grep -Eo '<Host([[:space:]>])' "$current" | wc -l | tr -d ' ')
  fi
  if [ "$host_count" -ne 1 ]; then
    if $is_strict; then
      ERRORMSG="the existing server.xml has $host_count Host elements; migrate those virtual hosts manually"
      return 1
    fi
    warn "The source server.xml has $host_count Host elements; those virtual hosts require manual migration"
  fi

  if [ -f "$target/conf/server.xml.postinstall" ]; then
    baseline="$target/conf/server.xml.postinstall"
  elif [ -f "$target/conf/server.xml.orig" ]; then
    baseline="$target/conf/server.xml.orig"
    warn "No server.xml.postinstall baseline exists; comparing with the pristine server.xml.orig may include installer changes"
  elif [ -f "$target/conf/server.xml.before-installtomcat" ]; then
    baseline="$target/conf/server.xml.before-installtomcat"
    warn "Using legacy server.xml.before-installtomcat as the baseline; the diff may include installer changes"
  else
    warn "This older installation has no server.xml baseline; preserve and manually review $current"
    return 0
  fi
  if ! diff -q "$baseline" "$current" >/dev/null 2>&1; then
    warn "Custom server.xml changes were detected relative to $baseline; they are not applied automatically"
    $ISQUIET || print_redacted_xml_diff "$baseline" "$current"
  else
    detail "No post-install server.xml changes were detected"
  fi
}

# Apply defaults belonging to the selected setup profile without replacing
# explicit environment or command-line values.
function apply_setup_type_defaults() {
  case "$SETUPTYPE" in
    custom)
      ISPROMPT=${ISPROMPT:-true}
      ISROLLBACKONFAILURE=${ISROLLBACKONFAILURE:-true}
      # Leave optional choices unset so ISPROMPT=true can ask for them. In
      # non-interactive mode prompt_boolean later supplies standard defaults.
      ISMIGRATECONFIGURATION=${ISMIGRATECONFIGURATION:-false}
      ISLOCALDEBUGGERENABLED=${ISLOCALDEBUGGERENABLED:-false}
      ISPREVIEWENABLED=${ISPREVIEWENABLED:-false}
      ISMAXPARAMETERCOUNTUNLIMITED=${ISMAXPARAMETERCOUNTUNLIMITED:-false}
      ISREPLACEEXISTINGINSTALL=${ISREPLACEEXISTINGINSTALL:-false}
    ;;
    standard)
      ISPROMPT=${ISPROMPT:-true}
      ISROLLBACKONFAILURE=${ISROLLBACKONFAILURE:-true}
      REMOVEEXAMPLEWEBAPPS=${REMOVEEXAMPLEWEBAPPS:-true}
      ISUNPACKWARS=${ISUNPACKWARS:-true}
      ISREDUCESPARETHREADS=${ISREDUCESPARETHREADS:-true}
      REMOVEMANAGERWEBAPPS=${REMOVEMANAGERWEBAPPS:-true}
      ISRUNONSTARTUP=${ISRUNONSTARTUP:-true}
      ISURIUTF8=${ISURIUTF8:-true}
      ISMIGRATEORIGINALWEBAPPS=${ISMIGRATEORIGINALWEBAPPS:-true}
      ISMIGRATECONFIGURATION=${ISMIGRATECONFIGURATION:-false}
      ISLOCALDEBUGGERENABLED=${ISLOCALDEBUGGERENABLED:-false}
      ISPREVIEWENABLED=${ISPREVIEWENABLED:-false}
      ISMAXPARAMETERCOUNTUNLIMITED=${ISMAXPARAMETERCOUNTUNLIMITED:-false}
      ISREPLACEEXISTINGINSTALL=${ISREPLACEEXISTINGINSTALL:-false}
    ;;
    pprod|pdev)
      # Author-specific convenience profiles. Explicit environment variables
      # and command-line options still override every value set here.
      if [ -z "$ISPROMPT" ]; then
        ISPROMPT=false
        ISNOPROMPTEXPLICIT=true
      fi
      ISROLLBACKONFAILURE=${ISROLLBACKONFAILURE:-false}
      REMOVEEXAMPLEWEBAPPS=${REMOVEEXAMPLEWEBAPPS:-true}
      ISUNPACKWARS=${ISUNPACKWARS:-true}
      ISREDUCESPARETHREADS=${ISREDUCESPARETHREADS:-true}
      REMOVEMANAGERWEBAPPS=${REMOVEMANAGERWEBAPPS:-true}
      ISRUNONSTARTUP=${ISRUNONSTARTUP:-true}
      ISURIUTF8=${ISURIUTF8:-true}
      ISMIGRATEORIGINALWEBAPPS=${ISMIGRATEORIGINALWEBAPPS:-true}
      ISMIGRATECONFIGURATION=${ISMIGRATECONFIGURATION:-true}
      ISPREVIEWENABLED=${ISPREVIEWENABLED:-true}
      ISMAXPARAMETERCOUNTUNLIMITED=${ISMAXPARAMETERCOUNTUNLIMITED:-true}
      ISREPLACEEXISTINGINSTALL=${ISREPLACEEXISTINGINSTALL:-true}
      if [ "$SETUPTYPE" = pdev ]; then
        ISLOCALDEBUGGERENABLED=${ISLOCALDEBUGGERENABLED:-true}
      else
        ISLOCALDEBUGGERENABLED=${ISLOCALDEBUGGERENABLED:-false}
      fi
    ;;
    hs)
      ISPROMPT=${ISPROMPT:-true}
      ISROLLBACKONFAILURE=${ISROLLBACKONFAILURE:-true}
      REMOVEEXAMPLEWEBAPPS=${REMOVEEXAMPLEWEBAPPS:-true}
      ISUNPACKWARS=${ISUNPACKWARS:-false}
      ISREDUCESPARETHREADS=${ISREDUCESPARETHREADS:-true}
      REMOVEMANAGERWEBAPPS=${REMOVEMANAGERWEBAPPS:-true}
      ISRUNONSTARTUP=${ISRUNONSTARTUP:-true}
      ISURIUTF8=${ISURIUTF8:-true}
      ISMIGRATEORIGINALWEBAPPS=${ISMIGRATEORIGINALWEBAPPS:-false}
      ISMIGRATECONFIGURATION=${ISMIGRATECONFIGURATION:-false}
      ISLOCALDEBUGGERENABLED=${ISLOCALDEBUGGERENABLED:-false}
      ISPREVIEWENABLED=${ISPREVIEWENABLED:-false}
      ISMAXPARAMETERCOUNTUNLIMITED=${ISMAXPARAMETERCOUNTUNLIMITED:-false}
      ISREPLACEEXISTINGINSTALL=${ISREPLACEEXISTINGINSTALL:-false}
    ;;
    *) ERRORMSG="Unrecognized setup type '$SETUPTYPE'; expected custom, standard, hs, pprod, or pdev"; return 1 ;;
  esac
}

# Resolve an optional boolean. Reading stdin is permitted only when
# ISPROMPT=true; otherwise an unset custom option receives its supplied default.
function prompt_boolean() {
  local name="$1"
  local prompt="$2"
  local default_value="$3"
  local value="${!name}"
  if [ -z "$value" ] && ! $ISPROMPT; then
    value="$default_value"
  elif [ -z "$value" ] && $ISPROMPT; then
    while true; do
      read -r -p "$prompt [$default_value] " value || {
        ERRORMSG="input ended while prompting for $name"
        return 1
      }
      value=${value:-$default_value}
      value=$(normalize_boolean_value "$value") && break
      echo "  Please enter true or false (legacy y/n is also accepted)."
    done
  fi
  value=$(normalize_boolean_value "$value") || {
    ERRORMSG="$name must be true or false"
    return 1
  }
  printf -v "$name" '%s' "$value"
}

# Choose a conservative Java heap from physical memory unless the caller set
# DEFAULTMEMSIZEMB explicitly. Linux uses /proc; macOS reports bytes via sysctl.
function set_default_memory_size() {
  local memory_mb
  if [ -n "$DEFAULTMEMSIZEMB" ]; then
    if [[ "$DEFAULTMEMSIZEMB" == *[!0-9]* || "$DEFAULTMEMSIZEMB" -eq 0 ]]; then
      ERRORMSG="DEFAULTMEMSIZEMB must be a positive whole number of megabytes"
      return 1
    fi
    return 0
  fi

  if [ -r /proc/meminfo ]; then
    memory_mb=$(awk '/^MemTotal:/ {print int($2 / 1024); exit}' /proc/meminfo)
  elif is_command_available sysctl; then
    memory_mb=$(sysctl -n hw.memsize 2>/dev/null | awk '{print int($1 / 1024 / 1024)}')
  fi

  # Unknown-memory systems get the smallest safe default.
  if [ -z "$memory_mb" ] || [ "$memory_mb" -le 2048 ]; then
    DEFAULTMEMSIZEMB=768
  elif [ "$memory_mb" -le 4096 ]; then
    DEFAULTMEMSIZEMB=1512
  else
    DEFAULTMEMSIZEMB=2500
  fi
}

# Return the address used to reach the Internet without contacting an external
# service. MYIP overrides detection; the result may be private when behind NAT.
function output_server_ip_address() {
  local address
  [ -n "$MYIP" ] && { echo "$MYIP"; return 0; }

  if is_command_available ip; then
    # Linux example:
    # 1.1.1.1 via 192.0.2.1 dev eth0 src 192.0.2.20 uid 1000 cache
    # Select the value immediately following the src field.
    address=$(ip --oneline route get 1.1.1.1 2>/dev/null |
      awk '{for (i = 1; i <= NF; i++) if ($i == "src") {print $(i + 1); exit}}')
    [ -n "$address" ] && { echo "$address"; return 0; }
  fi

  # macOS route output identifies the outbound interface; ipconfig then gives
  # that interface's IPv4 address without depending on interface names.
  if is_command_available route && is_command_available ipconfig; then
    local interface
    # macOS route output includes a line such as: interface: en0
    interface=$(route -n get 1.1.1.1 2>/dev/null | awk '/interface:/{print $2; exit}')
    address=$(ipconfig getifaddr "$interface" 2>/dev/null)
    [ -n "$address" ] && { echo "$address"; return 0; }
  fi

  # Last-resort portable fallback excludes loopback and link-local addresses.
  ifconfig 2>/dev/null |
    awk '/inet / {sub(/^addr:/, "", $2); if ($2 != "127.0.0.1" && $2 !~ /^169\.254\./) {print $2; exit}}'
}

# Count listeners on Tomcat's conventional shutdown, AJP, and HTTP ports.
function output_active_tomcat_port_count() {
  local port count=0
  for port in "$SHUTDOWNPORT" "$AJPPORT" "$HTTPPORT"; do
    is_tcp_port_listening "$port" && count=$((count + 1))
  done
  echo "$count"
}

function is_tcp_port_listening() {
  local port="$1"
  [ -n "$port" ] || return 1
  if is_command_available ss; then
    ss -lnt 2>/dev/null | grep -Eq ":${port}[[:space:]]"
  elif is_command_available netstat; then
    netstat -an 2>/dev/null | grep -i LISTEN | grep -Eq "[.:]${port}[[:space:]]"
  else
    return 1
  fi
}

# Return success when the configured service for this installation is running.
# Its listeners are expected during an upgrade and are checked again after the
# service has been stopped; all other default-port listeners are preflight errors.
function is_configured_tomcat_service_running() {
  if $ISSYSTEMD && [ -e "$SYSTEMDUNIT" ]; then
    systemctl --quiet is-active "$(basename "$SYSTEMDUNIT")"
    return
  fi
  [ -x "$INITSCRIPT" ] && [ "$("$INITSCRIPT" status 2>/dev/null | grep -c 'PIDs')" -gt 0 ]
}

# Print the resolved configuration without downloading, installing, or changing
# host state. This is intentionally a plan/validation mode, not command tracing.
function print_install_plan() {
  local install_scope=system
  local prompt_policy=interactive
  local tomcatctl_path=/usr/local/bin/tomcatctl
  local tomcatctl_link=
  $ISUSEROWNEDINSTALL && install_scope=user-owned
  if $ISUSEROWNEDINSTALL; then
    tomcatctl_path="$INSTALLTARGET/bin/tomcatctl"
    tomcatctl_link="$HOME/.local/bin/tomcatctl"
  fi
  ! $ISPROMPT && prompt_policy=non-interactive-safe
  $ISNOPROMPTEXPLICIT && prompt_policy=unattended-accept
  if [ "$OUTPUTFORMAT" = json ]; then
    printf '{"type":"plan","version":"%s","target":"%s","serviceUser":"%s","javaHeapMb":%s,"installScope":"%s","promptPolicy":"%s","replaceExisting":%s,"rollbackOnFailure":%s,"migrateWebapps":%s,"migrateConfiguration":%s,"publicHttp":%s,"localDebugger":%s,"debuggerPort":%s,"debuggerSuspend":%s,"enablePreview":%s,"enableNativeAccess":%s,"unlimitedParameters":%s,"unpackWars":%s,"requireApr":%s,"runAtStartup":%s,"postInstallTests":%s,"installTomcatctl":%s,"tomcatctlPath":"%s"}\n' \
      "$(json_escape "$tomcatversion")" "$(json_escape "$INSTALLTARGET")" \
      "$(json_escape "$TOMCATUSER")" "$DEFAULTMEMSIZEMB" \
      "$install_scope" "$prompt_policy" "$ISREPLACEEXISTINGINSTALL" "$ISROLLBACKONFAILURE" "$ISMIGRATEORIGINALWEBAPPS" "$ISMIGRATECONFIGURATION" \
      "$ISBINDHTTPPUBLIC" "$ISLOCALDEBUGGERENABLED" "$DEBUGGERPORT" "$ISDEBUGGERSUSPEND" \
      "$ISPREVIEWENABLED" "$ISNATIVEACCESSENABLED" "$ISMAXPARAMETERCOUNTUNLIMITED" "$ISUNPACKWARS" "$ISAPRREQUIRED" \
      "$ISRUNONSTARTUP" "$ISRUNPOSTINSTALLTESTS" "$ISINSTALLTOMCATCTL" "$(json_escape "$tomcatctl_path")"
    return 0
  fi
  echo "Tomcat installation plan"
  echo "  Version:              $tomcatversion"
  echo "  Target:               $INSTALLTARGET"
  echo "  Instance number:      $INSTANCENUMBER"
  echo "  Ports:                shutdown=$SHUTDOWNPORT ajp=$AJPPORT http=$HTTPPORT https=$HTTPSPORT"
  echo "  Service user:         $TOMCATUSER"
  echo "  Java heap:            ${DEFAULTMEMSIZEMB} MB"
  echo "  Install scope:        $install_scope"
  echo "  Prompt policy:        $prompt_policy"
  echo "  Replace existing:     $ISREPLACEEXISTINGINSTALL"
  echo "  Roll back on failure: $ISROLLBACKONFAILURE"
  echo "  Migrate webapps:      $ISMIGRATEORIGINALWEBAPPS"
  echo "  Migrate config:       $ISMIGRATECONFIGURATION"
  echo "  Bind HTTP publicly:   $ISBINDHTTPPUBLIC"
  echo "  Local debugger:       $ISLOCALDEBUGGERENABLED"
  $ISLOCALDEBUGGERENABLED && echo "  Debugger connection:  127.0.0.1:$DEBUGGERPORT (suspend: $ISDEBUGGERSUSPEND)"
  echo "  Java preview:         $ISPREVIEWENABLED"
  echo "  Native access:        $ISNATIVEACCESSENABLED"
  echo "  Unlimited parameters: $ISMAXPARAMETERCOUNTUNLIMITED"
  echo "  Unpack WARs:          $ISUNPACKWARS"
  echo "  Require APR:          $ISAPRREQUIRED"
  echo "  Run at startup:       $ISRUNONSTARTUP"
  echo "  Post-install tests:   $ISRUNPOSTINSTALLTESTS"
  echo "  Install tomcatctl:    $ISINSTALLTOMCATCTL"
  $ISINSTALLTOMCATCTL && echo "  tomcatctl command:    $tomcatctl_path"
  [ -n "$tomcatctl_link" ] && echo "  tomcatctl PATH link:  $tomcatctl_link"
}

# Summarize service startup and Connector elements without requiring an XML
# utility. Connector declarations are accumulated across multiple lines, e.g.:
# <Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol"
#            SSLEnabled="true">
#   <SSLHostConfig><Certificate certificateFile="conf/localhost.crt"
#                    certificateKeyFile="conf/localhost.key" /></SSLHostConfig>
# </Connector>
function summarize_tomcat_configuration() {
  local server_xml="${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
  $ISQUIET && return 0
  section "Tomcat startup and connector summary"
  if $ISSYSTEMD; then
    detail "Startup: systemd unit $SYSTEMDUNIT"
  elif $ISUSEROWNEDINSTALL; then
    detail "Startup: local script $INITSCRIPT (not enabled at boot)"
  else
    detail "Startup: init script $INITSCRIPT"
  fi
  if $ISLOCALDEBUGGERENABLED; then
    detail "Debugger: JDWP on 127.0.0.1:$DEBUGGERPORT (IDE attach; suspend=$ISDEBUGGERSUSPEND)"
  else
    detail "Debugger: disabled"
  fi
  detail "Java preview features: $ISPREVIEWENABLED"
  detail "Native access for ALL-UNNAMED: $ISNATIVEACCESSENABLED"

  [ -f "$server_xml" ] || { warn "Connector summary unavailable: server.xml was not found"; return 1; }
  awk '
    function emit_connector() {
      gsub(/[[:space:]]+/, " ", connector)
      port=protocol=address=ssl=certificate=keyfile=keystore=""
      if (match(connector, /port="[^"]+"/)) port=substr(connector,RSTART+6,RLENGTH-7)
      if (match(connector, /protocol="[^"]+"/)) protocol=substr(connector,RSTART+10,RLENGTH-11)
      if (match(connector, /address="[^"]+"/)) address=substr(connector,RSTART+9,RLENGTH-10)
      if (match(connector, /SSLEnabled="[^"]+"/)) ssl=substr(connector,RSTART+12,RLENGTH-13)
      if (match(connector, /certificateFile="[^"]+"/)) certificate=substr(connector,RSTART+17,RLENGTH-18)
      if (match(connector, /certificateKeyFile="[^"]+"/)) keyfile=substr(connector,RSTART+20,RLENGTH-21)
      if (match(connector, /keystoreFile="[^"]+"/)) keystore=substr(connector,RSTART+14,RLENGTH-15)
      if (output_format == "json")
        printf "{\"type\":\"connector\",\"port\":\"%s\",\"protocol\":\"%s\",\"address\":\"%s\",\"ssl\":\"%s\",\"certificate\":\"%s\",\"key\":\"%s\",\"keystore\":\"%s\"}\n", port, protocol, address, ssl, certificate, keyfile, keystore
      else
        printf "    Connector: port=%s protocol=%s address=%s ssl=%s certificate=%s key=%s keystore=%s\n", port, protocol, address, ssl, certificate, keyfile, keystore
      connector=""; collecting=0
    }
    /<!--/ {incomment=1}
    /-->/ {incomment=0; next}
    incomment {next}
    {
      if (!collecting && $0 ~ /<Connector([[:space:]>])/) {
        connector=$0; collecting=1
      } else if (collecting) {
        connector=connector " " $0
      }
      if (collecting && ($0 ~ /\/>/ || $0 ~ /<\/Connector>/)) emit_connector()
    }
  ' output_format="$OUTPUTFORMAT" "$server_xml"
}

# Check a focused set of installation invariants. These tests cover generated
# files and requested server.xml options without trying to test every webapp.
function test_tomcat_installation() {
  local target="${INSTALLTARGET:-/usr/local/tomcat}"
  local server_xml="$target/conf/server.xml"
  local failures=0
  section "Running focused post-install tests"

  [ -x "$target/bin/catalina.sh" ] || { error "Post-install check: catalina.sh is missing"; failures=$((failures + 1)); }
  [ -f "$server_xml" ] || { error "Post-install check: server.xml is missing"; failures=$((failures + 1)); }
  [ -f "$target/conf/server.xml.orig" ] || { error "Post-install check: pristine server.xml.orig baseline is missing"; failures=$((failures + 1)); }
  [ -f "$target/conf/server.xml.postinstall" ] || { error "Post-install check: server.xml.postinstall baseline is missing"; failures=$((failures + 1)); }
  [ -f "$target/conf/server.xml.postmigration" ] || { error "Post-install check: server.xml.postmigration audit snapshot is missing"; failures=$((failures + 1)); }
  [ -f "$target/bin/setenv.sh" ] || { error "Post-install check: setenv.sh is missing"; failures=$((failures + 1)); }
  if $ISLOCALDEBUGGERENABLED &&
     ! grep -Fq -- "-agentlib:jdwp=transport=dt_socket,server=y" "$target/bin/setenv.sh"; then
    error "Post-install check: requested JDWP configuration is missing from setenv.sh"
    failures=$((failures + 1))
  fi
  if $ISPREVIEWENABLED && ! grep -Fq -- '--enable-preview' "$target/bin/setenv.sh"; then
    error "Post-install check: requested --enable-preview option is missing from setenv.sh"
    failures=$((failures + 1))
  fi
  if $ISNATIVEACCESSENABLED &&
     ! grep -Fq -- '--enable-native-access=ALL-UNNAMED' "$target/bin/setenv.sh"; then
    error "Post-install check: requested native-access option is missing from setenv.sh"
    failures=$((failures + 1))
  fi
  if $ISLOCALDEBUGGERENABLED &&
     ! grep -Fq -- "address=127.0.0.1:$DEBUGGERPORT" "$target/bin/setenv.sh"; then
    error "Post-install check: JDWP is not bound to the requested loopback port"
    failures=$((failures + 1))
  fi

  if [ -f "$server_xml" ]; then
    if is_command_available xmlstarlet || is_command_available xmllint; then
      is_xml_file_valid "$server_xml" || { error "Post-install check: server.xml is not valid XML"; failures=$((failures + 1)); }
    else
      grep -q '</Server>' "$server_xml" || { error "Post-install check: server.xml has no closing Server element"; failures=$((failures + 1)); }
    fi
    ! $ISBINDHTTPPUBLIC && ! grep -Eq "<Connector[^>]*port=\"$HTTPPORT\"[^>]*address=\"127\\.0\\.0\\.1\"" "$server_xml" && { error "Post-install check: HTTP connector is not bound to localhost"; failures=$((failures + 1)); }
    ! $ISUNPACKWARS && ! grep -q 'unpackWARs="false"' "$server_xml" && { error "Post-install check: unpackWARs was not disabled"; failures=$((failures + 1)); }
    $ISURIUTF8 && ! grep -q 'URIEncoding="UTF-8"' "$server_xml" && { error "Post-install check: URIEncoding was not set to UTF-8"; failures=$((failures + 1)); }
    $ISMAXPARAMETERCOUNTUNLIMITED && ! grep -q 'maxParameterCount="-1"' "$server_xml" && { error "Post-install check: maxParameterCount was not disabled"; failures=$((failures + 1)); }
  fi
  $ISAPRREQUIRED && ! is_tomcat_native_installed && { error "Post-install check: required APR library is not detectable"; failures=$((failures + 1)); }

  if [ "$failures" -gt 0 ]; then
    ERRORMSG="$failures post-install test(s) failed"
    return 1
  fi
  detail "Post-install checks passed"
}

function cleanup_downloads() {
  local file
  for file in $DOWNLOADTMPFILES; do
    [ -f "$file" ] && rm -f "$file"
  done
  case "$INSTALLSTAGE" in
    */.installtomcat.[0-9]*.stage)
      [ -d "$INSTALLSTAGE" ] && rm -rf "$INSTALLSTAGE"
    ;;
  esac
}

# Download to a file using whichever common client is available.  Keeping this
# in one place lets old customer machines use wget while newer ones can use curl.
function download_file() {
  local url="$1"
  local destination="$2"
  local curl_options=(--fail --location --silent)
  $ISQUIET || curl_options+=(--show-error)

  if is_command_available curl && curl "${curl_options[@]}" --output "$destination" "$url"; then
    return 0
  fi
  if is_command_available wget && wget --quiet --output-document="$destination" "$url"; then
    return 0
  fi
  ERRORMSG="failed downloading $url with the available curl/wget clients"
  return 1
}

function download_stdout() {
  local curl_options=(--fail --location --silent)
  $ISQUIET || curl_options+=(--show-error)
  if is_command_available curl && curl "${curl_options[@]}" "$1"; then
    return 0
  fi
  if is_command_available wget && wget --quiet --output-document=- "$1"; then
    return 0
  fi
  ERRORMSG="failed downloading $1 with the available curl/wget clients"
  return 1
}

# Install the generic management command and generate completion from that same
# command, keeping its command list and completion vocabulary in one place.
function configure_user_tomcatctl_path() {
  local command_dir="$HOME/.local/bin"
  local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/rimuhosting"
  local config_file="$config_dir/tomcatctl-path.sh"
  local shell_name=${SHELL##*/}
  local profile login_profile
  TOMCATCTLPATHCONFIG=$config_file

  case ":$PATH:" in
    *":$command_dir:"*)
      # If an earlier install created our fragment, retain enough information
      # for the manifest/uninstaller to clean it up. Otherwise PATH needs no edit.
      [ -f "$config_file" ] || { TOMCATCTLPATHCONFIG=; return 0; }
    ;;
  esac

  mkdir -p "$config_dir" || return 1
  {
    echo '# installtomcat.sh managed command path'
    # $PATH is deliberately evaluated by future shells sourcing this file.
    # shellcheck disable=SC2016
    printf 'case ":$PATH:" in *":%s:"*) ;; *) export PATH="%s:$PATH" ;; esac\n' \
      "$command_dir" "$command_dir"
  } > "$config_file" || return 1

  case "$shell_name" in
    bash)
      # Interactive non-login Bash reads .bashrc. Login Bash reads the first
      # existing file in this list, so do not create .bash_profile and thereby
      # accidentally suppress an existing .profile.
      if [ -e "$HOME/.bash_profile" ]; then
        login_profile="$HOME/.bash_profile"
      elif [ -e "$HOME/.bash_login" ]; then
        login_profile="$HOME/.bash_login"
      else
        login_profile="$HOME/.profile"
      fi
      for profile in "$HOME/.bashrc" "$login_profile"; do
        TOMCATCTLPROFILEFILES="${TOMCATCTLPROFILEFILES}${TOMCATCTLPROFILEFILES:+$'\n'}$profile"
        grep -Fq "$config_file" "$profile" 2>/dev/null && continue
        printf '\n# installtomcat.sh: make tomcatctl available\n. "%s"\n' "$config_file" >> "$profile" || return 1
      done
    ;;
    zsh)
      profile="$HOME/.zshrc"
      TOMCATCTLPROFILEFILES=$profile
      if ! grep -Fq "$config_file" "$profile" 2>/dev/null; then
        printf '\n# installtomcat.sh: make tomcatctl available\n. "%s"\n' "$config_file" >> "$profile" || return 1
      fi
    ;;
    *)
      warn "Could not configure PATH for shell '$shell_name'; add $command_dir to PATH manually"
      return 0
    ;;
  esac
  detail "Configured $command_dir on PATH for future $shell_name shells"
}

function install_tomcatctl() {
  $ISINSTALLTOMCATCTL || { detail "Skipping tomcatctl installation"; return 0; }

  if $ISUSEROWNEDINSTALL; then
    TOMCATCTLFILE="$INSTALLTARGET/bin/tomcatctl"
    TOMCATCTLLINK="$HOME/.local/bin/tomcatctl"
    TOMCATCTLCOMPLETIONFILE="$INSTALLTARGET/bin/completions/tomcatctl"
    TOMCATCTLCOMPLETIONLINK="${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/tomcatctl"
  else
    TOMCATCTLFILE=/usr/local/bin/tomcatctl
    TOMCATCTLCOMPLETIONFILE=/etc/bash_completion.d/tomcatctl
  fi

  # installtomcat.sh is normally downloaded and run by itself, so fetch the
  # independently published management command instead of assuming a checkout.
  mkdir -p "$(dirname "$TOMCATCTLFILE")" || return 1
  if ! download_file "$TOMCATCTLURL" "$TOMCATCTLFILE"; then
    warn "Could not install optional tomcatctl from $TOMCATCTLURL"
    return 0
  fi
  chmod +x "$TOMCATCTLFILE" || return 1
  detail "Installed management command at $TOMCATCTLFILE"

  if $ISUSEROWNEDINSTALL; then
    mkdir -p "$(dirname "$TOMCATCTLLINK")" || return 1
    if [ -L "$TOMCATCTLLINK" ] && [ "$(readlink "$TOMCATCTLLINK")" = "$TOMCATCTLFILE" ]; then
      :
    elif [ -e "$TOMCATCTLLINK" ] || [ -L "$TOMCATCTLLINK" ]; then
      warn "Not replacing existing command $TOMCATCTLLINK; use $TOMCATCTLFILE directly"
      TOMCATCTLLINK=
    else
      ln -s "$TOMCATCTLFILE" "$TOMCATCTLLINK" || return 1
      detail "Linked $TOMCATCTLLINK to the instance command"
    fi
  fi

  if mkdir -p "$(dirname "$TOMCATCTLCOMPLETIONFILE")" 2>/dev/null; then
    "$TOMCATCTLFILE" completion bash > "$TOMCATCTLCOMPLETIONFILE" || return 1
    detail "Installed Bash completion at $TOMCATCTLCOMPLETIONFILE"
  else
    warn "Could not create the Bash completion directory; tomcatctl remains usable"
  fi
  if $ISUSEROWNEDINSTALL; then
    mkdir -p "$(dirname "$TOMCATCTLCOMPLETIONLINK")" || return 1
    if [ -e "$TOMCATCTLCOMPLETIONLINK" ] || [ -L "$TOMCATCTLCOMPLETIONLINK" ]; then
      if ! { [ -L "$TOMCATCTLCOMPLETIONLINK" ] &&
        [ "$(readlink "$TOMCATCTLCOMPLETIONLINK")" = "$TOMCATCTLCOMPLETIONFILE" ]; }; then
        warn "Not replacing existing completion $TOMCATCTLCOMPLETIONLINK"
        TOMCATCTLCOMPLETIONLINK=
      fi
    else
      ln -s "$TOMCATCTLCOMPLETIONFILE" "$TOMCATCTLCOMPLETIONLINK" || return 1
      detail "Linked Bash completion to the instance completion"
    fi
  fi
  $ISUSEROWNEDINSTALL && configure_user_tomcatctl_path
  return 0
}

# Record files outside INSTALLTARGET so removal is auditable and bounded. A
# runnable uninstaller is generated only for user-owned installs: system Tomcat
# directories are service-user writable and must not contain scripts later run
# as root.
function write_install_manifest() {
  local manifest="$INSTALLTARGET/.installtomcat-manifest"
  local uninstaller="$INSTALLTARGET/bin/uninstall-tomcat"
  local profile

  : > "$manifest" || return 1
  printf '# installtomcat.sh external artifacts; type|path|managed-config\n' >> "$manifest"
  if $ISUSEROWNEDINSTALL; then
    [ -n "$TOMCATCTLLINK" ] && printf 'remove|%s|\n' "$TOMCATCTLLINK" >> "$manifest"
    [ -n "$TOMCATCTLCOMPLETIONLINK" ] && printf 'remove|%s|\n' "$TOMCATCTLCOMPLETIONLINK" >> "$manifest"
    [ -n "$TOMCATCTLPATHCONFIG" ] && printf 'remove|%s|\n' "$TOMCATCTLPATHCONFIG" >> "$manifest"
    while IFS= read -r profile; do
      [ -n "$profile" ] && [ -n "$TOMCATCTLPATHCONFIG" ] &&
        printf 'profile|%s|%s\n' "$profile" "$TOMCATCTLPATHCONFIG" >> "$manifest"
    done <<< "$TOMCATCTLPROFILEFILES"
  else
    for profile in "$SYSTEMDUNIT" "$INITSCRIPT" "$LOGROTATEFILE" "$TOMCATCTLFILE" "$TOMCATCTLCOMPLETIONFILE"; do
      [ -n "$profile" ] || continue
      case "$profile" in "$INSTALLTARGET"/*) continue ;; esac
      [ -e "$profile" ] && printf 'remove|%s|\n' "$profile" >> "$manifest"
    done
  fi

  if ! $ISUSEROWNEDINSTALL; then
    detail "Recorded external system files in $manifest; review it before removing this instance"
    return 0
  fi

  cat > "$uninstaller" <<'EOUNINSTALL'
#!/bin/bash
set -u
target=$(cd "$(dirname "$0")/.." && pwd -P) || exit 1
manifest="$target/.installtomcat-manifest"
dry_run=false
[ "${1-}" = --dry-run ] && dry_run=true && shift
[ "$#" -eq 0 ] || { echo "Usage: $0 [--dry-run]" >&2; exit 2; }
[ -f "$manifest" ] || { echo "Missing installation manifest: $manifest" >&2; exit 1; }

while IFS='|' read -r type path config; do
  case "$type" in
    remove)
      [ -e "$path" ] || [ -L "$path" ] || continue
      if $dry_run; then echo "Would remove $path"; else rm -f "$path" || exit 1; fi
    ;;
    profile)
      [ -f "$path" ] || continue
      grep -Fq "$config" "$path" || continue
      if $dry_run; then
        echo "Would remove installtomcat.sh PATH lines from $path"
      else
        temporary=$(mktemp "${TMPDIR:-/tmp}/uninstall-tomcat-profile.XXXXXX") || exit 1
        mode=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path")
        awk -v config="$config" '
          $0 == "# installtomcat.sh: make tomcatctl available" {
            comment=$0
            if ((getline following) > 0 && index(following, config)) next
            print comment
            print following
            next
          }
          { print }
        ' "$path" > "$temporary" || exit 1
        chmod "$mode" "$temporary" && mv "$temporary" "$path" || exit 1
      fi
    ;;
  esac
done < "$manifest"

if $dry_run; then
  echo "Would preserve Tomcat data at $target and timestamped sibling backups"
else
  echo "Removed external integration files. Tomcat data remains at $target."
  echo "After preserving anything needed, remove that directory separately."
fi
EOUNINSTALL
  chmod +x "$uninstaller" || return 1
  detail "Installed safe external-file remover at $uninstaller"
}

# Returns 1 when no hashing tool is available, and 2 when a tool was found but
# failed on the file (unreadable, truncated).  Callers need to tell those apart:
# the pipe into awk would otherwise always report the exit status of awk.
function output_file_sha512() {
  local digest
  if is_command_available sha512sum; then
    digest=$(sha512sum "$1") || return 2
    printf '%s\n' "$digest" | awk '{print $1}'
  elif is_command_available shasum; then
    digest=$(shasum -a 512 "$1") || return 2
    printf '%s\n' "$digest" | awk '{print $1}'
  elif is_command_available openssl; then
    digest=$(openssl dgst -sha512 "$1") || return 2
    printf '%s\n' "$digest" | awk '{print $NF}'
  else
    return 1
  fi
}

function output_java_home() {
  local java_path
  java_path=$(command -v java 2>/dev/null) || return 1
  if is_command_available readlink; then
    java_path=$(readlink -f "$java_path" 2>/dev/null || echo "$java_path")
  fi
  dirname "$(dirname "$java_path")"
}

function output_java_major_version() {
  # Java 8:  java version "1.8.0_462"  => 8
  # Java 26: openjdk version "26.0.2"  => 26
  java -version 2>&1 | awk -F'"' 'NR == 1 {
    split($2, version, ".")
    if (version[1] == "1") print version[2]
    else print version[1]
    exit
  }'
}

# Java 9 and 10 require explicit legacy XML modules; Java 11+ removed those
# module names, so stale options must be removed during an upgrade.
function configure_legacy_java_modules() {
  local java_major setenv_file
  java_major=$(output_java_major_version) || return 1
  setenv_file="${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh"
  [ -f "$setenv_file" ] || return 0

  if [ "$java_major" -eq 9 ] || [ "$java_major" -eq 10 ]; then
    if grep -q '^JAVA_OPTS=' "$setenv_file" && ! grep -q '^JAVA_OPTS=.*add-modules' "$setenv_file"; then
      fnsedi 's/^JAVA_OPTS="/JAVA_OPTS="--add-modules java.xml.bind /' "$setenv_file"
      fnsedi 's/^JAVA_OPTS="/JAVA_OPTS="--add-modules java.xml.ws /' "$setenv_file"
      detail "Added Java 9/10 legacy XML modules to $setenv_file"
    fi
  elif [ "$java_major" -ge 11 ] && grep -q '^JAVA_OPTS=.*add-modules' "$setenv_file"; then
    fnsedi 's/--add-modules java.xml.bind //' "$setenv_file"
    fnsedi 's/--add-modules java.xml.ws //' "$setenv_file"
    detail "Removed unsupported legacy XML module options from $setenv_file"
  fi
}

# Java 9+ rejects the historical endorsed-standards directory mechanism.
function remove_unsupported_endorsed_directory() {
  local java_major endorsed_dir
  java_major=$(output_java_major_version) || return 1
  endorsed_dir="${INSTALLTARGET:-/usr/local/tomcat}/endorsed"
  if [ "$java_major" -ge 9 ] && [ -d "$endorsed_dir" ]; then
    mv "$endorsed_dir" "${endorsed_dir}.removed"
    detail "Moved obsolete endorsed directory to ${endorsed_dir}.removed"
  fi
}

function is_tomcat_native_installed() {
  [ -n "$(output_tomcat_native_library_file)" ]
}

# Locate Tomcat Native using the loader cache or a small set of bounded library
# globs. This deliberately avoids recursively walking /usr or other large trees.
function output_tomcat_native_library_file() {
  local candidate
  if is_command_available ldconfig; then
    # Typical loader-cache row:
    # libtcnative-1.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libtcnative-1.so
    candidate=$(ldconfig -p 2>/dev/null |
      awk '/libtcnative[^ ]*\.so/ {print $NF; exit}')
    [ -f "$candidate" ] && { echo "$candidate"; return 0; }
  fi

  for candidate in \
    /opt/homebrew/opt/tomcat-native/lib/libtcnative*.dylib \
    /usr/local/opt/tomcat-native/lib/libtcnative*.dylib \
    /opt/local/lib/libtcnative*.dylib \
    /usr/lib/libtcnative*.so* /usr/lib64/libtcnative*.so* \
    /usr/lib/*/libtcnative*.so* /usr/local/lib/libtcnative*.so*; do
    [ -f "$candidate" ] && { echo "$candidate"; return 0; }
  done
  return 1
}

# Add the discovered native-library directory once, regardless of architecture
# or package-manager layout.
function configure_tomcat_native_library_path() {
  local library_file library_dir setenv_file
  library_file=$(output_tomcat_native_library_file) || return 0
  library_dir=$(dirname "$library_file")
  setenv_file="${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh"
  grep -Fq -- "-Djava.library.path=$library_dir" "$setenv_file" && return 0
  # The literal dollar expression is intentionally written into setenv.sh.
  # shellcheck disable=SC2016
  printf 'CATALINA_OPTS="$CATALINA_OPTS -Djava.library.path=%s"\n' \
    "$library_dir" >> "$setenv_file"
  detail "Configured Tomcat Native library path: $library_dir"
}

# Configure JDWP only for this Tomcat JVM. Java 9+ supports an explicit
# loopback listen address; refusing older Java avoids exposing a debugger on all
# interfaces. suspend=y deliberately pauses Tomcat startup until an IDE attaches.
function configure_local_debugger() {
  local java_major setenv_file suspend_value=n
  $ISLOCALDEBUGGERENABLED || return 0
  java_major=$(output_java_major_version) || return 1
  if [ -z "$java_major" ] || [ "$java_major" -lt 9 ]; then
    ERRORMSG="local debugger configuration requires Java 9 or newer for a loopback-only JDWP listener"
    return 1
  fi
  $ISDEBUGGERSUSPEND && suspend_value=y
  setenv_file="${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh"
  # The literal CATALINA_OPTS expansion is evaluated later when Tomcat starts.
  # shellcheck disable=SC2016
  printf 'CATALINA_OPTS="$CATALINA_OPTS -agentlib:jdwp=transport=dt_socket,server=y,suspend=%s,address=127.0.0.1:%s"\n' \
    "$suspend_value" "$DEBUGGERPORT" >> "$setenv_file"
  detail "Enabled local JDWP debugging at 127.0.0.1:$DEBUGGERPORT (suspend=$suspend_value)"
}

# Add explicitly requested JVM capabilities only to the Tomcat process. Preview
# classes must match this exact JDK release; ALL-UNNAMED native access is broad
# and should be enabled only when an application/native library requires it.
function configure_optional_java_features() {
  local setenv_file="${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh"
  if $ISPREVIEWENABLED; then
    # shellcheck disable=SC2016
    printf '%s\n' 'CATALINA_OPTS="$CATALINA_OPTS --enable-preview"' >> "$setenv_file"
    detail "Enabled Java preview features for the Tomcat JVM"
  fi
  if $ISNATIVEACCESSENABLED; then
    # shellcheck disable=SC2016
    printf '%s\n' 'CATALINA_OPTS="$CATALINA_OPTS --enable-native-access=ALL-UNNAMED"' >> "$setenv_file"
    detail "Enabled native access for all classpath code (ALL-UNNAMED)"
  fi
}

# Tomcat Native/APR loads and binds JNI code. JDK 24 introduced native-access
# warnings for JNI, so enable access automatically there unless the caller made
# an explicit true/false choice. Older JVMs neither need nor all support it.
function resolve_native_access_for_apr() {
  local java_major is_apr_required
  is_apr_required=$(normalize_boolean_value "$ISAPRREQUIRED" 2>/dev/null) || return 0
  $is_apr_required || return 0
  $ISNATIVEACCESSCONFIGURED && return 0
  is_command_available java || return 0
  java_major=$(output_java_major_version) || return 0
  if [ -n "$java_major" ] && [ "$java_major" -ge 24 ]; then
    ISNATIVEACCESSENABLED=true
  fi
}

# Tomcat Native/APR is optional.  Do not modify the host package set here;
# explain how an administrator can opt in using the platform package manager.
function print_tomcat_native_install_help() {
  is_tomcat_native_installed && return 0

  detail "Optional Tomcat Native/APR library was not found; Tomcat will run without it"
  case "$DISTRO" in
    Mac)
      detail "Install later with Homebrew: brew install tomcat-native"
      detail "Or with MacPorts: sudo port install apr tomcat-native"
    ;;
    Debian|Ubuntu)
      detail "Install later: sudo apt update && sudo apt install libtcnative-1"
      detail "If unavailable, run: apt search libtcnative"
    ;;
    CentOS|RHEL|Fedora)
      detail "Install later: sudo dnf install tomcat-native"
      detail "RHEL/CentOS may require the matching EPEL repository first"
    ;;
    *)
      detail "Search the package manager for 'tomcat-native' or 'libtcnative'"
    ;;
  esac
  detail "Documentation: https://tomcat.apache.org/native-doc/"
}

# Install the optional native library only when ISAPRREQUIRED=true. Package-manager
# failures are fatal in that mode because the caller explicitly required APR.
function install_required_tomcat_native() {
  is_tomcat_native_installed && return 0
  $ISAPRREQUIRED || { print_tomcat_native_install_help; return 0; }
  if $ISUSEROWNEDINSTALL; then
    ERRORMSG="ISAPRREQUIRED=true in a user-owned install, but APR is not already installed"
    print_tomcat_native_install_help
    return 1
  fi

  detail "Tomcat Native/APR is required; attempting installation"
  case "$DISTRO" in
    Mac)
      if is_command_available port; then
        port install apr tomcat-native
      elif is_command_available brew && [ -n "$SUDO_USER" ]; then
        sudo -u "$SUDO_USER" brew install tomcat-native
      elif is_command_available brew; then
        brew install tomcat-native
      else
        ERRORMSG="ISAPRREQUIRED=true, but neither MacPorts nor Homebrew is available"
        print_tomcat_native_install_help
        return 1
      fi
    ;;
    Debian|Ubuntu)
      apt-get update && apt-get -y install libtcnative-1
    ;;
    CentOS|RHEL|Fedora)
      if is_command_available dnf; then
        dnf -y install tomcat-native
      elif is_command_available yum; then
        yum -y install tomcat-native
      else
        false
      fi
    ;;
    *)
      ERRORMSG="ISAPRREQUIRED=true, but automatic installation is unsupported on $DISTRO"
      print_tomcat_native_install_help
      return 1
    ;;
  esac || {
    ERRORMSG="ISAPRREQUIRED=true, but the Tomcat Native/APR package installation failed"
    print_tomcat_native_install_help
    return 1
  }

  if ! is_tomcat_native_installed; then
    ERRORMSG="ISAPRREQUIRED=true, but Tomcat Native/APR is still not detectable after installation"
    print_tomcat_native_install_help
    return 1
  fi
}

function verify_sha512() {
  local file="$1"
  local checksum_url="$2"
  local checksum_file="${file}.sha512"
  local expected actual

  DOWNLOADTMPFILES="$DOWNLOADTMPFILES $checksum_file"
  download_file "$checksum_url" "$checksum_file" || return 1
  expected=$(awk 'NR == 1 {print $1}' "$checksum_file")
  actual=$(output_file_sha512 "$file")
  case $? in
    0) ;;
    1)
      ERRORMSG="cannot verify $file: sha512sum, shasum, or openssl is required"
      return 1
    ;;
    *)
      ERRORMSG="cannot verify $file: the checksum tool could not read it"
      return 1
    ;;
  esac
  if [ -z "$actual" ]; then
    ERRORMSG="cannot verify $file: the checksum tool produced no digest"
    return 1
  fi
  if [ -z "$expected" ]; then
    ERRORMSG="no SHA-512 digest found in $checksum_url"
    return 1
  fi
  if [ "$expected" != "$actual" ]; then
    ERRORMSG="SHA-512 verification failed for $file"
    return 1
  fi
}

trap cleanup_downloads EXIT
trap 'cleanup_downloads; exit 1' HUP INT TERM

###
# Function: version
# Tell us what version the script is... duh :)
#
function version {
  echo "
 $0 (v3.2 \$Id: installtomcat.sh 826 2026-07-21 22:17:04Z peter \$)
 Copyright Rimuhosting.com
  "
  return 0
}

function output_tomcat_user() {
  # cmdline has highest priority
  [ -n "$TOMCATUSERCMDLINE" ] && echo "$TOMCATUSERCMDLINE" && return 0
  # then the original setup's user
  #ls -ld /usr/local/tomcat
  #drwxr-xr-x  17 peter  wheel  544 27 Aug 10:02 /usr/local/tomcat
  local dirowner=
  if [ -d "$MVDIRNAME" ]; then
    dirowner="$(stat -c '%U' "$MVDIRNAME" 2>/dev/null || stat -f '%Su' "$MVDIRNAME")"
  elif [ -d "$INSTALLTARGET" ]; then
    dirowner="$(stat -c '%U' "$INSTALLTARGET" 2>/dev/null || stat -f '%Su' "$INSTALLTARGET")"
  fi
  [ -n "$dirowner" ] && echo "$dirowner" && return 0
  echo "${TOMCATUSER:-tomcat}" && return 0
  return 0
}


###
# Function: usage
# Handy function to tell users whats what
# TODO: add boilerplate?
#
function usage {
  local migrate_note migrate_configuration_note install_java_note remove_examples_note unpack_note
  local threads_note remove_manager_note startup_note uri_note public_http_note
  local parameters_note apr_note permit_user_note tests_note debugger_note debugger_suspend_note debugger_port_note
  local preview_note native_access_note
  local tomcat_major_note
  local prompt_note prompt_default=true
  local replace_existing_note replace_existing_default=false
  local rollback_note rollback_default=true service_user service_user_note
  local install_tomcatctl_note detected_webapps_note xml_tool_note
  local migrate_default=true migrate_configuration_default=false unpack_default=true
  local debugger_default=false preview_default=false unlimited_parameters_default=false
  local permit_user_default=false
  local install_target_note
  if [ "$SETUPTYPE" = hs ]; then
    migrate_default=false
    unpack_default=false
  elif [ "$SETUPTYPE" = pprod ] || [ "$SETUPTYPE" = pdev ]; then
    prompt_default=false
    migrate_configuration_default=true
    preview_default=true
    unlimited_parameters_default=true
    [ "$SETUPTYPE" = pdev ] && debugger_default=true
    replace_existing_default=true
    rollback_default=false
    [ "$SETUPTYPE" = pdev ] && permit_user_default=true
  fi
  install_target_note=$(output_install_target_annotation)
  prompt_note=$(output_usage_boolean_annotation ISPROMPT "$prompt_default")
  migrate_note=$(output_usage_boolean_annotation ISMIGRATEORIGINALWEBAPPS "$migrate_default")
  replace_existing_note=$(output_usage_boolean_annotation ISREPLACEEXISTINGINSTALL "$replace_existing_default")
  rollback_note=$(output_usage_boolean_annotation ISROLLBACKONFAILURE "$rollback_default")
  migrate_configuration_note=$(output_usage_boolean_annotation ISMIGRATECONFIGURATION "$migrate_configuration_default")
  detected_webapps_note=$(output_detected_webapps_annotation)
  xml_tool_note=$(output_xml_tool_annotation)
  install_java_note=$(output_usage_boolean_annotation INSTALLJAVA true)
  remove_examples_note=$(output_usage_boolean_annotation REMOVEEXAMPLEWEBAPPS true)
  unpack_note=$(output_usage_boolean_annotation ISUNPACKWARS "$unpack_default")
  threads_note=$(output_usage_boolean_annotation ISREDUCESPARETHREADS true)
  remove_manager_note=$(output_usage_boolean_annotation REMOVEMANAGERWEBAPPS true)
  startup_note=$(output_usage_boolean_annotation ISRUNONSTARTUP true)
  uri_note=$(output_usage_boolean_annotation ISURIUTF8 true)
  public_http_note=$(output_usage_boolean_annotation ISBINDHTTPPUBLIC false)
  debugger_note=$(output_usage_boolean_annotation ISLOCALDEBUGGERENABLED "$debugger_default")
  debugger_suspend_note=$(output_usage_boolean_annotation ISDEBUGGERSUSPEND false)
  debugger_port_note=$(output_usage_value_annotation "$DEBUGGERPORT" 5005)
  preview_note=$(output_usage_boolean_annotation ISPREVIEWENABLED "$preview_default")
  native_access_note=$(output_usage_boolean_annotation ISNATIVEACCESSENABLED false)
  parameters_note=$(output_usage_boolean_annotation ISMAXPARAMETERCOUNTUNLIMITED "$unlimited_parameters_default")
  apr_note=$(output_usage_boolean_annotation ISAPRREQUIRED false)
  permit_user_note=$(output_usage_boolean_annotation ISUSERINSTALLPERMITTED "$permit_user_default")
  tests_note=$(output_usage_boolean_annotation ISRUNPOSTINSTALLTESTS true)
  install_tomcatctl_note=$(output_usage_boolean_annotation ISINSTALLTOMCATCTL true)
  service_user=$(output_tomcat_user)
  if [ "$service_user" = tomcat ]; then
    service_user_note="(default: tomcat)"
  else
    service_user_note="(default: tomcat; resolved: $service_user)"
  fi
  tomcat_major_note=$(output_usage_value_annotation "$MAJORTOMCATVERSION" 9)
  cat << EOUSAGE
Usage:
  $0 [standard|custom|hs|pprod|pdev] [options]
  Selected setup type: $SETUPTYPE

Core options:
  --version 8|9|10|11              Tomcat major version $tomcat_major_note
  --installtarget PATH             Install directory $install_target_note
                                   Overrides the path derived from --instance;
                                   the instance port offset still applies, and
                                   the service name uses PATH's basename
  --instance N                     Default path suffix and standard-port offset
                                   (default: 0; overridden path: --installtarget)
  --instance-number N              Alias for --instance
  --defaultmemsizemb MB            Explicit Java heap instead of automatic sizing
  --user USER                      Service account $service_user_note
  --prompt                         Ask for confirmation when needed $prompt_note
  --noprompt                       Accept resolved plan; never read stdin
  --migrate-only                   Migrate into an existing Tomcat; do not install
  --migration-source PATH          Old Tomcat to migrate from (default: newest
                                   INSTALLTARGET.old.TIMESTAMP sibling)

Boolean behavior options (true/false; legacy y/n is accepted):
  --migrate-original-webapps BOOL  Copy original webapps $migrate_note
                                   Currently detected: $detected_webapps_note
  --replace-existing BOOL          Back up and replace existing Tomcat $replace_existing_note
  --rollback-on-failure BOOL       Restore previous Tomcat after failure $rollback_note
  --migrate-configuration BOOL     Copy safe per-app context files $migrate_configuration_note
                                   server.xml changes are reported, never replayed
  --install-java BOOL              Install Java when missing $install_java_note
  --remove-example-webapps BOOL    Remove examples and docs $remove_examples_note
  --unpack-wars BOOL               Allow WAR unpacking $unpack_note
  --reduce-spare-threads BOOL      Tune low-idle threads $threads_note
  --remove-manager-webapps BOOL    Remove manager webapps $remove_manager_note
  --run-on-startup BOOL            Enable at boot $startup_note; user-owned: false
  --uri-utf8 BOOL                  Add URIEncoding=UTF-8 $uri_note
  --public-http BOOL               Bind HTTP beyond loopback $public_http_note
  --local-debugger BOOL            Enable loopback JDWP for local development $debugger_note
  --debugger-port PORT             IDE attach port $debugger_port_note
  --debugger-suspend BOOL          Wait for IDE before Tomcat starts $debugger_suspend_note
  --enable-preview BOOL            Permit preview-feature classes $preview_note
  --enable-native-access BOOL      Grant classpath code native access $native_access_note
                                   Auto-enabled for required APR on Java 24+
  --unlimited-parameters BOOL      Set maxParameterCount=-1 $parameters_note
  --require-apr BOOL               Require/install Native APR $apr_note
  --permit-user-install BOOL       Permit current non-root user $permit_user_note
  --run-post-install-tests BOOL    Validate the installation $tests_note
  --install-tomcatctl BOOL         Install management command $install_tomcatctl_note
                                   System: /usr/local/bin/tomcatctl
                                   User-owned: INSTALLTARGET/bin/tomcatctl,
                                   linked from ~/.local/bin; PATH is configured
                                   for future Bash/zsh shells when necessary
  --tomcat-version VERSION         Pin an exact release, such as 9.0.120
  --dry-run                        Validate and print the resolved plan only
  --quiet                          Show warnings, errors, and final result only
  --color auto|always|never        Control ANSI colour (default: auto)
  --output text|json               Human text or newline-delimited JSON events

Environment:
  The option names map to ISPROMPT, ISMIGRATEORIGINALWEBAPPS,
  ISREPLACEEXISTINGINSTALL,
  ISMIGRATECONFIGURATION, INSTALLJAVA,
  REMOVEEXAMPLEWEBAPPS, ISUNPACKWARS, ISREDUCESPARETHREADS,
  REMOVEMANAGERWEBAPPS, ISRUNONSTARTUP, ISURIUTF8, ISBINDHTTPPUBLIC,
  ISMAXPARAMETERCOUNTUNLIMITED, ISAPRREQUIRED, ISLOCALDEBUGGERENABLED,
  ISDEBUGGERSUSPEND, DEBUGGERPORT, INSTANCENUMBER, ISPREVIEWENABLED,
  ISNATIVEACCESSENABLED, and DEFAULTMEMSIZEMB.
  Additional controls are ISUSERINSTALLPERMITTED, ISDRYRUN,
  ISRUNPOSTINSTALLTESTS, ISINSTALLTOMCATCTL, ISROLLBACKONFAILURE,
  ISQUIET, COLOROUTPUT, OUTPUTFORMAT,
  TOMCATCTLURL, TOMCATVERSION, ISMIGRATEONLY, and MIGRATIONSOURCE.
  With sudo, pass environment values using 'sudo env NAME=value ...'.
  A permitted user-owned install defaults under \$XDG_DATA_HOME, or under
  \$HOME/.local/share/rimuhosting/tomcat when XDG_DATA_HOME is unset.

Installation scopes:
  system      Default except for non-root pdev. Requires the script's effective
              user to be root, normally
              by logging in as root or using sudo. Creates/manages the Tomcat
              service account, systemd or init integration, startup enablement,
              host packages when requested, Apache integration, and a system
              logrotate policy under /etc/logrotate.d. Tomcat logs remain under
              INSTALLTARGET/logs.
  user-owned  Selected when a non-root caller permits it with
              --permit-user-install true (environment: ISUSERINSTALLPERMITTED).
              pdev permits this by default; other setup types require an override.
              Uses files owned by that user, a local control script, local
              tomcatctl/completion paths, and no automatic startup. It skips
              service-user, /etc, package-manager, Apache, and logrotate changes.
  Running through sudo selects system scope; --permit-user-install only permits
  a caller that is actually non-root and does not make a sudo run user-owned.
  User-owned tomcatctl PATH configuration changes only that non-root caller's
  shell files. System/sudo installs use /usr/local/bin and edit no user profile.
  Every external integration file is recorded in INSTALLTARGET/.installtomcat-manifest.
  User-owned installs include INSTALLTARGET/bin/uninstall-tomcat; run it with
  --dry-run first. It removes only external integration and preserves the Tomcat
  directory, logs, applications, configuration, and timestamped sibling backups.
  System manifests are audit-only because a service-user-writable Tomcat tree
  must not contain an uninstaller that an administrator later executes as root.

How reported values are resolved:
  Each option shows its base default. If the effective value differs, help also
  shows "override". Effective values are calculated in this order: setup type,
  environment variables, then command-line arguments. Later command-line
  arguments take precedence. Consequently, changing the environment or options
  changes the values reported by --help and --dry-run.
  Some values are derived: on Java 24+, --require-apr true enables native access
  unless ISNATIVEACCESSENABLED or --enable-native-access explicitly sets it.

Existing installation replacement:
  --replace-existing true authorizes an unattended backup and replacement of a
  non-empty INSTALLTARGET. pprod and pdev enable it because they are upgrade
  convenience profiles with migration and audit settings already selected.
  Other setup types prompt by default before replacement and for any unresolved
  custom choices. Explicitly selecting --noprompt accepts the resolved unattended
  plan and never reads stdin. ISPROMPT=false is equivalent to --noprompt when the
  environment variable is explicitly set. pprod and pdev default to --noprompt.

Failure handling:
  --rollback-on-failure true restores the timestamped previous installation when
  installation, migration, startup, or validation fails. pdev and pprod default
  to false so the failed installation remains at INSTALLTARGET for diagnosis;
  the previous installation remains at INSTALLTARGET.old.TIMESTAMP.

Automatic heap sizing:
  Up to 2 GiB host RAM: 768 MB; over 2 through 4 GiB: 1512 MB;
  over 4 GiB: 2500 MB. DEFAULTMEMSIZEMB overrides this calculation.

Connector exposure:
  Public HTTP binding is disabled by default. Put Tomcat behind Apache or another
  frontend/reverse proxy, and expose that frontend instead of Tomcat's HTTP
  connector. Use --public-http true only when clients must connect to
  Tomcat directly, after configuring appropriate firewall and TLS controls.

Configuration migration:
  server.xml.orig records the pristine Tomcat distribution file, while
  server.xml.postinstall records the result of this installer, and
  server.xml.postmigration records the final file after migration tasks. The
  audit reports whether those files, webapp inventories, contexts,
  certificates/keystores, and shared libraries are the same or different.
  Raw server.xml diffs are reported with secrets redacted, never replayed.
  Optional XML tooling: $xml_tool_note

Tomcat 11 compatibility:
  Tomcat 11 requires Java 17 or newer and uses Jakarta APIs. Applications built
  for Tomcat 9 or earlier commonly use javax.* packages and generally require
  application changes or migration to jakarta.* before they will run on 11.

Local development debugger:
  --local-debugger true adds modern JDWP settings to bin/setenv.sh for this
  Tomcat only. Configure the IDE to attach to host 127.0.0.1 and port
  DEBUGGERPORT (default 5005). The listener is loopback-only and requires Java
  9 or newer. This is intended for developers on their local device, not a
  production server. Use --debugger-suspend true to debug application startup;
  Tomcat will not finish starting until the IDE attaches.

Optional Java features:
  --enable-preview true lets Tomcat load classes compiled with preview features
  for this exact Java release. It is off by default and is most appropriate for
  local development; preview classes from an older/newer JDK may not run.
  --enable-native-access true grants ALL-UNNAMED (all classpath code) access to
  restricted JNI/Foreign Function and Memory operations. Leave it off unless an
  application or native library reports that it needs native access. When
  --require-apr is true, it is enabled automatically on Java 24+ because Tomcat
  Native loads and binds JNI code; an explicit false overrides that behavior.

Examples:
  # Typical Apache HTTP reverse proxy: Tomcat stays on the safe loopback default.
  sudo $0 standard --noprompt

  # Apache AJP proxy where Tomcat Native/APR is an explicit requirement.
  sudo $0 standard --require-apr true --noprompt

  # Directly expose Tomcat's HTTP connector; first configure firewall and TLS.
  sudo $0 standard --public-http true --noprompt

  # Clean replacement that deliberately does not migrate customer webapps.
  sudo env ISMIGRATEORIGINALWEBAPPS=false $0 standard --noprompt

  # Small development VM/Mac with an explicit heap and interactive choices.
  sudo env DEFAULTMEMSIZEMB=768 ISPROMPT=true $0 custom

  # User-owned developer Tomcat with an IDE attaching to 127.0.0.1:5005.
  $0 custom --local-debugger true --permit-user-install true

  # Second user-owned instance: tomcat1, with standard ports incremented by 1.
  $0 custom --instance 1 --permit-user-install true

  # Local app compiled with preview features for the same installed JDK release.
  $0 custom --enable-preview true --permit-user-install true

  # Script-author production preset; review its preview/unlimited settings first.
  sudo $0 pprod --noprompt

  # Script-author local development preset with loopback JDWP debugging.
  $0 pdev

  # User-owned smoke install; skips systemd, /etc, package, user, and Apache changes.
  env ISUSERINSTALLPERMITTED=true $0 standard --noprompt

  # Report what would migrate from the newest timestamped backup; change nothing.
  sudo $0 --migrate-only --migrate-configuration true --dry-run

  # After stopping Tomcat, re-run safe migration tasks from that newest backup.
  sudo $0 --migrate-only --migrate-configuration true

  # Re-run migration from a specifically selected older installation.
  sudo $0 --migrate-only --migration-source /usr/local/tomcat.old.1700000000

  # Migrate webapps only; report configuration differences without copying it.
  sudo $0 --migrate-only --migrate-configuration false

  # Migrate contexts/certificates only into a named secondary Tomcat instance.
  sudo $0 --migrate-only --installtarget /usr/local/tomcat2 \\
    --migration-source /usr/local/tomcat2.old.1700000000 \\
    --migrate-original-webapps false --migrate-configuration true

Setup types:
  standard  Recommended server defaults; prompts before replacement (default)
  custom    Uses supplied values; unspecified values use standard defaults
  hs        Restrictive administrative setup; does not migrate old webapps
  pprod     Unattended author convenience: replace, migrate apps/config,
            preview, unlimited parameters; intended for system scope
  pdev      User-owned pprod-like setup plus local JDWP debugging on
            127.0.0.1:5005; run without sudo for user-owned scope
  pprod/pdev are personal convenience profiles for this script's author and are
  not generally recommended defaults. Environment and command-line overrides
  still take precedence over their preset values.
EOUSAGE
  return 0
}

function fnsedi() {
  is_command_available sw_vers && sw_vers | grep -qai 'Mac' && sed -i '' "$@" && return $?
  sed -i "$@" && return $?
  
  #[[ ! -z "$3" ]] && echo "Not expecting a 3rd argument" >&2 && return 1
  #[[ -z "$1" ]] || [[ -z "$2" ]] && echo "Expecting fnsedi \$regex \$file" >&2 && return 1
  #file="$2"
  #regex="$1"
  #[[ ! -f "$file" ]] && echo "No such file as $file" >&2 && return 1
  #sed "$regex" "$file" > "$file.$$" && mv "$file.$$" "$file"
  return 0
}

function require_config_pattern() {
  local description="$1"
  local pattern="$2"
  local file="$3"
  if ! grep -Eq "$pattern" "$file"; then
    ERRORMSG="$description was requested but could not be applied to $file; restore from ${file}.before-installtomcat if needed"
    return 1
  fi
}

# Generate a self-signed localhost certificate only when no certificate material
# was supplied or migrated. A partial pair is unsafe to replace automatically.
function ensure_localhost_rsa_certificate() {
  local confdir="${INSTALLTARGET:-/usr/local/tomcat}/conf"
  local key="$confdir/localhost-rsa-key.pem"
  local certificate="$confdir/localhost-rsa-cert.pem"
  local chain="$confdir/localhost-rsa-chain.pem"
  local certificate_log

  if ! { [ -f "$key" ] && [ -f "$certificate" ]; }; then
    if [ -e "$key" ] || [ -e "$certificate" ]; then
      ERRORMSG="localhost TLS configuration is incomplete; expected both $key and $certificate"
      return 1
    fi
    detail "Generating a localhost self-signed certificate"
    certificate_log="${TMPDIR:-/tmp}/installtomcat-openssl.$$.log"
    DOWNLOADTMPFILES="$DOWNLOADTMPFILES $certificate_log"
    if ! openssl req -x509 -newkey rsa:4096 -nodes -days 36500 \
      -keyout "$key" -out "$certificate" \
      -subj "/C=NZ/ST=Waikato/L=Cambridge/O=RimuHosting/OU=RimuHosting/CN=localhost" \
      2>"$certificate_log"; then
      ERRORMSG="failed generating the localhost self-signed certificate: $(tail -n 1 "$certificate_log")"
      return 1
    fi
    chmod u=rw,go= "$key" || {
      ERRORMSG="could not secure the generated localhost TLS private key"
      return 1
    }
  fi
  [ -f "$chain" ] || touch "$chain" || {
    ERRORMSG="could not create the localhost TLS certificate chain file"
    return 1
  }
  if ! $ISUSEROWNEDINSTALL; then
    chown "$TOMCATUSER":"$TOMCATUSER" "$key" "$certificate" "$chain" || {
      ERRORMSG="could not assign localhost TLS files to $TOMCATUSER"
      return 1
    }
  fi
  chmod u=rw,go= "$key" && chmod u=rw,go=r "$certificate" "$chain" || {
    ERRORMSG="could not set localhost TLS file permissions"
    return 1
  }
}

# Return success only when an uncommented Connector uses the requested port.
function has_active_connector_port() {
  local port="$1"
  local server_xml="$2"
  awk -v port="$port" '
    function check(tag) {
      return tag ~ "<Connector[[:space:]>]" && tag ~ "port=\"" port "\""
    }
    {
      line=$0
      while (length(line)) {
        if (in_comment) {
          end=index(line, "-->")
          if (!end) next
          line=substr(line, end + 3)
          in_comment=0
          continue
        }
        start=index(line, "<!--")
        text=start ? substr(line, 1, start - 1) : line
        if (collecting || text ~ /<Connector[[:space:]>]/) {
          tag=tag text
          collecting=1
          if (text ~ />/) {
            if (check(tag)) found=1
            tag=""
            collecting=0
          }
        }
        if (!start) break
        line=substr(line, start + 4)
        in_comment=1
      }
    }
    END { exit(found ? 0 : 1) }
  ' "$server_xml"
}

# Add the portable JSSE/NIO TLS connector after migration has supplied any PEM
# files. The nested HTTP/2 protocol is used when the JVM supports TLS ALPN.
function ensure_localhost_https_connector() {
  local server_xml="${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
  local max_parameter_count=""

  has_active_connector_port 8443 "$server_xml" && return 0
  $ISMAXPARAMETERCOUNTUNLIMITED && max_parameter_count=' maxParameterCount="-1"'
  detail "Adding the localhost HTTPS connector on port 8443"
  fnsedi "/<\\/Service>/i\\
    <Connector port=\"8443\" URIEncoding=\"UTF-8\" protocol=\"org.apache.coyote.http11.Http11NioProtocol\" maxThreads=\"150\" SSLEnabled=\"true\"${max_parameter_count}>\\
        <UpgradeProtocol className=\"org.apache.coyote.http2.Http2Protocol\" />\\
        <SSLHostConfig>\\
            <Certificate certificateKeyFile=\"conf/localhost-rsa-key.pem\" certificateFile=\"conf/localhost-rsa-cert.pem\" type=\"RSA\" />\\
        </SSLHostConfig>\\
    </Connector>" "$server_xml" || {
      ERRORMSG="could not add the localhost HTTPS connector to $server_xml"
      return 1
    }
  require_config_pattern "adding the localhost HTTPS connector" \
    '<Connector[^>]*port="8443"[^>]*Http11NioProtocol' "$server_xml"
}

# Instance zero retains Tomcat's standard ports. A numbered instance offsets
# every standard server, connector, redirect, and debugger port by that number.
function configure_instance_ports() {
  local server_xml="${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
  [ "$INSTANCENUMBER" -eq 0 ] && return 0
  detail "Offsetting Tomcat ports for instance $INSTANCENUMBER"
  fnsedi "s/port=\"8005\"/port=\"$SHUTDOWNPORT\"/g" "$server_xml" || return 1
  fnsedi "s/port=\"8009\"/port=\"$AJPPORT\"/g" "$server_xml" || return 1
  fnsedi "s/port=\"8080\"/port=\"$HTTPPORT\"/g" "$server_xml" || return 1
  fnsedi "s/port=\"8443\"/port=\"$HTTPSPORT\"/g" "$server_xml" || return 1
  fnsedi "s/redirectPort=\"8443\"/redirectPort=\"$HTTPSPORT\"/g" "$server_xml" || return 1
}

###
# Function: parsecommandline
# Take parameters as given on command line and set those up so we can do
# cooler stuff, or complain that nothing will work. Set some reasonable
# defaults so we dont have to type so much.
#
function parsecommandline {
  while [ -n "$1" ]; do
    PARAM=$1
    case "$1" in
      custom | standard | hs | pprod | pdev)
        SETUPTYPE=$PARAM
      ;;
      --version | -v)
        shift
        if [[ -z "$1" ]]; then
          echo "Error: $PARAM given without version, use 8, 9, 10, or 11"
          usage
          exit 1
          # Tomcat 6 and 7 support was intentionally disabled: both release
          # lines are end-of-life and no longer receive security fixes.
          # elif [[ "$1" != "6" && "$1" != "7" && "$1" != "8" && "$1" != "9" && "$1" != "10" && "$1" != "11" ]]; then
          elif [[ "$1" != "8" && "$1" != "9" && "$1" != "10" && "$1" != "11" ]]; then
          ERRORMSG="$PARAM given with unsupported version '$1', use 8, 9, 10, or 11"
          return 1
        fi
        MAJORTOMCATVERSION=$1
      ;;
      --noprompt)
        ISPROMPT=false
        ISPROMPTCONFIGURED=true
        ISNOPROMPTEXPLICIT=true
      ;;
      --prompt)
        ISPROMPT=true
        ISPROMPTCONFIGURED=true
        ISNOPROMPTEXPLICIT=false
      ;;
      --installtarget)
        shift
        if [[ -z "$1" ]]; then
          ERRORMSG="$PARAM given without an install folder"
          return 1
        fi
        if [[ -e "$1" && ! -d "$1" ]]; then
          ERRORMSG="target already $1 exists but is not a folder"
          return 1
        fi
        # Absolute Unix paths start with a slash.
        if [[ "$1" != /* ]]; then
          ERRORMSG="please pass in --installtarget as an absolute folder (eg: $DEFAULTINSTALLTARGET, gave $1)"
          return 1
        fi
        INSTALLTARGET="$1"
      ;;
      --instance|--instance-number)
        shift
        if [[ -z "$1" || "$1" == *[!0-9]* ]]; then
          ERRORMSG="$PARAM expects a non-negative number"
          return 1
        fi
        INSTANCENUMBER=$1
      ;;
      --remexample)
        shift
        if ! REMOVEEXAMPLEWEBAPPS=$(normalize_boolean_value "$1"); then
          ERRORMSG="$PARAM given with unexpected or missing value, use true or false"
          return 1
        fi
      ;;
      --disableunpackwars)
        shift
        local disable_unpack_wars
        if ! disable_unpack_wars=$(normalize_boolean_value "$1"); then
          ERRORMSG="$PARAM given with unexpected or missing value, use true or false"
          return 1
        fi
        $disable_unpack_wars && ISUNPACKWARS=false || ISUNPACKWARS=true
      ;;
      --reducesparethreads)
        shift
        if ! ISREDUCESPARETHREADS=$(normalize_boolean_value "$1"); then
          ERRORMSG="$PARAM given with unexpected or missing value, use true or false"
          return 1
        fi
      ;;
      --remadmin)
        shift
        if ! REMOVEMANAGERWEBAPPS=$(normalize_boolean_value "$1"); then
          ERRORMSG="$PARAM given with unexpected or missing value, use true or false"
          return 1
        fi
      ;;
      --public-http)
        shift
        ISBINDHTTPPUBLIC=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --local-debugger)
        shift
        ISLOCALDEBUGGERENABLED=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --debugger-suspend)
        shift
        ISDEBUGGERSUSPEND=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --debugger-port)
        shift
        if [[ -z "$1" || "$1" == *[!0-9]* || "$1" -lt 1 || "$1" -gt 65535 ]]; then
          ERRORMSG="$PARAM expects a TCP port from 1 through 65535"
          return 1
        fi
        DEBUGGERPORT=$1
      ;;
      --enable-preview)
        shift
        ISPREVIEWENABLED=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --enable-native-access)
        shift
        ISNATIVEACCESSENABLED=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
        ISNATIVEACCESSCONFIGURED=true
      ;;
      --migrate-original-webapps)
        shift
        ISMIGRATEORIGINALWEBAPPS=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --replace-existing)
        shift
        ISREPLACEEXISTINGINSTALL=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --migrate-configuration)
        shift
        ISMIGRATECONFIGURATION=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --migrate-only)
        ISMIGRATEONLY=true
      ;;
      --migration-source)
        shift
        if [[ -z "$1" || "$1" != /* ]]; then
          ERRORMSG="$PARAM expects an absolute directory path"
          return 1
        fi
        MIGRATIONSOURCE=${1%/}
      ;;
      --install-java)
        shift
        INSTALLJAVA=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --remove-example-webapps)
        shift
        REMOVEEXAMPLEWEBAPPS=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --unpack-wars)
        shift
        ISUNPACKWARS=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --reduce-spare-threads)
        shift
        ISREDUCESPARETHREADS=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --remove-manager-webapps)
        shift
        REMOVEMANAGERWEBAPPS=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --run-on-startup)
        shift
        ISRUNONSTARTUP=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --uri-utf8)
        shift
        ISURIUTF8=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --unlimited-parameters)
        shift
        ISMAXPARAMETERCOUNTUNLIMITED=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --require-apr)
        shift
        ISAPRREQUIRED=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --permit-user-install)
        shift
        ISUSERINSTALLPERMITTED=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --dry-run)
        ISDRYRUN=true
      ;;
      --quiet)
        ISQUIET=true
      ;;
      --color)
        shift
        COLOROUTPUT="$1"
      ;;
      --output)
        shift
        OUTPUTFORMAT="$1"
      ;;
      --run-post-install-tests)
        shift
        ISRUNPOSTINSTALLTESTS=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --install-tomcatctl)
        shift
        ISINSTALLTOMCATCTL=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --rollback-on-failure)
        shift
        ISROLLBACKONFAILURE=$(normalize_boolean_value "$1") || { ERRORMSG="$PARAM expects true or false"; return 1; }
      ;;
      --tomcat-version)
        shift
        TOMCATVERSION="$1"
      ;;
      --runonstartup)
        shift
        if ! ISRUNONSTARTUP=$(normalize_boolean_value "$1"); then
          ERRORMSG="$PARAM given with unexpected or missing value, use true or false"
          return 1
        fi
      ;;
      --defaultmemsizemb|--defaultmemsize)
        shift
        if [[ -z "$1" || "$1" == *[!0-9]* || "$1" -eq 0 ]]; then
          ERRORMSG="$PARAM given expected memory number (in mb)"
          return 1
        fi
        DEFAULTMEMSIZEMB=$1
      ;;
      --user)
        shift
        if [[ -z "$1" || "$1" == *[!a-zA-Z0-9_.-]* || "$1" == -* ]]; then
          ERRORMSG="$PARAM given user name"
          return 1
        fi
        TOMCATUSERCMDLINE=$1
      ;;
      --uriutf8)
        shift
        if ! ISURIUTF8=$(normalize_boolean_value "$1"); then
          ERRORMSG="$PARAM given with unexpected or missing value, use true or false"
          return 1
        fi
      ;;
      --skip-migrate-webapps)
        ISMIGRATEORIGINALWEBAPPS=false
      ;;
      --skip-java)
        INSTALLJAVA=false
      ;;
      -h|help|-help|--help|?|-?|--?)
        # Defer output until every argument has been parsed, so options after
        # --help are reflected in the effective values just like options before it.
        ISSHOWHELP=true
      ;;
      *)
        ERRORMSG="unrecognised paramter '$PARAM'"
        return 1;
      ;;
    esac
    shift
  done
  
  # pdev is designed to run directly as the developer and therefore permits a
  # user-owned installation by default. pprod and general profiles stay system-
  # scoped unless a non-root caller explicitly opts into user ownership.
  if [ -z "$ISUSERINSTALLPERMITTED" ]; then
    if [ "$SETUPTYPE" = pdev ]; then
      ISUSERINSTALLPERMITTED=true
    else
      ISUSERINSTALLPERMITTED=false
    fi
  fi
  normalize_boolean_variable ISUSERINSTALLPERMITTED || return 1
  normalize_boolean_variable ISDRYRUN || return 1
  normalize_boolean_variable ISRUNPOSTINSTALLTESTS || return 1
  normalize_boolean_variable ISQUIET || return 1
  if [[ -z "$INSTANCENUMBER" || "$INSTANCENUMBER" == *[!0-9]* ]]; then
    ERRORMSG="INSTANCENUMBER must be a non-negative number"
    return 1
  fi
  INSTANCENUMBER=$((10#$INSTANCENUMBER))
  local instance_suffix=$INSTANCENUMBER
  [ "$instance_suffix" = 0 ] && instance_suffix=
  SHUTDOWNPORT=$((8005 + INSTANCENUMBER))
  AJPPORT=$((8009 + INSTANCENUMBER))
  HTTPPORT=$((8080 + INSTANCENUMBER))
  HTTPSPORT=$((8443 + INSTANCENUMBER))
  if [ "$HTTPSPORT" -gt 65535 ]; then
    ERRORMSG="INSTANCENUMBER is too large; the HTTPS port would exceed 65535"
    return 1
  fi
  DEBUGGERPORT=${DEBUGGERPORT:-$((5005 + INSTANCENUMBER))}
  if [ "$(id -u)" -ne 0 ] && $ISUSERINSTALLPERMITTED; then
    ISUSEROWNEDINSTALL=true
    INSTALLTARGET=${INSTALLTARGET:-${XDG_DATA_HOME:-$HOME/.local/share}/rimuhosting/tomcat${instance_suffix}}
    TOMCATUSER=${TOMCATUSERCMDLINE:-$(id -un)}
  else
    INSTALLTARGET=${INSTALLTARGET:-${DEFAULTINSTALLTARGET}${instance_suffix}}
  fi
  while [[ "$INSTALLTARGET" != "/" && "$INSTALLTARGET" == */ ]]; do
    INSTALLTARGET=${INSTALLTARGET%/}
  done
  if [[ "$INSTALLTARGET" == "/" ]]; then
    ERRORMSG="refusing to use / as the Tomcat install target"
    return 1
  fi
  #SYSTEMDUNIT="/etc/systemd/system/tomcat.service"
  # e.g. if installing in /usr/local/tomcatfoo create a system/tomcatfoo.service file
  SYSTEMDUNIT="/etc/systemd/system/$(basename "${INSTALLTARGET}").service"
  
  # Detect a running systemd instance, not merely an installed systemctl binary.
  if ! $ISUSEROWNEDINSTALL && is_command_available systemctl && [ -d /run/systemd/system ]; then
    ISSYSTEMD=true
  fi
  
  #INITSCRIPT="/etc/init.d/tomcat"
  INITSCRIPT="/etc/init.d/$(basename "${INSTALLTARGET}")"
  $ISUSEROWNEDINSTALL && INITSCRIPT="${INSTALLTARGET}/bin/tomcat-local"
  # e.g. INSTALLTARGET of /usr/local/tomcafoo => init file in /usr/local/tomcatfoo/bin/tomcatfoo
  $ISSYSTEMD && INITSCRIPT="${INSTALLTARGET}/bin/$(basename "${INSTALLTARGET}")"
  # e.g. mac
  ! $ISSYSTEMD && [ ! -d /etc/init.d ] && INITSCRIPT="${INSTALLTARGET}/bin/$(basename "${INSTALLTARGET}")"
  
  apply_setup_type_defaults || return 1

  # Validate environment overrides and normalize all user-facing booleans once,
  # so the remaining code can execute true/false values directly.
  local boolean_name
  local boolean_names="ISPROMPT ISMIGRATEORIGINALWEBAPPS ISREPLACEEXISTINGINSTALL
    ISMIGRATECONFIGURATION ISMIGRATEONLY
    ISLOCALDEBUGGERENABLED ISDEBUGGERSUSPEND ISPREVIEWENABLED
    ISNATIVEACCESSENABLED INSTALLJAVA
    REMOVEEXAMPLEWEBAPPS ISUNPACKWARS ISREDUCESPARETHREADS
    REMOVEMANAGERWEBAPPS ISRUNONSTARTUP ISURIUTF8 ISBINDHTTPPUBLIC
    ISMAXPARAMETERCOUNTUNLIMITED ISAPRREQUIRED
    ISUSERINSTALLPERMITTED ISDRYRUN ISRUNPOSTINSTALLTESTS
    ISINSTALLTOMCATCTL ISROLLBACKONFAILURE ISQUIET"
  for boolean_name in $boolean_names; do
    normalize_boolean_variable "$boolean_name" || return 1
  done
  # Resolve the service account before help, dry-run, and requirement checks so
  # every report agrees with the user that the installation will actually use.
  TOMCATUSER=$(output_tomcat_user)
  if $ISPROMPTCONFIGURED && ! $ISPROMPT; then
    ISNOPROMPTEXPLICIT=true
  fi
  resolve_native_access_for_apr
  $ISUSEROWNEDINSTALL && ISRUNONSTARTUP=false
  if [[ -z "$DEBUGGERPORT" || "$DEBUGGERPORT" == *[!0-9]* ||
        "$DEBUGGERPORT" -lt 1 || "$DEBUGGERPORT" -gt 65535 ]]; then
    ERRORMSG="DEBUGGERPORT must be a TCP port from 1 through 65535"
    return 1
  fi
  if $ISSHOWHELP; then
    version
    usage
    exit 0
  fi

  # Resolve every optional choice before performing downloads or host changes.
  # Only ISPROMPT=true permits these helpers to read from stdin.
  prompt_boolean ISMIGRATEORIGINALWEBAPPS "  Migrate original webapps?" true || return 1
  prompt_boolean REMOVEEXAMPLEWEBAPPS "  Remove example and documentation webapps?" true || return 1
  prompt_boolean ISUNPACKWARS "  Allow Tomcat to unpack WAR files?" true || return 1
  prompt_boolean ISREDUCESPARETHREADS "  Reduce spare connector threads?" true || return 1
  prompt_boolean REMOVEMANAGERWEBAPPS "  Remove manager webapps?" true || return 1
  prompt_boolean ISRUNONSTARTUP "  Enable Tomcat at startup?" true || return 1
  prompt_boolean ISURIUTF8 "  Set connector URI encoding to UTF-8?" true || return 1

  # Migration-only mode needs resolved paths and booleans, but deliberately
  # skips distro/version discovery so it performs no network lookup.
  $ISMIGRATEONLY && return 0

  set_default_memory_size || return 1
  
  # detect distro and release
  if is_command_available sw_vers && sw_vers | grep -qai 'Mac'; then
    # $ sw_vers
    # ProductName:  Mac OS X
    # ProductVersion: 10.12.4
    # BuildVersion: 16E195
    DISTRO="Mac"
    RELEASE="$(sw_vers | grep ProductV | awk '{print $2}')"
    # echo | xargs echo foo => nothing on mac and --no-run-if-empty not supported
    XARGNORUNIFEMPTY=
    elif [ -r /etc/os-release ]; then
    # Typical fields: ID=debian and VERSION_ID="13". Extract only exact keys;
    # values may be quoted, while descriptive fields contain spaces.
    DISTRO=$(sed -n 's/^ID=//p' /etc/os-release | head -n1 | tr -d '"')
    RELEASE=$(sed -n 's/^VERSION_ID=//p' /etc/os-release | head -n1 | tr -d '"')
    case "$DISTRO" in
      debian) DISTRO=Debian ;;
      ubuntu) DISTRO=Ubuntu ;;
      centos) DISTRO=CentOS ;;
      rhel) DISTRO=RHEL ;;
      fedora) DISTRO=Fedora ;;
    esac
    elif [ -e /etc/redhat-release ]; then
    DISTRO=$(awk '/release/ {print $1; exit}' /etc/redhat-release)
    RELEASE=$(awk '/release/ {print $3; exit}' /etc/redhat-release | cut -d. -f1)
    elif [ -e /etc/debian_version ]; then
    DISTRO=Debian
    RELEASE=$(cat /etc/debian_version)
  else
    warn "Unknown distribution; some host integration may be unavailable"
  fi
  [[ -z "$DISTRO" ]] && warn "Could not identify the distribution"
  [[ -z "$RELEASE" ]] && warn "Could not identify the distribution release"
  
  # set env for correct installation of required version
  # use archive instead of www, which has older versions, and should keep working even if the verison changes
  tsrc="https://archive.apache.org/dist/tomcat/tomcat-$MAJORTOMCATVERSION"
  # Current Apache index row:
  # <img src="/icons/folder.gif" alt="[DIR]"> <a href="v9.0.120/">v9.0.120/</a> 2026-07-07 12:36 -
  # Strip tags to expose v9.0.120/, remove v and /, then sort each numeric field
  # so, for example, 9.0.120 is newer than 9.0.99.
  if [ -n "$TOMCATVERSION" ]; then
    if [[ ! "$TOMCATVERSION" =~ ^${MAJORTOMCATVERSION}\.[0-9]+\.[0-9]+$ ]]; then
      ERRORMSG="TOMCATVERSION '$TOMCATVERSION' must be a numeric Tomcat $MAJORTOMCATVERSION version"
      return 1
    fi
    tomcatversion="$TOMCATVERSION"
  else
    tomcatversion=$(download_stdout "$tsrc/" |
      grep "v$MAJORTOMCATVERSION\." |
      sed 's/<[^>]*>/ /g' |
      awk '{print $1}' |
      sed 's/v//;s/\///' |
      sort -t . -k 1,1n -k 2,2n -k 3,3n |
      tail -n1)
  fi
  export tomcatversion
  if [[ -z $tomcatversion ]]; then
    # hardcode version if we couldnt guess it
    # Tomcat 6 and 7 fallbacks are disabled because those release lines are
    # end-of-life; silently installing them would deploy known-unpatched code.
    # [[ "$MAJORTOMCATVERSION" = "6" ]] && export tomcatversion=6.0.53
    # [[ "$MAJORTOMCATVERSION" = "7" ]] && export tomcatversion=7.0.92
    [[ "$MAJORTOMCATVERSION" = "8" ]] && export tomcatversion=8.5.100
    [[ "$MAJORTOMCATVERSION" = "9" ]] && export tomcatversion=9.0.120
    [[ "$MAJORTOMCATVERSION" = "10" ]] && export tomcatversion=10.1.57
    [[ "$MAJORTOMCATVERSION" = "11" ]] && export tomcatversion=11.0.24
    warn "Could not discover the latest Tomcat release; using fallback $tomcatversion"
    ERRORMSG=
  fi
  tomcaturl="$tsrc/v$tomcatversion/bin/apache-tomcat-$tomcatversion.tar.gz"
  # e.g. parsing Server number:  8.0.23.0
  if [ -x "${INSTALLTARGET:-/usr/local/tomcat}/bin/version.sh" ] && "${INSTALLTARGET:-/usr/local/tomcat}/bin/version.sh" | grep -qai "Server number.* $tomcatversion"; then
    success "Tomcat $tomcatversion is already installed at ${INSTALLTARGET:-/usr/local/tomcat}"
    exit 0
  fi
  return 0
}


###
# Function: installreqs
# Make sure any essential components need for this script or the tomcat install are available
#
function installreqs {
  section "Verify installation requirements"
  if [[ $(id -u) != "0" ]] && ! $ISUSEROWNEDINSTALL; then
    ERRORMSG="Run as root, or explicitly permit a user-owned install with --permit-user-install true (environment: ISUSERINSTALLPERMITTED=true)"
    return 1
  fi
  
  detail "Checking for an existing Tomcat installation"
  if [ -d "${INSTALLTARGET:-/usr/local/tomcat}" ] && find "${INSTALLTARGET:-/usr/local/tomcat}" ! -path "${INSTALLTARGET:-/usr/local/tomcat}" -prune -print -quit | grep -q .; then
    # only care if folder is non-empty
    if $ISREPLACEEXISTINGINSTALL; then
      detail "Existing installation will be timestamped, backed up, replaced, migrated, and audited"
    elif $ISPROMPT; then
      warn "Target ${INSTALLTARGET:-/usr/local/tomcat} is not empty; press Ctrl-C to stop or Enter to back it up"
      read -r -s || { ERRORMSG="input ended while confirming the existing install"; return 1; }
    elif $ISNOPROMPTEXPLICIT; then
      detail "Explicit --noprompt accepts the resolved unattended backup, replacement, migration, and audit plan"
    else
      ERRORMSG="${INSTALLTARGET:-/usr/local/tomcat} is not empty; use --noprompt to accept the resolved unattended plan, --replace-existing true to authorize replacement explicitly, or --prompt to confirm interactively"
      return 1
    fi
  fi

  # Detect unrelated listeners before downloading Java or the Tomcat archive.
  # A running service belonging to this install target is expected during an
  # upgrade; installtomcat stops it and repeats this check before replacement.
  local activeports
  activeports=$(output_active_tomcat_port_count)
  if [ "$activeports" -gt 0 ]; then
    if is_configured_tomcat_service_running; then
      detail "The existing Tomcat service is using $activeports default port(s); these will be rechecked after it stops"
    else
      ERRORMSG="Seen $activeports default Tomcat port(s) open. Stop the conflicting Tomcat or Java service before installing."
      return 1
    fi
  fi
  if $ISLOCALDEBUGGERENABLED && is_tcp_port_listening "$DEBUGGERPORT"; then
    if is_configured_tomcat_service_running; then
      detail "The existing Tomcat service is using debugger port $DEBUGGERPORT; it will be rechecked after shutdown"
    else
      ERRORMSG="debugger port $DEBUGGERPORT is already in use"
      return 1
    fi
  fi

  # Surface configuration work before any installer or archive download.
  if [ -f "${INSTALLTARGET:-$DEFAULTINSTALLTARGET}/conf/server.xml" ] &&
     { is_command_available xmlstarlet || is_command_available xmllint; }; then
    is_xml_file_valid "${INSTALLTARGET:-$DEFAULTINSTALLTARGET}/conf/server.xml" || {
      ERRORMSG="the existing server.xml is not valid XML"
      return 1
    }
  fi
  report_existing_configuration_changes || return 1
  
  # TODO add java presence install
  [[ -e /etc/profile.d/java.sh ]] && source /etc/profile.d/java.sh
  if ! is_command_available java; then
    if $ISUSEROWNEDINSTALL; then
      ERRORMSG="java is required in PATH for a user-owned installation"
      return 1
    fi
    if ! $INSTALLJAVA; then
      ERRORMSG="java is not in the system path and INSTALLJAVA=false"
      return 1
    fi
    local installjava_tmp="${TMPDIR:-/tmp}/installjava.sh.$$"
    DOWNLOADTMPFILES="$DOWNLOADTMPFILES $installjava_tmp"
    download_file https://public.rimuhosting.com/installjava.sh "$installjava_tmp" || {
      ERRORMSG="failed downloading installjava.sh"
      return 1
    }
    bash "$installjava_tmp" || {
      ERRORMSG="installjava.sh failed"
      return 1
    }
    # Add the installed Java binary directory to PATH.
    source /etc/profile.d/java.sh
  fi
  local java_major
  java_major=$(output_java_major_version)
  if [[ -z "$java_major" || "$java_major" == *[!0-9]* ]]; then
    ERRORMSG="could not determine the installed Java version"
    return 1
  fi
  resolve_native_access_for_apr
  if $ISLOCALDEBUGGERENABLED && [ "$java_major" -lt 9 ]; then
    ERRORMSG="--local-debugger requires Java 9 or newer for a loopback-only JDWP listener; detected Java $java_major"
    return 1
  fi
  if $ISPREVIEWENABLED && ! java --enable-preview -version >/dev/null 2>&1; then
    ERRORMSG="the installed Java $java_major runtime does not support --enable-preview"
    return 1
  fi
  if $ISNATIVEACCESSENABLED &&
     ! java --enable-native-access=ALL-UNNAMED -version >/dev/null 2>&1; then
    ERRORMSG="the installed Java $java_major runtime does not support --enable-native-access=ALL-UNNAMED"
    return 1
  fi
  # The old Tomcat 7/Java 6 compatibility check is disabled together with
  # Tomcat 7 support; accepting an EOL runtime would weaken a new install.
  # if [[ $(java -version 2>&1 | egrep -c '1.6|1.7|1.8|version "9|version "[12][0-9]|build [12][0-9]' ) -lt 1 && "$MAJORTOMCATVERSION" = "7" ]]; then
  #   ERRORMSG="Tomcat 7 requires Java SE 6.0 or later. please install that before proceeding."
  #   return 1
  # fi
  # Enforce the minimum Java release supported by each Tomcat major release.
  if [[ "$MAJORTOMCATVERSION" -eq 8 && "$java_major" -lt 7 ]] ||
     [[ "$MAJORTOMCATVERSION" -eq 9 && "$java_major" -lt 8 ]] ||
     [[ "$MAJORTOMCATVERSION" -eq 10 && "$java_major" -lt 11 ]] ||
     [[ "$MAJORTOMCATVERSION" -eq 11 && "$java_major" -lt 17 ]]; then
    ERRORMSG="Java $java_major is not supported by Tomcat $MAJORTOMCATVERSION. Please install a compatible Java version before proceeding.
    Try the script at https://public.rimuhosting.com/installjava.sh ?"
    return 1
  fi
  return 0
  
}

function is_apache_enabled_or_running() {
  local service
  for service in apache2 httpd; do
    if is_command_available systemctl && {
      systemctl is-active --quiet "$service" || systemctl is-enabled --quiet "$service"
    }; then
      return 0
    fi
    if ! is_command_available systemctl && is_command_available service &&
       service "$service" status >/dev/null 2>&1; then
      return 0
    fi
  done
  return 1
}

function apachetweaks() {
  $ISUSEROWNEDINSTALL && return 0
  is_command_available a2enmod || return 0
  if ! is_apache_enabled_or_running; then
    detail "Apache is not enabled or running; skipping Apache integration"
    return 0
  fi
  for i in proxy_http headers ssl proxy_ajp rewrite; do
    a2enmod "$i"
  done

  # looking for ProxyPass / ajp://127.0.0.1:8009/ retry=0
  [ -d /etc/apache2 ] && if find /etc/apache2/ /etc/http -type f -exec grep -l '127.0.0.1:8009' {} \; 2>/dev/null | grep -q .; then
    local ajpline
    ajpline=$(grep --line-number 'efine an AJP 1.3 Connector on port 8009' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml" | sed 's/:.*//')
  fi
  if [ -n "$ajpline" ]; then
  
    #<!-- Define an AJP 1.3 Connector on port 8009 -->
    #<Connector protocol="AJP/1.3"
    #           address="::1"
    #           port="8009"
    #           redirectPort="8443" />
    # =>
    #<!-- Define an AJP 1.3 Connector on port 8009 -->
    #<Connector protocol="AJP/1.3"
    #           address="127.0.0.1" secretRequired="false"
    #           port="8009"
    #           redirectPort="8443" />
      
    detail "Found an Apache AJP proxy; enabling the localhost AJP connector"
    fnsedi "$((ajpline+1)) s/<"'!'"--//" "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    fnsedi "$((ajpline+1)),$((ajpline+6)) s/-->//" "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    
    if ! grep -qai secretRequired "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"; then
      fnsedi "$((ajpline+1)),$((ajpline+6)) s/address=\"::1\"/address=\"::1\" secretRequired=\"false\"/" "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    fi 
    fnsedi "$((ajpline+1)),$((ajpline+6)) s/address=\"::1\"/address=\"127.0.0.1\"/" "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
  else
    detail "No Apache AJP proxy was found; leaving the AJP connector disabled"
  fi
  return 0

}


###
# Function: installtomcat
# Actually do the tomcat package install
#
function installtomcat {
  section "Install Tomcat $tomcatversion"

  local tomcat_archive="${TMPDIR:-/tmp}/apache-tomcat-$tomcatversion.$$.tar.gz"
  DOWNLOADTMPFILES="$DOWNLOADTMPFILES $tomcat_archive"
  detail "Downloading and verifying the Apache SHA-512 checksum"
  download_file "$tomcaturl" "$tomcat_archive" || {
    ERRORMSG="failed downloading Tomcat package from $tomcaturl"
    return 1
  }
  verify_sha512 "$tomcat_archive" "$tomcaturl.sha512" || return 1
  tar tzf "$tomcat_archive" >/dev/null 2>&1 || {
    ERRORMSG="downloaded Tomcat package is not a readable gzip tar archive"
    return 1
  }
  local installtop installstage
  installtop=$(dirname "${INSTALLTARGET:-/usr/local/tomcat}")
  if $ISUSEROWNEDINSTALL; then
    mkdir -p "$installtop" || { ERRORMSG="cannot create local install parent $installtop"; return 1; }
  fi
  installstage="$installtop/.installtomcat.$$.stage"
  INSTALLSTAGE="$installstage"
  if [ -e "$installstage" ]; then
    ERRORMSG="temporary install directory already exists: $installstage"
    return 1
  fi
  mkdir "$installstage" || { ERRORMSG="cannot create $installstage"; return 1; }
  tar xzf "$tomcat_archive" -C "$installstage" || {
    ERRORMSG="failed uncompressing Tomcat package into $installstage"
    return 1
  }
  if [ ! -x "$installstage/apache-tomcat-$tomcatversion/bin/catalina.sh" ]; then
    ERRORMSG="Tomcat package did not contain the expected installation directory"
    return 1
  fi
  
  detail "Checking for an installation that must be backed up"
  # important not to overdo this, eg we may have multiple instances installed,
  # Dont touch anything that might be not be part of installtarget. DONT do a
  # massive cleanup of 'old' init scripts
  
  # old or conflicting backup location
  MVDIRNAME="${INSTALLTARGET:-/usr/local/tomcat}.$MVDIRSTAMP"
  [ -e "${INSTALLTARGET:-/usr/local/tomcat}" ] && ISOLDTARGETPRESENT=true
  [ -e "$SYSTEMDUNIT" ] && ISOLDSYSTEMDUNITPRESENT=true
  [ -e "$INITSCRIPT" ] && ISOLDINITSCRIPTPRESENT=true
  ISINSTALLACTIVATED=true
  
  # stop service and backup pre-existing init script, dont remove it yet
  if [ -e "$SYSTEMDUNIT" ]; then
    if systemctl --quiet is-active "$(basename "$SYSTEMDUNIT")"; then
      ISOLDSERVICERUNNING=true
      detail "Stopping the existing systemd service"
      systemctl stop "$(basename "$SYSTEMDUNIT")" >> /dev/null 2>&1
      if systemctl --quiet is-active "$(basename "$SYSTEMDUNIT")"; then
        ERRORMSG="tomcat not stopped cleanly, this should not happen (BUG)"
        return 1
      fi
    fi
    detail "Backed up systemd unit $SYSTEMDUNIT"
    cp "$SYSTEMDUNIT" "$(dirname "${INSTALLTARGET:-/usr/local/tomcat}")/$(basename "$INITSCRIPT").$MVDIRSTAMP.service"
  elif [ -e "${INITSCRIPT}" ]; then
    if [ "$("${INITSCRIPT}" status | grep -c 'PIDs')" -gt 0 ]; then
      ISOLDSERVICERUNNING=true
      detail "Stopping the existing init-script service"
      "${INITSCRIPT}" stop >> /dev/null 2>&1
      if [[ $("${INITSCRIPT}" status | grep -c 'PIDs') -gt 0 ]]; then
        ERRORMSG="tomcat not stopped cleanly, this should not happen (BUG)"
        return 1
      fi
    fi
    detail "Backed up init script $INITSCRIPT"
    cp "$INITSCRIPT" "$(dirname "${INSTALLTARGET:-/usr/local/tomcat}")/$(basename "$INITSCRIPT").$MVDIRSTAMP.init"
  fi
  
  # look for other scripts that may reference *exactly* the same installtarget
  # and move those away as well. Mostly though this bit should do nothing
  # TODO check for distribution install and abort so we dont mess that up?
  # TODO we dont care about upstart since we never set init scripts for that,
  # but maybe distro did?
  
  local otherinitscripts=""
  
  # sysvinit
  if ! $ISUSEROWNEDINSTALL && [ -d /etc/init.d/ ]; then
    # find all files, count matches with installtarget inside them, ignore
    # zero matches or dot files. trim the count.
    # grep -Hc emits rows such as /etc/init.d/tomcat:1; the final cut returns
    # only paths whose files contain this instance's exact installation target.
    otherinitscripts=$(
      find /etc/init.d/ -type f -exec grep -Hc "$INSTALLTARGET" {} \; | \
      grep -Ev ":0$|/\." | \
      grep -v "${INITSCRIPT}" | \
      cut -d: -f1
    )
    for i in $otherinitscripts; do
      # ignore any results that reference trailing alphanum on target. leading
      # ones are already eliminated by the above find+grep
      grep -qai "${INSTALLTARGET}[a-zA-Z0-9]" "$i" && continue
      
      # TODO clean up old sysvinit links
      
      # move them out of the way.
      if [ ! -e "$(dirname "${INSTALLTARGET:-/usr/local/tomcat}")/$(basename "$i").$MVDIRSTAMP.initd" ]; then
        detail "Moved conflicting init script $i into the backup directory"
        mv "$i" "$(dirname "${INSTALLTARGET:-/usr/local/tomcat}")/$(basename "$i").$MVDIRSTAMP.initd"
      else
        warn "Init script $i may conflict with $INSTALLTARGET; an existing backup prevented moving it"
      fi
    done
  fi
  
  # systemd
  if ! $ISUSEROWNEDINSTALL && [ -d /etc/systemd/system/ ]; then
    # find all files, count matches with installtarget inside them, ignore
    # zero matches or dot files. trim the count.
    # grep -Hc emits rows such as /etc/systemd/system/tomcat.service:1.
    otherinitscripts=$(
      find /etc/systemd/system/ -type f -exec grep -Hc "$INSTALLTARGET" {} \; | \
      grep -Ev ":0$|/\." | \
      grep -v "${SYSTEMDUNIT}" | \
      cut -d: -f1
    )
    for i in $otherinitscripts; do
      # ignore any results that reference trailing alphanum on target. leading
      # ones are already eliminated by the above find+grep
      grep -qai "${INSTALLTARGET}[a-zA-Z0-9]" "$i" && continue
      
      # clean up systemd links
      systemctl disable "$(basename "$i")"
      
      # move them out of the way.
      if [ ! -e "$(dirname "${INSTALLTARGET:-/usr/local/tomcat}")/$(basename "$i").$MVDIRSTAMP.service" ]; then
        detail "Moved conflicting systemd unit $i into the backup directory"
        mv "$i" "$(dirname "${INSTALLTARGET:-/usr/local/tomcat}")/$(basename "$i").$MVDIRSTAMP.service"
      else
        warn "Systemd unit $i may conflict with $INSTALLTARGET; an existing backup prevented moving it"
      fi
    done
  fi
  
  if [ -e "${INITSCRIPT}" ]; then
    "${INITSCRIPT}" stop
    detail "Removing the old init script after backup"
    rm -f "${INITSCRIPT}"
  fi
  
  if [ -e "$SYSTEMDUNIT" ] ; then
    true
    #ERRORMSG="tomcat unit file $SYSTEMDUNIT already exists (BUG)"
    #return 1
    # overwrite
  fi
  
  # Recheck after stopping the configured service. Any remaining listener is a
  # different process and would prevent the replacement Tomcat from starting.
  local activeports
  activeports=$(output_active_tomcat_port_count)
  if [ "x${activeports}" != "x0" ]; then
    ERRORMSG="Seen ${activeports} default Tomcat port(s) open. Check for another Java service or a Tomcat process that did not stop."
    return 1
  fi
  if $ISLOCALDEBUGGERENABLED && is_tcp_port_listening "$DEBUGGERPORT"; then
    ERRORMSG="debugger port $DEBUGGERPORT is still in use after stopping the existing Tomcat service"
    return 1
  fi
  
  # move old tomcat installs out of the way with a datestamp
  if [ -e "${INSTALLTARGET:-/usr/local/tomcat}" ]; then
    detail "Moving the existing installation to $MVDIRNAME"
    mv "${INSTALLTARGET:-/usr/local/tomcat}" "$MVDIRNAME"
  fi
  
  detail "Activating the new installation"
  
  if $ISSYSTEMD; then
    detail "Writing systemd unit $SYSTEMDUNIT"
    JAVAHOME=$(output_java_home) || { ERRORMSG="could not determine JAVA_HOME"; return 1; }
    cat << ENDOFUNIT > "$SYSTEMDUNIT"
# Systemd unit file for $(basename "${INSTALLTARGET}") in ${INSTALLTARGET}
#

[Unit]
Description=Apache Tomcat Web Application Container
After=syslog.target network.target
Documentation=https://rimuhosting.com/tomcat.jsp

[Service]
# The downloaded javainitscript starts Tomcat as a background process and does
# not call sd_notify, so Type=notify/NotifyAccess=all would wait for a readiness
# notification that never arrives.
#Type=notify
#NotifyAccess=all
Type=forking
# A fixed /usr/java/jdk path was disabled because Java may be installed by the
# OS package manager or Homebrew; use the Java executable selected above.
#Environment=JAVA_HOME=/usr/java/jdk
Environment=JAVA_HOME=${JAVAHOME}
Environment=CATALINA_HOME=${INSTALLTARGET}
# setenv.sh is shell code, not a systemd EnvironmentFile.  Catalina sources it
# itself, while systemd's parser would misread shell expansion and quoting.
#EnvironmentFile=${INSTALLTARGET}/bin/setenv.sh
# Run the downloaded shell wrapper through Bash. This also works when the
# installation filesystem is mounted noexec, where executing the wrapper
# directly would fail with systemd status 203/EXEC.
ExecStart=/bin/bash ${INITSCRIPT} start
ExecStop=/bin/bash ${INITSCRIPT} stop
# RemainAfterExit is disabled because Type=forking tracks the Tomcat child; it
# could otherwise report the service active after the JVM had exited.
#RemainAfterExit=yes
User=${TOMCATUSER}
Group=${TOMCATUSER}
# Restart only unexpected failures; "always" also restarts deliberate stops.
#Restart=always
Restart=on-failure
RestartSec=30
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
ENDOFUNIT
    systemctl daemon-reload
  else
    detail "Skipping systemd unit creation"
  fi
  
  detail "Installing Tomcat into ${INSTALLTARGET:-/usr/local/tomcat}"
  cd "$installtop" || { ERRORMSG="cannot change to install directory $installtop"; return 1; }
  mv "$installstage/apache-tomcat-$tomcatversion" "${INSTALLTARGET:-/usr/local/tomcat}" || {
    ERRORMSG="failed activating staged Tomcat package"
    return 1
  }
  rmdir "$installstage"
  INSTALLSTAGE=
  
  # codex:deprecated Applications now supply their own compatible JDBC and mail
  # dependencies. Installing these old shared jars caused unnecessary classpath
  # coupling: mysql-connector 5.1.35 and JavaMail 1.5.3-era mail/activation.
  # DIR=$(if [ -d "${INSTALLTARGET:-/usr/local/tomcat}/common/lib" ]; then echo common/lib; else echo lib; fi)
  # for lib in mysql-connector mail activation; do
  #   download_file "https://downloads.rimuhosting.com/$lib.jar" \
  #     "${INSTALLTARGET:-/usr/local/tomcat}/$DIR/$lib.jar" || return 1
  # done
  
  # Install init script.  will be in /etc/init.d/ if non-systemd, else with systemd will be in the install directory
  detail "Creating the service control script"
  if $ISUSEROWNEDINSTALL; then
    # The local wrapper never changes users or integrates with the host init system.
    cat << EOLOCALINIT > "$INITSCRIPT"
#!/bin/bash
HOMEDIR="$INSTALLTARGET"
case "\$1" in
  start) "\$HOMEDIR/bin/startup.sh" ;;
  stop) "\$HOMEDIR/bin/shutdown.sh" ;;
  restart) "\$HOMEDIR/bin/shutdown.sh" >/dev/null 2>&1 || true; "\$HOMEDIR/bin/startup.sh" ;;
  status)
    pids=\$(pgrep -f "Dcatalina.base=\$HOMEDIR" 2>/dev/null || true)
    [ -n "\$pids" ] && echo "PIDs \$pids" || { echo "Not running"; exit 1; }
  ;;
  *) echo "Usage: \$0 {start|stop|restart|status}"; exit 2 ;;
esac
EOLOCALINIT
    chmod +x "$INITSCRIPT"
  elif ! download_file "https://public.rimuhosting.com/javainitscript" "$INITSCRIPT"; then
    ERRORMSG="failed downloading init script";
    [ -e /etc/init.d/ ] && return 1
    warn "$ERRORMSG; continuing because /etc/init.d is unavailable"
  else
    chmod +x "$INITSCRIPT"
    # Keep the launcher usable when INSTALLTARGET is mounted noexec. Bash can
    # read these scripts even though the kernel may not execute them directly.
    fnsedi 's#RUNCOMMAND="$INITSCRIPT start"#RUNCOMMAND="/bin/bash $INITSCRIPT start"#' "$INITSCRIPT"
    fnsedi 's#STOPCOMMAND="$INITSCRIPT stop"#STOPCOMMAND="/bin/bash $INITSCRIPT stop"#' "$INITSCRIPT"
  fi
  
  # make sure the init script is pointing at the right place
  if [ -f "$INITSCRIPT" ] && [ "${INSTALLTARGET:-/usr/local/tomcat}" != "$DEFAULTINSTALLTARGET" ]; then
    fnsedi "s#HOMEDIR=$DEFAULTINSTALLTARGET#HOMEDIR=${INSTALLTARGET:-/usr/local/tomcat}#" "$INITSCRIPT"
  fi
  
  #for security reasons
  detail "Moving non-essential bundled webapps out of the deployment directory"
  cd "${INSTALLTARGET:-/usr/local/tomcat}" || { ERRORMSG="cannot change to ${INSTALLTARGET:-/usr/local/tomcat}"; return 1; }
  # Preserve the untouched file shipped by this Tomcat release. This is a
  # reference point, not a configuration file that Tomcat reads.
  cp conf/server.xml conf/server.xml.orig || { ERRORMSG="cannot create conf/server.xml.orig"; return 1; }
  mkdir -p webapps.removed
  if [ -e server/webapps ]; then mv server/webapps webapps.removed/server.webapps; fi
  if [ -e webapps/balancer ]; then mv webapps/balancer webapps/webdav webapps.removed/; fi
  if [ -e webapps/examples ]; then mv webapps/examples webapps.removed/; fi
  if [ -e conf/Catalina/localhost ]; then
    for i in conf/Catalina/localhost/*.xml; do
      [ -e "$i" ] || continue
      mv "$i" webapps.removed/
    done
  fi
  
  # override default memory settings
  detail "Configuring the Java heap at ${DEFAULTMEMSIZEMB} MB"
  #codex:deprecated Earlier installs left commented -Xdebug/-Xrunjdwp examples
  # in every setenv.sh and developers had to restore local changes after each
  # upgrade. --local-debugger now generates a scoped modern JDWP option.
  cat << EOJ > "${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh"
#small-ish
#JAVA_OPTS="-Xms16m -Xmx160m -Djava.awt.headless=true"

#big, vm
#JAVA_OPTS="-Xms128m -Xmx1512m -Dmail.host=localhost -Djava.awt.headless=true"

#regular, default
JAVA_OPTS="-Xmx${DEFAULTMEMSIZEMB:-1024}m -Djava.awt.headless=true -Dmail.host=localhost"
EOJ

  configure_local_debugger || return 1
  configure_optional_java_features || return 1
  configure_legacy_java_modules || return 1
  # Preview mode is disabled: --enable-preview must match the exact JDK release
  # used at compile time and is unsafe to add globally to every Tomcat JVM.
  # if java -version 2>&1 | egrep -qai 'version "?[23][0-9]' && [ -f ${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh ] && ! grep -qai "^JAVA_OPTS=.*--enable-preview" ${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh; then
  #   fnsedi 's/^JAVA_OPTS="/JAVA_OPTS="--enable-preview /' ${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh && echo "Added --enable-preview to ${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh"
  # fi

  # Unconditional apt installation was replaced by the ISAPRREQUIRED opt-in.
  # [ -e /etc/debian_version ] && which dpkg 2>/dev/null >/dev/null && ! dpkg -l | grep -qai libtcnative && echo "  Attempting to install libtcnative-1..." && apt-get -y install libtcnative-1
  install_required_tomcat_native || return 1
  configure_tomcat_native_library_path
  remove_unsupported_endorsed_directory || return 1
  prompt_boolean ISREDUCESPARETHREADS "  Reduce big spare thread count?" true || return 1
  if $ISREDUCESPARETHREADS; then
    detail "Reducing idle connector thread counts"
    if [ ! -e "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml" ]; then
      ERRORMSG="${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml was not found"
      return 1
    fi
    fnsedi 's/minSpareThreads="25"/minSpareThreads="1"/g' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    fnsedi 's/maxSpareThreads="75"/maxSpareThreads="2"/g' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    fnsedi 's/maxSpareThreads="25"/maxSpareThreads="2"/g' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    fnsedi 's/maxSpareThreads="150"/maxSpareThreads="2"/g' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    #echo "$MAJORTOMCATVERSION" | grep -qai 8 && fnsedi 's//g' ${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml
    fnsedi 's/tcpThreadCount="6"/tcpThreadCount="1"/g' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    fnsedi 's/maxThreads="4"/maxThreads="2"/g' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    fnsedi 's/minSpareThreads="2"/minSpareThreads="1"/g' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    fnsedi 's/maxSpareThreads="4"/maxSpareThreads="2"/g' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    # make the selected protocols listen just on the localhost address
    # TODO make this a seperate option?
    detail "Restricting connector exposure"
    fnsedi 's/redirectPort="8443" protocol="HTTP\/1.1"/redirectPort="8443" address="127.0.0.1" protocol="HTTP\/1.1"/' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    fnsedi 's/Connector port="8009"/Connector port="8009" address="127.0.0.1"/' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    if ! $ISBINDHTTPPUBLIC; then
      fnsedi 's/Connector port="8080"/Connector port="8080" address="127.0.0.1"/' "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
      require_config_pattern \
        "binding the HTTP connector to localhost" \
        '<Connector[^>]*port="8080"[^>]*address="127\.0\.0\.1"' \
        "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml" || return 1
    fi
     
  fi

  # Setting maxParameterCount to -1 disables Tomcat's parameter-count limit.
  # This is independent of the spare-thread tuning option above.
  if $ISMAXPARAMETERCOUNTUNLIMITED; then
    fnsedi 's/maxParameterCount="[0-9][0-9]*"/maxParameterCount="-1"/g' \
      "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
    require_config_pattern "disabling maxParameterCount" \
      'maxParameterCount="-1"' \
      "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml" || return 1
  fi

  if [ -d "$MVDIRNAME/conf" ]; then
    while IFS= read -r i; do
      [ -e "${INSTALLTARGET:-/usr/local/tomcat}/conf/$(basename "$i")" ] && { detail "Keeping existing $(basename "$i")"; continue; }
      cp "$i" "${INSTALLTARGET:-/usr/local/tomcat}/conf/" && detail "Migrated $(basename "$i")"
    done < <(find "$MVDIRNAME/conf/" -name '*.pem')
  fi
  prompt_boolean REMOVEEXAMPLEWEBAPPS "  Remove all example and documentation webapps?" true || return 1
  if $REMOVEEXAMPLEWEBAPPS; then
    detail "Removing example and documentation webapps"
    applist="webapps/jsp-examples webapps/examples webapps/servlets-examples webapps/tomcat-docs webapps/docs webapps/ROOT"
    for i in $applist; do if [ -e "$i" ]; then mv "$i" webapps.removed/; fi; done
  fi
  
  prompt_boolean ISUNPACKWARS "  Allow Tomcat to unpack WAR files?" true || return 1
  if ! $ISUNPACKWARS; then
    detail "Disabling automatic WAR unpacking"
    fnsedi 's/unpackWARs="true"/unpackWARs="false"/' conf/server.xml
    require_config_pattern "disabling unpackWARs" 'unpackWARs="false"' conf/server.xml || return 1
  fi
  
  prompt_boolean ISURIUTF8 "  Set connector URI encoding to UTF-8?" true || return 1
  if $ISURIUTF8; then
    detail "Setting connector URI encoding to UTF-8"
    fnsedi -r 's/Connector port="([0-9]+)"/Connector port="\1" URIEncoding="UTF-8"/' conf/server.xml
    require_config_pattern "setting URI encoding to UTF-8" '<Connector[^>]*URIEncoding="UTF-8"' conf/server.xml || return 1
  fi
  
  prompt_boolean REMOVEMANAGERWEBAPPS "  Remove the Tomcat manager webapps?" true || return 1
  if $REMOVEMANAGERWEBAPPS; then
    detail "Removing manager webapps"
    if [ -e webapps/host-manager ]; then mv webapps/host-manager webapps/manager webapps.removed/; fi
  else
    detail "Keeping manager webapps"
  fi
  
  while true; do
    # doing this since the default is 1.6.  And that will spit an error if you use @Override, or
    # do something like new Comparator<Foo> {..} which has new default methods in 1.8
    if java -version 2>&1 | grep -Eqai '1.8|version "9|version "[12][0-9]|build [12][0-9]' > /dev/null; then
      JSPVERSION=1.8
    fi
    if java -version 2>&1 | grep -qai 1.7 > /dev/null; then
      JSPVERSION=1.7
    fi
    # Only recent tomcat 9+ supports better than version 1.8 as a value in the web.xml
    if [ "$MAJORTOMCATVERSION" -gt 8 ]; then
      # javac version output seems easier to parse and more consistent than java --version
      # javac --version
      # javac 11.0.2
      # javac 16
      # all of this so we can use the new java text blocks!!!! 
      if is_command_available javac && jdkver="$(javac --version | grep -E '^javac [12][0-9](\.|$)' | sed 's/javac //' | sed 's/\..*//')" && [ -n "$jdkver" ] ; then
        JSPVERSION=$jdkver
      fi
    fi
    #  org.apache.jasper.compiler.JDTCompiler.generateClass Unsupported target VM [17] requested, using [16]
    if [ -z "$JSPVERSION" ]; then break; fi
    if grep -qai 'name>compilerTargetVM' "${INSTALLTARGET}/conf/web.xml"; then break; fi
    fnsedi "s|er.servlet.JspServlet</servlet-class>|er.servlet.JspServlet</servlet-class>\
<init-param><param-name>compilerTargetVM</param-name><param-value>$JSPVERSION</param-value></init-param>\
    <init-param><param-name>compilerSourceVM</param-name><param-value>$JSPVERSION</param-value></init-param>|" "${INSTALLTARGET}/conf/web.xml"
    detail "Set JSP compiler source and target to Java $JSPVERSION"
    break;
  done
  # TOMCATUSER was resolved before the new root-owned distribution was
  # activated. Do not re-resolve it from INSTALLTARGET here or the unit user
  # and the final ownership can diverge on a clean installation.
  if ! $ISUSEROWNEDINSTALL; then
    # System installs receive a host-level logrotate policy.
    detail "Writing logrotate policy $LOGROTATEFILE"
    mkdir -p /etc/logrotate.d
    LOGROTATEFILE="/etc/logrotate.d/$(basename "${INSTALLTARGET:-/usr/local/tomcat}")"
    if [ -f "$LOGROTATEFILE" ]; then
      LOGROTATEBACKUP="$(dirname "${INSTALLTARGET:-/usr/local/tomcat}")/$(basename "$LOGROTATEFILE").$MVDIRSTAMP.logrotate"
      cp "$LOGROTATEFILE" "$LOGROTATEBACKUP"
    fi
    echo "${INSTALLTARGET:-/usr/local/tomcat}/logs/catalina.out {
maxsize 1
su $TOMCATUSER $TOMCATUSER
copytruncate
daily
nocreate
size 5M
rotate 7
missingok
sharedscripts
prerotate
  find ${INSTALLTARGET:-/usr/local/tomcat}/logs/ -mtime +7 -type f | grep -E '\.txt|\.log|\.gz' | xargs $XARGNORUNIFEMPTY -i{} rm -f '{}'
endscript
}
    " > "$LOGROTATEFILE"
  fi

  detail "Checking service account $TOMCATUSER"
  # create tomcat user and group if they do not exist
  if ! $ISUSEROWNEDINSTALL && [[ $(id "$TOMCATUSER" 2>&1 | grep -ic "No such user") -gt 0 ]]; then
    detail "Creating service user and group $TOMCATUSER"
    if [[ "$DISTRO" == "Debian" || $DISTRO == "Ubuntu" ]]; then
      adduser --system --group "$TOMCATUSER" --home "${INSTALLTARGET:-/usr/local/tomcat}" >> /dev/null 2>&1
      elif [[ "$DISTRO" == "CentOS" || $DISTRO == "RHEL" || $DISTRO == "Fedora" ]]; then
      groupadd -r -f "$TOMCATUSER"  >> /dev/null 2>&1
      useradd -r -s /sbin/nologin -d "${INSTALLTARGET:-/usr/local/tomcat}" -g "$TOMCATUSER" "$TOMCATUSER" >> /dev/null 2>&1
      elif [[ "$DISTRO" == "Mac" ]]; then
      /usr/sbin/sysadminctl -addUser "$TOMCATUSER" -shell /usr/bin/false -home "${INSTALLTARGET:-/usr/local/tomcat}"
      # dscl . -create "${INSTALLTARGET:-/usr/local/tomcat}" UserShell /bin/nologin
      #  #groupadd -r -f "$TOMCATUSER"  >> /dev/null 2>&1
    else
      warn "Unknown distribution; attempting generic service-user creation"
      groupadd "$TOMCATUSER"  >> /dev/null 2>&1
      useradd -s /sbin/nologin -d "${INSTALLTARGET:-/usr/local/tomcat}" -g "$TOMCATUSER" "$TOMCATUSER" >> /dev/null 2>&1
    fi
  fi
  if ! $ISUSEROWNEDINSTALL && [[ $(id "$TOMCATUSER" 2>&1 | grep -ic "No such user") -gt 0 ]]; then
    ERRORMSG="failed adding $TOMCATUSER user, bailing. Check your system?"
    return 1
  fi
  
  # set tomcat files to be available under the right user
  if $ISUSEROWNEDINSTALL; then
    detail "Keeping files owned by $(id -un) for the local smoke install"
  elif [[ $SETUPTYPE != "hs" ]]; then
    detail "Setting Tomcat ownership to $TOMCATUSER"
    if [ "$DISTRO" == "Mac" ]; then
      chown -R "$TOMCATUSER":"$TOMCATUSER" "${INSTALLTARGET:-/usr/local/tomcat}"
    else
      chown -R "$TOMCATUSER":"$TOMCATUSER" "${INSTALLTARGET:-/usr/local/tomcat}"
    fi
    [ -f "$INITSCRIPT" ] && fnsedi "s/TOMCAT_USER=root/TOMCAT_USER=$TOMCATUSER/" "$INITSCRIPT"
    [ -f "$INITSCRIPT" ] && fnsedi "s/TOMCAT_USER=tomcat/TOMCAT_USER=$TOMCATUSER/" "$INITSCRIPT"
  else
    chown -R root:root "${INSTALLTARGET:-/usr/local/tomcat}"
    [ -f "$INITSCRIPT" ] && fnsedi "s/TOMCAT_USER=tomcat/TOMCAT_USER=root/" "$INITSCRIPT"
  fi
  
  # set tomcat running on startup if needed/wanted
  prompt_boolean ISRUNONSTARTUP "  Make Tomcat run on startup?" true || return 1
  if $ISRUNONSTARTUP; then
    # make tomcat start on startup
    detail "Enabling Tomcat at system startup"
    if $ISSYSTEMD; then
      systemctl enable "$(basename "$SYSTEMDUNIT")"
      elif [ -e /etc/debian_version ]; then
      update-rc.d "$(basename "$INITSCRIPT")" defaults
      elif is_command_available chkconfig; then
      chkconfig --add "$(basename "$INITSCRIPT")"
      elif [ "$DISTRO" == "Mac" ]; then
      warn "Automatic startup integration is not implemented for macOS"
    else
      warn "Unknown distribution; Tomcat was not enabled at boot"
    fi
  fi
  install_tomcatctl || {
    ERRORMSG="failed installing tomcatctl or its Bash completion"
    return 1
  }
  write_install_manifest || {
    ERRORMSG="failed recording external installation artifacts"
    return 1
  }
  return 0
}

###
# Function: portapps
# Do final system tweaks to make the install more useable
#
function portapps {
  [[ ! -d "$MVDIRNAME" ]] && return
  local target="${INSTALLTARGET:-/usr/local/tomcat}"
  local i destination context_name security_name source_tomcat_major

  if $ISMIGRATEORIGINALWEBAPPS; then
    detail "Checking for custom webapps to migrate"
    if [ -x "$MVDIRNAME/bin/version.sh" ]; then
      source_tomcat_major=$("$MVDIRNAME/bin/version.sh" 2>/dev/null |
        sed -n 's/.*Server number:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1)
    fi
    if [ -n "$source_tomcat_major" ] && [ "$source_tomcat_major" -le 9 ] &&
       [ "$MAJORTOMCATVERSION" -ge 10 ]; then
      warn "Migrating webapps from Tomcat $source_tomcat_major to Tomcat $MAJORTOMCATVERSION; javax.* applications generally require migration to jakarta.*"
    fi
    if ! cd "$MVDIRNAME/webapps"; then warn "Could not open the old webapp directory; original webapps were not copied"; return 1; fi
    for i in *; do
      [[ "$i" == "*" ]] && continue
      if [[ "$i" == "ROOT" && -e "ROOT/tomcat.gif" ]]; then
        detail "Skipped bundled webapp ROOT"
        continue
      fi
      case "$i" in
        docs|examples|manager|host-manager|test)
          detail "Skipped bundled webapp $i"
          continue
        ;;
      esac
      # Prefer the WAR as the authoritative deployment when both the archive
      # and Tomcat's generated exploded directory are present.
      if [ -d "$i" ] && [ -f "$i.war" ]; then
        detail "Skipped generated exploded webapp $i; $i.war will be migrated"
        continue
      fi
      destination="$target/webapps/$i"
      if [ -e "$destination" ]; then
        detail "Skipped webapp $i because $destination already exists"
        continue
      fi
      cp -a "$i" "$target/webapps/" || { ERRORMSG="failed migrating webapp $i"; return 1; }
      if ! $ISUSEROWNEDINSTALL; then
        chown -R "$TOMCATUSER":"$TOMCATUSER" "$destination" || { ERRORMSG="failed setting ownership on migrated webapp $i"; return 1; }
      fi
      detail "Migrated webapp $i"
    done
  else
    detail "Skipping original webapp migration"
  fi

  if $ISMIGRATECONFIGURATION; then
    detail "Migrating safe per-application context configuration"
    mkdir -p "$target/conf/Catalina/localhost" || { ERRORMSG="cannot create the context configuration directory"; return 1; }
    for i in "$MVDIRNAME"/conf/Catalina/localhost/*.xml; do
      [ -f "$i" ] || continue
      context_name=$(basename "$i")
      destination="$target/conf/Catalina/localhost/$context_name"
      if [ -e "$destination" ]; then
        detail "Skipped context $context_name because the destination exists"
        continue
      fi
      if { is_command_available xmlstarlet || is_command_available xmllint; } && ! is_xml_file_valid "$i"; then
        ERRORMSG="cannot migrate invalid context XML $i"
        return 1
      fi
      cp -a "$i" "$destination" || { ERRORMSG="failed migrating context $context_name"; return 1; }
      if ! $ISUSEROWNEDINSTALL; then
        chown "$TOMCATUSER":"$TOMCATUSER" "$destination" || { ERRORMSG="failed setting ownership on context $context_name"; return 1; }
      fi
      detail "Migrated context configuration $context_name"
    done
    detail "Migrating non-conflicting certificates, keys, and keystores"
    for i in "$MVDIRNAME"/conf/*; do
      [ -f "$i" ] || continue
      security_name=$(basename "$i")
      case "$security_name" in
        *.pem|*.crt|*.cer|*.key|*.p12|*.pfx|*.jks|*.keystore) ;;
        *) continue ;;
      esac
      destination="$target/conf/$security_name"
      if [ -e "$destination" ]; then
        if cmp -s "$i" "$destination"; then
          detail "Certificate/keystore $security_name is already identical"
        else
          warn "Skipped differing certificate/keystore $security_name because the destination exists"
        fi
        continue
      fi
      cp -a "$i" "$destination" || { ERRORMSG="failed migrating certificate/keystore $security_name"; return 1; }
      if ! $ISUSEROWNEDINSTALL; then
        chown "$TOMCATUSER":"$TOMCATUSER" "$destination" || { ERRORMSG="failed setting ownership on $security_name"; return 1; }
      fi
      detail "Migrated certificate/keystore $security_name"
    done
  else
    detail "Skipping configuration migration; custom server.xml changes still require review"
  fi
  
  local ORIGLIBS NEWLIBS MISSING_LIBS
  ORIGLIBS=$(mktemp "${TMPDIR:-/tmp}/installtomcat-origlibs.XXXXXX")
  NEWLIBS=$(mktemp "${TMPDIR:-/tmp}/installtomcat-newlibs.XXXXXX")
  
  [ -d "$MVDIRNAME/lib" ] && find "$MVDIRNAME/lib" -type f -exec basename {} \; | sort > "$ORIGLIBS"
  find "${INSTALLTARGET:-/usr/local/tomcat}/lib" -type f -exec basename {} \; | sort > "$NEWLIBS"
  MISSING_LIBS=$(comm -23 "$ORIGLIBS" "$NEWLIBS")
  if [ -n "$MISSING_LIBS" ]; then
    warn "Some original libraries were not migrated; review this list: $MISSING_LIBS"
  fi
  rm -f "$ORIGLIBS" "$NEWLIBS"
  
  #codex:refactor Host validation used to happen here after downloads and file
  # migration. report_existing_configuration_changes now performs it during
  # preflight, using xmlstarlet when available and a bounded fallback otherwise.
  detail "Checking configurations that require manual migration"
  
  # Show security-sensitive configuration differences without overwriting them.
  if [ -f "$MVDIRNAME/conf/tomcat-users.xml" ] &&
     [ -f "${INSTALLTARGET:-/usr/local/tomcat}/conf/tomcat-users.xml" ] &&
     ! diff -q "${INSTALLTARGET:-/usr/local/tomcat}/conf/tomcat-users.xml" "$MVDIRNAME/conf/tomcat-users.xml" > /dev/null; then
    warn "tomcat-users.xml differs from the original; showing the diff"
    $ISQUIET || print_redacted_xml_diff "$MVDIRNAME/conf/tomcat-users.xml" "${INSTALLTARGET:-/usr/local/tomcat}/conf/tomcat-users.xml"
  fi
  if [ -f "$MVDIRNAME/bin/setenv.sh" ] &&
     [ -f "${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh" ] &&
     ! diff -q "${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh" "$MVDIRNAME/bin/setenv.sh" > /dev/null; then
    warn "setenv.sh differs from the original; showing the diff"
    $ISQUIET || diff -u "$MVDIRNAME/bin/setenv.sh" "${INSTALLTARGET:-/usr/local/tomcat}/bin/setenv.sh"
  fi
  if [ -f "$MVDIRNAME/conf/server.xml" ] &&
     [ -f "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml" ] &&
     ! diff -q "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml" "$MVDIRNAME/conf/server.xml" > /dev/null; then
    warn "server.xml differs from the original; showing the diff"
    $ISQUIET || print_redacted_xml_diff "$MVDIRNAME/conf/server.xml" "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml"
  fi
  return 0
}

# Snapshot the final configuration and summarize migration results. The audit
# stays bounded to immediate deployment/configuration entries; it does not walk
# every file inside exploded applications.
function audit_migration_result() {
  local is_snapshot="${1:-true}"
  local target="${INSTALLTARGET:-/usr/local/tomcat}"
  local postinstall="$target/conf/server.xml.postinstall"
  local postmigration="$target/conf/server.xml.postmigration"

  section "Migration audit"
  if $is_snapshot; then
    cp "$target/conf/server.xml" "$postmigration" || {
      ERRORMSG="could not create the server.xml.postmigration audit snapshot"
      return 1
    }
    detail "Saved final server configuration as $postmigration"
  else
    postmigration="$target/conf/server.xml"
    detail "Dry run: auditing the current server.xml without creating server.xml.postmigration"
  fi
  if [ -f "$postinstall" ] && cmp -s "$postinstall" "$postmigration"; then
    detail "server.xml versus server.xml.postinstall: same"
  elif [ -f "$postinstall" ]; then
    warn "server.xml versus server.xml.postinstall: different"
    $ISQUIET || print_redacted_xml_diff "$postinstall" "$postmigration"
  else
    warn "server.xml.postinstall is unavailable; the final server.xml cannot be compared with the installer baseline"
  fi

  [ -d "$MVDIRNAME" ] || { detail "No older installation was available for a source/target audit"; return 0; }
  report_migration_inventory "Webapp deployment" output_webapp_entry_names "$MVDIRNAME" "$target"
  report_migration_file_contents "WAR" output_webapp_entry_names "$MVDIRNAME" "$target" webapps
  detail "Exploded webapp contents are not recursively compared; copied/skipped results are listed above"
  report_migration_inventory "Per-app context" output_context_names "$MVDIRNAME" "$target"
  report_migration_file_contents "Per-app context" output_context_names "$MVDIRNAME" "$target" conf/Catalina/localhost
  report_migration_inventory "Certificate/keystore" output_security_file_names "$MVDIRNAME" "$target"
  report_migration_file_contents "Certificate/keystore" output_security_file_names "$MVDIRNAME" "$target" conf
  report_migration_inventory "Shared library" output_shared_library_names "$MVDIRNAME" "$target"
  report_migration_file_contents "Shared library" output_shared_library_names "$MVDIRNAME" "$target" lib
}

# Print the most recent installer backup by its numeric .old.TIMESTAMP suffix.
# Only immediate sibling directories are considered; no filesystem walk occurs.
function output_latest_migration_source() {
  local candidate timestamp
  for candidate in "${INSTALLTARGET}.old."*; do
    [ -d "$candidate" ] || continue
    timestamp=${candidate##*.old.}
    [[ "$timestamp" == *[!0-9]* ]] && continue
    printf '%s\t%s\n' "$timestamp" "$candidate"
  done | sort -n | tail -n 1 | cut -f 2-
}

# Re-run migration independently of installation. Existing destinations are
# skipped by portapps, making the supported copy operations repeatable.
function run_migration_only() {
  local target_tomcat_major
  section "Migrate an older Tomcat installation into the current installation"
  if [ ! -d "$INSTALLTARGET" ] || [ ! -x "$INSTALLTARGET/bin/catalina.sh" ]; then
    ERRORMSG="migration target $INSTALLTARGET is not an installed Tomcat"
    return 1
  fi
  target_tomcat_major=$("$INSTALLTARGET/bin/version.sh" 2>/dev/null |
    sed -n 's/.*Server number:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1)
  [ -n "$target_tomcat_major" ] && MAJORTOMCATVERSION=$target_tomcat_major
  if [ -z "$MIGRATIONSOURCE" ]; then
    MIGRATIONSOURCE=$(output_latest_migration_source)
  fi
  [ -n "$MIGRATIONSOURCE" ] || {
    ERRORMSG="no migration source was supplied and no ${INSTALLTARGET}.old.TIMESTAMP backup was found"
    return 1
  }
  [ "$MIGRATIONSOURCE" != "$INSTALLTARGET" ] || {
    ERRORMSG="migration source and target must be different directories"
    return 1
  }
  if [ ! -d "$MIGRATIONSOURCE" ] || [ ! -f "$MIGRATIONSOURCE/conf/server.xml" ]; then
    ERRORMSG="migration source $MIGRATIONSOURCE is not an installed Tomcat"
    return 1
  fi
  MVDIRNAME=$MIGRATIONSOURCE
  detail "Source: $MVDIRNAME"
  detail "Target: $INSTALLTARGET"
  detail "Source webapps: $(output_detected_webapps_annotation "$MVDIRNAME")"
  detail "Migrate webapps: $ISMIGRATEORIGINALWEBAPPS"
  detail "Migrate per-app contexts: $ISMIGRATECONFIGURATION"
  report_existing_configuration_changes "$MVDIRNAME" false || return 1
  if $ISDRYRUN; then
    audit_migration_result false || return 1
    detail "Dry run: no webapps or context files were copied"
    return 0
  fi
  if is_configured_tomcat_service_running; then
    ERRORMSG="the target Tomcat is running; stop it before copying applications or context files (tomcatctl --tomcat-home $INSTALLTARGET stop)"
    return 1
  fi
  [ -w "$INSTALLTARGET" ] || {
    ERRORMSG="migration target $INSTALLTARGET is not writable by $(id -un)"
    return 1
  }
  if [ -n "$TOMCATUSERCMDLINE" ]; then
    TOMCATUSER=$TOMCATUSERCMDLINE
  elif $ISUSEROWNEDINSTALL; then
    TOMCATUSER=$(id -un)
  else
    # Use the current deployment directory owner rather than the old source
    # owner; the current instance may deliberately run under a different user.
    TOMCATUSER=$(stat -c '%U' "$INSTALLTARGET/webapps" 2>/dev/null || stat -f '%Su' "$INSTALLTARGET/webapps")
  fi
  [ -n "$TOMCATUSER" ] || {
    ERRORMSG="could not determine the current Tomcat user; supply it with --user USER"
    return 1
  }
  portapps || return 1
  ensure_localhost_rsa_certificate || return 1
  ensure_localhost_https_connector || return 1
  audit_migration_result || return 1
  if ! is_xml_file_valid "$INSTALLTARGET/conf/server.xml"; then
    ERRORMSG="migration completed but the target server.xml is not valid XML"
    return 1
  fi
  success "Migration tasks completed; Tomcat was not restarted"
}

function rollback_install() {
  local target="${INSTALLTARGET:-/usr/local/tomcat}"
  local failedtarget
  failedtarget="${target}.failed.$(date +%s)"
  local backup backupname original initbackup

  $ISINSTALLACTIVATED || return 0
  if ! $ISROLLBACKONFAILURE; then
    warn "Automatic rollback is disabled; preserving the failed installation at $target"
    if [ -d "$MVDIRNAME" ]; then
      warn "The previous installation remains available at $MVDIRNAME"
    fi
    warn "Use tomcatctl logs catalina, tomcatctl logs application, or the files under $target/logs to troubleshoot"
    return 0
  fi
  if [ -d "$MVDIRNAME" ]; then
    warn "Restoring the previous Tomcat installation from $MVDIRNAME"
    if [ -e "$target" ]; then
      mv "$target" "$failedtarget" || return 1
      warn "Preserved the failed installation at $failedtarget"
    fi
    mv "$MVDIRNAME" "$target" || return 1
  elif ! $ISOLDTARGETPRESENT && [ -e "$target" ]; then
    mv "$target" "$failedtarget" || return 1
    warn "Preserved the failed installation at $failedtarget"
  fi

  initbackup="$(dirname "$target")/$(basename "$INITSCRIPT").$MVDIRSTAMP.init"
  if [ -f "$initbackup" ]; then
    cp "$initbackup" "$INITSCRIPT"
  elif ! $ISOLDINITSCRIPTPRESENT && ! $ISSYSTEMD && [ -e "$INITSCRIPT" ]; then
    mv "$INITSCRIPT" "$(dirname "$target")/$(basename "$INITSCRIPT").failed.$(date +%s).init"
  fi
  if ! $ISUSEROWNEDINSTALL; then
    for backup in "$(dirname "$target")"/*."$MVDIRSTAMP".initd; do
      [ -f "$backup" ] || continue
      backupname=$(basename "$backup")
      original=${backupname%."$MVDIRSTAMP".initd}
      mv "$backup" "/etc/init.d/$original"
    done
    for backup in "$(dirname "$target")"/*."$MVDIRSTAMP".service; do
      [ -f "$backup" ] || continue
      backupname=$(basename "$backup")
      original=${backupname%."$MVDIRSTAMP".service}
      case "$original" in
        *.service) cp "$backup" "/etc/systemd/system/$original" ;;
        *) cp "$backup" "/etc/systemd/system/$original.service" ;;
      esac
    done
    if ! $ISOLDSYSTEMDUNITPRESENT && [ -e "$SYSTEMDUNIT" ]; then
      mv "$SYSTEMDUNIT" "$(dirname "$target")/$(basename "$SYSTEMDUNIT").failed.$(date +%s)"
    fi
  fi
  if [ -n "$LOGROTATEBACKUP" ] && [ -f "$LOGROTATEBACKUP" ]; then
    cp "$LOGROTATEBACKUP" "$LOGROTATEFILE"
  elif [ -n "$LOGROTATEFILE" ] && [ -f "$LOGROTATEFILE" ]; then
    mv "$LOGROTATEFILE" "$(dirname "$target")/$(basename "$LOGROTATEFILE").failed.$(date +%s).logrotate"
  fi
  $ISSYSTEMD && systemctl daemon-reload
  if $ISOLDSERVICERUNNING; then
    if $ISSYSTEMD; then
      systemctl restart "$(basename "$SYSTEMDUNIT")" >/dev/null 2>&1
    elif [ -x "$INITSCRIPT" ]; then
      "$INITSCRIPT" restart >/dev/null 2>&1
    fi
  fi
}


if ! parsecommandline "$@"; then
  echo
  version
  usage
  error "Invalid installation configuration: $ERRORMSG"
  echo
  exit 1
fi
if ! initialize_output; then
  error "$ERRORMSG"
  exit 1
fi
if $ISMIGRATEONLY; then
  if ! run_migration_only; then
    error "Migration failed: $ERRORMSG"
    exit 1
  fi
  exit 0
fi
if $ISDRYRUN; then
  print_install_plan
  exit 0
fi
CALLER=$(ps -p "$PPID" -o args= 2>/dev/null | awk '{print $NF}')
$ISPROMPT && [ "$CALLER" = "-bash" ] && version

if ! installreqs; then
  error "Requirement check failed: $ERRORMSG"
  exit 1
fi

if ! installtomcat; then
  rollback_install
  error "Installation failed: $ERRORMSG"
  exit 1
fi

# enable common apache modules if apache is in place
apachetweaks || true

if ! portapps; then
  rollback_install
  error "Migration failed: $ERRORMSG"
  exit 1
fi
if ! ensure_localhost_rsa_certificate || ! ensure_localhost_https_connector; then
  rollback_install
  error "Installation failed: $ERRORMSG"
  exit 1
fi
if ! configure_instance_ports; then
  rollback_install
  error "Installation failed: could not configure instance ports"
  exit 1
fi
# Record the exact server.xml produced by this installer after every automated
# edit. Future upgrades compare the live file with this baseline so only later
# administrator/application changes need manual reconciliation.
if ! cp "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml" \
  "${INSTALLTARGET:-/usr/local/tomcat}/conf/server.xml.postinstall"; then
  rollback_install
  error "Installation failed: could not create the server.xml.postinstall baseline"
  exit 1
fi
if ! audit_migration_result; then
  rollback_install
  error "Migration audit failed: $ERRORMSG"
  exit 1
fi

section "Start Tomcat and verify the service"
ISRUNNINGOK=false
if $ISSYSTEMD; then
  systemctl restart "$(basename "$SYSTEMDUNIT")"
  systemctl --quiet is-active "$(basename "$SYSTEMDUNIT")" && ISRUNNINGOK=true
else
  "$INITSCRIPT" restart >> /dev/null 2>&1
  [[ $("$INITSCRIPT" status | grep -c 'PIDs') -gt 0 ]] && ISRUNNINGOK=true
fi

if ! $ISRUNNINGOK; then
  error "Tomcat did not start cleanly; inspect the service logs or retry without migration"
  rollback_install
  exit 1
else
  # Prefer the address on the default outbound route; MYIP can override this
  # when the externally visible address differs because of NAT or routing.
  if ! $ISBINDHTTPPUBLIC; then
    ipaddr=127.0.0.1
  else
    ipaddr=$(output_server_ip_address)
  fi
  success "Tomcat $tomcatversion is running at http://$ipaddr:$HTTPPORT"
  summarize_tomcat_configuration
  if $ISRUNPOSTINSTALLTESTS && ! test_tomcat_installation; then
    rollback_install
    error "Post-install validation failed: $ERRORMSG"
    exit 1
  fi
  if $ISINSTALLTOMCATCTL && [ -x "${TOMCATCTLFILE:-}" ]; then
    detail "Manage this instance with: $TOMCATCTLFILE --instance $INSTANCENUMBER status"
    detail "Run '$TOMCATCTLFILE --help' for restart, application/access logs, configuration, and health commands."
  fi
fi

# EOF
