#!/usr/bin/env bash
#
# Installs a 0G Nova Adventure server on this machine.
#
# WHAT IT ASSUMES: that you run Linux services and would rather read a script than click through a
# wizard. It does the tedious, error-prone parts - creating a role and database, applying the
# schema in the right order, writing a unit file with the right dependencies - and leaves the
# decisions to you.
#
# WHAT IT DOES NOT DO: install PostgreSQL or turn a firewall on. Those are your machine's
# business and you almost certainly have opinions about them.
#
#   sudo ./install.sh                 install to /opt/zerog-nova-server
#   sudo ./install.sh --prefix /srv   somewhere else
#   sudo ./install.sh --no-systemd    files and database only
#
#   sudo ./install.sh --admin "You" --name "My Server" --tags PVE
#
# BROWSER (WEBGL) PLAYERS ARE SET UP AUTOMATICALLY when this machine has a public IP: Caddy is
# installed, gets a certificate for that IP, and the server announces a wss:// address. Ports 80
# and 443 must be reachable from the internet. Options:
#   --domain NAME   use a hostname you own instead of the IP (its DNS must point here)
#   --no-web        skip browser access; desktop players only

set -euo pipefail

PREFIX="/opt"
INSTALL_SYSTEMD=1
SERVICE_USER="zerog"
DB_NAME="zerog_nova_adventure"
DB_USER="zerog"
ADMIN_USER=""
DOMAIN=""
WEB=1
SERVER_NAME=""
SERVER_TAGS=""

while [[ $# -gt 0 ]]; do
    case "$1" in
        --prefix)     PREFIX="$2"; shift 2 ;;
        --user)       SERVICE_USER="$2"; shift 2 ;;
        --admin)      ADMIN_USER="$2"; shift 2 ;;
        --db-name)    DB_NAME="$2"; shift 2 ;;
        --db-user)    DB_USER="$2"; shift 2 ;;
        --domain)     DOMAIN="$2"; shift 2 ;;
        --no-web)     WEB=0; shift ;;
        --name)       SERVER_NAME="$2"; shift 2 ;;
        --tags)       SERVER_TAGS="$2"; shift 2 ;;
        --no-systemd) INSTALL_SYSTEMD=0; shift ;;
        -h|--help)    sed -n '2,24p' "$0"; exit 0 ;;
        *) echo "Unknown option: $1" >&2; exit 2 ;;
    esac
done

SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$PREFIX/zerog-nova-server"

say()  { printf '  %s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
die()  { printf '\nERROR: %s\n' "$*" >&2; exit 1; }

[[ $EUID -eq 0 ]] || die "Run this with sudo - it creates a system user and a systemd unit."

step "Checking what is already here"

# Installed when missing, so running this script on its own works on a bare machine too - the
# bootstrap does the same first, but nobody is obliged to have started here. Debian/Ubuntu only:
# their package creates the cluster and starts it; elsewhere that is the host's decision.
if ! command -v psql >/dev/null 2>&1; then
    if command -v apt-get >/dev/null 2>&1; then
        say "PostgreSQL not found - installing it..."
        DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
        DEBIAN_FRONTEND=noninteractive apt-get install -y -qq postgresql >/dev/null 2>&1 \
            || die "Could not install PostgreSQL with apt. Install it yourself, then run this again."
        systemctl enable --now postgresql >/dev/null 2>&1 || true
    else
        die "psql not found, and this is not Debian or Ubuntu. Install PostgreSQL and start it, then run this again."
    fi
fi
say "psql: $(psql --version | head -1)"

# The install below talks to the local cluster as the postgres user; a package that installed but
# did not start (a container, a masked unit) would otherwise fail much later with a confusing error.
sudo -u postgres psql -tAc "SELECT 1" >/dev/null 2>&1 \
    || die "PostgreSQL is installed but not answering. Start it (systemctl start postgresql) and run this again."

systemctl --version >/dev/null 2>&1 || INSTALL_SYSTEMD=0
[[ $INSTALL_SYSTEMD -eq 1 ]] || say "systemd: skipped"

# RE-RUNNING ON A LIVE SERVER is the normal way to change one setting, and it must not copy new
# binaries over ones that are executing - "Text file busy" aborts the script a third of the way in.
# So a running server is stopped first and brought back at the end. Remembered rather than assumed:
# an install that was not running is not started here (the caller decides when).
RESTART_UNITS=()
if [[ $INSTALL_SYSTEMD -eq 1 ]]; then
    for unit in zerog-server zerog-gateway; do
        if systemctl is-active --quiet "$unit" 2>/dev/null; then
            RESTART_UNITS+=("$unit")
        fi
    done
    if [[ ${#RESTART_UNITS[@]} -gt 0 ]]; then
        say "stopping ${RESTART_UNITS[*]} for the update (they are started again at the end)"
        systemctl stop zerog-gateway zerog-server 2>/dev/null || true
    fi
fi

# The server binds a UDP port and the gateway a TCP one; a port already taken is far easier to
# diagnose now than as a service that starts and immediately exits.
for port in 8081 25312; do
    if ss -lntu 2>/dev/null | grep -q ":$port "; then
        say "WARNING: something is already listening on port $port"
    fi
done

step "Installing files to $TARGET"

install -d -m 0755 "$TARGET"

# RUNNING FROM THE INSTALL ITSELF IS A SUPPORTED WAY TO RUN THIS, and it must not try to copy
# the files over themselves. "cp: 'x' and 'x' are the same file" is an ERROR, not a no-op, and
# under "set -e" it aborts the whole script - so a host who did the most natural thing available
# to them (re-run the installer that is sitting in their server folder, to change one setting)
# got a failure a third of the way through, with no clue that the location was the problem.
#
# Compared with realpath rather than as strings: $TARGET is assembled from $PREFIX and may differ
# by a trailing slash, a symlink or a relative segment while naming the very same directory.
SAME_PLACE=0
if [[ -d "$TARGET" ]] && [[ "$(realpath "$SOURCE")" == "$(realpath "$TARGET")" ]]; then
    SAME_PLACE=1
fi

# Where database updates land, and where a host can drop their own. Made before the copy below,
# so sql/README.txt has somewhere to go.
install -d -m 0755 "$TARGET/sql" "$TARGET/data"

if [[ $SAME_PLACE -eq 1 ]]; then
    say "running from the install itself - files are already in place, nothing copied"
else
    cp -r "$SOURCE/server" "$SOURCE/gateway" "$TARGET/"
    install -m 0644 "$SOURCE/jwt-public.pem" "$SOURCE/schema.sql" "$TARGET/"
    [[ -f "$SOURCE/seed.sql" ]] && install -m 0644 "$SOURCE/seed.sql" "$TARGET/"
    [[ -f "$SOURCE/VERSION" ]]  && install -m 0644 "$SOURCE/VERSION"  "$TARGET/"

    # The release stamp update.sh reads and rewrites. Copied like VERSION rather than required,
    # because a package built before stamping existed does not have one - and update.sh writes it
    # on the first pass anyway.
    [[ -f "$SOURCE/version.txt" ]] && install -m 0644 "$SOURCE/version.txt" "$TARGET/"

    # The updater. It belongs beside the binaries because update.sh derives every path from its
    # own location - the service runs it as an ExecStartPre and passes it nothing.
    install -m 0755 "$SOURCE/update.sh" "$TARGET/"
    [[ -f "$SOURCE/sql/README.txt" ]] && install -m 0644 "$SOURCE/sql/README.txt" "$TARGET/sql/"
fi

chmod +x "$TARGET/server/ZeroGNovaAdventure.Server" "$TARGET/gateway/ZeroGNovaAdventure.Gateway"

say "binaries, schema, public key and updater in place"

step "Service account"

if id -u "$SERVICE_USER" >/dev/null 2>&1; then
    say "user '$SERVICE_USER' already exists"
else
    useradd --system --home-dir "$TARGET" --shell /usr/sbin/nologin "$SERVICE_USER"
    say "created system user '$SERVICE_USER'"
fi

step "Database"

# A password is generated rather than asked for. It is written to one root-only file and used by
# exactly one service, so a human never needs to know it - and a password a human never types is
# a password that never ends up in a shell history.
DB_PASS="$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 24)"

if sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1; then
    say "role '$DB_USER' exists - leaving its password alone"
    DB_PASS=""
else
    sudo -u postgres psql -qc "CREATE ROLE \"$DB_USER\" LOGIN PASSWORD '$DB_PASS';" >/dev/null
    say "created role '$DB_USER'"
fi

if sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
    say "database '$DB_NAME' exists - schema and seed NOT reapplied"
    FRESH_DB=0
else
    sudo -u postgres createdb -O "$DB_USER" "$DB_NAME"
    say "created database '$DB_NAME'"
    FRESH_DB=1
fi

if [[ $FRESH_DB -eq 1 ]]; then
    # SCHEMA THEN SEED, in that order, and both as the owning role so every table belongs to the
    # account the server connects as. The schema comes from the live game rather than from EF
    # migrations - only a handful of those are discoverable, and Migrate() alone builds a fraction
    # of the tables.
    say "applying schema..."
    PGPASSWORD="$DB_PASS" psql -q -h localhost -U "$DB_USER" -d "$DB_NAME" -f "$TARGET/schema.sql" >/dev/null

    # WHICH SCHEMA THIS SERVER WAS BUILT FROM, recorded so update.sh knows which database scripts
    # it already contains and does not replay them.
    #
    # The version comes from the schema FILE, not from the release: a package can ship a schema
    # dumped some time before it was built, and it is the dump that decides which changes are
    # already in these tables. An unstamped schema records nothing, and update.sh then applies
    # every script - correct, if slower, and the safe direction to be wrong in.
    SCHEMA_VERSION="$(sed -n 's/^--[[:space:]]*schema-version:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$TARGET/schema.sql" | head -1)"
    if [[ -n "$SCHEMA_VERSION" ]]; then
        install -d -m 0755 "$TARGET/data"
        printf '{\n  "schemaVersion": "%s"\n}\n' "$SCHEMA_VERSION" > "$TARGET/data/schema-version.json"
        chown "$SERVICE_USER:$SERVICE_USER" "$TARGET/data/schema-version.json" 2>/dev/null || true
        say "schema version $SCHEMA_VERSION recorded"
    else
        say "schema carries no version stamp - every database script will be applied"
    fi
    if [[ -f "$TARGET/seed.sql" ]]; then
        say "applying world data..."
        PGPASSWORD="$DB_PASS" psql -q -h localhost -U "$DB_USER" -d "$DB_NAME" -f "$TARGET/seed.sql" >/dev/null
    fi
    say "database ready"
fi

step "Configuration"

CONFIG="$TARGET/server/server.env"

if [[ -f "$CONFIG" ]]; then
    say "server.env exists - left untouched"
else
    umask 077
    cat > "$CONFIG" <<EOF
# 0G Nova Adventure - server settings.
# Read by the game server at startup. Keep it out of backups you share.

DB_HOST=localhost
DB_PORT=5432
DB_NAME=$DB_NAME
DB_USER=$DB_USER
DB_PASS=$DB_PASS

# Verifies player logins against the central account service. It cannot sign anything,
# which is why it is safe to ship.
JWT_PUBLIC_KEY_FILE=$TARGET/jwt-public.pem
EOF
    chown "$SERVICE_USER:$SERVICE_USER" "$CONFIG"
    chmod 0600 "$CONFIG"
    say "wrote $CONFIG (0600, owned by $SERVICE_USER)"
fi

# WHO OWNS THIS SERVER.
#
# The first account to connect under this name is promoted to Admin, once, and from then on ranks
# are managed deliberately from inside the game. Without it nobody on a Linux server can ever hold
# a rank at all: the promotion is the only way the first one is created, and every in-game admin
# control is gated behind it. The Windows launcher writes this the moment a host names themselves;
# there was no equivalent here, which made a self-hosted Linux server permanently ownerless.
#
# Applied even when server.env already exists, unlike everything above it. A host re-running the
# installer to set this is asking for exactly one thing, and "left untouched" would silently
# ignore them.
if [[ -n "$ADMIN_USER" ]]; then
    if grep -q '^SERVER_ADMIN_USERNAME=' "$CONFIG" 2>/dev/null; then
        sed -i "s|^SERVER_ADMIN_USERNAME=.*|SERVER_ADMIN_USERNAME=$ADMIN_USER|" "$CONFIG"
        say "server owner updated to '$ADMIN_USER'"
    else
        printf '\n# The account promoted to Admin on its first connect.\nSERVER_ADMIN_USERNAME=%s\n' \
            "$ADMIN_USER" >> "$CONFIG"
        say "server owner set to '$ADMIN_USER'"
    fi
    chown "$SERVICE_USER:$SERVICE_USER" "$CONFIG"
    chmod 0600 "$CONFIG"
fi

# Sets KEY=value in server.env, replacing any existing line. Rewritten with grep -v rather than
# sed so a name containing '/', '&' or '|' needs no escaping. DeploymentConfig reads everything
# after the first '=', so spaces need no quoting either.
set_env() {
    local key="$1" value="$2" tmp
    tmp="$(mktemp)"
    grep -v "^$key=" "$CONFIG" > "$tmp" || true
    printf '%s=%s\n' "$key" "$value" >> "$tmp"
    cat "$tmp" > "$CONFIG"
    rm -f "$tmp"
    chown "$SERVICE_USER:$SERVICE_USER" "$CONFIG"
    chmod 0600 "$CONFIG"
}

# server.env ONLY SEEDS THE SERVER'S SETTINGS, ONCE. On its first start the server copies
# ZEROG_SERVER_NAME / _TAGS / _PUBLIC_URL / _GATEWAY_PORT into data/server-settings.json, and from
# then on that file wins and server.env is never consulted for them again. So a value changed by
# re-running this installer (or set after a first start that happened without it) would be silently
# ignored. This writes the file as well when it exists. Called only while the server is stopped.
#   set_setting <Key> <str|int|bool|tags> <value>
set_setting() {
    local file="$TARGET/data/server-settings.json"
    [[ -f "$file" ]] || return 0
    if command -v python3 >/dev/null 2>&1; then
        python3 - "$file" "$1" "$2" "$3" <<'PY' || say "WARNING: could not update $file"
import json, re, sys
path, key, kind, val = sys.argv[1:5]
with open(path, encoding="utf-8-sig") as f:
    data = json.load(f)
if kind == "int":
    data[key] = int(val)
elif kind == "bool":
    data[key] = val.lower() == "true"
elif kind == "tags":
    data[key] = [t for t in re.split(r"[,\s#]+", val) if t]
else:
    data[key] = val
with open(path, "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2)
PY
    else
        say "WARNING: python3 not found - edit $file by hand ($1), or delete it to re-read server.env"
    fi
    chown "$SERVICE_USER:$SERVICE_USER" "$file" 2>/dev/null || true
}

# LISTING. A name is what makes the server register with the rendezvous. Written to server.env
# rather than the unit file because the unit is regenerated on every run of this script.
if [[ -n "$SERVER_NAME" ]]; then
    set_env ZEROG_SERVER_NAME "$SERVER_NAME"
    set_setting Name str "$SERVER_NAME"
    set_setting Register bool true
    say "server name set to '$SERVER_NAME'"
fi
if [[ -n "$SERVER_TAGS" ]]; then
    set_env ZEROG_SERVER_TAGS "$SERVER_TAGS"
    set_setting Tags tags "$SERVER_TAGS"
    say "server tags set to '$SERVER_TAGS'"
fi

# BROWSER (WEBGL) ACCESS. A browser on an HTTPS page can only open wss://, which needs a
# certificate, so this server has to speak TLS itself. Two ways to get one:
#   --domain NAME   an ordinary certificate for a hostname you own
#   (default)       a certificate for this machine's own public IP, from Let's Encrypt's
#                   short-lived IP certificates - no domain, no DNS record, nothing to buy
# Caddy does both and renews on its own. --no-web skips all of it.
#
# NEVER FATAL. The game server works without this, so a failure here leaves a working desktop-only
# server and says why, instead of aborting an install that is otherwise done.

# The address other machines reach this one at. Asks the local routing table first - free, and
# right for a VPS - then falls back to the rendezvous, which already sees every caller's address.
# Deliberately not a third-party "what is my IP" site.
detect_public_ip() {
    local ip
    ip="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1)"
    if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] \
       && ! [[ "$ip" =~ ^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.) ]]; then
        echo "$ip"; return 0
    fi
    # Behind 1:1 NAT (most clouds) the local address is private; the rendezvous sees the real one.
    ip="$(curl -fsS -m 8 https://rendezvous.zerognova.com/api/myip 2>/dev/null | sed -n 's/.*"address"[[:space:]]*:[[:space:]]*"\([0-9.]*\)".*/\1/p')"
    [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "$ip"
}

# Distro packages of Caddy are too old to issue IP certificates (they need 2.10+), so it is
# installed from Caddy's own apt repository. An existing recent Caddy is left alone.
ensure_caddy() {
    # True when the installed Caddy is 2.10 or newer. Checked AGAIN after installing, because a
    # package manager "succeeding" only means SOME caddy arrived: on Ubuntu the distro's own 2.6
    # wins whenever Caddy's repository is rejected, and 2.6 fails to parse the config below.
    caddy_recent_enough() {
        local ver
        command -v caddy >/dev/null 2>&1 || return 1
        ver="$(caddy version 2>/dev/null | sed -n 's/^v\([0-9]*\.[0-9]*\).*/\1/p' | head -1)"
        [[ -n "$ver" ]] && [[ "${ver%%.*}" -gt 2 || ( "${ver%%.*}" -eq 2 && "${ver##*.}" -ge 10 ) ]]
    }

    caddy_recent_enough && return 0
    command -v caddy >/dev/null 2>&1 && say "existing Caddy ($(caddy version | head -1)) is too old for IP certificates - upgrading"

    command -v apt-get >/dev/null 2>&1 || { say "no apt here - install Caddy 2.10+ yourself"; return 1; }
    say "installing Caddy from caddyserver's apt repository..."
    export DEBIAN_FRONTEND=noninteractive
    apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gpg >/dev/null 2>&1 || true
    curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
        | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg || return 1
    # 0644, NOT gpg's default 0600. apt verifies the repository as the unprivileged _apt user, which
    # cannot read a root-only keyring - so the repository is reported "not signed" and ignored.
    chmod 0644 /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \
        > /etc/apt/sources.list.d/caddy-stable.list || return 1
    chmod 0644 /etc/apt/sources.list.d/caddy-stable.list
    if ! apt-get update 2>&1 | grep -q 'dl.cloudsmith.io'; then
        say "warning: Caddy's repository did not respond to apt"
    fi
    apt-get install -y caddy >/dev/null 2>&1 || return 1

    if ! caddy_recent_enough; then
        say "installed Caddy is $(caddy version 2>/dev/null | head -1), still older than 2.10 -"
        say "the caddyserver.com repository was probably not usable (see: apt-cache policy caddy)."
        return 1
    fi
}

setup_web() {
    local site tls_block=""

    if [[ -n "$DOMAIN" ]]; then
        site="$DOMAIN"
    else
        say "looking up this machine's public address..."
        WEB_IP="$(detect_public_ip || true)"
        if [[ -z "$WEB_IP" ]]; then
            say "could not find a public IPv4 address - browser access skipped."
            say "(behind a home router this is expected: only desktop players can join)"
            return 1
        fi
        say "public address: $WEB_IP"
        site="https://$WEB_IP"
        # shortlived is the only Let's Encrypt profile that issues for an IP.
        tls_block=$'    tls {\n        issuer acme {\n            profile shortlived\n        }\n    }\n'
    fi

    # PORTS 80 AND 443 MUST BE FREE - or already Caddy's. Let's Encrypt validates on 80 (and on every
    # renewal, not just the first), and browsers connect on 443. A machine that already serves a
    # website has both taken, and installing Caddy beside nginx/Apache would leave an enabled unit
    # that fails to bind - and could win the race for port 80 after a reboot and take that website
    # down. So it is checked BEFORE anything is installed, and the answer is "not here".
    local busy=""
    for p in 80 443; do
        local owner
        owner="$(ss -lntpH "sport = :$p" 2>/dev/null | grep -o 'users:(("[^"]*"' | head -1 | sed 's/users:(("//; s/"$//')"
        if [[ -n "$owner" && "$owner" != "caddy" ]]; then
            busy="$busy $p ($owner)"
        fi
    done
    if [[ -n "$busy" ]]; then
        say "port(s)$busy already in use by another program."
        say "Browser access needs 80 and 443 free, so it is NOT being set up. Nothing was"
        say "installed or changed. Desktop players can still join this server normally."
        say "To serve browsers too: run this on a machine with those ports free, or see the"
        say "README section on putting an existing web server (nginx/Apache) in front."
        return 1
    fi

    ensure_caddy || { say "Caddy could not be installed - browser access skipped."; return 1; }
    say "caddy: $(caddy version | head -1)"

    # Only /ws is proxied; Caddy upgrades the WebSocket by itself. Appended rather than overwritten
    # because the host may already serve other sites from this Caddyfile.
    local caddyfile="/etc/caddy/Caddyfile"
    install -d -m 0755 /etc/caddy
    touch "$caddyfile"
    if grep -qF "$site {" "$caddyfile"; then
        say "$caddyfile already has a block for $site - left untouched"
    else
        cp -p "$caddyfile" "$caddyfile.zerog-backup"
        printf '\n# 0G Nova Adventure - browser players reach the gateway through here.\n%s {\n%s    reverse_proxy /ws localhost:8081\n}\n' \
            "$site" "$tls_block" >> "$caddyfile"
        # A Caddyfile that does not parse takes down every site Caddy serves, not just ours - so
        # the addition is validated and taken back out if it is not accepted.
        if ! caddy validate --config "$caddyfile" --adapter caddyfile >/dev/null 2>&1; then
            mv -f "$caddyfile.zerog-backup" "$caddyfile"
            say "Caddy rejected the configuration - change removed, browser access skipped."
            return 1
        fi
        rm -f "$caddyfile.zerog-backup"
        say "added $site to $caddyfile"
    fi

    # Certificate validation arrives on port 80, and players on 443. Opened only when ufw is
    # already in use - this script does not turn a firewall on.
    if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
        ufw allow 80/tcp >/dev/null 2>&1 && ufw allow 443/tcp >/dev/null 2>&1 && say "ufw: opened 80 and 443"
    fi

    systemctl enable caddy >/dev/null 2>&1 || true
    systemctl restart caddy || { say "caddy did not start - check: journalctl -u caddy"; return 1; }

    # DECLARE THE ADDRESS ONLY ONCE IT WORKS. Declaring a wss:// address that does not answer would
    # list this server for browsers and fail every click - worse than not listing it. Caddy fetches
    # the certificate on first use, which takes a few seconds; the check is a real, verifying TLS
    # request (no -k), so it passes only when a browser would accept the certificate too.
    local host="${DOMAIN:-$WEB_IP}" ok=0
    say "waiting for the certificate (up to 90s)..."
    for _ in $(seq 1 30); do
        if curl -fsS -m 5 -o /dev/null "https://$host/" 2>/dev/null \
           || [[ "$(curl -sS -m 5 -o /dev/null -w '%{http_code}' "https://$host/ws" 2>/dev/null)" =~ ^(101|400|426|200)$ ]]; then
            ok=1; break
        fi
        sleep 3
    done

    if [[ $ok -ne 1 ]]; then
        say "no valid certificate yet. Usual causes: ports 80/443 closed at your provider's"
        say "firewall, or (with --domain) DNS not pointing here. Browser access NOT declared."
        say "Fix that, then run this installer again - it is safe to repeat."
        say "Details: journalctl -u caddy"
        return 1
    fi

    # Declared, because the rendezvous cannot discover a reverse proxy by probing port 8081.
    set_env ZEROG_SERVER_PUBLIC_URL "wss://$host/ws"
    set_env ZEROG_GATEWAY_PORT 443
    # A RESUMED REGISTRATION NEVER RE-SENDS THE ADDRESS. A server that already registered keeps its
    # saved id and only heartbeats; the declared address travels solely on a brand-new registration.
    # So when the address is changing on an existing server, the saved identity is dropped - the id
    # and secret only, NOT the join code, which the fresh registration asks for back - and the server
    # registers again on its next start carrying the new address. Skipped when nothing changed.
    local state="$TARGET/data/rendezvous.json" settings_file="$TARGET/data/server-settings.json"
    if [[ -f "$state" ]] && ! grep -qF "\"wss://$host/ws\"" "$settings_file" 2>/dev/null \
       && command -v python3 >/dev/null 2>&1; then
        python3 - "$state" <<'PY' && say "registration will be renewed with the new address (join code kept)"
import json, sys
with open(sys.argv[1], encoding="utf-8-sig") as f:
    d = json.load(f)
d["ServerId"] = None
d["Secret"] = None
with open(sys.argv[1], "w", encoding="utf-8") as f:
    json.dump(d, f, indent=2)
PY
    fi
    set_setting PublicUrl str "wss://$host/ws"
    set_setting GatewayPort int 443
    say "certificate OK - server will announce wss://$host/ws"
}

if [[ $WEB -eq 1 || -n "$DOMAIN" ]]; then
    step "Browser access"
    WEB_IP=""
    setup_web || true
fi

chown -R "$SERVICE_USER:$SERVICE_USER" "$TARGET"

if [[ $INSTALL_SYSTEMD -eq 1 ]]; then
    step "systemd"

    for unit in zerog-server zerog-gateway; do
        sed -e "s|@TARGET@|$TARGET|g" -e "s|@USER@|$SERVICE_USER|g" \
            "$SOURCE/$unit.service" > "/etc/systemd/system/$unit.service"
        chmod 0644 "/etc/systemd/system/$unit.service"
    done

    systemctl daemon-reload
    systemctl enable zerog-server zerog-gateway >/dev/null 2>&1
    say "units installed and enabled at boot"

    # NOT STARTED. Whoever is installing this may want to set a server name first, and a service
    # that starts before it is configured just fills the journal with the same complaint.
    say "not started yet - see below"
fi

if [[ ${#RESTART_UNITS[@]} -gt 0 ]]; then
    step "Restarting"
    # Server before gateway, the order they depend on each other.
    systemctl start zerog-server zerog-gateway 2>/dev/null \
        && say "started again: ${RESTART_UNITS[*]}" \
        || say "WARNING: could not restart - run: systemctl start zerog-server zerog-gateway"
fi

cat <<EOF

== Installed

  Files      $TARGET
  Database   $DB_NAME (role $DB_USER)
  Settings   $CONFIG
  Owner      ${ADMIN_USER:-NOBODY - see below}
$(if [[ -z "$ADMIN_USER" ]]; then cat <<'OWNER'

== Nobody owns this server yet

  No account can hold a rank here until one is named, and every in-game admin control
  is behind that rank - including naming the server and listing it publicly.

  Re-run the installer with your 0G Nova Adventure account name:

    sudo bash install.sh --admin "YourAccountName"

  The first login under that name becomes Admin. It is safe to run again; it changes
  that one setting and leaves your world, database and settings alone.
OWNER
fi)

== To appear in the in-game server list

  Easiest - one command. Browser (WebGL) access is set up too when this machine has a
  public IP and ports 80 and 443 are open (see "Browser access" above for what happened):

    sudo bash install.sh --name "My Server Name" --tags PVE,Creative

  Or by hand. Add to /etc/systemd/system/zerog-server.service, under [Service]:

    Environment="ZEROG_SERVER_NAME=My Server Name"
    Environment=ZEROG_SERVER_TAGS=PVE,Creative

  A name is all it takes. The server then registers itself, gets a join code, and is
  listed - the same way the official server is. Leave the name out and it stays private,
  reachable only by an address you hand out yourself.

  If your server is behind a hostname with TLS rather than a bare port, add:

    Environment=ZEROG_SERVER_PUBLIC_URL=wss://your.domain/ws
    Environment=ZEROG_GATEWAY_PORT=443

  Without that, the rendezvous probes port 8081 to work out how players reach you.
  BROWSER PLAYERS NEED THAT LINE: a page served over HTTPS cannot open a plain ws://
  connection, so a server without a certificate is desktop-only.

== Start it

  sudo systemctl start zerog-server zerog-gateway
  sudo systemctl status zerog-server --no-pager
  sudo journalctl -u zerog-server -f

  Players connect to  ws://<this machine>:8081/ws

EOF
