#!/usr/bin/env bash
#
# Activate an uploaded release for one instance.
#
# The CLI has already put the release files in place; everything that makes them
# live happens here, in one transaction. If any step fails the previous release
# is put back and the services are restarted before exiting non-zero.
#
# usage: apply.sh <instance> <release> [options]
#
# Only one of apply, destroy, rollback and restore runs for an instance at a
# time; a second waits for the first (--lock-wait seconds) and then gives up. A
# release that arrives after a newer one has gone live is refused rather than
# put live over it.
set -Eeuo pipefail

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=lib.sh
. "$SCRIPT_DIR/lib.sh"

require_provisioned
[ "$(id -u)" -eq 0 ] || die "apply must run as root"

# SvelteKit 3 and adapter-node 6 need Node 22.17. A server provisioned before
# them can be older, and a release started on it fails as a crash loop the
# health check reports as "did not become healthy". Refused here instead,
# before anything is touched; `vela provision` upgrades Node in place.
node_at_least 22 17 \
	|| die "this server runs Node $(node -v 2>/dev/null || echo '(none)'), and vela apps need 22.17 or later - run 'vela provision' to upgrade it"

INSTANCE=${1:-}; shift || true
RELEASE=${1:-}; shift || true
[ -n "$INSTANCE" ] && [ -n "$RELEASE" ] || die "usage: apply.sh <instance> <release> [options]"
require_instance_id "$INSTANCE"
require_release_id "$RELEASE"

APP_NAME=$INSTANCE
APP_ID=$INSTANCE
SITE_NAME=""
ENV_TAG=prod
PREVIEW=0
DOMAIN=""
MANAGED=""
HEALTH_PATH=/
KEEP=5
PB_VERSION=""
BACKEND=1
GIT_SHA=""
LOCK_WAIT=300
SU_CREATED=0
MIGRATED=0

while [ $# -gt 0 ]; do
	case "$1" in
		--lock-wait) LOCK_WAIT=$2; shift 2 ;;
		--name) APP_NAME=$2; shift 2 ;;
		--app-id) APP_ID=$2; shift 2 ;;
		--env) ENV_TAG=$2; shift 2 ;;
		--site-name) SITE_NAME=$2; shift 2 ;;
		--preview) PREVIEW=$2; shift 2 ;;
		--domain) DOMAIN=$2; shift 2 ;;
		--managed) MANAGED=$2; shift 2 ;;
		--health-path) HEALTH_PATH=$2; shift 2 ;;
		--keep) KEEP=$2; shift 2 ;;
		--pb-version) PB_VERSION=$2; shift 2 ;;
		--backend) BACKEND=$2; shift 2 ;;
		--git-sha) GIT_SHA=$2; shift 2 ;;
		*) die "unknown argument: $1" ;;
	esac
done

# Commands that drop to the app user inherit this working directory, and the
# directory the CLI happened to invoke from is usually one it cannot stat.
cd "$VELA_ROOT"

APP=$(app_dir "$INSTANCE")
ETC=$(etc_dir "$INSTANCE")
RELEASE_DIR="$APP/releases/$RELEASE"
WEB_UNIT=$(unit_web "$INSTANCE")
PB_UNIT=$(unit_pb "$INSTANCE")

lock_instance "$INSTANCE" "$LOCK_WAIT"

# The instance was removed after this deploy began: a pull request closed while
# its last push was still building, say. Going ahead would recreate what the
# removal was for, so the upload is dropped instead - and with it the instance
# directory, when the upload is all that is in it. A `shared/` left by a
# non-purge destroy is kept. Checked before anything else, since a purge that
# ran mid-upload may have taken part of the release with it.
if removed_since_release "$INSTANCE" "$RELEASE"; then
	rm -rf "${RELEASE_DIR:?}"
	rmdir "$APP/releases" "$APP" 2>/dev/null || true
	printf 'error: %s was removed at %s, after this deploy began (release %s) - not recreating it\n' \
		"$INSTANCE" "$(removed_at "$INSTANCE")" "$RELEASE" >&2
	exit "$REMOVED_EXIT"
fi

[ -d "$RELEASE_DIR" ] || die "release $RELEASE was not uploaded to $RELEASE_DIR"
[ -f "$RELEASE_DIR/build/index.js" ] || die \
	"release $RELEASE has no build/index.js - the app must build with @sveltejs/adapter-node"

# A release that sorts at or below the active one arrived late: a newer deploy
# went live while this one was building or waiting for the lock. Putting it
# live would take the site backwards, so it is dropped - its own directory
# only; what is live is not touched.
ACTIVE=$(state_get "$INSTANCE" activeRelease 2>/dev/null || true)
if [ -n "$ACTIVE" ] && [ -L "$APP/current" ] && ! [[ "$RELEASE" > "$ACTIVE" ]]; then
	rm -rf "${RELEASE_DIR:?}"
	die "release $RELEASE is not newer than the active release $ACTIVE on $INSTANCE - a newer deploy went live first, so this one was dropped"
fi

# Whether the release this one replaces had a PocketBase backend. The CLI
# detects the backend from the project on every deploy, so an instance can gain
# one (`vela bless` between two deploys) or lose one; both are handled below.
# Empty on a first deploy.
PREVIOUS_BACKEND=""
if [ -f "$(state_file "$INSTANCE")" ]; then
	PREVIOUS_BACKEND=$(instance_backend "$INSTANCE")
fi

# Where this app keeps state that has to outlive a release.
#
# An app that works this out from its own working directory puts it inside the
# release, which is the one place it cannot survive: `current` moves on the next
# deploy and the pruner deletes what it left behind. An instance with a database
# shares PocketBase's directory, so anything the app writes there is inside the
# archives `vela backup` takes and inside the directory `vela restore` swaps. An
# instance without one gets a directory of its own, which nothing backs up -
# there is no database to back it up alongside.
if [ "$BACKEND" = "1" ]; then
	APP_DATA_DIR="$APP/shared/pb_data"
else
	APP_DATA_DIR="$APP/shared/data"
fi

mkdir -p "$APP"/{releases,shared,bin,deps} "$APP_DATA_DIR"
mkdir -p "$ETC"
chmod 0700 "$ETC"

# Only the directories just created need their ownership set, and only at the
# top level: everything below the data directory is written by PocketBase (or
# the app) as $VELA_USER already. Recursing here would walk every uploaded file
# on every deploy, which makes deploy time grow with the size of the app's
# storage forever.
chown "$VELA_USER:$VELA_USER" \
	"$APP" "$APP/releases" "$APP/shared" "$APP_DATA_DIR" "$APP/bin" "$APP/deps"
# The release is the exception - rsync uploaded it as whoever we ssh'd in as.
chown -R "$VELA_USER:$VELA_USER" "$RELEASE_DIR"

# A data directory the app cannot write is a dead instance, and it fails as an
# opaque 500 rather than anything that names a cause. This is what the blanket
# recurse above used to paper over; checking costs one stat, repairing costs a
# walk that now happens only when something is actually wrong.
if ! runuser -u "$VELA_USER" -- test -w "$APP_DATA_DIR"; then
	log "repairing ownership under shared/"
	chown -R "$VELA_USER:$VELA_USER" "$APP/shared"
fi

# An instance gaining a backend moves its data directory: VELA_DATA_DIR was
# shared/data and is now shared/pb_data, which is what `vela backup` archives.
# Whatever the app kept follows it, into a pb_data that PocketBase has not yet
# written - once there is a database in there, nothing is moved over it.
if [ "$PREVIOUS_BACKEND" = "false" ] && [ "$BACKEND" = "1" ] \
	&& [ -d "$APP/shared/data" ] && [ -n "$(ls -A "$APP/shared/data")" ]; then
	if [ ! -e "$APP/shared/pb_data/data.db" ]; then
		log "adding a backend - moving shared/data into shared/pb_data"
		find "$APP/shared/data" -mindepth 1 -maxdepth 1 -exec mv -t "$APP/shared/pb_data" {} +
	else
		log "warning: this instance now has a backend, and its data directory is $APP/shared/pb_data"
		log "warning: what the app kept in $APP/shared/data was left there"
	fi
fi

# ---------------------------------------------------------------- ports & env

PORTS=$(allocate_ports "$INSTANCE")
WEB_PORT=$(printf '%s' "$PORTS" | jq -r .web)
PB_PORT=$(printf '%s' "$PORTS" | jq -r .pb)

# The canonical host: a domain the user's own DNS points here outranks a
# managed velastack.app name, which then redirects to it at the edge.
PRIMARY_DOMAIN=$(printf '%s' "$DOMAIN" | cut -d, -f1 | tr -d ' ')
[ -n "$PRIMARY_DOMAIN" ] || PRIMARY_DOMAIN=$(printf '%s' "$MANAGED" | cut -d, -f1 | tr -d ' ')
if [ -n "$PRIMARY_DOMAIN" ]; then
	PRIMARY_URL="https://$PRIMARY_DOMAIN"
else
	PRIMARY_URL="http://127.0.0.1:$WEB_PORT"
fi

# The instance's own env layer is managed only by `vela env`. Nothing here
# reads or rewrites it; the file is created so a first `vela env list` has
# something to read.
[ -f "$ETC/env" ] || { : > "$ETC/env"; chmod 0600 "$ETC/env"; chown root:root "$ETC/env"; }

# The shared layers - app-wide, and one for every preview - live under
# /etc/vela/scopes, and the units are templates on the instance name alone, so
# they reach those layers through fixed-name symlinks written here. A link to a
# layer nobody has written yet dangles, which systemd skips like a missing file.
link_scope "$APP_ID" all "$ETC"
if [ "$PREVIEW" = "1" ]; then
	link_scope "$APP_ID" preview "$ETC"
else
	rm -f "$ETC/scope.preview.env" "$ETC/scope.preview.public.env"
fi

runtime_tmp=$(mktemp "$ETC/.runtime.XXXXXX")
{
	printf '# Generated by vela on each deploy. Edit /etc/vela/apps/%s/env instead.\n' "$INSTANCE"
	printf 'NODE_ENV=production\n'
	printf 'HOST=127.0.0.1\n'
	printf 'PORT=%s\n' "$WEB_PORT"
	runtime_origin_lines "$DOMAIN" "$PRIMARY_URL"
	# Caddy is the only thing that reaches the app, so without these
	# `getClientAddress()` is always 127.0.0.1. Caddy replaces any
	# X-Forwarded-For a client sends with the address it saw (or, on a managed
	# route, the one the Worker reports), so the last entry is the visitor.
	printf 'ADDRESS_HEADER=x-forwarded-for\n'
	printf 'XFF_DEPTH=1\n'
	# Only an instance with a PocketBase gets pointed at one: a URL to a port
	# nothing listens on is not a setting, it is a trap. Kept for one more
	# deploy when the backend is being removed, so that a failed deploy can put
	# the previous release - which still needs it - back.
	if [ "$BACKEND" = "1" ] || [ "$PREVIOUS_BACKEND" = "true" ]; then
		printf 'PB_PORT=%s\n' "$PB_PORT"
		printf 'POCKETBASE_URL=http://127.0.0.1:%s\n' "$PB_PORT"
	fi
	printf 'VELA_DATA_DIR=%s\n' "$APP_DATA_DIR"
	# PocketBase names the app in its emails after APP_NAME, and links them to
	# ORIGIN (above) unless APP_URL says otherwise. The name is the one
	# src/lib/site.ts declares, so it is set from code on every deploy.
	[ -z "$SITE_NAME" ] || printf 'APP_NAME=%s\n' "$SITE_NAME"
	printf 'VELA_APP_ID=%s\n' "$APP_ID"
	printf 'VELA_APP_NAME=%s\n' "$APP_NAME"
	printf 'VELA_ENV=%s\n' "$ENV_TAG"
	printf 'VELA_RELEASE=%s\n' "$RELEASE"
} > "$runtime_tmp"
chmod 0600 "$runtime_tmp"
chown root:root "$runtime_tmp"
mv -f "$runtime_tmp" "$ETC/runtime.env"

# ------------------------------------------------------------------- runtime

if [ "$BACKEND" = "1" ]; then
	[ -n "$PB_VERSION" ] || die "--pb-version is required for an app with a backend"
	PB_BIN=$("$SCRIPT_DIR/pocketbase.sh" "$PB_VERSION" | tail -1)
	ln -sfn "$PB_BIN" "$APP/bin/.pocketbase.tmp"
	mv -Tf "$APP/bin/.pocketbase.tmp" "$APP/bin/pocketbase"
fi

# --------------------------------------------------------------- dependencies
#
# node_modules is keyed by the lockfile hash and shared between releases that
# pin the same dependencies: redeploys of unchanged dependencies skip the
# install entirely, and a rollback still finds the tree its own lockfile
# described.
#
# The key also carries the Node ABI and npm's major. Native modules
# (better-sqlite3, argon2) are compiled for one ABI, so a tree installed before
# `vela provision` moved Node to a new major would fail to load after it; and
# npm majors disagree on what a lockfile has to contain. Either change installs
# afresh.

install_deps() {
	local lock="$RELEASE_DIR/package-lock.json"
	local key foreign="" name runtime
	local -a install_cmd
	runtime="abi$(node -p process.versions.modules 2>/dev/null)-npm$(npm -v 2>/dev/null | cut -d. -f1)"
	[[ $runtime =~ ^abi[0-9]+-npm[0-9]+$ ]] || die "could not read the Node and npm versions ($runtime)"
	# `--no-engine-strict`: vela projects set engine-strict for development, but a
	# production install takes only `dependencies` - a dev tool that wants a newer
	# Node than the server runs is not a reason to refuse the deploy.
	local -a flags=(--omit=dev --no-audit --no-fund --no-engine-strict --loglevel=error)
	if [ -f "$lock" ]; then
		key=$({ printf '%s\n' "$runtime"; cat "$lock"; } | sha256sum | cut -c1-16)
		install_cmd=(npm ci "${flags[@]}")
	elif [ -f "$RELEASE_DIR/package.json" ]; then
		# A project pinned by pnpm, yarn or bun: npm cannot replay the lockfile, so
		# this resolves afresh, but the key still follows it - a dependency bump
		# that only touched the lockfile must not reuse the old tree.
		for name in pnpm-lock.yaml yarn.lock bun.lock bun.lockb; do
			if [ -f "$RELEASE_DIR/$name" ]; then foreign="$RELEASE_DIR/$name"; break; fi
		done
		key=$({ printf '%s\n' "$runtime"; cat "$RELEASE_DIR/package.json" ${foreign:+"$foreign"}; } \
			| sha256sum | cut -c1-16)
		install_cmd=(npm install "${flags[@]}")
	else
		log "no package.json in release - skipping dependency install"
		return 0
	fi

	local deps="$APP/deps/$key"
	# Written only once npm has finished: an install that died part-way leaves a
	# node_modules behind, and the next deploy with the same key must not take
	# it for a complete tree.
	local done_marker="$deps/.vela-installed"
	if [ ! -f "$done_marker" ]; then
		log "installing dependencies ($key)"
		rm -rf "$deps"
		mkdir -p "$deps"
		cp "$RELEASE_DIR/package.json" "$deps/"
		if [ -f "$lock" ]; then cp "$lock" "$deps/"; fi
		# npm reads a yarn.lock beside package.json for resolution hints.
		if [ "${foreign##*/}" = "yarn.lock" ]; then cp "$foreign" "$deps/"; fi
		# `prepare` is a development hook (`svelte-kit sync`, husky) whose tools
		# are devDependencies, which are not installed here: it can only print
		# "svelte-kit: not found". Dependencies' own install scripts still run.
		node -e '
			const fs = require("fs");
			const file = process.argv[1];
			const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
			if (pkg.scripts && pkg.scripts.prepare) {
				delete pkg.scripts.prepare;
				fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n");
			}
		' "$deps/package.json" || die "could not read package.json"
		if [ -f "$RELEASE_DIR/.npmrc" ]; then cp "$RELEASE_DIR/.npmrc" "$deps/"; fi
		chown -R "$VELA_USER:$VELA_USER" "$deps"
		# npm needs a writable HOME and cache; $VELA_ROOT itself stays root-owned
		# so the app user can never swap out the scripts root runs.
		mkdir -p "$VELA_ROOT/cache/npm"
		chown -R "$VELA_USER:$VELA_USER" "$VELA_ROOT/cache"
		# 8>&-: npm must not inherit the instance lock, or a child it leaves
		# behind would hold it after this script has exited.
		( cd "$deps" && runuser -u "$VELA_USER" -- env \
			HOME="$deps" npm_config_cache="$VELA_ROOT/cache/npm" \
			"${install_cmd[@]}" >&2 8>&- ) || die "dependency install failed"
		: > "$done_marker"
	else
		log "dependencies already installed ($key)"
	fi
	ln -sfn "$deps/node_modules" "$RELEASE_DIR/.node_modules.tmp"
	mv -Tf "$RELEASE_DIR/.node_modules.tmp" "$RELEASE_DIR/node_modules"
	printf '%s' "$key" > "$RELEASE_DIR/.vela-deps"
}
install_deps

# --------------------------------------------------------------- the swap

PREVIOUS=""
if [ -L "$APP/current" ]; then
	PREVIOUS=$(basename "$(readlink -f "$APP/current")")
fi

# From here on the instance is being mutated. Any exit before ACTIVATED=1 puts
# the previous release back - a failed deploy must never leave a dead service.
IN_TRANSACTION=0
ACTIVATED=0

restore() {
	log "deploy failed - restoring ${PREVIOUS:-nothing}"
	# The schema moved with this release; the previous one must not run
	# against it. Same steps `vela rollback` takes, best-effort here because
	# the app is being put back either way and the alternative is a dead site.
	if [ "$BACKEND" = "1" ] && [ "$MIGRATED" = "1" ] && [ -n "$PREVIOUS" ] \
		&& [ -d "$APP/releases/$PREVIOUS" ]; then
		systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true
		revert_migrations "$APP" "$RELEASE_DIR/migrations" "$APP/releases/$PREVIOUS/migrations" \
			|| log "down migrations failed - the database schema is ahead of $PREVIOUS; run 'vela rollback' once it is fixed"
	fi
	if [ -n "$PREVIOUS" ] && [ -d "$APP/releases/$PREVIOUS" ]; then
		ln -sfn "$APP/releases/$PREVIOUS" "$APP/.current.tmp"
		mv -Tf "$APP/.current.tmp" "$APP/current"
		# runtime.env was already written for the release that failed; the
		# services about to restart must read the one they will actually run.
		set_runtime_release "$ETC" "$PREVIOUS"
		# The previous release needs its PocketBase whether or not this one did.
		if [ "$BACKEND" = "1" ] || [ "$PREVIOUS_BACKEND" = "true" ]; then
			systemctl restart "$PB_UNIT" >/dev/null 2>&1 || true
		fi
		systemctl restart "$WEB_UNIT" >/dev/null 2>&1 || true
	else
		systemctl stop "$WEB_UNIT" >/dev/null 2>&1 || true
		if [ "$BACKEND" = "1" ]; then systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true; fi
	fi
}

on_exit() {
	local code=$?
	if [ "$IN_TRANSACTION" = "1" ] && [ "$ACTIVATED" = "0" ]; then restore; fi
	exit "$code"
}
trap on_exit EXIT

IN_TRANSACTION=1

log "stopping services"
systemctl stop "$WEB_UNIT" >/dev/null 2>&1 || true
if [ "$BACKEND" = "1" ]; then
	systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true

	if [ -d "$RELEASE_DIR/migrations" ]; then
		log "running migrations"
		runuser -u "$VELA_USER" -- "$APP/bin/pocketbase" \
			--dir "$APP/shared/pb_data" \
			--migrationsDir "$RELEASE_DIR/migrations" \
			migrate up >&2
		MIGRATED=1
	fi

	# ---------------------------------------------------- superuser bootstrap
	#
	# The app authenticates to its own PocketBase as a superuser on every render,
	# so a database without one answers every request with a 401 and the deploy
	# fails its health check. A brand new instance has an empty pb_data, which is
	# why the account is created here rather than assumed to exist.
	#
	# $ETC/env is otherwise `vela env`'s alone. This is the single exception, and
	# it writes only on the deploy that creates the account. From then on the env
	# file is the source of truth: a value changed there is pushed into the
	# database by the next deploy, which is what keeps the two from drifting.
	SU_EMAIL=$(env_file_get "$ETC/env" POCKETBASE_SUPERUSER_EMAIL || true)
	SU_PASSWORD=$(env_file_get "$ETC/env" POCKETBASE_SUPERUSER_PASSWORD || true)

	if [ -z "$SU_EMAIL" ] || [ -z "$SU_PASSWORD" ]; then
		SU_EMAIL=${SU_EMAIL:-admin@${PRIMARY_DOMAIN:-$INSTANCE.invalid}}
		SU_PASSWORD=$(random_secret)
		SU_CREATED=1
	fi

	reconcile_superuser "$APP" "$ETC" "$SU_EMAIL" "$SU_PASSWORD"

	# Written only once the database holds the account, so a failure above leaves
	# nothing behind and the next deploy simply tries again.
	if [ "$SU_CREATED" = "1" ]; then
		env_file_append "$ETC/env" POCKETBASE_SUPERUSER_EMAIL "$SU_EMAIL"
		env_file_append "$ETC/env" POCKETBASE_SUPERUSER_PASSWORD "$SU_PASSWORD"
	fi
fi

log "activating release $RELEASE"
ln -sfn "$RELEASE_DIR" "$APP/.current.tmp"
mv -Tf "$APP/.current.tmp" "$APP/current"
# A human-readable alias for the opaque app id, purely for looking around.
if [ "$ENV_TAG" = "prod" ]; then LINK_LABEL=$APP_NAME; else LINK_LABEL="$APP_NAME-$ENV_TAG"; fi
LINK_NAME=$(printf '%s' "$LINK_LABEL" | tr -c 'A-Za-z0-9._-' '-')
if [ -n "$LINK_NAME" ]; then
	# A staging name of its own: a fixed one would be shared with every other
	# deploy on the server, and two of them would swap each other's links.
	link_tmp=$(mktemp -u "$VELA_ROOT/by-name/.link.XXXXXX")
	{ ln -sfn "$APP" "$link_tmp" && mv -Tf "$link_tmp" "$VELA_ROOT/by-name/$LINK_NAME"; } \
		|| rm -f "$link_tmp" || true
fi

# The units come from the same upload as this script, so a server provisioned
# by an older CLI gets the current ones on its next deploy rather than running
# the old env file list against new layers nothing reads.
for unit in vela-web@.service vela-pb@.service; do
	cmp -s "$SCRIPT_DIR/systemd/$unit" "/etc/systemd/system/$unit" \
		|| install -m 0644 "$SCRIPT_DIR/systemd/$unit" "/etc/systemd/system/$unit"
done
systemctl daemon-reload

if [ "$BACKEND" = "1" ]; then
	log "starting PocketBase on 127.0.0.1:$PB_PORT"
	systemctl enable "$PB_UNIT" >/dev/null 2>&1 || true
	systemctl restart "$PB_UNIT"
	wait_for_http "http://127.0.0.1:$PB_PORT/api/health" 60 0.5 \
		|| die "PocketBase did not become healthy - journalctl -u $PB_UNIT"

	# Answering on the port is not the same as agreeing with $ETC/env, and a
	# disagreement shows up as every render 401ing rather than as a failed start.
	#
	# The fingerprint above cannot see a database that was replaced underneath
	# an env file that did not change, so a first failure is repaired rather than
	# reported: force the upsert and ask again. Only a second failure is real.
	if ! assert_superuser_auth "$PB_PORT" "$SU_EMAIL" "$SU_PASSWORD"; then
		log "the database disagrees with this instance's credentials - repairing"
		# Stopped for the upsert: it opens the same SQLite file directly, and a
		# running PocketBase is one writer too many to reason about.
		systemctl stop "$PB_UNIT" >/dev/null 2>&1 || true
		reconcile_superuser "$APP" "$ETC" "$SU_EMAIL" "$SU_PASSWORD" --force
		systemctl start "$PB_UNIT"
		wait_for_http "http://127.0.0.1:$PB_PORT/api/health" 60 0.5 \
			|| die "PocketBase did not come back after the credential repair"
		assert_superuser_auth "$PB_PORT" "$SU_EMAIL" "$SU_PASSWORD" \
			|| die "PocketBase does not accept this instance's superuser credentials"
	fi
fi

log "starting app on 127.0.0.1:$WEB_PORT"
systemctl enable "$WEB_UNIT" >/dev/null 2>&1 || true
systemctl restart "$WEB_UNIT"
wait_for_http "http://127.0.0.1:$WEB_PORT$HEALTH_PATH" 60 0.5 "$PRIMARY_DOMAIN" \
	|| die "app did not become healthy at $HEALTH_PATH - journalctl -u $WEB_UNIT"

ACTIVATED=1

# An instance that no longer has a backend keeps its database on disk - only
# `vela destroy deployment --purge` removes data - but the unit serving it is
# retired, or `vela status` would keep reporting a PocketBase and every restart
# would bring it back up.
if [ "$PREVIOUS_BACKEND" = "true" ] && [ "$BACKEND" = "0" ]; then
	log "removing the backend - stopping PocketBase; $APP/shared/pb_data is kept"
	systemctl disable --now "$PB_UNIT" >/dev/null 2>&1 || true
fi

# --------------------------------------------------------------------- state

state_merge "$INSTANCE" "$(jq -c -n \
	--arg app "$APP_ID" --arg name "$APP_NAME" --arg env "$ENV_TAG" \
	--arg instance "$INSTANCE" --arg release "$RELEASE" --arg previous "$PREVIOUS" \
	--arg domain "$DOMAIN" --arg managed "$MANAGED" --arg url "$PRIMARY_URL" \
	--arg health "$HEALTH_PATH" --arg pb "$PB_VERSION" \
	--arg sha "$GIT_SHA" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
	--argjson web "$WEB_PORT" --argjson pbport "$PB_PORT" --argjson backend "$BACKEND" \
	--argjson preview "$PREVIEW" \
	'{appId: $app, name: $name, env: $env, instance: $instance, preview: ($preview == 1),
	  activeRelease: $release, previousRelease: $previous, domain: $domain,
	  managed: $managed, url: $url,
	  healthCheckPath: $health, pocketbaseVersion: $pb, gitSha: $sha,
	  webPort: $web, pbPort: $pbport, backend: ($backend == 1), deployedAt: $at}')"
# A deploy that began after the last removal is live; the mark has done its job.
clear_removed "$INSTANCE"

# ------------------------------------------------------------------ routing
#
# Two files, written independently and each only if the whole Caddy config
# still validates with it in place (`caddy_install` keeps the previous one
# otherwise). Hosts the user's DNS points here get a site block of their own
# and a certificate each; managed velastack.app hosts arrive through the
# Worker at this server's origin site and are picked out by header.

CADDY_SNIPPET="$VELA_ETC/caddy/$INSTANCE.caddy"
ROUTE_SNIPPET="$VELA_ETC/caddy/routes/$INSTANCE.route"
ROUTING_CHANGED=0

# From here the release is live and stays live: a routing failure below
# exits non-zero with the site running the new release and its routes as
# they were, which is what the messages say.
caddy_lock

if [ -n "$DOMAIN" ]; then
	hosts=$(printf '%s' "$DOMAIN" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' | paste -sd, - | sed 's/,/, /g')
	tmp=$(mktemp "$VELA_ETC/caddy/.snippet.XXXXXX")
	{
		printf '# Managed by vela - app %s (%s)\n' "$APP_NAME" "$INSTANCE"
		printf '%s {\n' "$hosts"
		# The app precompresses its built assets but not rendered pages; Caddy
		# passes an already-encoded response through untouched.
		printf '\tencode zstd gzip\n'
		# Files from static/ come back with an ETag and no Cache-Control, so a
		# browser asks again on every view. Unhashed, so an hour rather than
		# forever; `?` leaves whatever the app or PocketBase set alone.
		printf '\t@static path *.jpg *.jpeg *.png *.gif *.webp *.avif *.svg *.ico *.woff *.woff2\n'
		printf '\theader @static ?Cache-Control "public, max-age=3600"\n'
		printf '\treverse_proxy 127.0.0.1:%s\n' "$WEB_PORT"
		printf '}\n'
	} > "$tmp"
	caddy_install "$tmp" "$CADDY_SNIPPET" \
		|| die "release $RELEASE is live, but the generated Caddy config for $DOMAIN is invalid and was not installed - routing is unchanged"
	ROUTING_CHANGED=1
elif [ -f "$CADDY_SNIPPET" ]; then
	rm -f "$CADDY_SNIPPET"
	ROUTING_CHANGED=1
fi

if [ -n "$MANAGED" ]; then
	[ -f "$VELA_ETC/caddy/00-origin.caddy" ] \
		|| die "managed hostnames need this server's origin site - the CLI runs origin.sh first"
	# A matcher name is an identifier; instance ids carry dashes.
	matcher=vela_$(printf '%s' "$INSTANCE" | tr -c 'a-zA-Z0-9_' '_')
	tmp=$(mktemp "$VELA_ETC/caddy/routes/.route.XXXXXX")
	{
		printf '# Managed by vela - app %s (%s)\n' "$APP_NAME" "$INSTANCE"
		printf '@%s {\n' "$matcher"
		printf '%s' "$MANAGED" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' \
			| while IFS= read -r host; do printf '\theader X-Velastack-Host %s\n' "$host"; done
		printf '}\n'
		# The app should see the public host and the visitor, not the origin
		# name and the Cloudflare edge that carried the request here.
		printf 'handle @%s {\n' "$matcher"
		printf '\treverse_proxy 127.0.0.1:%s {\n' "$WEB_PORT"
		printf '\t\theader_up Host {http.request.header.X-Velastack-Host}\n'
		printf '\t\theader_up X-Forwarded-For {http.request.header.X-Velastack-Client-IP}\n'
		printf '\t}\n}\n'
	} > "$tmp"
	caddy_install "$tmp" "$ROUTE_SNIPPET" \
		|| die "release $RELEASE is live, but the generated Caddy route for $MANAGED is invalid and was not installed - routing is unchanged"
	ROUTING_CHANGED=1
elif [ -f "$ROUTE_SNIPPET" ]; then
	rm -f "$ROUTE_SNIPPET"
	ROUTING_CHANGED=1
fi

[ "$ROUTING_CHANGED" = 0 ] || caddy_reload
caddy_unlock

# ------------------------------------------------------------------- pruning

if [ "$KEEP" -gt 0 ] 2>/dev/null; then
	mapfile -t old < <(ls -1 "$APP/releases" | sort -r | tail -n +$((KEEP + 1)))
	for rel in "${old[@]:-}"; do
		[ -n "$rel" ] || continue
		[ "$rel" = "$RELEASE" ] && continue
		[ "$rel" = "$PREVIOUS" ] && continue
		# Uploaded by a deploy that is waiting on the lock: not ours to prune.
		if [[ "$rel" > "$RELEASE" ]]; then continue; fi
		log "pruning release $rel"
		rm -rf "${APP:?}/releases/$rel"
	done
	# Drop dependency trees no surviving release refers to.
	for deps in "$APP"/deps/*; do
		[ -d "$deps" ] || continue
		key=$(basename "$deps")
		if ! grep -rqs -- "$key" "$APP"/releases/*/.vela-deps; then
			log "pruning dependencies $key"
			rm -rf "$deps"
		fi
	done
fi

emit_result \
	--arg instance "$INSTANCE" --arg release "$RELEASE" --arg domain "$DOMAIN" --arg managed "$MANAGED" \
	--arg url "$PRIMARY_URL" --argjson web "$WEB_PORT" --argjson pb "$PB_PORT" --argjson su "$SU_CREATED" \
	'{instance: $instance, release: $release, domain: $domain, managed: $managed, url: $url,
	  webPort: $web, pbPort: $pb, superuserCreated: ($su == 1)}'
