#!/usr/bin/env bash
#
# SPDX-FileCopyrightText: 2026 Max Fiedler
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# The Cairn Nextcloud app's development environment, in one script.
#
# The goal is that a person who has never seen this repository can run
# `nextcloud_app/dev up`, wait, and have a working Nextcloud with the app enabled
# and real-shaped health data in it. Everything else here exists to keep that
# promise true on the second run and the tenth.
#
#   ./dev up        start it, install it, enable the app, load data
#   ./dev help      everything else
#
# Docker is the only prerequisite.

set -euo pipefail

APP_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${APP_ROOT}/.." && pwd)"
COMPOSE_FILE="${APP_ROOT}/docker/compose.yaml"
ENV_FILE="${APP_ROOT}/docker/.env"
ENV_EXAMPLE="${APP_ROOT}/docker/.env.example"
GENERATED_DIR="${APP_ROOT}/dev-data/generated"
LOCAL_DIR="${APP_ROOT}/dev-data/local"
GENERATOR="${REPO_ROOT}/tool/generate_dev_health_data.py"

# How long to wait for Nextcloud's first-run install. It unpacks the whole
# server tree into the volume, which on a cold cache is genuinely slow.
INSTALL_TIMEOUT_SECONDS=300

# One pinned image per Nextcloud major appinfo/info.xml claims to support.
# Tag *and* digest, like every other image here — a matrix that resolved tags at
# run time would silently start testing something else.
#
# When info.xml's min-version or max-version moves, this table moves with it.
# The two are the same claim: one is what the app store enforces, the other is
# what actually gets run.
MATRIX_IMAGES=(
	"32|nextcloud:32.0.14-apache@sha256:915ee67db2cdd703f22e718aa5ad13114db8a9ce8f8262c5c993894c8751f97a"
	"33|nextcloud:33.0.8-apache@sha256:c81c841aef5d757147313a0e6465cb871eb279affcd732792698fb5d0a15dcdc"
	"34|nextcloud:34.0.3-apache@sha256:368cd1c75bc4a32c2aee8a119cae31bcebff80c374782f404c440c3295726814"
)

# The interpreter appinfo/info.xml declares as the floor. None of the Nextcloud
# images above ships it — they carry 8.3, 8.4 and 8.5 — so without this the
# `<php min-version>` half of the compatibility claim would go untested while
# looking covered.
MATRIX_PHP_FLOOR="php:8.2-cli-alpine@sha256:6d3dcc922fa36d06d6eefba5697a0aee599cf2f306b3cc2700a6526bc8fd5c09"

# Exactly what a release tarball contains, as an allowlist.
#
# An allowlist, not an exclude list, and that is the whole point: an exclude
# list ships whatever nobody thought to exclude — a stray .env, a key, an
# export someone left in the tree. This can only ever omit something, which
# fails visibly the first time the app is installed.
#
# Note what is absent. `vendor/` is not here because the app has no runtime
# dependencies at all: `composer.json` requires only a PHP version, and
# Nextcloud autoloads OCA\Cairn from lib/ itself. What ships is first-party
# code and the built bundle, with nothing to audit underneath.
PACKAGE_PATHS=(
	"appinfo"
	"lib"
	"templates"
	"img"
	"l10n"
	"js"
	"LICENSE"
	"README.md"
)

# The tarball lands here. Gitignored: it is a build artefact.
PACKAGE_DIR="build"

# The matrix runs its own stack on its own port, so it never collides with a
# `dev up` instance you have open.
MATRIX_PROJECT="cairn-matrix"
MATRIX_PORT="18080"

# Metric folders a valid Cairn tree contains at least one of.
METRIC_SLUGS=(heart-rate steps sleep activity weight)

say() { printf '%s\n' "$*"; }
step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
warn() { printf 'warning: %s\n' "$*" >&2; }

# Every failure exits with an actionable second line. A dev environment that
# fails opaquely costs more than one that does not exist.
die() {
	printf 'error: %s\n' "$1" >&2
	if [[ $# -gt 1 ]]; then
		printf '%s\n' "$2" >&2
	fi
	exit 1
}

# Set only by `matrix`, which runs its own throwaway stack. Everything else
# uses the project name declared in compose.yaml, so a matrix run can never
# stop, reseed or destroy the instance you are working in.
COMPOSE_PROJECT=""

compose() {
	if [[ -n "${COMPOSE_PROJECT}" ]]; then
		docker compose --file "${COMPOSE_FILE}" --project-name "${COMPOSE_PROJECT}" "$@"
	else
		docker compose --file "${COMPOSE_FILE}" "$@"
	fi
}

# occ, as the web-server user, without a TTY so this works from scripts and CI.
occ() {
	compose exec -T --user www-data nextcloud php occ "$@"
}

# Read a KEY=VALUE out of docker/.env without sourcing it: that file is
# hand-edited and a stray backtick should not run anything.
env_value() {
	local key="$1" value
	[[ -f "${ENV_FILE}" ]] || return 0
	value="$(sed -n -E "s/^[[:space:]]*${key}=(.*)$/\1/p" "${ENV_FILE}" | tail -1)"
	# Strip one layer of surrounding quotes, if present.
	value="${value%\"}"
	value="${value#\"}"
	value="${value%\'}"
	value="${value#\'}"
	printf '%s' "${value}"
}

# The shell environment wins over docker/.env, matching how compose itself
# resolves variables.
setting() {
	local key="$1" fallback="${2:-}" from_env
	if [[ -n "${!key:-}" ]]; then
		printf '%s' "${!key}"
		return 0
	fi
	from_env="$(env_value "${key}")"
	printf '%s' "${from_env:-${fallback}}"
}

admin_user() { setting CAIRN_ADMIN_USER admin; }
admin_password() { setting CAIRN_ADMIN_PASSWORD admin; }
dev_port() { setting CAIRN_DEV_PORT 8080; }
base_url() { printf 'http://localhost:%s' "$(dev_port)"; }

# Called once from main(), in the main shell. Doing this inside the accessors
# would be useless: they run inside command substitutions, where `die`'s exit
# kills only the subshell and the caller carries on with an empty value.
validate_settings() {
	local user port
	user="$(admin_user)"
	port="$(dev_port)"

	# The admin name becomes a path component inside the container, and that
	# path is handed to `rm -rf` in cmd_seed. Quoting stops a value from
	# *executing* anything, but it does not stop `../../..` from resolving
	# somewhere else first, so the value is constrained rather than just quoted.
	# `.` is allowed because real user names contain it, which means the
	# character class alone still admits `.` and `..` — and `..` is precisely the
	# traversal this check exists to stop: it would turn the seed target into
	# /var/www/html/data/../files/Cairn and hand that to `rm -rf`. Reject any
	# name that is only dots, separately from the character check.
	if ! [[ "${user}" =~ ^[A-Za-z0-9._-]+$ ]] || [[ "${user}" =~ ^\.+$ ]]; then
		die "CAIRN_ADMIN_USER '${user}' is not a plain user name." "Use only letters, digits, dot, dash and underscore, and not just dots — it becomes a path inside the container."
	fi
	if ! [[ "${port}" =~ ^[0-9]+$ ]] || [[ "${port}" -lt 1 ]] || [[ "${port}" -gt 65535 ]]; then
		die "CAIRN_DEV_PORT '${port}' is not a port number." "Set it to a value between 1 and 65535 in nextcloud_app/docker/.env."
	fi
}

require_docker() {
	command -v docker >/dev/null 2>&1 \
		|| die "docker is not installed." "Install Docker, then run this again: https://docs.docker.com/engine/install/"
	docker compose version >/dev/null 2>&1 \
		|| die "the Docker Compose v2 plugin is missing." "Install docker-compose-plugin; the old standalone docker-compose will not work."
	docker info >/dev/null 2>&1 \
		|| die "the Docker daemon is not reachable." "Start Docker (or add yourself to the 'docker' group) and try again."
}

ensure_env_file() {
	if [[ -f "${ENV_FILE}" ]]; then
		return 0
	fi
	cp "${ENV_EXAMPLE}" "${ENV_FILE}"
	say "Created docker/.env from the example. It is gitignored; edit it to point"
	say "CAIRN_SEED_DIR at your own Cairn export."
}

container_running() {
	[[ -n "$(compose ps --quiet --status running nextcloud 2>/dev/null)" ]]
}

check_port_free() {
	local port
	port="$(dev_port)"
	container_running && return 0
	if (exec 3<>"/dev/tcp/127.0.0.1/${port}") 2>/dev/null; then
		exec 3>&- 3<&-
		die "port ${port} is already in use by something else." "Set CAIRN_DEV_PORT in nextcloud_app/docker/.env to a free port."
	fi
	return 0
}

wait_for_install() {
	local waited=0
	step "Waiting for Nextcloud to install itself"
	while [[ "${waited}" -lt "${INSTALL_TIMEOUT_SECONDS}" ]]; do
		# occ exits non-zero until the server tree is unpacked and configured,
		# so its failure is the normal state here rather than an error.
		if occ status --output=json 2>/dev/null | grep -q '"installed":true'; then
			say "Nextcloud is up after ${waited}s."
			return 0
		fi
		sleep 3
		waited=$((waited + 3))
		if [[ $((waited % 30)) -eq 0 ]]; then
			say "  still installing (${waited}s)..."
		fi
	done

	# Returns rather than dying: callers run this inside conditionals, and a
	# `die` there exits the script with its own explanation discarded.
	warn "Nextcloud did not finish installing within ${INSTALL_TIMEOUT_SECONDS}s."
	say "Last 50 lines of the container log:" >&2
	compose logs --tail=50 nextcloud >&2 || true
	return 1
}

# Turn off APCu as Nextcloud's local cache.
#
# The image enables it, and APCu memory is per-process: `occ` runs in the CLI
# process and updates the database and *its* cache, while the Apache worker goes
# on serving the value it cached earlier. A config change then looks like it was
# accepted and quietly does nothing — verified here, where bumping the asset
# cachebuster took effect only intermittently until this was switched off.
#
# It has to be done by emptying config/apcu.config.php: `occ config:system:delete
# memcache.local` reports success but only edits config.php, and that separate
# file re-supplies the value on the next request. config/ is in the image's
# upgrade-exclude list, so this survives restarts.
#
# Nextcloud will warn that no memory cache is configured. On a throwaway
# single-user dev container that warning is much the cheaper of the two problems.
disable_apcu() {
	# A quoted heredoc, so $CONFIG reaches PHP as written rather than being
	# expanded by either shell on the way.
	compose exec -T nextcloud tee /var/www/html/config/apcu.config.php >/dev/null <<'PHP_CONFIG'
<?php
$CONFIG = array();
PHP_CONFIG
	compose exec -T nextcloud chown www-data:www-data /var/www/html/config/apcu.config.php

	# And out of config.php, which needs doing second. Nextcloud merges every
	# config/*.config.php into one view and writes that whole merged view back to
	# config.php on any `occ config:system:set` — so by the time we get here the
	# APCu line has usually been copied across, and emptying the source file
	# alone would leave it in place.
	occ config:system:delete memcache.local >/dev/null 2>&1 || true
}

# Hand custom_apps back to the web-server user.
#
# Bind-mounting the app at custom_apps/cairn makes Docker create the parent
# custom_apps directory itself, owned by root — before the image's entrypoint
# would have created and chowned it. Nextcloud then has an apps path it has been
# told is writable but is not, and Settings › Apps returns a 500 ("Cannot write
# into apps directory") on every visit. The app itself is unaffected, which is
# exactly what makes this worth fixing here: it breaks a page a contributor will
# open to check their app is listed, and points the blame somewhere else.
#
# Only the parent is touched. The mount below it stays read-only.
fix_custom_apps_owner() {
	compose exec -T nextcloud chown www-data:www-data /var/www/html/custom_apps
}

enable_app() {
	step "Enabling the Cairn app"
	# Quieter logs and no sample files in the way of the seeded data.
	occ config:system:set loglevel --value=2 --type=integer >/dev/null
	occ config:system:set skeletondirectory --value="" >/dev/null

	disable_apcu
	fix_custom_apps_owner

	# The first-run wizard opens a full-screen modal over whatever you navigated
	# to, on every fresh instance. Useful for a real user, pure noise for someone
	# who just ran `dev up` to look at their app.
	occ app:disable firstrunwizard >/dev/null 2>&1 || true

	occ app:enable cairn >/dev/null 2>&1 || true

	# Verify rather than trust. `app:enable` can report success while the app is
	# rejected for a version mismatch, and a dev environment that claims to have
	# enabled the app but did not is the single most confusing failure here.
	if ! occ app:list --output=json 2>/dev/null | grep -q '"cairn"'; then
		warn "The app did not enable. Nextcloud's own reason:"
		occ app:enable cairn >&2 || true
		die "the Cairn app is not enabled." "Usually appinfo/info.xml's <nextcloud max-version> is behind the container's version."
	fi
	say "Cairn is enabled."
}

# Resolve where the health data comes from, most explicit first. Echoes the
# directory; callers treat empty as "nothing usable was found".
resolve_seed_dir() {
	local explicit="${1:-}" configured
	if [[ -n "${explicit}" ]]; then
		printf '%s' "${explicit}"
		return 0
	fi
	configured="$(setting CAIRN_SEED_DIR)"
	if [[ -n "${configured}" ]]; then
		printf '%s' "${configured}"
		return 0
	fi
	if [[ -f "${LOCAL_DIR}/manifest.json" ]]; then
		printf '%s' "${LOCAL_DIR}"
		return 0
	fi
	printf ''
}

# A Cairn tree has a manifest and at least one dated shard. Checking both stops
# the most common mistake by far: pointing at the folder *containing* Cairn.
validate_seed_dir() {
	local dir="$1" slug
	[[ -d "${dir}" ]] || die "seed directory '${dir}' does not exist." "$(seed_help)"
	[[ -f "${dir}/manifest.json" ]] || die "no manifest.json in '${dir}'." "$(seed_help)"
	for slug in "${METRIC_SLUGS[@]}"; do
		if compgen -G "${dir}/${slug}/*/*.jsonl" >/dev/null; then
			return 0
		fi
	done
	die "no <metric>/<year>/<date>.jsonl shards under '${dir}'." "$(seed_help)"
}

seed_help() {
	cat <<'HELP'
Point it at the Cairn folder itself — the one holding manifest.json and the
metric directories — not at its parent. Sources are tried in this order:
  1. the path you pass:  ./dev seed /path/to/Cairn
  2. CAIRN_SEED_DIR in nextcloud_app/docker/.env
  3. nextcloud_app/dev-data/local/   (gitignored; drop an export in here)
  4. a synthetic tree, generated automatically when none of the above exist
HELP
}

generate_fixture() {
	if [[ -f "${GENERATED_DIR}/manifest.json" ]]; then
		return 0
	fi
	command -v python3 >/dev/null 2>&1 \
		|| die "no seed data configured and python3 is missing, so none can be generated." "$(seed_help)"
	step "Generating a synthetic Cairn tree"
	say "No real export configured, so building one that never belonged to anybody."
	python3 "${GENERATOR}" --out "${GENERATED_DIR}" --force
}

cmd_seed() {
	local source_dir user target
	source_dir="$(resolve_seed_dir "${1:-}")"
	if [[ -z "${source_dir}" ]]; then
		generate_fixture
		source_dir="${GENERATED_DIR}"
	fi
	source_dir="$(cd -- "${source_dir}" && pwd)"
	validate_seed_dir "${source_dir}"

	container_running || die "the dev instance is not running." "Start it first: nextcloud_app/dev up"

	user="$(admin_user)"
	target="/var/www/html/data/${user}/files/Cairn"

	step "Seeding health data"
	# Printed in full and unabbreviated: when this is a real export, you should
	# be able to see at a glance exactly whose data is about to be copied.
	say "Source: ${source_dir}"

	# Replace rather than merge, so the counts this prints describe the folder
	# that now exists instead of the union of every seed ever run.
	compose exec -T nextcloud rm -rf "${target}"
	compose exec -T nextcloud mkdir -p "${target}"
	compose cp "${source_dir}/." "nextcloud:${target}/"
	# `compose cp` writes as root; Nextcloud reads as www-data.
	compose exec -T nextcloud chown -R www-data:www-data "${target}"

	say "Indexing..."
	occ files:scan --path="/${user}/files/Cairn" --quiet 2>/dev/null \
		|| occ files:scan "${user}" --quiet >/dev/null

	local slug count total=0
	for slug in "${METRIC_SLUGS[@]}"; do
		count="$(find "${source_dir}/${slug}" -name '*.jsonl' 2>/dev/null | wc -l | tr -d ' ')"
		total=$((total + count))
		printf '  %-12s %4s shards\n' "${slug}" "${count}"
	done
	say "  ${total} shards in total."
}

cmd_up() {
	local seed=1
	if [[ "${1:-}" = "--no-seed" ]]; then
		seed=0
	fi

	ensure_env_file
	check_port_free

	step "Starting the dev instance"
	compose up --detach --wait --wait-timeout 120 nextcloud 2>/dev/null \
		|| compose up --detach nextcloud

	wait_for_install || die "the dev instance never became ready." "Try 'nextcloud_app/dev reset' for a clean slate, or read the log above."
	enable_app
	# So a restart always serves the assets currently on disk.
	occ config:app:set theming cachebuster --value="$(date +%s%N)" >/dev/null
	if [[ "${seed}" -eq 1 ]]; then
		cmd_seed
	fi

	step "Ready"
	say "  $(base_url)"
	say "  user $(admin_user) / password $(admin_password)"
	say ""
	say "Open Cairn from the top navigation bar."
	say "Edit any PHP file under nextcloud_app/ and reload — the app is mounted live."
	say ""
	say "  ./dev build    rebuild the frontend after editing anything in src/"
	say "  ./dev watch    rebuild it on every save"
	say "  ./dev check    run the read-only guard and validate info.xml"
	say "  ./dev occ ...  run any occ command"
	say "  ./dev reset    destroy the instance and everything seeded into it"
}

# Nextcloud serves app assets with `Cache-Control: max-age=15778463, immutable`,
# so a browser is told never to revalidate them. The only thing that varies the
# URL is the `?v=<md5(appVersion)>-<theming cachebuster>` suffix, and the app
# version does not change while you are editing. Bumping the cachebuster is
# therefore the supported way to make an edited stylesheet, script or icon
# actually load — otherwise it takes a hard reload in every browser you test in.
#
# (Setting the `debug` system config would drop the suffix entirely, which
# sounds better and is worse: with no query string and an `immutable` header,
# the browser has even less reason to look again.)
cmd_refresh() {
	container_running || die "the dev instance is not running." "Start it first: nextcloud_app/dev up"
	occ config:app:set theming cachebuster --value="$(date +%s%N)" >/dev/null
	say "Asset URLs bumped. A normal reload now picks up CSS, JS and icon changes."
	say "PHP and template changes never needed this — they are read on every request."
}

cmd_deps() {
	require_docker
	step "Installing dependencies"
	say "Composer and npm run in their own containers; nothing needs to be on"
	say "your machine but Docker."
	composer_run install --no-interaction --no-progress
	node_run npm install --no-audit --no-fund
	node_run npm run build
}

# npm runs in its own container too, so a contributor needs no Node either.
node_run() {
	CAIRN_UID="$(id -u)" CAIRN_GID="$(id -g)" compose run --rm node "$@"
}

cmd_build() {
	require_docker
	step "Building the frontend"
	node_run npm run build
	# The bundle URL only changes when the cachebuster does, so bump it here or
	# the browser keeps the previous build.
	if container_running; then
		occ config:app:set theming cachebuster --value="$(date +%s%N)" >/dev/null
	fi
}

cmd_watch() {
	require_docker
	step "Watching the frontend"
	say "Rebuilds on save. Reload the page after each build — and note that a"
	say "reload alone is enough only because './dev up' turned off asset caching."
	node_run npm run watch
}

# Composer runs in its own container too, so a contributor needs no PHP either.
composer_run() {
	CAIRN_UID="$(id -u)" CAIRN_GID="$(id -g)" compose run --rm composer "$@"
}

cmd_lint() {
	require_docker
	if [[ ! -f "${APP_ROOT}/vendor/bin/psalm" ]]; then
		die "the PHP analysers are not installed." "Run 'nextcloud_app/dev deps' first."
	fi
	step "PHP static analysis"
	composer_run exec -- psalm --no-cache --no-progress
	step "PHP coding standard"
	composer_run exec -- php-cs-fixer fix --dry-run --diff
	step "Frontend lint"
	node_run npm run lint
	step "Read-only guard and info.xml"
	cmd_check
}

cmd_test() {
	container_running || die "the dev instance is not running." "Tests run on the container's PHP, which is the one the app ships against: nextcloud_app/dev up"
	if [[ ! -f "${APP_ROOT}/vendor/bin/phpunit" ]]; then
		die "PHPUnit is not installed." "Run 'nextcloud_app/dev deps' first."
	fi
	step "Unit tests"
	# --cache-directory into /tmp because the app is mounted read-only in this
	# container, which is the point.
	compose exec -T nextcloud php /var/www/html/custom_apps/cairn/vendor/bin/phpunit \
		--configuration /var/www/html/custom_apps/cairn/phpunit.xml \
		--cache-directory /tmp/.phpunit "$@"
}

# Everything the app must survive on one Nextcloud version, in order of how
# early it would fail. Echoes one line per check; returns non-zero on the first
# failure so the summary can say which step it was.
#
# `source` (the default) drives the working tree mounted into the container;
# `package` drives an unpacked release tarball, which has no tests/ to run and
# is instead checked for not carrying any.
matrix_probe() {
	local base="http://localhost:${MATRIX_PORT}"
	local auth
	auth="$(admin_user):$(admin_password)"

	# The header is what makes an API route reachable with basic auth:
	# Nextcloud exempts declared API clients from the CSRF check that a browser
	# session needs.
	local api=(--silent --show-error --user "${auth}" --header "OCS-APIRequest: true")

	if ! occ app:list --output=json 2>/dev/null | grep -q '"cairn"'; then
		say "    app did not enable"
		return 1
	fi
	say "    app enables"

	# occ answers over the CLI as soon as the install is written, which is a
	# little before Apache is serving. Wait for the socket rather than letting
	# the first request fail and read as a broken app.
	local waited=0
	until curl --silent --output /dev/null --max-time 5 "${base}/status.php"; do
		waited=$((waited + 1))
		if [[ "${waited}" -gt 30 ]]; then
			say "    the server never started answering on ${base}"
			return 1
		fi
		sleep 1
	done

	local page
	page="$(curl "${api[@]}" "${base}/index.php/apps/cairn/")" || return 1
	for needle in 'id="cairn"' 'initial-state-cairn-hasRoot' 'cairn-main.mjs'; do
		if ! grep -q "${needle}" <<<"${page}"; then
			say "    page is missing ${needle}"
			return 1
		fi
	done
	say "    page renders, bundle and initial state present"

	local bundle
	bundle="$(grep -o '/custom_apps/cairn/js/cairn-main.mjs[^\"]*' <<<"${page}" | head -1)"
	if [[ "$(curl "${api[@]}" --output /dev/null --write-out '%{http_code}' "${base}${bundle}")" != "200" ]]; then
		say "    bundle is not served"
		return 1
	fi
	say "    bundle is served"

	local endpoint key body
	for endpoint in "files|metrics" "steps?days=3|series" "heart-rate?days=3|series" \
		"weight?days=7|series" "sleep?nights=1|nights" "activity?days=7|workouts"; do
		key="${endpoint#*|}"
		body="$(curl "${api[@]}" "${base}/index.php/apps/cairn/api/v1/${endpoint%|*}")" || return 1
		if ! grep -q "\"${key}\"" <<<"${body}"; then
			say "    /api/v1/${endpoint%|*} did not return ${key}: ${body:0:120}"
			return 1
		fi
	done
	say "    all six endpoints answer"

	if [[ "${1:-source}" == "package" ]]; then
		# The release must not carry its own scaffolding. Asserting the absence
		# is the check: an allowlist can only omit, and this is where an
		# accidental addition to PACKAGE_PATHS would show up.
		local unwanted=() dir
		for dir in tests src docker vendor node_modules dev-data; do
			if compose exec -T nextcloud \
				test -e "/var/www/html/custom_apps/cairn/${dir}" >/dev/null 2>&1; then
				unwanted+=("${dir}")
			fi
		done
		if [[ ${#unwanted[@]} -gt 0 ]]; then
			say "    the installed app carries development files: ${unwanted[*]}"
			return 1
		fi
		say "    no development files shipped"
	else
		# Under this version's PHP, which is the point: the app claims 8.2 and
		# each image ships a different interpreter. Only meaningful against the
		# working tree — the tarball has no tests/, by design.
		compose exec -T nextcloud php /var/www/html/custom_apps/cairn/tests/read_only_guard.php >/dev/null || return 1
		compose exec -T nextcloud php /var/www/html/custom_apps/cairn/tests/validate_info_xml.php >/dev/null || return 1
		say "    read-only guard and info.xml pass on this PHP"
	fi

	# A 200 can still hide a caught exception. The log is where those surface.
	if compose exec -T nextcloud sh -c 'grep -c "custom_apps/cairn" /var/www/html/data/nextcloud.log 2>/dev/null || true' \
		| tr -d "[:space:]" | grep -qvE "^0?$"; then
		say "    the server log mentions this app — inspect it"
		compose exec -T nextcloud sh -c 'grep "custom_apps/cairn" /var/www/html/data/nextcloud.log | tail -3' >&2 || true
		return 1
	fi
	say "    server log is clean"
}

# Bring one version up, probe it, tear it down. Never leaves a stack running.
matrix_one() {
	local version="$1" image="$2" status=0
	step "Nextcloud ${version}"

	export CAIRN_NC_IMAGE="${image}" CAIRN_DEV_PORT="${MATRIX_PORT}"
	COMPOSE_PROJECT="${MATRIX_PROJECT}"

	compose down --volumes --remove-orphans >/dev/null 2>&1 || true
	compose up --detach nextcloud >/dev/null 2>&1

	if wait_for_install; then
		say "    php $(compose exec -T nextcloud php -r 'echo PHP_VERSION;' 2>/dev/null)"
		disable_apcu >/dev/null 2>&1 || true
		fix_custom_apps_owner >/dev/null 2>&1 || true
		occ config:system:set loglevel --value=2 --type=integer >/dev/null 2>&1 || true
		occ app:disable firstrunwizard >/dev/null 2>&1 || true
		occ app:enable cairn >/dev/null 2>&1 || true
		matrix_seed || status=1
		if [[ "${status}" -eq 0 ]]; then
			matrix_probe || status=1
		fi
	else
		say "    Nextcloud never finished installing"
		status=1
	fi

	compose down --volumes --remove-orphans >/dev/null 2>&1 || true
	COMPOSE_PROJECT=""
	unset CAIRN_NC_IMAGE CAIRN_DEV_PORT
	return "${status}"
}

# The generated tree, never a real export: a compatibility run is not the place
# for anybody's health history.
matrix_seed() {
	generate_fixture >/dev/null 2>&1 || return 1
	local target
	target="/var/www/html/data/$(admin_user)/files/Cairn"
	compose exec -T nextcloud rm -rf "${target}" >/dev/null 2>&1
	compose exec -T nextcloud mkdir -p "${target}" >/dev/null 2>&1
	compose cp "${GENERATED_DIR}/." "nextcloud:${target}/" >/dev/null 2>&1 || return 1
	compose exec -T nextcloud chown -R www-data:www-data "${target}" >/dev/null 2>&1
	occ files:scan --path="/$(admin_user)/files/Cairn" --quiet >/dev/null 2>&1 || true
}

# Parse every file under the oldest PHP the app claims. A syntax check is not a
# compatibility test, but it is the half that fails loudly and it is the half
# the Nextcloud images cannot cover.
matrix_php_floor() {
	step "PHP floor (info.xml says $(php_min_version))"
	local output
	if ! output="$(docker run --rm --volume "${APP_ROOT}:/app:ro" --workdir /app \
		"${MATRIX_PHP_FLOOR}" sh -c '
			for f in $(find lib tests -name "*.php"); do
				php -l "$f" >/dev/null || exit 1
			done' 2>&1)"; then
		say "    a file does not parse on the declared floor:"
		printf '%s\n' "${output}" >&2
		return 1
	fi
	say "    every file parses"
}

# What appinfo/info.xml declares, so the check and the claim cannot drift.
php_min_version() {
	sed -n -E 's/.*<php[^>]*min-version="([0-9.]+)".*/\1/p' "${APP_ROOT}/appinfo/info.xml" | head -1
}

cmd_matrix() {
	require_docker
	if [[ ! -f "${APP_ROOT}/js/cairn-main.mjs" ]]; then
		die "the frontend bundle is not built." "Run 'nextcloud_app/dev build' first — the matrix checks that it loads."
	fi

	say "Checking every Nextcloud major appinfo/info.xml claims."
	say "Runs as project '${MATRIX_PROJECT}' on port ${MATRIX_PORT}, so your own"
	say "instance is left alone."

	local results=() entry version image
	if matrix_php_floor; then
		results+=("php $(php_min_version)|ok")
	else
		results+=("php $(php_min_version)|FAILED")
	fi

	for entry in "${MATRIX_IMAGES[@]}"; do
		version="${entry%%|*}"
		image="${entry#*|}"
		if matrix_one "${version}" "${image}"; then
			results+=("Nextcloud ${version}|ok")
		else
			results+=("Nextcloud ${version}|FAILED")
		fi
	done

	step "Summary"
	local failed=0
	for entry in "${results[@]}"; do
		printf '  %-14s %s\n' "${entry%%|*}" "${entry#*|}"
		[[ "${entry#*|}" == "ok" ]] || failed=1
	done
	if [[ "${failed}" -ne 0 ]]; then
		die "at least one supported Nextcloud version does not work." "Fix it, or narrow appinfo/info.xml — the compatibility claim has to be true."
	fi
	say ""
	say "info.xml's 32-34 claim holds."
}

# The app version, from the one file that declares it.
app_version() {
	sed -n -E 's|.*<version>([^<]+)</version>.*|\1|p' "${APP_ROOT}/appinfo/info.xml" | head -1
}

cmd_package() {
	require_docker
	local version staging tarball
	version="$(app_version)"
	if [[ -z "${version}" ]]; then
		die "appinfo/info.xml has no <version>." "The app store keys releases on it; it cannot be inferred."
	fi

	step "Building the frontend"
	node_run npm run build

	step "Staging cairn ${version}"
	staging="$(mktemp -d)"
	# shellcheck disable=SC2064  # expand now: the path must be captured here.
	trap "rm -rf '${staging}'" EXIT
	mkdir -p "${staging}/cairn"

	local path
	for path in "${PACKAGE_PATHS[@]}"; do
		if [[ ! -e "${APP_ROOT}/${path}" ]]; then
			die "the release is missing '${path}'." "Run 'nextcloud_app/dev build' if it is js/, otherwise check PACKAGE_PATHS in this script."
		fi
		cp -R "${APP_ROOT}/${path}" "${staging}/cairn/"
	done

	# Source maps are for developing, not for shipping: two megabytes of them
	# would be most of the tarball.
	find "${staging}/cairn/js" -name '*.map' -delete

	package_assert_clean "${staging}/cairn"

	mkdir -p "${APP_ROOT}/${PACKAGE_DIR}"
	tarball="${APP_ROOT}/${PACKAGE_DIR}/cairn-${version}.tar.gz"

	package_sign "${staging}/cairn" || return 1

	step "Packing"
	# Deterministic: a fixed timestamp, owner and sort order, so the same input
	# produces the same bytes. The same reasoning as the reproducible APK builds
	# in docs/RELEASE.md — a tarball nobody can reproduce is one nobody can check.
	tar --create --gzip --file "${tarball}" \
		--directory "${staging}" \
		--sort=name --owner=0 --group=0 --numeric-owner \
		--mtime="@0" --mode='go-w' \
		cairn

	say "  ${tarball}"
	say "  $(du -h "${tarball}" | cut -f1), sha256 $(sha256sum "${tarball}" | cut -c1-16)…"
	package_signature "${tarball}"
}

# Nothing may ship that was not asked for. Belt to the allowlist's braces: if a
# future PACKAGE_PATHS entry pulls in a whole directory, this is what notices.
package_assert_clean() {
	local root="$1" found
	found="$(find "${root}" \( \
		-name '.env' -o -name '*.key' -o -name '*.pem' -o -name '*.jsonl' \
		-o -name 'node_modules' -o -name 'vendor' -o -name '.git' \
		-o -name '*.map' -o -name 'dev-data' \
	\) -print)"
	if [[ -n "${found}" ]]; then
		say "Refusing to package: the staging tree contains files that must not ship." >&2
		while IFS= read -r offender; do
			printf '  %s\n' "${offender}" >&2
		done <<<"${found}"
		die "the release tree is not clean." "Fix PACKAGE_PATHS in this script rather than deleting by hand."
	fi
	say "  $(find "${root}" -type f | wc -l | tr -d ' ') files, nothing unexpected"
}

# Nextcloud verifies an app's integrity from a signature.json inside it, and the
# app store verifies the download from a detached signature. Both use the
# certificate Nextcloud counter-signs for this app id; see docs/RELEASE.md §6.
package_sign() {
	local root="$1" key cert
	key="$(setting CAIRN_SIGN_KEY "${HOME}/.nextcloud/certificates/cairn.key")"
	cert="$(setting CAIRN_SIGN_CERT "${HOME}/.nextcloud/certificates/cairn.crt")"

	if [[ ! -f "${key}" || ! -f "${cert}" ]]; then
		step "Signing"
		say "  skipped: no signing certificate."
		say "  An unsigned tarball installs from disk but the app store will not"
		say "  take it. Requesting a certificate is a one-time step — see"
		say "  docs/RELEASE.md §6."
		return 0
	fi

	container_running || die "signing needs the dev instance." "occ generates signature.json: run 'nextcloud_app/dev up' first."

	step "Signing"
	compose cp "${root}" "nextcloud:/tmp/cairn-sign" >/dev/null
	compose cp "${key}" "nextcloud:/tmp/sign.key" >/dev/null
	compose cp "${cert}" "nextcloud:/tmp/sign.crt" >/dev/null
	occ integrity:sign-app --privateKey=/tmp/sign.key --certificate=/tmp/sign.crt \
		--path=/tmp/cairn-sign >/dev/null
	compose cp "nextcloud:/tmp/cairn-sign/appinfo/signature.json" \
		"${root}/appinfo/signature.json" >/dev/null
	# The key must not outlive the operation, even in a throwaway container.
	compose exec -T nextcloud rm -rf /tmp/cairn-sign /tmp/sign.key /tmp/sign.crt >/dev/null
	say "  appinfo/signature.json written"
}

# The app store's upload form wants a detached signature over the tarball.
package_signature() {
	local tarball="$1" key
	key="$(setting CAIRN_SIGN_KEY "${HOME}/.nextcloud/certificates/cairn.key")"
	[[ -f "${key}" ]] || return 0

	if ! command -v openssl >/dev/null 2>&1; then
		say "  openssl is not installed; the store's detached signature was not generated."
		return 0
	fi
	step "Detached signature (for the app store upload form)"
	openssl dgst -sha512 -sign "${key}" "${tarball}" | openssl base64 -A
	echo
}

# Install the tarball the way a user would, on a clean instance, and check the
# app actually works from it.
#
# Packaging that has only ever been inspected is packaging that has never been
# tested: the failure mode is a missing file nobody notices until somebody else
# installs it. This unpacks the artefact into a fresh Nextcloud and drives it.
cmd_verify_package() {
	require_docker
	local tarball version status=0
	version="$(app_version)"
	tarball="${APP_ROOT}/${PACKAGE_DIR}/cairn-${version}.tar.gz"
	[[ -f "${tarball}" ]] || die "no tarball at ${tarball}." "Build one first: nextcloud_app/dev package"

	step "Installing cairn ${version} from the tarball"
	say "Runs as project '${MATRIX_PROJECT}' on port ${MATRIX_PORT}, so your own"
	say "instance is left alone."

	export CAIRN_DEV_PORT="${MATRIX_PORT}"
	COMPOSE_PROJECT="${MATRIX_PROJECT}"
	# A compose file with no bind mount of the working tree. The ordinary one
	# mounts nextcloud_app/ over custom_apps/cairn, which would mean testing the
	# working tree while believing you were testing the artefact.
	COMPOSE_FILE="${APP_ROOT}/docker/compose.verify.yaml"
	compose down --volumes --remove-orphans >/dev/null 2>&1 || true

	compose up --detach nextcloud >/dev/null 2>&1
	if wait_for_install; then
		disable_apcu >/dev/null 2>&1 || true
		compose cp "${tarball}" "nextcloud:/tmp/cairn.tar.gz" >/dev/null
		compose exec -T nextcloud tar -xzf /tmp/cairn.tar.gz -C /var/www/html/custom_apps >/dev/null
		compose exec -T nextcloud chown -R www-data:www-data /var/www/html/custom_apps >/dev/null
		occ app:disable firstrunwizard >/dev/null 2>&1 || true
		occ app:enable cairn >/dev/null 2>&1 || true
		matrix_seed || status=1
		if [[ "${status}" -eq 0 ]]; then
			matrix_probe package || status=1
		fi
	else
		say "    Nextcloud never finished installing"
		status=1
	fi

	compose down --volumes --remove-orphans >/dev/null 2>&1 || true
	COMPOSE_PROJECT=""
	COMPOSE_FILE="${APP_ROOT}/docker/compose.yaml"
	unset CAIRN_DEV_PORT

	if [[ "${status}" -ne 0 ]]; then
		die "the packaged app does not work when installed." "Something the app needs is missing from PACKAGE_PATHS."
	fi
	say ""
	say "The tarball installs and runs."
}

cmd_check() {
	container_running || die "the dev instance is not running." "Checks run inside the container: nextcloud_app/dev up"
	step "Read-only guard"
	compose exec -T nextcloud php /var/www/html/custom_apps/cairn/tests/read_only_guard.php
	step "info.xml"
	compose exec -T nextcloud php /var/www/html/custom_apps/cairn/tests/validate_info_xml.php
}

cmd_reset() {
	step "Destroying the dev instance"
	# `down -v` removes the named volume, and with it the seeded health data.
	# For a real export this is the erase step, not merely a cleanup.
	compose down --volumes --remove-orphans
	rm -rf "${GENERATED_DIR}"
	say "Volume, container and generated fixtures are gone."
	say "Run './dev up' for a clean instance."
}

cmd_status() {
	if container_running; then
		say "running at $(base_url) (user $(admin_user))"
		if occ app:list --output=json 2>/dev/null | grep -q '"cairn"'; then
			say "cairn: enabled"
		else
			say "cairn: NOT enabled — try './dev up'"
		fi
	else
		say "not running. Start it with './dev up'."
	fi
}

usage() {
	cat <<'HELP'
Cairn Nextcloud app — development environment

  ./dev up [--no-seed]   Start Nextcloud, enable the app, load health data.
                         Safe to re-run; it is idempotent.
  ./dev seed [DIR]       Reload health data. Without DIR, resolves in order:
                         CAIRN_SEED_DIR, dev-data/local/, then a generated tree.
  ./dev refresh          Bump asset URLs after editing CSS, JS or an icon.
                         PHP and templates never need this.
  ./dev deps             Install PHP and frontend dependencies and build the
                         bundle, all in their own containers. Needed once.
  ./dev build            Rebuild the frontend bundle.
  ./dev watch            Rebuild the frontend on every save.
  ./dev test [ARGS...]   Run the unit tests on the container's PHP.
  ./dev lint             Everything static: psalm, the PHP coding standard,
                         the frontend lint, the read-only guard and info.xml.
  ./dev matrix           Run the app against every Nextcloud major info.xml
                         claims. Uses its own project and port.
  ./dev package          Build a release tarball for the app store, signed if
                         a certificate is configured.
  ./dev verify-package   Install that tarball on a clean Nextcloud and drive it.
  ./dev check            Run the read-only guard and validate appinfo/info.xml.
  ./dev status           Is it running, and is the app enabled?
  ./dev occ ARGS...      Run an occ command, e.g. ./dev occ app:list
  ./dev logs [-f]        Tail the server log.
  ./dev shell            A root shell in the container.
  ./dev down             Stop it, keeping the data.
  ./dev reset            Destroy it, including everything seeded into it.

Configuration lives in nextcloud_app/docker/.env (gitignored, created on first
run): CAIRN_SEED_DIR, CAIRN_DEV_PORT, CAIRN_ADMIN_USER, CAIRN_ADMIN_PASSWORD.

Seeded health data lives in a Docker volume, never in this repository. If you
seeded a real export, './dev reset' is how you erase it.
HELP
}

main() {
	local command="${1:-help}"
	if [[ $# -gt 0 ]]; then
		shift
	fi
	case "${command}" in
		help | --help | -h)
			usage
			return 0
			;;
	esac

	require_docker
	validate_settings

	case "${command}" in
		up) cmd_up "$@" ;;
		seed) cmd_seed "$@" ;;
		check) cmd_check ;;
		refresh) cmd_refresh ;;
		deps) cmd_deps ;;
		build) cmd_build ;;
		watch) cmd_watch ;;
		lint) cmd_lint ;;
		matrix) cmd_matrix ;;
		package) cmd_package ;;
		verify-package) cmd_verify_package ;;
		test) cmd_test "$@" ;;
		status) cmd_status ;;
		reset) cmd_reset ;;
		occ) occ "$@" ;;
		logs) compose logs "$@" nextcloud ;;
		shell) compose exec nextcloud bash ;;
		down) compose down ;;
		*)
			printf 'error: unknown command %s\n' "${command}" >&2
			usage >&2
			exit 1
			;;
	esac
}

main "$@"
