#!/bin/sh
# CMSnap-LITE installer — downloads the binary, creates a hardened systemd
# service running as a non-root user, and tunes settings.json for the
# detected topology (direct / behind a proxy). POSIX sh, no bashisms.
#
#   sudo sh ./install.sh                 # local ./cmsnap-<arch> or download
#   CMS_DOMAIN=example.com sudo -E sh ./install.sh
#   docker: see docker/DOCKER.md instead.
#
# Everything is overridable via environment (see the constants + the
# CMS_* questions below). Non-interactive: set the answers as env vars.
set -eu

# ── Constants (override via env) ─────────────────────────────────────
# Version this installer publishes — release_dl.sh stamps it from Cargo.toml.
CMS_VERSION="0.2.6"
CMS_URL_BASE="${CMS_URL_BASE:-https://sqliteonline.com/cmsnap/dl}"
CMS_URL="${CMS_URL:-}"          # exact binary URL (wins over URL_BASE)
CMS_SHA256="${CMS_SHA256:-}"    # exact sha256 of the binary (optional)
CMS_DIR="${CMS_DIR:-/opt/cmsnap}"
CMS_BIN="${CMS_BIN:-/usr/local/bin/cmsnap}"
# Courtesy name: kept as a symlink to $CMS_BIN (also the pre-0.3 binary path).
CMS_ALIAS="/usr/local/bin/cms"
CMS_USER="${CMS_USER:-cmsnap}"
UNIT_PATH="/etc/systemd/system/cmsnap.service"
URL_PLACEHOLDER="https://dl.example.com/cmsnap/latest"

die()  { echo "error: $*" >&2; exit 1; }
info() { echo "==> $*"; }
warn() { echo "warning: $*" >&2; }

# ask NAME PROMPT DEFAULT  — env override → /dev/tty → default.
# Prints the resulting answer. yes/no answers validated by the caller.
ask() {
    _name="$1"; _prompt="$2"; _default="$3"
    eval "_env=\${$_name:-}"
    if [ -n "$_env" ]; then echo "$_env"; return; fi
    if [ -r /dev/tty ]; then
        printf '%s ' "$_prompt" > /dev/tty
        IFS= read -r _ans < /dev/tty || _ans=""
        [ -n "$_ans" ] && { echo "$_ans"; return; }
    fi
    echo "$_default"
}

is_yes() { case "$1" in [Yy]|[Yy][Ee][Ss]) return 0;; *) return 1;; esac; }

# Final summary — defined early so every exit path can call it.
finish() {
    echo
    echo "== CMSnap installed =="
    echo "  binary : $CMS_BIN ($ARCH)"
    echo "  site   : $CMS_DIR  (user $CMS_USER)"
    echo "  service: systemctl status cmsnap"
    [ "${STARTED:-0}" = 1 ] && echo "  admin password: printed above by def-help"
    echo "  firewall: open inbound 80${domain:+ and 443}"
    echo "  license: $CMS_BIN license · third-party: $CMS_BIN notices"
    echo "  remove : systemctl disable --now cmsnap; rm $UNIT_PATH; systemctl daemon-reload"
    echo "           (leaves $CMS_BIN, user $CMS_USER, $CMS_DIR — remove by hand if wanted)"
}

# ── 1. Env validation + arch + tty ───────────────────────────────────
case "$CMS_DIR" in /*) ;; *) die "CMS_DIR must be an absolute path"; esac
case "$CMS_BIN" in /*) ;; *) die "CMS_BIN must be an absolute path"; esac
case "$CMS_DIR$CMS_BIN" in *[!A-Za-z0-9_/.-]*) die "CMS_DIR/CMS_BIN: unsafe characters"; esac
case "$CMS_USER" in [a-z_][a-z0-9_-]*) ;; *) die "CMS_USER '$CMS_USER' is not a valid system user name"; esac

case "$(uname -m)" in
    x86_64)         ARCH=amd64 ;;
    aarch64|arm64)  ARCH=arm64 ;;
    riscv64)        ARCH=riscv64 ;;
    *) die "unsupported architecture: $(uname -m) (64-bit only: amd64/arm64/riscv64)" ;;
esac

SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)

# ── 1b. Version gate — an installed current version needs no download ─
# Only for the plain-download path: CMS_URL or a local binary means the
# user brings a specific build, and the gate must not shadow it.
if [ -z "$CMS_URL" ] && [ ! -f "$SELF_DIR/cmsnap-$ARCH" ] && [ ! -f "$SELF_DIR/cmsnap" ] \
    && [ -f "$UNIT_PATH" ] && [ -x "$CMS_BIN" ] \
    && cur=$("$CMS_BIN" --version 2>/dev/null | sed 's#.*/##') && [ -n "$cur" ]; then
    if [ "$cur" = "$CMS_VERSION" ]; then
        info "installed version $cur is current — nothing to update"
        ans="$(ask CMS_REINSTALL 'Reinstall the same version anyway? [y/N]' N)"
        is_yes "$ans" || exit 0
    else
        info "installed: $cur, latest: $CMS_VERSION"
        ans="$(ask CMS_UPDATE "Update to $CMS_VERSION? [Y/n]" Y)"
        is_yes "$ans" || { info "no changes made."; exit 0; }
    fi
fi

# ── 2. Docker vs native — BEFORE touching the system ─────────────────
if command -v docker >/dev/null 2>&1; then
    echo "Docker detected. CMSnap ships container images — see docker/DOCKER.md:"
    echo "  docker run -d --name cmsnap -v cmsnap:/site -p 80:8080 cmsnap:latest"
    echo "  (published directly? clear trusted_proxies to [] — DOCKER.md explains why)"
    ans="$(ask CMS_DOCKER 'Continue with the NATIVE systemd install instead? [Y/n]' Y)"
    is_yes "$ans" || { info "leaving Docker to you; no changes made."; exit 0; }
fi

# ── 3. Native requirements ───────────────────────────────────────────
[ "$(id -u)" = 0 ] || die "run as root (sudo sh ./install.sh)"
command -v systemctl >/dev/null 2>&1 || die "systemd not found — this installer targets systemd distros; use Docker instead (docker/DOCKER.md)"
systemctl list-units >/dev/null 2>&1 || die "systemd is not running (PID 1) — use Docker instead"
command -v useradd >/dev/null 2>&1 || die "useradd not found — unsupported distro; use Docker instead"

# ── 4. Fetch the candidate binary into a temp file (no system change) ─
have_dl() { command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; }
download() {  # $1 url  $2 dest ; non-fatal (returns non-zero) on failure
    if command -v curl >/dev/null 2>&1; then
        curl -fSL "$1" -o "$2"
    else
        wget -O "$2" "$1"
    fi
}
ensure_downloader() {
    have_dl && return 0
    pm=""
    for c in apt-get dnf yum apk pacman zypper; do
        command -v "$c" >/dev/null 2>&1 && { pm="$c"; break; }
    done
    [ -n "$pm" ] || die "need curl or wget (no known package manager to install one)"
    ans="$(ask CMS_INSTALL_CURL "curl not found. Install it now via $pm? [Y/n]" Y)"
    is_yes "$ans" || die "install curl or wget, or place the cmsnap binary next to this script"
    case "$pm" in
        apt-get) apt-get update && apt-get install -y curl ;;
        dnf|yum) "$pm" install -y curl ;;
        apk)     apk add --no-cache curl ;;
        pacman)  pacman -Sy --noconfirm curl ;;
        zypper)  zypper install -y curl ;;
    esac
    have_dl || die "curl installation failed"
}

TMP_BIN=$(mktemp)
TMP_SUMS=""
cleanup() { rm -f "$TMP_BIN" "${TMP_SUMS:-/nonexistent}"; }
trap cleanup EXIT

SRC=""
if [ -n "$CMS_URL" ]; then
    ensure_downloader
    info "downloading $CMS_URL"
    download "$CMS_URL" "$TMP_BIN" || die "download failed: $CMS_URL"
    SRC="$CMS_URL"
elif [ -f "$SELF_DIR/cmsnap-$ARCH" ]; then
    info "using local binary $SELF_DIR/cmsnap-$ARCH"
    cp "$SELF_DIR/cmsnap-$ARCH" "$TMP_BIN"
    SRC="$SELF_DIR/cmsnap-$ARCH"
elif [ -f "$SELF_DIR/cmsnap" ]; then
    info "using local binary $SELF_DIR/cmsnap"
    cp "$SELF_DIR/cmsnap" "$TMP_BIN"
    SRC="$SELF_DIR/cmsnap"
elif [ "$CMS_URL_BASE" != "$URL_PLACEHOLDER" ]; then
    ensure_downloader
    SRC="$CMS_URL_BASE/cmsnap-$ARCH"
    info "downloading $SRC"
    download "$SRC" "$TMP_BIN" || die "download failed: $SRC"
else
    die "no binary: set CMS_URL / CMS_URL_BASE, or place cmsnap-$ARCH next to this script"
fi

# Candidate checks: non-empty, ELF magic, sha256, and (native arch) --version.
[ -s "$TMP_BIN" ] || die "downloaded binary is empty"
magic=$(od -An -tx1 -N4 "$TMP_BIN" | tr -d ' \n')
[ "$magic" = "7f454c46" ] || die "not an ELF binary (a download error page?)"

verify_sha() {
    command -v sha256sum >/dev/null 2>&1 || { warn "sha256sum absent — skipping checksum"; return; }
    if [ -n "$CMS_SHA256" ]; then
        echo "$CMS_SHA256  $TMP_BIN" | sha256sum -c - >/dev/null 2>&1 \
            || die "checksum mismatch (CMS_SHA256)"
        info "checksum ok (CMS_SHA256)"
        return
    fi
    case "$SRC" in
        http*://*)
            base=$(basename "$SRC")
            TMP_SUMS=$(mktemp)
            if download "${SRC%/*}/SHA256SUMS" "$TMP_SUMS" 2>/dev/null; then
                want=$(awk -v f="$base" '$2==f || $2=="*"f {print $1}' "$TMP_SUMS" | head -1)
                [ -n "$want" ] || { warn "no $base line in SHA256SUMS — skipping"; return; }
                got=$(sha256sum "$TMP_BIN" | cut -d' ' -f1)
                [ "$want" = "$got" ] || die "checksum mismatch for $base"
                info "checksum ok (SHA256SUMS)"
            else
                warn "no SHA256SUMS next to the binary — skipping checksum"
            fi ;;
        *) : ;;  # local file: nothing to fetch sums from
    esac
}
verify_sha

chmod 755 "$TMP_BIN"
# Native arch → the binary must run and be a tune-capable build.
if "$TMP_BIN" --version >/dev/null 2>&1; then
    "$TMP_BIN" tune --help >/dev/null 2>&1 || die "binary has no 'tune' command — too old for this installer"
else
    warn "cannot exec the binary here (cross-arch?) — skipping run check"
fi

# ── 5. Mode by unit (NOT by port) ────────────────────────────────────
install_binary() {  # atomic replace, keeping a .old for rollback
    if [ -f "$CMS_BIN" ]; then mv "$CMS_BIN" "$CMS_BIN.old"; fi
    cp "$TMP_BIN" "$CMS_BIN.new" && mv "$CMS_BIN.new" "$CMS_BIN"
}

link_alias() {  # courtesy `cms`: only when the name is free or already ours
    [ "$CMS_BIN" = "$CMS_ALIAS" ] && return 0
    if [ ! -e "$CMS_ALIAS" ] || [ "$(readlink "$CMS_ALIAS" 2>/dev/null)" = "$CMS_BIN" ]; then
        ln -sf "$CMS_BIN" "$CMS_ALIAS"
    fi
}

write_unit() {
    cat > "$UNIT_PATH" <<EOF
[Unit]
Description=CMSnap-LITE — a whole website from one settings.json
After=network.target

[Service]
User=$CMS_USER
WorkingDirectory=$CMS_DIR
ExecStart=$CMS_BIN
ExecReload=$CMS_BIN reload
Restart=always
RestartSec=2
RuntimeDirectory=cmsnap
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=$CMS_DIR
ProtectHome=yes
PrivateTmp=yes
UMask=0077

[Install]
WantedBy=multi-user.target
EOF
    systemctl daemon-reload
}

if [ -f "$UNIT_PATH" ]; then
    info "existing service found — UPDATE mode (config/site untouched)"
    # Pre-0.3 layout: the real binary IS $CMS_ALIAS and the unit calls it
    # with the removed `run` argument — regenerate the unit for $CMS_BIN
    # and turn the old name into the courtesy symlink.
    MIGRATE=0
    if [ "$CMS_BIN" != "$CMS_ALIAS" ] && [ -f "$CMS_ALIAS" ] && [ ! -L "$CMS_ALIAS" ]; then
        MIGRATE=1
        cp "$UNIT_PATH" "$UNIT_PATH.old"
    fi
    systemctl stop cmsnap 2>/dev/null || true
    install_binary
    [ "$MIGRATE" = 1 ] && write_unit
    if systemctl start cmsnap 2>/dev/null; then
        rm -f "$CMS_BIN.old" "$UNIT_PATH.old"
        if [ "$MIGRATE" = 1 ]; then
            ln -sf "$CMS_BIN" "$CMS_ALIAS"
            info "migrated: binary is $CMS_BIN, $CMS_ALIAS now points to it"
        fi
        link_alias
        info "updated and restarted."
    else
        warn "new binary failed to start — rolling back"
        if [ -f "$CMS_BIN.old" ]; then mv "$CMS_BIN.old" "$CMS_BIN"; else rm -f "$CMS_BIN"; fi
        if [ -f "$UNIT_PATH.old" ]; then
            mv "$UNIT_PATH.old" "$UNIT_PATH"
            systemctl daemon-reload
        fi
        systemctl start cmsnap || true
        echo "check: journalctl -u cmsnap -n 50"
        exit 1
    fi
    echo "  binary : $CMS_BIN ($ARCH)"
    echo "  status : systemctl status cmsnap"
    exit 0
fi

# ── 6. Fresh/adopt: user, dir, binary ────────────────────────────────
id -u "$CMS_USER" >/dev/null 2>&1 || {
    useradd --system --no-create-home --shell /usr/sbin/nologin "$CMS_USER"
    info "created system user: $CMS_USER"
}
FRESH=1
[ -f "$CMS_DIR/settings.json" ] && FRESH=0   # adopt an existing site
mkdir -p "$CMS_DIR"
install_binary
link_alias
[ "$FRESH" = 1 ] && chown -R "$CMS_USER:$CMS_USER" "$CMS_DIR"

run_as_cms() { su -s /bin/sh "$CMS_USER" -c 'cd "$1" && shift; exec "$@"' -- sh "$CMS_DIR" "$@"; }

# ── ADOPT: existing site, no unit — install unit, validate, no tune ──
if [ "$FRESH" = 0 ]; then
    info "existing settings.json in $CMS_DIR — ADOPT mode (config untouched)"
    write_unit
    if run_as_cms "$CMS_BIN" check >/dev/null 2>&1; then
        systemctl enable --now cmsnap && info "service enabled and started."
    else
        systemctl enable cmsnap
        warn "settings.json did not pass 'cmsnap check' — service enabled but NOT started"
        echo "fix it, then: systemctl start cmsnap  (details: cd $CMS_DIR && $CMS_BIN check)"
    fi
    finish
    exit 0
fi

# ── 7. Topology probe (fresh only) ───────────────────────────────────
# Prints the listener name on $1, or nothing if free.
port_listener() {
    p="$1"
    if command -v ss >/dev/null 2>&1; then
        ss -ltnpH "sport = :$p" 2>/dev/null | sed -n 's/.*users:(("\([^"]*\)".*/\1/p' | head -1
    elif command -v netstat >/dev/null 2>&1; then
        netstat -ltnp 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {print $7}' | sed 's#.*/##' | head -1
    elif command -v lsof >/dev/null 2>&1; then
        lsof -iTCP:"$p" -sTCP:LISTEN -Fc 2>/dev/null | sed -n 's/^c//p' | head -1
    else
        # POSIX fallback: /proc/net/tcp{,6}, hex port, state 0A = LISTEN.
        # No process name — only busy/free.
        hp=$(printf '%04X' "$p")
        if awk -v hp=":$hp" '$4=="0A" && $2 ~ hp"$" {f=1} END{exit !f}' \
            /proc/net/tcp /proc/net/tcp6 2>/dev/null; then
            echo "?"
        fi
    fi
}
port_free() { [ -z "$(port_listener "$1")" ]; }

# Does the domain resolve to this machine? Compares its A/AAAA records
# against local addresses and the public IP. 0 = match, 1 = points
# elsewhere, 2 = does not resolve. Leaves the list in $d_ips for messages.
domain_points_here() {
    d_ips=$(getent ahosts "$1" 2>/dev/null | awk '{ print $1 }' | sort -u | tr '\n' ' ')
    [ -n "$d_ips" ] || return 2
    my_ips=$(ip -o addr show scope global 2>/dev/null | awk '{ sub(/\/.*/, "", $4); print $4 }')
    [ -n "$my_ips" ] || my_ips=$(hostname -I 2>/dev/null)
    pub=""
    if command -v curl >/dev/null 2>&1; then
        pub=$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)
    elif command -v wget >/dev/null 2>&1; then
        pub=$(wget -qO- -T 5 https://api.ipify.org 2>/dev/null || true)
    fi
    for d in $d_ips; do
        for mine in $my_ips $pub; do
            [ "$d" = "$mine" ] && return 0
        done
    done
    return 1
}

L80="$(port_listener 80)"
if [ -z "$L80" ]; then echo "port 80: free"; else echo "port 80: $L80"; fi

# ── 8. Choose topology + plan the tune ───────────────────────────────
TUNE_PLAN=""     # printed to run later if the site isn't created now
NGINX_PORT=""
if [ -z "$L80" ]; then
    domain="$(ask CMS_DOMAIN 'Domain for HTTPS (Let'\''s Encrypt), empty for plain HTTP:' '')"
    if [ -n "$domain" ]; then
        case "$domain" in *[!A-Za-z0-9.-]*|*..*|.*|*.) die "invalid domain '$domain'";; esac
        if port_free 443; then
            use_acme=1
            rc=0; domain_points_here "$domain" || rc=$?
            if [ "$rc" = 0 ]; then
                info "dns ok: $domain points to this server"
            else
                if [ "$rc" = 2 ]; then warn "$domain does not resolve"
                else warn "$domain resolves to: $d_ips— not this server"; fi
                echo "  a certificate request now would fail and burn the Let's Encrypt limit (5 failed tries per hour)"
                ans="$(ask CMS_ACME_FORCE 'Request HTTPS anyway? [y/N]' N)"
                is_yes "$ans" || use_acme=0
            fi
            if [ "$use_acme" = 1 ]; then
                TUNE_PLAN="tune direct --port 80 --acme $domain"
                echo "plan: direct HTTPS for $domain (keep ports 80 and 443 open)"
            else
                TUNE_PLAN="tune direct --port 80"
                echo "plan: plain HTTP on 80; when DNS is ready:"
                echo "  cd $CMS_DIR && $CMS_BIN tune direct --port 80 --acme $domain && systemctl restart cmsnap"
            fi
        else
            warn "port 443 is busy ($(port_listener 443)) — cannot serve HTTPS directly"
            TUNE_PLAN="tune direct --port 80"
            echo "plan: plain HTTP on 80 (free 443 or use a proxy for HTTPS)"
        fi
    else
        TUNE_PLAN="tune direct --port 80"
        echo "plan: plain HTTP on 80"
    fi
else
    # Port 80 taken by someone else → proxy mode on a free local port.
    for p in 8080 8081 8082 8083 8084 8085 8086 8087 8088 8089 \
             8090 8091 8092 8093 8094 8095 8096 8097 8098 8099; do
        port_free "$p" && { NGINX_PORT="$p"; break; }
    done
    [ -n "$NGINX_PORT" ] || die "no free port in 8080..8099 for proxy mode"
    TUNE_PLAN="tune proxy --port $NGINX_PORT"
    echo "plan: proxy mode on 127.0.0.1:$NGINX_PORT (something already owns port 80)"
fi

# ── 9. Example site + apply the tune ─────────────────────────────────
SITE_READY=0
ans="$(ask CMS_CREATE_SITE 'Create the example site here? [Y/n]' Y)"
if is_yes "$ans"; then
    info "unpacking the example site (admin password is printed below)"
    run_as_cms "$CMS_BIN" def-help
    # shellcheck disable=SC2086
    run_as_cms "$CMS_BIN" $TUNE_PLAN
    SITE_READY=1
else
    warn "no example site — place your settings.json in $CMS_DIR, then run:"
    echo "  cd $CMS_DIR && $CMS_BIN $TUNE_PLAN && systemctl start cmsnap"
fi

# ── 10. nginx (fresh proxy only, on request) ─────────────────────────
if [ -n "$NGINX_PORT" ] && [ "$SITE_READY" = 1 ] && [ "$L80" = nginx ]; then
    ans="$(ask CMS_NGINX 'nginx owns port 80. Add a reverse-proxy config? [y/N]' N)"
    if is_yes "$ans"; then
        ndom="$(ask CMS_DOMAIN 'server_name for the nginx config:' _)"
        conf_dir=/etc/nginx/sites-available
        link_dir=/etc/nginx/sites-enabled
        [ -d "$conf_dir" ] || { conf_dir=/etc/nginx/conf.d; link_dir=""; }
        conf="$conf_dir/cmsnap.conf"
        tmpn=$(mktemp)
        cat > "$tmpn" <<EOF
# keepalive pool: without it nginx opens a new TCP connection to the app
# for every request, capping throughput several times below the engine.
upstream cmsnap_up {
    server 127.0.0.1:$NGINX_PORT;
    keepalive 64;
}
server {
    listen 80;
    server_name $ndom;
    location / {
        proxy_pass http://cmsnap_up;
        proxy_http_version 1.1;
        proxy_pass_header Server;
        proxy_set_header Connection "";
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }
}
EOF
        [ -f "$conf" ] && cp "$conf" "$conf.bak" && warn "backed up existing $conf → $conf.bak"
        mv "$tmpn" "$conf"
        [ -n "$link_dir" ] && ln -sf "$conf" "$link_dir/cmsnap.conf"
        if nginx -t 2>/dev/null; then
            systemctl reload nginx && info "nginx configured for $ndom → 127.0.0.1:$NGINX_PORT"
        else
            warn "nginx -t failed — restoring previous config"
            if [ -f "$conf.bak" ]; then mv "$conf.bak" "$conf"; else rm -f "$conf" "${link_dir:+$link_dir/cmsnap.conf}"; fi
            echo "fix nginx manually; CMSnap still installed on 127.0.0.1:$NGINX_PORT"
        fi
        echo "TLS for $ndom: use certbot (adds ssl_certificate to this server block)"
    fi
elif [ -n "$NGINX_PORT" ]; then
    echo "reverse proxy: forward to http://127.0.0.1:$NGINX_PORT and pass"
    echo "  Host, X-Real-IP=\$remote_addr, X-Forwarded-Proto=\$scheme"
fi

# ── 11. Unit + start ─────────────────────────────────────────────────
write_unit
if [ "$SITE_READY" = 1 ] && run_as_cms "$CMS_BIN" check >/dev/null 2>&1; then
    if systemctl enable --now cmsnap 2>/dev/null; then
        STARTED=1
    else
        STARTED=0
        warn "service failed to start"
        echo "  systemctl status cmsnap ; journalctl -u cmsnap -n 50"
    fi
else
    systemctl enable cmsnap
    STARTED=0
    echo "service enabled but not started (no valid site yet)."
fi

# ── 12. Summary ──────────────────────────────────────────────────────
finish
