#!/bin/bash
# alpha-velxio — lifecycle for the self-hosted Velxio simulator container.
#
# Velxio (velxio.dev, AGPL, ghcr.io/davidmonterocrespo24/velxio) is a browser
# circuit + microcontroller simulator. Inside the container: nginx on :80 serving
# the SPA, and a FastAPI backend on 127.0.0.1:8001 that does the real server-side
# work — arduino-cli compilation, board cores and the Arduino library registry.
#
# The panel talks to the container's HTTP API directly (no root needed for that).
# This helper exists only for what DOES need root: docker, the nginx vhost and
# the certificate.
#
# ⚠️ The container is published on 127.0.0.1 ONLY. Reaching it from outside must
# go through the panel's nginx vhost, so the simulator never becomes an open port
# on the box.
#
# ⚠️ First boot is SLOW (~2-3 min) and downloads several hundred MB of toolchains
# (avr-gcc, rp2040, ESP32 cores). Nothing listens until that finishes — a check
# that reads "not serving" during init is not a failure. `status` reports the
# difference so the UI can show initialising rather than broken.
set -u

[ -r /etc/alpha-panel.conf ] && . /etc/alpha-panel.conf
DOMAIN="${PRIMARY_DOMAIN:-$(hostname -d 2>/dev/null || true)}"; DOMAIN="${DOMAIN:-localhost}"   # the owner's domain, never a literal
EMAIL="${ADMIN_EMAIL:-}"

IMAGE="ghcr.io/davidmonterocrespo24/velxio:master"
# ⚠️ Name, CPU and memory are CHOSEN AT INSTALL and stored in the state dir —
# a customer may call their instance anything. Every verb resolves the name via
# name_of(), so nothing is pinned to the vendor's default.
DEFAULT_NAME="alpha-velxio"
STATE_DIR="/var/www/alpha-private/velxio"
PORT_FILE="$STATE_DIR/port"
SUB_FILE="$STATE_DIR/sub"
NAME_FILE="$STATE_DIR/name"
CPUS_FILE="$STATE_DIR/cpus"
MEM_FILE="$STATE_DIR/mem"
# The skin is authored in the add-on and published here, where nginx can serve
# it. Keeping the source in the add-on means a panel deploy updates the theme.
# Published under the add-on's own data folder (not loose at /var/www) so the
# File Manager shows it with the add-on; VX_SKIN_PUB in the add-on matches.
SKIN_SRC="/var/www/alpha/Addons/velxio/v1.0.0/skin"
SKIN_DIR="/var/www/Addons/velxio/skin"
FONT_SRC="/usr/share/fonts/truetype/spacegrotesk/SpaceGrotesk.ttf"

install_skin() {
  mkdir -p "$SKIN_DIR" || return 1
  # -r: the skin has subfolders now (boards/ images)
  [ -d "$SKIN_SRC" ] && cp -rf "$SKIN_SRC"/* "$SKIN_DIR"/ 2>/dev/null
  # Self-host the display face: no CDN, nothing third-party, CSP-safe.
  [ -f "$FONT_SRC" ] && cp -f "$FONT_SRC" "$SKIN_DIR/SpaceGrotesk.ttf" 2>/dev/null
  # The panel's uploaded brand assets win over the repo fallbacks, so a
  # Settings re-upload reaches the sim host on the next `skin` run.
  BRAND="/var/www/alpha/assets/brand"
  [ -f "$BRAND/favicon.png" ] && cp -f "$BRAND/favicon.png" "$SKIN_DIR/favicon.png" 2>/dev/null
  [ -f "$BRAND/logo.png" ]    && cp -f "$BRAND/logo.png"    "$SKIN_DIR/logo.png"    2>/dev/null
  # The landing is authored domain-neutral; render the platform links here so
  # the repo never carries a literal domain.
  [ -f "$SKIN_DIR/landing.html" ] && sed -i "s/__ALPHA_APEX__/$DOMAIN/g" "$SKIN_DIR/landing.html" 2>/dev/null
  chown -R www-data:www-data "$SKIN_DIR" 2>/dev/null
  chmod 755 "$SKIN_DIR" 2>/dev/null
  return 0
}

mkdir -p "$STATE_DIR" 2>/dev/null || true

port_of() { [ -s "$PORT_FILE" ] && cat "$PORT_FILE" || echo 3080; }
name_of() { [ -s "$NAME_FILE" ] && cat "$NAME_FILE" || echo "$DEFAULT_NAME"; }
cpus_of() { [ -s "$CPUS_FILE" ] && cat "$CPUS_FILE" || echo ""; }
mem_of()  { [ -s "$MEM_FILE" ]  && cat "$MEM_FILE"  || echo ""; }
# the data volume follows the instance name so two instances never share state
vol_of()  { echo "$(name_of)-data"; }
NAME="$(name_of)"
sub_of()  { [ -s "$SUB_FILE" ]  && cat "$SUB_FILE"  || echo ""; }

# A background image download (`pull`). The web request returns at once; the
# panel polls `status` (pulling/pull_log) for progress. ⚠️ The pull runs under
# `script` because Docker 29 only prints per-layer byte progress to a tty.
PULL_PID="$STATE_DIR/pull.pid"
PULL_LOG="$STATE_DIR/pull.log"
pull_running() {
  local p; p=$(cat "$PULL_PID" 2>/dev/null) || return 1
  case "$p" in ''|*[!0-9]*) return 1;; esac
  kill -0 "$p" 2>/dev/null && grep -qa "docker pull" "/proc/$p/cmdline" 2>/dev/null
}
# Bytes the image really occupies on disk. `image inspect .Size` is the
# COMPRESSED content size under the containerd store (0.93 GB for a 4.45 GB
# image), so read what `docker image ls` reports and convert it.
img_disk() {
  docker image ls --format '{{.Size}}' "$IMAGE" 2>/dev/null | head -1 | awk '
    { v=$0; u=v; sub(/[0-9.]+/,"",u); sub(/[A-Za-z]+$/,"",v)
      m=1; if(u=="kB"||u=="KB")m=1e3; else if(u=="MB")m=1e6; else if(u=="GB")m=1e9; else if(u=="TB")m=1e12
      printf "%.0f", v*m }'
}
vol_bytes() {
  local mp; mp=$(docker volume inspect -f '{{.Mountpoint}}' "$(vol_of)" 2>/dev/null) || { echo 0; return; }
  [ -d "$mp" ] && du -sb "$mp" 2>/dev/null | cut -f1 || echo 0
}

# Is the in-container API answering yet? (0 = yes)
api_up() {
  local p; p=$(port_of)
  [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 4 "http://127.0.0.1:$p/health" 2>/dev/null)" = "200" ]
}

# Docker arrives with the virtualization step, which a fresh box may not have
# run — this add-on is optional, so the panel does not force it. Say so plainly
# rather than failing later with a confusing "pull failed".
if ! command -v docker >/dev/null 2>&1; then
  if [ "${1:-}" = "status" ]; then
    printf 'image\t0\ncontainer\tno-docker\nport\t%s\nsub\t\ndomain\t%s\nurl\t\napi\tdown\n' "$(port_of)" "$DOMAIN"
    exit 0
  fi
  echo "ERR docker is not installed on this server"; exit 2
fi

case "${1:-}" in

  status)
    have=0; docker image inspect "$IMAGE" >/dev/null 2>&1 && have=1
    st=$(docker inspect -f '{{.State.Status}}' "$NAME" 2>/dev/null || echo "")
    printf 'image\t%s\n' "$have"
    printf 'container\t%s\n' "${st:-absent}"
    printf 'port\t%s\n' "$(port_of)"
    printf 'sub\t%s\n' "$(sub_of)"
    printf 'domain\t%s\n' "$DOMAIN"
    if [ -n "$(sub_of)" ]; then printf 'url\thttps://%s.%s\n' "$(sub_of)" "$DOMAIN"; else printf 'url\t\n'; fi
    if [ "$st" = "running" ]; then
      if api_up; then printf 'api\tup\n'; else printf 'api\tinitialising\n'; fi
    else
      printf 'api\tdown\n'
    fi
    printf 'image_id\t%s\n' "$(docker image inspect -f '{{.Id}}' "$IMAGE" 2>/dev/null | cut -c8-19)"
    printf 'started\t%s\n' "$(docker inspect -f '{{.State.StartedAt}}' "$NAME" 2>/dev/null)"
    printf 'size\t%s\n' "$(docker image inspect -f '{{.Size}}' "$IMAGE" 2>/dev/null)"
    printf 'disk\t%s\n' "$( [ "$have" = 1 ] && img_disk || echo 0)"
    printf 'vol\t%s\n' "$(vol_bytes)"
    printf 'volname\t%s\n' "$(vol_of)"
    if pull_running; then printf 'pulling\t1\n'; else printf 'pulling\t0\n'; fi
    printf 'imageref\t%s\n' "$IMAGE"
    ;;

  # pull — download the image in the background WITHOUT creating a container,
  # so the space is spent (or the install made instant) when the admin chooses.
  pull)
    if pull_running; then echo "OK already downloading"; exit 0; fi
    : > "$PULL_LOG"; chmod 644 "$PULL_LOG"
    setsid nohup bash -c 'echo $$ > "$1"; script -qec "docker pull $2" /dev/null >> "$3" 2>&1; echo "EXIT:$?" >> "$3"' \
      _ "$PULL_PID" "$IMAGE" "$PULL_LOG" >/dev/null 2>&1 < /dev/null &
    echo "OK downloading"
    ;;

  # rmi — delete the downloaded image to give the disk back. Refused while a
  # container exists (it is built from the image) or a download is running.
  rmi)
    pull_running && { echo "ERR the image is still downloading"; exit 2; }
    docker inspect "$NAME" >/dev/null 2>&1 && { echo "ERR remove the container first — it runs from this image"; exit 2; }
    docker image inspect "$IMAGE" >/dev/null 2>&1 || { echo "OK not downloaded"; exit 0; }
    docker image rm "$IMAGE" >/dev/null 2>&1 || { echo "ERR docker refused to delete the image"; exit 2; }
    echo "OK image deleted"
    ;;

  # install [port] [name] [cpus] [memMB] — pull if needed, (re)create the
  # container. Everything after the port is optional AND REMEMBERED, so a
  # customer's own container name and sizing survive every later verb.
  install)
    p="${2:-$(port_of)}"
    case "$p" in ''|*[!0-9]*) echo "ERR bad port"; exit 2;; esac
    n="${3:-$(name_of)}"
    case "$n" in ''|*[!a-zA-Z0-9_.-]*) echo "ERR bad container name"; exit 2;; esac
    c="${4:-$(cpus_of)}"; case "$c" in *[!0-9.]*) c="";; esac
    m="${5:-$(mem_of)}";  case "$m" in *[!0-9]*)  m="";; esac

    # a different name than last time is a RENAME: retire the old container so
    # the box is not left holding an orphan under the previous name
    old="$(name_of)"
    [ "$old" != "$n" ] && docker rm -f "$old" >/dev/null 2>&1

    echo "$p" > "$PORT_FILE"; echo "$n" > "$NAME_FILE"
    if [ -n "$c" ]; then echo "$c" > "$CPUS_FILE"; else rm -f "$CPUS_FILE"; fi
    if [ -n "$m" ]; then echo "$m" > "$MEM_FILE";  else rm -f "$MEM_FILE";  fi
    NAME="$n"

    pull_running && { echo "ERR the image is still downloading — install when it finishes"; exit 2; }
    docker image inspect "$IMAGE" >/dev/null 2>&1 || docker pull "$IMAGE" || { echo "ERR pull failed"; exit 2; }
    docker rm -f "$NAME" >/dev/null 2>&1
    set -- run -d --name "$NAME" --restart unless-stopped \
      -p "127.0.0.1:$p:80" -v "$(vol_of):/root/.arduino15"
    [ -n "$c" ] && set -- "$@" --cpus "$c"
    [ -n "$m" ] && set -- "$@" --memory "${m}m"
    docker "$@" "$IMAGE" >/dev/null 2>&1 || { echo "ERR run failed"; exit 2; }
    echo "OK installed name=$NAME port=$p${c:+ cpus=$c}${m:+ mem=${m}MB}"
    ;;

  start)   docker start "$NAME" >/dev/null 2>&1 && echo OK || { echo "ERR start"; exit 2; } ;;
  stop)    docker stop  "$NAME" >/dev/null 2>&1 && echo OK || { echo "ERR stop";  exit 2; } ;;
  restart) docker restart "$NAME" >/dev/null 2>&1 && echo OK || { echo "ERR restart"; exit 2; } ;;

  # update — pull a newer image and recreate. The named volume keeps installed
  # cores and libraries, so an update never re-downloads the toolchains.
  update)
    before=$(docker image inspect -f '{{.Id}}' "$IMAGE" 2>/dev/null)
    docker pull "$IMAGE" >/dev/null 2>&1 || { echo "ERR pull failed"; exit 2; }
    after=$(docker image inspect -f '{{.Id}}' "$IMAGE" 2>/dev/null)
    if [ "$before" = "$after" ]; then echo "OK already-latest"; exit 0; fi
    p=$(port_of); c=$(cpus_of); m=$(mem_of)
    docker rm -f "$NAME" >/dev/null 2>&1
    # ⚠️ recreate with the SAME volume and the SAME limits the admin chose, or
    # an update silently resets the sizing and orphans the data volume
    set -- run -d --name "$NAME" --restart unless-stopped \
      -p "127.0.0.1:$p:80" -v "$(vol_of):/root/.arduino15"
    [ -n "$c" ] && set -- "$@" --cpus "$c"
    [ -n "$m" ] && set -- "$@" --memory "${m}m"
    docker "$@" "$IMAGE" >/dev/null 2>&1 \
      || { echo "ERR run failed"; exit 2; }
    echo "OK updated"
    ;;

  # remove                the container only — image + toolchains stay, reinstall is instant
  # remove --image        also the image — toolchains/libraries (the volume) stay
  # remove --purge        everything: container, image, volume, remembered port
  remove)
    pull_running && { echo "ERR the image is still downloading"; exit 2; }
    docker rm -f "$NAME" >/dev/null 2>&1
    case "${2:-}" in
      --image)
        docker image rm "$IMAGE" >/dev/null 2>&1 ;;
      --purge)
        docker volume rm "$(vol_of)" >/dev/null 2>&1
        docker image rm "$IMAGE" >/dev/null 2>&1
        rm -f "$PORT_FILE" ;;
    esac
    echo OK
    ;;

  logs)
    n="${2:-120}"; case "$n" in ''|*[!0-9]*) n=120;; esac
    docker logs --tail "$n" "$NAME" 2>&1
    ;;

  # expose <sub> — publish at <sub>.<domain> over TLS.
  # ⚠️ WebSockets are required: the simulator streams over /api/simulation/ws/.
  expose)
    sub="${2:-}"
    case "$sub" in ''|*[!a-z0-9-]*) echo "ERR bad subdomain"; exit 2;; esac
    p=$(port_of); fqdn="$sub.$DOMAIN"; conf="/etc/nginx/sites-available/sub-$sub"
    install_skin
    if [ -f "$conf" ] && ! grep -q "# ALPHA-VELXIO" "$conf"; then echo "ERR SUB_TAKEN"; exit 2; fi
    cat > "$conf" <<NGINX
# ALPHA-VELXIO — self-hosted Velxio simulator ($NAME on 127.0.0.1:$p)
server {
    listen 80; listen [::]:80;
    server_name $fqdn;
    location ^~ /.well-known/acme-challenge/ { allow all; root /var/www/alpha; default_type "text/plain"; }
    location / { return 301 https://\$host\$request_uri; }
}
NGINX
    ln -sf "$conf" "/etc/nginx/sites-enabled/sub-$sub"
    nginx -t >/dev/null 2>&1 || { rm -f "/etc/nginx/sites-enabled/sub-$sub"; echo "ERR nginx test failed"; exit 2; }
    systemctl reload nginx >/dev/null 2>&1
    if [ -n "$EMAIL" ]; then
      certbot certonly --webroot -w /var/www/alpha -d "$fqdn" \
        --non-interactive --agree-tos -m "$EMAIL" >/dev/null 2>&1
    fi
    if [ -d "/etc/letsencrypt/live/$fqdn" ]; then
      cat > "$conf" <<NGINX
# ALPHA-VELXIO — self-hosted Velxio simulator ($NAME on 127.0.0.1:$p)
server {
    listen 80; listen [::]:80;
    server_name $fqdn;
    location ^~ /.well-known/acme-challenge/ { allow all; root /var/www/alpha; default_type "text/plain"; }
    location / { return 301 https://\$host\$request_uri; }
}
server {
    # ⚠️ The old form on purpose: "http2 on;" is nginx >= 1.25 and is an
    # "unknown directive" on 1.24, which is what ships with Ubuntu 24.04.
    listen 443 ssl http2; listen [::]:443 ssl http2;
    server_name $fqdn;
    ssl_certificate     /etc/letsencrypt/live/$fqdn/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/$fqdn/privkey.pem;

    # The simulator ships large WASM payloads and uploads firmware.
    client_max_body_size 64m;

    # Our own landing page. The upstream image's marketing site — its name,
    # pricing, donation links, about pages — never reaches a visitor; the app
    # itself (/editor, /examples, assets, API, websocket) proxies untouched.
    location = / {
        root $SKIN_DIR;
        try_files /landing.html =404;
    }
    location = /index.html { return 301 /; }

    # The panel's own favicon at every icon URL the app's HTML references.
    location ~ ^/(favicon\.(ico|svg|png)|favicon-(16x16|32x32)\.png|apple-touch-icon\.png)$ {
        alias $SKIN_DIR/favicon.png;
        types { } default_type image/png;
        access_log off;
        expires 7d;
    }

    # The upstream marketing/SEO pages carry the app's own brand — send them home.
    location ~ ^/(about|pricing|classroom|blog(/.*)?)$ { return 301 /; }
    location ~ ^/[a-z0-9-]+-(simulator|emulator)$ { return 301 /; }

    # The Alpha skin, served beside the app. Kept OUT of the container so the
    # image stays pristine and the theme survives every update.
    location /_alpha/ {
        alias $SKIN_DIR/;
        access_log off;
        expires 1h;
    }

    location / {
        # The image ships X-Frame-Options: SAMEORIGIN, which stops the panel's
        # Live Preview from framing it. Hide it and allow framing by our own
        # hosts instead.
        # ⚠️ A WILDCARD deliberately, never the admin hostname: this header is
        # public, and naming the admin host here would publish it to anyone who
        # fetches the page. (An add_header at this level also drops the
        # inherited X-Frame-Options, which is what we want.)
        proxy_hide_header X-Frame-Options;
        add_header Content-Security-Policy "frame-ancestors 'self' https://*.$DOMAIN" always;

        # Inject the skin into every HTML response.
        # ⚠️ Accept-Encoding must be cleared or the upstream replies gzipped and
        # sub_filter has nothing to match on — it fails silently.
        proxy_set_header Accept-Encoding "";
        sub_filter_types text/html;
        sub_filter_once off;
        sub_filter '</head>' '<link rel="stylesheet" href="/_alpha/skin.css"><script defer src="/_alpha/brand.js"></script></head>';
        # ⚠️ The upstream image hard-codes the AUTHOR'S Google Analytics id, so a
        # self-hosted instance reports its visitors to someone else. Drop it.
        sub_filter '<script async src="https://www.googletagmanager.com/gtag/js?id=G-HPD8HTV3TG"></script>' '';
        # The app's static HTML titles itself after the upstream project; brand.js
        # keeps runtime rewrites in line, this catches the tab title at load.
        sub_filter '<title>Velxio — Free Online Circuit & Arduino Simulator | SPICE · ESP32 · RP2040 · ATtiny85 · Custom Chips</title>' '<title>Alpha Sim — Circuit & Microcontroller Studio</title>';
        sub_filter '<meta property="og:site_name" content="Velxio" />' '<meta property="og:site_name" content="Alpha Sim" />';

        proxy_pass http://127.0.0.1:$p;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;          # /api/simulation/ws/
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        proxy_read_timeout 3600s;                          # long compile jobs
        proxy_buffering off;
    }
}
NGINX
      # ⚠️ Roll the file back if it does not parse. Leaving a broken vhost in
      # sites-enabled makes `nginx -t` fail for the WHOLE box, so the next
      # unrelated reload — anyone's — fails too. Never leave one behind.
      if ! nginx -t >/dev/null 2>&1; then
        rm -f "/etc/nginx/sites-enabled/sub-$sub" "$conf"
        nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1
        echo "ERR nginx test failed — vhost rolled back"; exit 2
      fi
      # ⚠️ restart, not reload: reload does not always swap a fresh cert in.
      systemctl restart nginx >/dev/null 2>&1
      echo "$sub" > "$SUB_FILE"
      echo "OK https://$fqdn"
    else
      echo "$sub" > "$SUB_FILE"
      echo "OK http://$fqdn (no certificate yet)"
    fi
    ;;

  # Re-publish the theme after editing it, without touching nginx.
  skin)
    install_skin && echo "OK skin at $SKIN_DIR" || { echo "ERR skin install failed"; exit 2; }
    ;;

  unexpose)
    sub=$(sub_of); [ -z "$sub" ] && { echo OK; exit 0; }
    rm -f "/etc/nginx/sites-enabled/sub-$sub" "/etc/nginx/sites-available/sub-$sub"
    nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1
    rm -f "$SUB_FILE"
    echo OK
    ;;

  *)
    echo "usage: alpha-velxio {status|skin|install [port] [name] [cpus] [memMB]|start|stop|restart|update|pull|rmi|remove [--image|--purge]|logs [n]|expose <sub>|unexpose}"
    exit 1
    ;;
esac
