#!/usr/bin/env bash
#
# install.sh — HTTP bootstrap for a DevKit global release.
#
# Official use, run on the target server in an already root shell:
#
#   curl -fsSL https://devkit.okone.it/install.sh | bash
#
# This is only a bootstrap. It resolves the requested release, downloads its
# archive, verifies the external SHA-256, extracts it after inspecting it,
# verifies the release's own internal SHA256SUMS, runs the release's own ./install
# and confirms the active version. It never reimplements the Go installer's work:
# it does not create users, groups, keys or sudo configuration, and it never runs
# `repair server`. Integrity here is the archive checksum; it protects against
# corruption and does not replace HTTPS transport security or a future signature.
set -Eeuo pipefail

readonly DEFAULT_BASE_URL="https://devkit.okone.it"

# Injectable tools keep the runtime validation intact while letting the tests
# drive the bootstrap with a curl mock and a scratch "current" symlink. In
# production these are the real command and the real global path.
CURL_BIN="${DEVKIT_CURL_BIN:-curl}"
CURRENT_LINK="${DEVKIT_CURRENT_LINK:-/opt/okone/devkit/current}"

# ---------------------------------------------------------------------------
# Exit codes (documented in docs/operations/HTTP_DISTRIBUTION.md).
#   0  installed; optional doctor healthy or not requested
#   1  installed; optional doctor found divergences
#   2  invalid usage
#   3  a prerequisite, download, verification or installation failed
# ---------------------------------------------------------------------------
readonly EXIT_OK=0
readonly EXIT_DIVERGENCES=1
readonly EXIT_USAGE=2
readonly EXIT_FAILED=3

log() { printf '%s\n' "$*" >&2; }
warn() { printf 'WARNING: %s\n' "$*" >&2; }
die() {
	local code="$1"
	shift
	printf 'ERROR: %s\n' "$*" >&2
	exit "$code"
}

usage() {
	cat >&2 <<'EOF'
usage: install.sh [options]
  --channel stable|candidate   resolve the release through a channel (default: stable)
  --release RELEASE_ID         resolve an explicit release (exclusive with --channel)
  --base-url URL               repository base URL (default: https://devkit.okone.it)
  --allow-insecure-http        allow an http:// base URL (pilot only)
  --doctor                     run `devkit doctor server` after installing
  --keep-temp                  keep the temporary directory and print its path
  --skip-base-packages         do not provision the base package baseline (air-gapped hosts)
  --help                       show this help

Environment: DEVKIT_BASE_URL, DEVKIT_CHANNEL. Command-line options win over them.
EOF
}

# ---------------------------------------------------------------------------
# Arguments and environment.
# ---------------------------------------------------------------------------
channel=""
release=""
base_url=""
allow_http=0
run_doctor=0
keep_temp=0
skip_base=0
channel_set=0
release_set=0

while (($# > 0)); do
	case "$1" in
	--channel) channel="${2:?--channel needs a value}"; channel_set=1; shift 2 ;;
	--channel=*) channel="${1#*=}"; channel_set=1; shift ;;
	--release) release="${2:?--release needs a value}"; release_set=1; shift 2 ;;
	--release=*) release="${1#*=}"; release_set=1; shift ;;
	--base-url) base_url="${2:?--base-url needs a value}"; shift 2 ;;
	--base-url=*) base_url="${1#*=}"; shift ;;
	--allow-insecure-http) allow_http=1; shift ;;
	--doctor) run_doctor=1; shift ;;
	--keep-temp) keep_temp=1; shift ;;
	--skip-base-packages) skip_base=1; shift ;;
	-h | --help) usage; exit "$EXIT_OK" ;;
	*) usage; die "$EXIT_USAGE" "unknown argument: $1" ;;
	esac
done

# CLI wins over the environment; the environment fills only what CLI left unset.
if ((!channel_set)) && [[ -n "${DEVKIT_CHANNEL:-}" ]]; then
	channel="$DEVKIT_CHANNEL"
	channel_set=1
fi
if [[ -z "$base_url" && -n "${DEVKIT_BASE_URL:-}" ]]; then
	base_url="$DEVKIT_BASE_URL"
fi
[[ -n "$base_url" ]] || base_url="$DEFAULT_BASE_URL"

if ((channel_set && release_set)); then
	die "$EXIT_USAGE" "--channel and --release are mutually exclusive"
fi
if ((!release_set)); then
	[[ -n "$channel" ]] || channel="stable"
	case "$channel" in
	stable | candidate) : ;;
	*) die "$EXIT_USAGE" "invalid channel (want stable or candidate): $channel" ;;
	esac
fi

# ---------------------------------------------------------------------------
# Validators (shared shape rules, no network yet).
# ---------------------------------------------------------------------------
valid_release_id() {
	[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?\+[0-9a-f]{7,40}-[a-z0-9]+-[a-z0-9]+$ ]]
}
valid_sha256() { [[ "$1" =~ ^[0-9a-f]{64}$ ]]; }

if ((release_set)); then
	valid_release_id "$release" || die "$EXIT_USAGE" "invalid release id: $release"
fi

# normalize_base_url strips trailing slashes and enforces a safe scheme with no
# embedded credentials. file://, ftp:// and data: are refused outright.
normalize_base_url() {
	local url="$1"
	while [[ "$url" == */ ]]; do url="${url%/}"; done
	local scheme="${url%%://*}"
	case "$scheme" in
	https) : ;;
	http)
		((allow_http)) || die "$EXIT_USAGE" "http:// requires --allow-insecure-http (pilot only): $url"
		;;
	*) die "$EXIT_USAGE" "unsupported base URL scheme (want https, or http with --allow-insecure-http): $url" ;;
	esac
	[[ "$url" == *"://"* ]] || die "$EXIT_USAGE" "invalid base URL: $url"
	local authority="${url#*://}"
	authority="${authority%%/*}"
	[[ -n "$authority" ]] || die "$EXIT_USAGE" "invalid base URL (no host): $url"
	if [[ "$authority" == *@* ]]; then
		die "$EXIT_USAGE" "base URL must not carry credentials"
	fi
	printf '%s' "$url"
}
base_url="$(normalize_base_url "$base_url")"

# ---------------------------------------------------------------------------
# Prerequisites.
# ---------------------------------------------------------------------------
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
	die "$EXIT_FAILED" "this bootstrap must run as root (effective UID 0)"
fi
for tool in "$CURL_BIN" tar sha256sum mktemp rm mkdir; do
	command -v -- "$tool" >/dev/null 2>&1 || die "$EXIT_FAILED" "required command not found: $tool"
done

# ---------------------------------------------------------------------------
# Base packages. A new server needs a small, explicit baseline for the DevKit
# workflow: transfer and trust (curl, wget, ca-certificates), archive handling
# (tar, gzip), version control (git), the managed-access model (sudo) and
# scripting (python3). Missing ones are provisioned through apt on Debian. This
# is best effort by design: a host without apt, offline or air-gapped is never
# blocked — the baseline is reported and the bootstrap continues so the release
# still installs. --skip-base-packages turns the step off entirely.
# ---------------------------------------------------------------------------
readonly BASE_PACKAGES="curl wget ca-certificates tar gzip git sudo python3"

missing_base_packages() {
	local pkg
	for pkg in $BASE_PACKAGES; do
		if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q 'install ok installed'; then
			printf '%s ' "$pkg"
		fi
	done
}

ensure_base_packages() {
	if ((skip_base)); then
		log "Skipping the base package baseline (--skip-base-packages)"
		return 0
	fi
	if ! command -v apt-get >/dev/null 2>&1 || ! command -v dpkg-query >/dev/null 2>&1; then
		warn "apt is not available: not provisioning the base packages ($BASE_PACKAGES); install them manually"
		return 0
	fi
	local missing
	missing="$(missing_base_packages)"
	missing="${missing% }"
	if [[ -z "$missing" ]]; then
		log "Base packages already present"
		return 0
	fi
	log "Installing base packages: $missing"
	export DEBIAN_FRONTEND=noninteractive
	apt-get update -qq || warn "apt-get update failed; attempting install against the current package index"
	# Word splitting on the missing set is intentional: each token is one package.
	# shellcheck disable=SC2086
	if ! apt-get install -y --no-install-recommends $missing; then
		warn "could not provision some base packages; still missing: $(missing_base_packages)"
		return 0
	fi
	local still
	still="$(missing_base_packages)"
	still="${still% }"
	[[ -z "$still" ]] || warn "base packages still missing after install: $still"
}
ensure_base_packages

# ---------------------------------------------------------------------------
# Download helper: a temporary directory with a cleanup trap.
# ---------------------------------------------------------------------------
tmp="$(mktemp -d)"
cleanup() {
	if ((keep_temp)); then
		log "temporary directory kept at $tmp"
	else
		rm -rf -- "$tmp"
	fi
}
trap cleanup EXIT

# fetch downloads a URL to a destination file, failing on any HTTP error. It uses
# bounded timeouts and a small retry budget for transient errors only; curl does
# not retry a permanent 404. The file is written only on success.
fetch() {
	local url="$1" dest="$2"
	"$CURL_BIN" --fail --silent --show-error --location \
		--connect-timeout 15 --max-time 600 \
		--retry 2 --retry-delay 1 --retry-connrefused \
		--output "$dest" -- "$url"
}

# ---------------------------------------------------------------------------
# Resolve the release: through a channel document or an explicit id.
# ---------------------------------------------------------------------------
expected_sha=""
archive_path=""

parse_channel_document() {
	# Strict parse of a version 1 channel document. It never sources or evals the
	# file: the channel is data, not a script. Exactly these keys, once each, no
	# unknown key, no empty value.
	local file="$1"
	local schema="" doc_release="" doc_archive="" doc_sha=""
	local -A seen=()
	local line key value
	while IFS= read -r line || [[ -n "$line" ]]; do
		[[ -n "$line" ]] || continue
		[[ "$line" == *=* ]] || die "$EXIT_FAILED" "malformed channel line: $line"
		key="${line%%=*}"
		value="${line#*=}"
		[[ -n "$value" ]] || die "$EXIT_FAILED" "empty value for channel key: $key"
		[[ -z "${seen[$key]:-}" ]] || die "$EXIT_FAILED" "duplicate channel key: $key"
		seen[$key]=1
		case "$key" in
		schema_version) schema="$value" ;;
		release_id) doc_release="$value" ;;
		archive) doc_archive="$value" ;;
		sha256) doc_sha="$value" ;;
		*) die "$EXIT_FAILED" "unknown channel key: $key" ;;
		esac
	done <"$file"

	[[ "$schema" == "1" ]] || die "$EXIT_FAILED" "unsupported channel schema version: ${schema:-<missing>}"
	[[ -n "$doc_release" ]] || die "$EXIT_FAILED" "channel declares no release_id"
	valid_release_id "$doc_release" || die "$EXIT_FAILED" "channel declares an invalid release_id: $doc_release"
	valid_sha256 "$doc_sha" || die "$EXIT_FAILED" "channel declares an invalid sha256"
	# The archive path is derived, not trusted: it must be exactly the canonical
	# path for the declared release, with no traversal and no absolute component.
	local expected_archive="releases/$doc_release/devkit-$doc_release.tar.gz"
	[[ "$doc_archive" == "$expected_archive" ]] ||
		die "$EXIT_FAILED" "channel archive path is not consistent with its release_id"

	release="$doc_release"
	expected_sha="$doc_sha"
	archive_path="$expected_archive"
}

if ((release_set)); then
	log "Resolving release $release"
	archive_path="releases/$release/devkit-$release.tar.gz"
	sha_file="$tmp/external.sha256"
	fetch "$base_url/$archive_path.sha256" "$sha_file" ||
		die "$EXIT_FAILED" "could not download the external checksum for $release"
	# The .sha256 file is `<64 hex><two spaces><basename>`; trust neither field
	# without validating both.
	read -r sha_hex sha_name <"$sha_file" || die "$EXIT_FAILED" "malformed external checksum file"
	valid_sha256 "$sha_hex" || die "$EXIT_FAILED" "external checksum is not a 64 hex sha256"
	[[ "$sha_name" == "devkit-$release.tar.gz" ]] ||
		die "$EXIT_FAILED" "external checksum names an unexpected archive: $sha_name"
	expected_sha="$sha_hex"
else
	log "Resolving channel $channel"
	channel_file="$tmp/channel.env"
	fetch "$base_url/channels/$channel.env" "$channel_file" ||
		die "$EXIT_FAILED" "could not download channel $channel"
	parse_channel_document "$channel_file"
fi

log "Selected release $release"

# ---------------------------------------------------------------------------
# Download the archive to a temporary file.
# ---------------------------------------------------------------------------
archive_base="devkit-$release.tar.gz"
archive_tmp="$tmp/$archive_base"
log "Downloading $archive_path"
fetch "$base_url/$archive_path" "$archive_tmp" ||
	die "$EXIT_FAILED" "could not download the release archive"

# ---------------------------------------------------------------------------
# External verification: the archive must match the declared checksum before any
# extraction or code execution.
# ---------------------------------------------------------------------------
actual_sha="$(sha256sum -- "$archive_tmp" | awk '{print $1; exit}')"
if [[ "$actual_sha" != "$expected_sha" ]]; then
	die "$EXIT_FAILED" "archive checksum mismatch; refusing to extract"
fi
log "Archive checksum verified"

# ---------------------------------------------------------------------------
# Inspect the archive before extracting: single expected root, no absolute path,
# no traversal, no symlink or hardlink.
# ---------------------------------------------------------------------------
release_subdir="devkit-$release"
if ! listing="$(tar tzvf "$archive_tmp" 2>/dev/null)"; then
	die "$EXIT_FAILED" "the downloaded archive is not a valid tar.gz"
fi
while IFS= read -r line; do
	[[ -n "$line" ]] || continue
	entry_type="${line:0:1}"
	name="${line##* }"
	case "$entry_type" in
	- | d) : ;;
	*) die "$EXIT_FAILED" "archive holds a non regular entry: $name" ;;
	esac
	case "$name" in
	/*) die "$EXIT_FAILED" "archive holds an absolute path: $name" ;;
	*..*) die "$EXIT_FAILED" "archive holds a traversal path: $name" ;;
	esac
	top="${name%%/*}"
	[[ "$top" == "$release_subdir" ]] || die "$EXIT_FAILED" "archive root is not $release_subdir: $name"
done <<<"$listing"

tar xzf "$archive_tmp" -C "$tmp" || die "$EXIT_FAILED" "could not extract the archive"
release_root="$tmp/$release_subdir"
[[ -d "$release_root" ]] || die "$EXIT_FAILED" "the archive did not contain $release_subdir"

# ---------------------------------------------------------------------------
# Internal verification: the shape of the files the bootstrap is about to run,
# then the release's own SHA256SUMS. The shape guards run first so a symlinked or
# non-executable install is refused as such, never masked by a checksum result.
# ---------------------------------------------------------------------------
# install must be a real executable file, never a symlink to follow.
[[ ! -L "$release_root/install" ]] || die "$EXIT_FAILED" "the release install is a symlink; refusing to run it"
[[ -f "$release_root/install" && -x "$release_root/install" ]] ||
	die "$EXIT_FAILED" "the release install is not an executable regular file"
[[ ! -L "$release_root/bin/devkit" ]] || die "$EXIT_FAILED" "bin/devkit is a symlink; refusing to trust it"
[[ -f "$release_root/bin/devkit" && -x "$release_root/bin/devkit" ]] ||
	die "$EXIT_FAILED" "bin/devkit is not an executable regular file"
for required in release.json manifest.json access/developers.yaml; do
	[[ -f "$release_root/$required" ]] || die "$EXIT_FAILED" "the release is missing $required"
done

(cd -- "$release_root" && sha256sum -c SHA256SUMS >/dev/null 2>&1) ||
	die "$EXIT_FAILED" "the release failed its internal SHA256SUMS verification"
log "Internal release verified"

# ---------------------------------------------------------------------------
# Install: hand control to the release's own installer. The bootstrap never
# copies, activates or configures anything itself.
# ---------------------------------------------------------------------------
log "Installing $release"
(cd -- "$release_root" && ./install) || die "$EXIT_FAILED" "the release installer failed"

# ---------------------------------------------------------------------------
# Confirm the active version matches the release we installed.
# ---------------------------------------------------------------------------
release_commit="${release#*+}"
release_commit="${release_commit%%-*}"
version_out="$(devkit version 2>/dev/null || true)"
active_commit="$(printf '%s\n' "$version_out" | sed -n 's/^commit:[[:space:]]*//p' | head -n1)"
if [[ -z "$active_commit" ]]; then
	die "$EXIT_FAILED" "could not read the active devkit version"
fi
if [[ "$active_commit" != "$release_commit" ]]; then
	die "$EXIT_FAILED" "active commit $active_commit does not match the installed release commit $release_commit"
fi
# The active release directory should be the one we installed; confirm it when
# the link resolves, but do not invent an installer detail if it does not.
if active_target="$(readlink -f -- "$CURRENT_LINK" 2>/dev/null)" && [[ -n "$active_target" ]]; then
	if [[ "$(basename -- "$active_target")" != "$release" ]]; then
		die "$EXIT_FAILED" "the active release $(basename -- "$active_target") does not match $release"
	fi
else
	warn "could not resolve $CURRENT_LINK to confirm the active release directory"
fi
log "Installed $release"

# ---------------------------------------------------------------------------
# Optional doctor. It never repairs anything, and never runs repair server.
# ---------------------------------------------------------------------------
final_exit="$EXIT_OK"
if ((run_doctor)); then
	log "Diagnosing the server with devkit doctor server"
	doctor_status=0
	devkit doctor server >&2 || doctor_status=$?
	case "$doctor_status" in
	0) log "doctor server: healthy" ;;
	1) log "doctor server: divergences found"; final_exit="$EXIT_DIVERGENCES" ;;
	2 | 3) die "$EXIT_FAILED" "doctor server could not complete (exit $doctor_status); the install itself succeeded" ;;
	*) die "$EXIT_FAILED" "doctor server returned an unexpected exit $doctor_status" ;;
	esac
fi

exit "$final_exit"
