#!/bin/sh
# dotup — the front door. One command from a bare machine to a finished one.
#
# It is a picker and an installer, not a picker next to an installer. Tick boxes
# on groups AND on individual packages; one toggle rule everywhere: expand the
# row to its leaves, and if every leaf is on turn them all off, otherwise turn
# them all on. That single rule covers a group row, a package row, and a bulk
# toggle over a filtered set.
#
#   dotup                  the picker, then install what you ticked
#   dotup --unattended     no UI: safe defaults, never prompts, never private
#   dotup --print          resolve and print every command, install nothing
#
# POSIX sh on purpose: this runs on a bare box before anything is installed,
# and macOS still ships bash 3.2 (no associative arrays).

set -eu

SELF=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/$(basename -- "$0")
HERE=$(dirname -- "$SELF")
STATE=${DOTUP_STATE:-${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles}
SEL=$STATE/selected
EXP=$STATE/expanded

# The manifest is data, not a script, so it does not live in bin/. Checked in
# the development layout first so the repo's own test suite and a checkout both
# work without setting anything.
if   [ -n "${DOTUP_MANIFEST:-}" ];        then MANIFEST=$DOTUP_MANIFEST
elif [ -f "$HERE/packages.tsv" ];         then MANIFEST=$HERE/packages.tsv
else MANIFEST=${XDG_DATA_HOME:-$HOME/.local/share}/dotup/packages.tsv
fi

[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && HAS_DISPLAY=1 || HAS_DISPLAY=0
case $(uname -s) in Darwin) PLAT=brew ;; *) PLAT=apt ;; esac

UNATTENDED=0
DRYRUN=0
ASSUME_YES=0

mkdir -p "$STATE"
[ -f "$SEL" ] || : > "$SEL"
[ -f "$EXP" ] || : > "$EXP"

R=''; DIM=''; B=''; GRN=''; YEL=''; RED=''
if [ -t 1 ]; then
	R=$(printf '\033[0m'); DIM=$(printf '\033[2m'); B=$(printf '\033[1m')
	GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RED=$(printf '\033[31m')
fi
say()  { printf '%s\n' "$*" >&2; }
warn() { printf '%s!%s %s\n' "$YEL" "$R" "$*" >&2; }
err()  { printf '%s✗%s %s\n' "$RED" "$R" "$*" >&2; }
head_() { printf '\n%s%s%s\n' "$B" "$*" "$R" >&2; }

# ---------------------------------------------------------------- leaves ----
# Expand a row key to the package keys it covers.
#   g:agents      -> agents/codex agents/pi ...
#   p:agents/pi   -> agents/pi
leaves() {
	for key in "$@"; do
		case $key in
		g:*) grp=${key#g:}
			awk -F'\t' -v g="$grp" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;;
		p:*) printf '%s\n' "${key#p:}" ;;
		esac
	done
}

# ------------------------------------------------------------------ deps ----
# Dependencies are @needs directives, not a sixth column: the TSV stays five
# wide and greppable, and a package with no dependencies costs nothing to read.
#
#   @needs <group/pkg> <dep> [dep ...]      dep is group/pkg or a whole group
#
# There is no "requires" state to display. Ticking a box ticks what it needs;
# unticking one unticks what needed it. The counts on screen move as it happens,
# so the closure is visible rather than described.

# stdin: keys -> stdout: those keys plus everything they need, transitively.
expand_deps() {
	work=$(sort -u); prev=
	while [ "$work" != "$prev" ]; do
		prev=$work
		add=$(printf '%s\n' "$work" | while read -r k; do
			[ -n "$k" ] || continue
			awk -F'\t' -v k="$k" '$1=="@needs" && $2==k {print $3}' "$MANIFEST"
		done | tr ' ' '\n' | while read -r d; do
			[ -n "$d" ] || continue
			case $d in
			*/*) printf '%s\n' "$d" ;;
			*)   awk -F'\t' -v g="$d" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;;
			esac
		done)
		work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u)
	done
	printf '%s\n' "$work"
}

# stdin: keys -> stdout: those keys plus everything that needs them.
expand_rdeps() {
	work=$(sort -u); prev=
	while [ "$work" != "$prev" ]; do
		prev=$work
		add=$(printf '%s\n' "$work" | while read -r k; do
			[ -n "$k" ] || continue
			awk -F'\t' -v k="$k" -v g="${k%%/*}" '$1=="@needs"{
				n=split($3, d, " ")
				for(i=1;i<=n;i++) if (d[i]==k || d[i]==g) print $2 }' "$MANIFEST"
		done)
		work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u)
	done
	printf '%s\n' "$work"
}

# ---------------------------------------------------------------- toggle ----
# All on -> all off. Anything else -> all on.
cmd_toggle() {
	want=$(leaves "$@" | sort -u)
	[ -n "$want" ] || return 0
	all_on=1
	for k in $want; do grep -qxF "$k" "$SEL" || { all_on=0; break; }; done
	# Decide direction on what you touched, then widen along the dependency
	# edges: switching on pulls in what it needs, switching off drops what
	# needed it. Either way the ticks never describe a broken machine.
	if [ "$all_on" -eq 1 ]; then want=$(printf '%s\n' "$want" | expand_rdeps)
	else                         want=$(printf '%s\n' "$want" | expand_deps); fi
	tmp=$STATE/.sel.$$
	grep -vxF -f - "$SEL" > "$tmp" <<-EOF || :
		$want
	EOF
	if [ "$all_on" -eq 0 ]; then printf '%s\n' "$want" >> "$tmp"; fi
	sort -u "$tmp" > "$SEL" && rm -f "$tmp"
}

cmd_expand() {
	for key in "$@"; do
		case $key in g:*) g=${key#g:} ;; p:*) g=${key#p:}; g=${g%%/*} ;; *) continue ;; esac
		tmp=$STATE/.exp.$$
		if grep -qxF "$g" "$EXP"; then grep -vxF "$g" "$EXP" > "$tmp" || :
		else cp "$EXP" "$tmp"; printf '%s\n' "$g" >> "$tmp"; fi
		mv "$tmp" "$EXP"
	done
}

cmd_expand_all() {
	if [ -s "$EXP" ]; then : > "$EXP"
	else awk -F'\t' '!/^[#@]/ && NF>=3 {print $1}' "$MANIFEST" | awk '!seen[$0]++' > "$EXP"; fi
}

# --------------------------------------------------------------- presets ----
cmd_preset() {
	case ${1:-defaults} in
	none) : > "$SEL" ;;
	safe) awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="safe" {print $1"/"$2}' "$MANIFEST" > "$SEL" ;;
	defaults)
		awk -F'\t' -v d="$HAS_DISPLAY" \
			'!/^[#@]/ && NF>=3 && ($3=="safe" || ($3=="gui" && d==1)) {print $1"/"$2}' \
			"$MANIFEST" > "$SEL" ;;
	esac
}

# ---------------------------------------------------------------- render ----
cmd_render() {
	awk -F'\t' -v selfile="$SEL" -v expfile="$EXP" -v plat="$PLAT" -v disp="$HAS_DISPLAY" '
	function sev(f){ return f=="invasive"?3 : f=="private"?2 : f=="gui"?1 : 0 }
	function lbl(s){ return s==3?"invasive" : s==2?"private" : s==1?"gui" : "safe" }
	function col(s){ return s==3?RED : s==2?MAG : s==1?CYA : GRN }
	# Linux is apt-then-brew, not apt-instead-of-brew: all three machines run
	# linuxbrew, and omp/herdr/lazygit exist only as taps.
	# The fallback runs one way only. Linux is apt-then-brew because all three
	# machines run linuxbrew and omp/herdr/lazygit exist only as taps. There is
	# no apt on a Mac, so a bare `-` in the brew column means unavailable — the
	# other direction would offer to `apt install davfs2` on macOS.
	function src(a,b,   v,p){ if (plat=="brew") { v=b; p="brew" }
	                   else { v=a; p="apt"; if (v=="-") { v=b; p="brew" } }
	                   if (v=="-") return "unavailable"
	                   if (substr(v,1,1)=="-") return substr(v,2)
	                   return p" "v }
	function cut(s,n){ return length(s)>n ? substr(s,1,n-1) "\342\200\246" : s }
	BEGIN{
		R="\033[0m"; DIM="\033[2m"; B="\033[1m"
		GRN="\033[32m"; YEL="\033[33m"; RED="\033[31m"; CYA="\033[36m"; MAG="\033[35m"
		while((getline l < selfile) > 0) sel[l]=1
		while((getline l < expfile) > 0) expd[l]=1
	}
	# @group<TAB>note — a group whose members fail the safe test for several
	# different reasons needs its own line. Deriving it from the first child
	# would show "opens port 22" on a group that also mounts setuid helpers.
	$1=="@needs" || $1=="@spec" { next }
	/^@/ { gnote[substr($1,2)] = $2; next }
	/^#/ || NF<3 { next }
	{
		g=$1; p=$2; f=$3; key=g"/"p
		n++; G[n]=g; P[n]=p; F[n]=f; A[n]=$4; Bc[n]=$5; NT[n]=$6
		if (!(g in seen)) { seen[g]=1; order[++ng]=g }
		tot[g]++
		if (key in sel) { selc[g]++; on[n]=1 }
		if (sev(f) > worst[g]) worst[g]=sev(f)
		if (f=="invasive" || f=="private") why[g]=($6!="" && why[g]=="") ? $6 : why[g]
	}
	END{
		for (i=1; i<=ng; i++) {
			g=order[i]; s=worst[g]; c=selc[g]+0; t=tot[g]
			mark = (c==t) ? "x" : (c>0 ? "~" : " ")
			mc   = (c==t) ? GRN : (c>0 ? YEL : DIM)
			arrow = (g in expd) ? "\342\226\276" : "\342\226\270"
			reason = (g in gnote) ? gnote[g] : why[g]
			note = (s>=2 && reason!="") ? "  " col(s) reason R : ""
			printf "%s %s[%s]%s %s%-13s%s %s%5s%s  %s%-8s%s%s\t%s\n",
				arrow, mc, mark, R, B, g, R, DIM, c"/"t, R, col(s), lbl(s), R, cut(note,44), "g:" g
			if (!(g in expd)) continue
			for (j=1; j<=n; j++) {
				if (G[j]!=g) continue
				m  = (j in on) ? "x" : " "
				mc = (j in on) ? GRN : DIM
				d  = src(A[j], Bc[j])
				if (NT[j]!="") d = d " \302\267 " NT[j]
				printf "    %s[%s]%s %-18s %s%s%s\t%s\n",
					mc, m, R, P[j], DIM, cut(d,46), R, "p:" g "/" P[j]
			}
		}
	}' "$MANIFEST"
}

# --------------------------------------------------------------- explain ----
cmd_explain() {
	key=${1:-}
	awk -F'\t' -v key="$key" -v selfile="$SEL" -v plat="$PLAT" '
	function src(a,b,   v,p){ if (plat=="brew") { v=b; p="brew" }
	                   else { v=a; p="apt"; if (v=="-") { v=b; p="brew" } }
	                   if (v=="-") return "not available"
	                   if (substr(v,1,1)=="-") return substr(v,2)
	                   return p " install " v }
	BEGIN{
		R="\033[0m"; DIM="\033[2m"; B="\033[1m"; RED="\033[31m"; GRN="\033[32m"
		while((getline l < selfile) > 0) sel[l]=1
		split(key, kk, ":"); kind=kk[1]; rest=substr(key, index(key,":")+1)
		if (kind=="g") { wantg=rest } else { split(rest, pp, "/"); wantg=pp[1]; wantp=pp[2] }
	}
	# A key may carry several @needs lines; keep them all, not just the last.
	$1=="@needs" { if ($2==rest) needs = needs (needs==""?"":" ") $3; next }
	$1=="@spec"  { if ($2==rest) spec=$3; next }
	/^@/ { if (substr($1,2)==wantg) gnote=$2; next }
	/^#/ || NF<3 { next }
	$1==wantg {
		if (wantp=="" ) {
			t++; if (($1"/"$2) in sel) c++
			list = list sprintf("  %s %s\n", (($1"/"$2) in sel)?GRN "\342\234\223" R:DIM "\342\227\246" R, $2)
			if ($6!="" && flagnote=="") flagnote=$6
			f=$3
		} else if ($2==wantp) {
			printf "%s%s%s\n\n", B, $2, R
			printf "%sgroup%s     %s\n", DIM, R, $1
			printf "%srisk%s      %s\n", DIM, R, $3
			printf "%sapt%s       %s\n", DIM, R, ($4=="-"?"not available":$4)
			printf "%sbrew%s      %s\n", DIM, R, ($5=="-"?"not available":$5)
			printf "%sinstall%s   %s\n", DIM, R, src($4,$5)
			if ($6!="") printf "\n%s\n", $6
			done=1
		}
	}
	END{
		if (done) {
			if (spec!="")  printf "\n%sspec%s      %s\n", DIM, R, spec
			if (needs!="") printf "\n%sneeds%s     %s%s\n", DIM, R, needs, DIM " (ticked automatically)" R
			printf "\n%sstate%s     %s\n", DIM, R, (key_in_sel()) ? GRN "selected" R : DIM "not selected" R
			exit
		}
		printf "%s%s%s  %s%d of %d selected%s\n\n", B, wantg, R, DIM, c, t, R
		printf "%s\n", list
		if (f=="invasive") printf "%sinvasive%s  %s\n", RED, R, (gnote!="" ? gnote : flagnote)
		if (f=="private")  printf "%s\n", (gnote!="" ? gnote : "one password, typed after the install finishes")
	}
	function key_in_sel(){ return (rest in sel) }
	' "$MANIFEST"
}

# ------------------------------------------------------------- resolution ----
# One place decides, for a selected package on this platform, what actually
# installs it. Everything downstream — the plan, the installer, the dry run —
# reads this and nothing else, so they cannot disagree.
#
# Output: <channel><TAB><argument>
#   apt|brew            argument is the package name
#   npm|uv|snap|deb|flatpak   argument is the @spec, defaulting to the pkg name
#   tarball|script|xcode|builtin   bespoke by nature; dispatched on the key
#   unavailable         neither column offers it here
resolve() {
	awk -F'\t' -v key="$1" -v plat="$PLAT" '
	$1=="@spec" { if ($2==key) spec=$3; next }
	/^[#@]/ || NF<3 { next }
	($1"/"$2)==key { pkg=$2; apt=$4; brw=$5; found=1 }
	END{
		if (!found) { print "missing\t"; exit }
		# apt-then-brew, not apt-instead-of-brew: omp, herdr and lazygit have no
		# apt package at all and install perfectly well from linuxbrew. The
		# fallback is one-directional on purpose — there is no apt on a Mac, so
		# a bare `-` in the brew column is the end of the road, not a reason to
		# go looking in a column that names Debian packages.
		if (plat=="brew") { v=brw; p="brew" }
		else { v=apt; p="apt"; if (v=="-") { v=brw; p="brew" } }
		if (v=="-") { print "unavailable\t"; exit }
		if (substr(v,1,1)=="-") { print substr(v,2) "\t" (spec!="" ? spec : pkg); exit }
		print p "\t" v
	}' "$MANIFEST"
}

# Every selected key, minus the private rows — those are not packages.
selected_packages() {
	awk -F'\t' -v selfile="$SEL" '
	BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
	/^[#@]/ || NF<3 { next }
	$3=="private" { next }
	($1"/"$2) in sel { print $1"/"$2 }' "$MANIFEST"
}

selected_private() {
	awk -F'\t' -v selfile="$SEL" '
	BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
	/^[#@]/ || NF<3 { next }
	$3=="private" && (($1"/"$2) in sel) { print $1"/"$2 }' "$MANIFEST"
}

# Build <channel><TAB><arg><TAB><key> for everything selected.
plan_table() {
	selected_packages | while read -r k; do
		[ -n "$k" ] || continue
		printf '%s\t%s\n' "$(resolve "$k")" "$k"
	done
}

cmd_plan() {
	head_ "plan"
	plan_table | sort | awk -F'\t' '
	BEGIN{ R="\033[0m"; B="\033[1m"; DIM="\033[2m"; YEL="\033[33m" }
	{ ch=$1; arg=$2; key=$3
	  if (ch=="unavailable" || ch=="missing") { bad = bad "    " key "\n"; nb++; next }
	  line[ch] = line[ch] "    " sprintf("%-28s %s", key, arg) "\n"; n++ }
	END{
		for (c in line) printf "  %s%s%s\n%s", B, c, R, line[c]
		printf "\n  %d packages\n", n
		if (nb) printf "\n  %sno source on this platform (%d):%s\n%s", YEL, nb, R, bad
	}'
}

# --------------------------------------------------------------- installer ---
# Nothing here is clever. It batches what can be batched, refuses what it cannot
# reach, and never lets one bad package sink the other thirty.
# Written as an `if`, not `A || { B && C; }`: that form is one || list, so when
# `command -v sudo` fails the whole list fails and `set -e` exits the script at
# load. On a non-root machine with no sudo, dotup died before printing anything.
SUDO=
if [ "$(id -u)" != 0 ] && command -v sudo >/dev/null 2>&1; then SUDO=sudo; fi
FAILED=$STATE/.failed.$$
APT_UPDATED=0

have() { find_tool "$1" >/dev/null 2>&1; }

# A tool installed a moment ago is not on this process's PATH: the astral
# installer drops uv in ~/.local/bin, npm -g honours the ~/.npmrc prefix, the go
# tarball lands in /usr/local/go, and linuxbrew lives outside a non-login PATH.
# The fix is to look in the places we just wrote to -- NOT to export a modified
# PATH. A tool that refuses to shadow your fzf has no business rewriting PATH
# for its own convenience either, and a child process's PATH would be a lie the
# moment dotup exits.
find_tool() {
	command -v "$1" 2>/dev/null && return 0
	for ft_c in "$HOME/.local/bin/$1" "$HOME/.npm-global/bin/$1" \
	            "/usr/local/bin/$1" "/usr/local/go/bin/$1" \
	            "/home/linuxbrew/.linuxbrew/bin/$1" "/opt/homebrew/bin/$1"; do
		[ -x "$ft_c" ] && { printf '%s\n' "$ft_c"; return 0; }
	done
	return 1
}
# Collapse a space-separated list to canonical form; an all-blank list becomes
# empty, so a channel with nothing in it prints no header.
norm() { printf '%s\n' "$*" | tr ' ' '\n' | grep . | tr '\n' ' ' | sed 's/ $//'; }

# Strip userinfo from a URL for tracing: https://user:token@host/p -> https://host/p
# Used wherever a URL that may carry a credential is about to be printed.
redact_url() { printf '%s\n' "$1" | sed 's#://[^/@]*@#://<redacted>@#'; }

BWS_PIN=2.1.0

# The Bitwarden Secrets Manager CLI, installed by the PRIVATE tier only.
#
# It cannot be a manifest row. `selected_packages` drops every row flagged
# `private` -- "private is never a package" is the invariant that lets
# --unattended be safe -- so a private row can never install anything. And it
# must not be a `safe` row either: the defaults preset ticks every safe package,
# which would put a Bitwarden binary and a 12 MB GitHub download on every
# throwaway public VM, for a tool those machines have no credential to use.
#
# So it lives here, called from cmd_private beside the token it exists to read.
# Nothing installs bws unless something is about to hand it a token.
ensure_bws() {
	have bws && { say "  bws $(bws --version 2>/dev/null | awk '{print $2}') already present"; return 0; }
	command -v unzip >/dev/null 2>&1 || { err "bws needs unzip; install it and re-run"; return 1; }
	# musl, not gnu: the gnu build pins a glibc newer than some LTS images
	# carry, and this has to work on whatever a fresh VM turns out to be.
	case $(uname -s) in
	Darwin) t=macos-universal ;;
	*)      case $(uname -m) in
		x86_64|amd64)  t=x86_64-unknown-linux-musl ;;
		aarch64|arm64) t=aarch64-unknown-linux-musl ;;
		*) err "no bws build for $(uname -m)"; return 1 ;;
		esac ;;
	esac
	# Pinned, the same way fzf is, and for a better reason than caution.
	#
	# There is no way to resolve "the current bws" without authenticating.
	# sdk-sm is a monorepo with per-component tags, so /releases/latest
	# redirects to whatever shipped last — measured 2026-08-17, that is
	# `python-v2.1.0`, not bws. And the unauthenticated releases LIST endpoint
	# returns an empty array for this repo: an earlier version of this function
	# resolved the version through it and worked on the dev box only because
	# `gh` had authenticated that shell. In a bare container it returned nothing
	# and the install failed with "could not resolve the current bws version".
	#
	# Release-download URLs need no auth at all, so pin and move on. Bump BWS_PIN
	# deliberately; the checksum below is what makes that safe.
	v=$BWS_PIN
	b=https://github.com/bitwarden/sdk-sm/releases/download/bws-v$v
	say "  fetching bws $v"
	curl -fsSL "$b/bws-$t-$v.zip" -o /tmp/bws.zip 2>/dev/null \
		|| { err "bws download failed"; return 1; }
	# A binary about to hold the key to every other credential is worth verifying.
	if have sha256sum && curl -fsSL "$b/bws-sha256-checksums-$v.txt" -o /tmp/bws.sums 2>/dev/null; then
		want=$(awk -v f="bws-$t-$v.zip" '$2==f || $2=="*"f {print $1}' /tmp/bws.sums | head -1)
		got=$(sha256sum /tmp/bws.zip | awk '{print $1}')
		if [ -n "$want" ] && [ "$want" != "$got" ]; then
			err "bws checksum mismatch — refusing to install"
			rm -f /tmp/bws.zip /tmp/bws.sums; return 1
		fi
		[ -n "$want" ] && say "  bws checksum verified"
		rm -f /tmp/bws.sums
	else
		warn "  bws checksums unavailable — installing unverified"
	fi
	mkdir -p "$HOME/.local/bin" /tmp/bws.d
	unzip -oq /tmp/bws.zip -d /tmp/bws.d 2>/dev/null \
		|| { err "bws extract failed"; rm -rf /tmp/bws.zip /tmp/bws.d; return 1; }
	install -m 755 "$(find /tmp/bws.d -type f -name bws | head -1)" "$HOME/.local/bin/bws" \
		|| { err "bws install failed"; rm -rf /tmp/bws.zip /tmp/bws.d; return 1; }
	rm -rf /tmp/bws.zip /tmp/bws.d
	say "  bws $v installed to ~/.local/bin"
}

run() {
	if [ "$DRYRUN" -eq 1 ]; then printf '  + %s\n' "$*"; return 0; fi
	printf '%s  + %s%s\n' "$DIM" "$*" "$R" >&2
	"$@"
}
run_sh() {
	if [ "$DRYRUN" -eq 1 ]; then printf '  + %s\n' "$1"; return 0; fi
	printf '%s  + %s%s\n' "$DIM" "$1" "$R" >&2
	sh -c "$1"
}
note_fail() { printf '%s\t%s\n' "$1" "$2" >> "$FAILED"; err "$1: $2"; }

apt_update_once() {
	[ "$APT_UPDATED" -eq 0 ] || return 0
	APT_UPDATED=1
	run_sh "${SUDO:+$SUDO }apt-get update -qq" || warn "apt-get update failed; continuing with stale lists"
}

# apt refuses the whole batch when one name is unknown, so ask first. A name apt
# does not know is not a dead end: if the brew column offers it, it moves there.
# That is what "apt-then-brew" has to mean in practice, and it is the difference
# between 34 packages installed and 0.
apt_known() { apt-cache show "$1" >/dev/null 2>&1; }

install_apt() {
	pkgs=$(norm "$1")
	[ -n "$pkgs" ] || return 0
	head_ "apt"
	apt_update_once
	keep=; moved=; unknown=
	for p in $pkgs; do
		if [ "$DRYRUN" -eq 1 ] || ! have apt-cache || apt_known "$p"; then keep="$keep $p"
		else unknown="$unknown $p"; fi
	done
	for p in $unknown; do
		k=$(awk -F'\t' -v p="$p" '!/^[#@]/ && NF>=3 && index(" "$4" "," "p" ") {print $1"/"$2; exit}' "$MANIFEST")
		b=$(awk -F'\t' -v p="$p" '!/^[#@]/ && NF>=3 && index(" "$4" "," "p" ") {print $5; exit}' "$MANIFEST")
		case $b in
		-|-*) note_fail "${k:-$p}" "apt does not know '$p' and there is no brew fallback" ;;
		*)    warn "apt does not know '$p' — falling back to brew '$b'"; moved="$moved $b" ;;
		esac
	done
	if [ -n "$keep" ]; then
		# shellcheck disable=SC2086
		if ! run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y$(printf ' %s' $keep)"; then
			warn "batch install failed; retrying one at a time so one bad package does not sink the rest"
			for p in $keep; do
				run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y $p" \
					|| note_fail "$p" "apt install failed"
			done
		fi
	fi
	BREW_EXTRA=$moved
}

install_brew() {
	pkgs=$(norm "$1")
	[ -n "$pkgs" ] || return 0
	head_ "brew"
	BREW=$(find_tool brew || echo brew)
	if ! have brew && [ "$DRYRUN" -eq 0 ]; then
		# Installing a second package manager is exactly the kind of thing the
		# `invasive` flag exists to refuse doing on your behalf. Say what to run.
		warn "brew is not installed; skipping:$pkgs"
		warn "  install it first:  /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
		for p in $pkgs; do note_fail "$p" "brew missing"; done
		return 0
	fi
	for p in $pkgs; do run_sh "$BREW install $p" || note_fail "$p" "brew install failed"; done
}

install_npm() {
	specs=$(norm "$1")
	[ -n "$specs" ] || return 0
	head_ "npm"
	NPM=$(find_tool npm || echo npm)
	if ! have npm && [ "$DRYRUN" -eq 0 ]; then
		# Ubuntu's `nodejs` package ships node WITHOUT npm; the manifest asks
		# apt for both. If this fires anyway, node itself did not land.
		for p in $specs; do note_fail "$p" "npm missing — core/node did not install"; done
		return 0
	fi
	# shellcheck disable=SC2086
	run_sh "$NPM install -g$(printf ' %s' $specs)" || {
		warn "batch npm install failed; retrying one at a time"
		for p in $specs; do run_sh "$NPM install -g $p" || note_fail "$p" "npm install failed"; done
	}
}

install_uv() {
	tools=$(norm "$1")
	[ -n "$tools" ] || return 0
	head_ "uv"
	UV=$(find_tool uv || echo uv)
	if ! have uv && [ "$DRYRUN" -eq 0 ]; then
		for t in $tools; do note_fail "$t" "uv missing — core/uv did not install"; done
		return 0
	fi
	for t in $tools; do run_sh "$UV tool install $t" || note_fail "$t" "uv tool install failed"; done
}

install_snap() {
	names=$(norm "$1")
	[ -n "$names" ] || return 0
	head_ "snap"
	if ! have snap && [ "$DRYRUN" -eq 0 ]; then
		for n in $names; do note_fail "$n" "snapd is not present on this machine"; done
		return 0
	fi
	for n in $names; do run_sh "${SUDO:+$SUDO }snap install $n" || note_fail "$n" "snap install failed"; done
}

install_flatpak() {
	ids=$(norm "$1")
	[ -n "$ids" ] || return 0
	head_ "flatpak"
	if ! have flatpak && [ "$DRYRUN" -eq 0 ]; then
		for i in $ids; do note_fail "$i" "flatpak is not present on this machine"; done
		return 0
	fi
	for i in $ids; do
		run_sh "flatpak install -y --noninteractive flathub $i" || note_fail "$i" "flatpak install failed"
	done
}

# Two forms, because two real packages need two different things:
#   https://…/x.deb                       a stable vendor URL (chrome)
#   gh:<owner>/<repo>:<asset substring>   latest release asset (ghostty)
install_deb() {
	srcs=$(norm "$1")
	[ -n "$srcs" ] || return 0
	head_ "deb"
	for s in $srcs; do
		url=$s
		case $s in
		gh:*)
			spec=${s#gh:}; repo=${spec%%:*}; match=${spec#*:}
			if [ "$DRYRUN" -eq 1 ]; then
				printf '  + resolve latest %s asset matching *%s*\n' "$repo" "$match"
				url="https://github.com/$repo/releases/latest/<asset matching *$match*>"
			else
				url=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null \
					| awk -F'"' -v m="$match" '/browser_download_url/ && index($4,m) {print $4; exit}')
				[ -n "$url" ] || { note_fail "$repo" "no release asset matching *$match*"; continue; }
			fi ;;
		esac
		f=${TMPDIR:-/tmp}/dotup-$$.deb
		run_sh "curl -fsSL '$url' -o '$f'" || { note_fail "$url" "download failed"; continue; }
		run_sh "${SUDO:+$SUDO }apt-get install -y '$f'" || note_fail "$url" "dpkg install failed"
		run_sh "rm -f '$f'"
	done
}

# Bespoke by nature: each of these needs arch detection, a destination outside
# $HOME, and a symlink. A data column cannot express that honestly, so the
# dispatch is on the key and the code says what it does.
install_bespoke() {
	bsp=$(norm "$2")
	[ -n "$bsp" ] || return 0
	head_ "$1"
	for key in $bsp; do
		case $key in
		core/neovim)
			# The one place the channel is prescribed rather than "whatever the
			# package manager has": 24.04's apt candidate is 0.9.5 and LazyVim
			# needs 0.12 for the kitty graphics protocol.
			if have nvim && [ "$DRYRUN" -eq 0 ]; then
				v=$(nvim --version 2>/dev/null | awk 'NR==1{print $2}' | tr -d 'v')
				case $v in 0.1[2-9]*|0.[2-9]*|[1-9]*) say "  nvim $v already above 0.12 — leaving it"; continue ;; esac
			fi
			case $(uname -m) in
			x86_64|amd64)  a=x86_64 ;;
			aarch64|arm64) a=arm64 ;;
			*) note_fail "$key" "no neovim tarball for $(uname -m)"; continue ;;
			esac
			b=https://github.com/neovim/neovim/releases/latest/download
			run_sh "curl -fsSL '$b/nvim-linux-$a.tar.gz' -o /tmp/nvim.tgz || curl -fsSL '$b/nvim-linux64.tar.gz' -o /tmp/nvim.tgz" \
				|| { note_fail "$key" "tarball download failed"; continue; }
			run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf /tmp/nvim.tgz -C /opt/nvim --strip-components=1" \
				|| { note_fail "$key" "tarball extract failed"; continue; }
			run_sh "${SUDO:+$SUDO }ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim"
			run_sh "rm -f /tmp/nvim.tgz" ;;
		core/go)
			if have go && [ "$DRYRUN" -eq 0 ]; then say "  go already present — leaving it"; continue; fi
			case $(uname -s) in Darwin) o=darwin ;; *) o=linux ;; esac
			case $(uname -m) in x86_64|amd64) a=amd64 ;; aarch64|arm64) a=arm64 ;;
				*) note_fail "$key" "no go tarball for $(uname -m)"; continue ;; esac
			if [ "$DRYRUN" -eq 1 ]; then v='go1.X.Y'
			else v=$(curl -fsSL 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); fi
			[ -n "$v" ] || { note_fail "$key" "could not resolve the current go version"; continue; }
			run_sh "curl -fsSL 'https://go.dev/dl/$v.$o-$a.tar.gz' -o /tmp/go.tgz" \
				|| { note_fail "$key" "tarball download failed"; continue; }
			run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf /tmp/go.tgz -C /usr/local" \
				|| { note_fail "$key" "tarball extract failed"; continue; }
			run_sh "rm -f /tmp/go.tgz" ;;
		core/chezmoi)
			# Circular by nature: dotup arrives *via* chezmoi. Present already
			# in every case that matters; here for the one where it is not.
			if have chezmoi && [ "$DRYRUN" -eq 0 ]; then say "  chezmoi already present — leaving it"; continue; fi
			run_sh "sh -c \"\$(curl -fsLS get.chezmoi.io)\" -- -b \"\$HOME/.local/bin\"" \
				|| note_fail "$key" "installer failed" ;;
		core/uv)
			if have uv && [ "$DRYRUN" -eq 0 ]; then say "  uv already present — leaving it"; continue; fi
			run_sh "curl -LsSf https://astral.sh/uv/install.sh | sh" || note_fail "$key" "installer failed" ;;
		core/build-tools)
			# macOS only — the Linux side is build-essential through apt.
			if [ "$DRYRUN" -eq 0 ] && xcode-select -p >/dev/null 2>&1; then
				say "  Xcode command line tools already installed"; continue
			fi
			run_sh "xcode-select --install" || note_fail "$key" "xcode-select --install failed" ;;
		core/zsh|networking/openssh-server)
			say "  $key: built into macOS, nothing to install" ;;
		*) note_fail "$key" "no handler for channel '$1'" ;;
		esac
	done
}

cmd_install() {
	: > "$FAILED"
	tbl=$STATE/.plan.$$
	plan_table > "$tbl"

	# Two filters, and they are the reason this is safe to run unattended.
	# `private` is never a package: it needs a password nobody is there to type.
	# `invasive` is never installed by a run with nobody at the keyboard, even
	# if a stale state file says otherwise — the boundary holds because of what
	# this refuses, not because of what it was asked.
	if [ "$UNATTENDED" -eq 1 ]; then
		inv=$(awk -F'\t' -v selfile="$SEL" '
			BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
			!/^[#@]/ && NF>=3 && $3=="invasive" && (($1"/"$2) in sel) {print $1"/"$2}' "$MANIFEST")
		if [ -n "$inv" ]; then
			warn "unattended: refusing invasive packages$(printf ' %s' $inv)"
			for k in $inv; do
				awk -F'\t' -v k="$k" '$3!=k' "$tbl" > "$tbl.f"; mv "$tbl.f" "$tbl"
			done
		fi
	fi

	col()  { awk -F'\t' -v c="$1" '$1==c {print $2}' "$tbl" | sort -u | tr '\n' ' '; }
	keys() { awk -F'\t' -v c="$1" '$1==c {print $3}' "$tbl" | sort -u | tr '\n' ' '; }

	bad=$(awk -F'\t' '$1=="unavailable" || $1=="missing" {print $3}' "$tbl" | tr '\n' ' ')
	[ -z "$bad" ] || warn "no source on this platform:$bad"

	# Order is a fixed pipeline, not a topological sort, because the real
	# manifest has exactly two ordering constraints and both are channel-level:
	# npm needs node (apt/brew), and uv tools need uv (script).
	BREW_EXTRA=
	install_apt   "$(col apt)"
	install_brew  "$(col brew) $BREW_EXTRA"
	install_bespoke script  "$(keys script)"
	install_bespoke tarball "$(keys tarball)"
	install_bespoke builtin "$(keys builtin)"
	install_bespoke xcode   "$(keys xcode)"
	install_snap    "$(col snap)"
	install_deb     "$(col deb)"
	install_flatpak "$(col flatpak)"
	install_npm     "$(col npm)"
	install_uv      "$(col uv)"

	rm -f "$tbl"
	if [ -s "$FAILED" ]; then
		head_ "did not install"
		while IFS="	" read -r what why; do printf '  %s%-32s%s %s\n' "$RED" "$what" "$R" "$why" >&2; done < "$FAILED"
		n=$(grep -c . "$FAILED")
		rm -f "$FAILED"
		printf '\n%s%d package(s) did not install.%s Everything else did.\n' "$YEL" "$n" "$R" >&2
		return 1
	fi
	rm -f "$FAILED"
	head_ "done"
	return 0
}

# ----------------------------------------------------------------- private ---
# The tick does not do the work, it schedules it. git, chezmoi and bws have to
# exist before either row can act, and a password typed at picker time would sit
# in memory for the ten minutes of package downloads in between. So it happens
# here, immediately before it is used, and never at all without a human.
PRIV_SRC=${DOTUP_PRIVATE_SRC:-${XDG_DATA_HOME:-$HOME/.local/share}/dotfiles-private}
BWS_TOKEN=${DOTUP_BWS_TOKEN:-${XDG_CONFIG_HOME:-$HOME/.config}/bitwarden/bws-token}

# The private tier gets its OWN config file, and this is load-bearing.
#
# chezmoi renders .chezmoi.toml.tmpl to the config path, and without -c that is
# ~/.config/chezmoi/chezmoi.toml for BOTH tiers. The private template asks seven
# [data] questions once (promptStringOnce) -- name, email, signing key, four
# gitea addresses. Re-running the PUBLIC installer afterwards rewrites that same
# file, and since the public tier's rendered config carries no [data] block, the
# seven answers are simply gone.
#
# The failure is silent, which is what makes it worth a separate file rather
# than a warning: the private templates degrade politely when their data is
# missing -- config.local emits a comment telling you to re-run init instead of
# failing the apply -- so the first symptom is `git commit` not knowing who you
# are, days later, with nothing connecting it to the install you ran.
PRIV_CFG=${DOTUP_PRIVATE_CFG:-${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/private.toml}

# git's credential store for the private remote.
#
# The clone URL the endpoint hands back carries the token inline, and handing
# that to `chezmoi init` puts a live credential in two durable places: the
# init process's argv, which ANY account on the box can read out of
# /proc/<pid>/cmdline for as long as the clone runs, and the resulting
# .git/config, which keeps it until the tree is deleted. Neither is fixed by
# not printing it -- the earlier redaction work only covered the trace line.
#
# Splitting the credential out of the URL fixes both: git reads the secret from
# this file at 600, and the remote it records is clean.
PRIV_CRED=${DOTUP_PRIVATE_CRED:-$STATE/private-credentials}

cmd_private() {
	rows=$(selected_private)
	[ -n "$rows" ] || return 0

	# The §1 boundary is structural: it holds because there is nobody to type a
	# password, not because of a policy this is obeying.
	if [ "$UNATTENDED" -eq 1 ]; then
		warn "unattended: the private tier needs a password nobody is here to type — skipped"
		return 0
	fi
	[ "$DRYRUN" -eq 0 ] || { head_ "private"; printf '  + prompt for URL, username, password (interactive only)\n'; return 0; }
	[ -t 0 ] || { warn "no terminal: the private tier needs a password — skipped"; return 0; }

	want_repo=0; want_bws=0
	case $rows in *private/private-repo*) want_repo=1 ;; esac
	case $rows in *private/bws-secrets*)  want_bws=1  ;; esac
	# A satisfied row is silent. You only see the prompt for something absent.
	[ ! -d "$PRIV_SRC" ] || { say "  private repo already present at $PRIV_SRC"; want_repo=0; }
	[ ! -r "$BWS_TOKEN" ] || { say "  bws token already present"; want_bws=0; }
	[ "$want_repo" -eq 1 ] || [ "$want_bws" -eq 1 ] || return 0

	head_ "private tier"
	say "  The address is in no repository. Leave it blank to stay public-only."
	# Read into variables: nothing reaches argv, so nothing reaches `ps`.
	P_URL=''; P_USER=''; P_PW=''; p_in=''
	# shellcheck disable=SC2064
	trap 'unset P_URL P_USER P_PW P_BLOB p_repo p_tok p_cred 2>/dev/null || :' EXIT INT TERM

	# Ask, and keep asking.
	#
	# These three questions come at the very END of a run, after every package is
	# installed, because a password typed at picker time would sit in memory
	# through ten minutes of downloads. That ordering is right, and it is exactly
	# what made one mistyped character so expensive: any non-200 used to be fatal,
	# so a typo meant re-running the entire install to get back to this prompt.
	# Nothing about a wrong password justifies reinstalling a compiler.
	#
	# URL and username persist across attempts and blank keeps them, because the
	# password is the thing you get wrong, and retyping an address you already
	# pasted correctly is its own source of error.
	p_try=0
	while :; do
		p_try=$((p_try + 1))
		if [ "$p_try" -gt 5 ]; then
			err "five failed attempts — stopping rather than looping."
			say "  Nothing else needs redoing. Re-run only this step: dotup private"
			return 1
		fi

		if [ -n "$P_URL" ]; then printf '  Bootstrap URL [%s]: ' "$P_URL" >&2
		else                     printf '  Bootstrap URL: ' >&2; fi
		IFS= read -r p_in || :
		case $p_in in
		q|Q) say "  public-only machine. Nothing was asked for."; return 0 ;;
		'')  [ -n "$P_URL" ] || { say "  public-only machine. Nothing was asked for."; return 0; } ;;
		*)   P_URL=$p_in ;;
		esac
		# Accept the directory OR the full file URL, because both are in
		# circulation: the rotation scripts print the file form and say to store
		# THAT in Bitwarden, so pasting what you saved is the likely case. Without
		# this the request becomes .../bootstrap.env/bootstrap.env, and the 404
		# would be reported below as a credential problem.
		P_URL=${P_URL%/}
		P_URL=${P_URL%/bootstrap.env}

		if [ -n "$P_USER" ]; then printf '  Username [%s]: ' "$P_USER" >&2
		else                      printf '  Username:      ' >&2; fi
		IFS= read -r p_in || :
		[ -z "$p_in" ] || P_USER=$p_in

		printf '  Password:      ' >&2
		stty -echo 2>/dev/null || :; IFS= read -r P_PW || :; stty echo 2>/dev/null || :; printf '\n' >&2

		# curl -K - reads its config, credentials included, from stdin rather than
		# the command line, so nothing reaches `ps`. The status is captured
		# alongside the body instead of relying on --fail, because "it failed" is
		# not a useful thing to say here: 401, 404 and an unreachable host each
		# need a different next move, and the old single message blamed the
		# credentials for all three. The body is used only when the code is 200,
		# so an error page is still never parsed as a blob.
		p_resp=$(printf 'user = "%s:%s"\nsilent\nwrite-out = "\\n%%{http_code}"\n' "$P_USER" "$P_PW" \
			| curl -K - --connect-timeout 15 -m 120 "$P_URL/bootstrap.env" 2>/dev/null) || :
		p_code=$(printf '%s\n' "$p_resp" | tail -n1)
		P_BLOB=$(printf '%s\n' "$p_resp" | sed '$d')
		p_resp=''
		P_PW=''

		case $p_code in
		200) break ;;
		401) err "wrong username or password." ;;
		404) err "reached the host, but no bootstrap.env is there — check the route in the URL." ;;
		000) err "could not reach that address (DNS, TLS, or the host is down)." ;;
		*)   err "the endpoint answered HTTP $p_code." ;;
		esac
		P_BLOB=''
		say "  Try again, or type q at the URL prompt to stay public-only."
	done
	unset P_PW p_in p_code p_try 2>/dev/null || :

	# Contract with the endpoint (phase 4 writes the file this parses):
	#   two KEY=VALUE lines, no quoting, no shell
	#   PRIVATE_REPO_URL=https://<user>:<read-only-token>@host/path/dotfiles-private.git
	#   BWS_ACCESS_TOKEN=<machine account token, scoped to one project>
	p_repo=$(printf '%s\n' "$P_BLOB" | sed -n 's/^PRIVATE_REPO_URL=//p' | head -1)
	p_tok=$(printf '%s\n'  "$P_BLOB" | sed -n 's/^BWS_ACCESS_TOKEN=//p'  | head -1)
	unset P_BLOB

	if [ "$want_bws" -eq 1 ]; then
		if [ -n "$p_tok" ]; then
			mkdir -p "$(dirname "$BWS_TOKEN")"
			( umask 077; printf '%s\n' "$p_tok" > "$BWS_TOKEN" )
			chmod 600 "$BWS_TOKEN"
			say "  bws token written, mode 600"
			ensure_bws
		else err "the blob carried no BWS_ACCESS_TOKEN"; fi
	fi
	unset p_tok

	if [ "$want_repo" -eq 1 ]; then
		if [ -n "$p_repo" ]; then
			# Split the credential out of the URL before anything executes.
			# p_cred is the bare scheme://user:token@host that
			# git-credential-store wants -- host only, no path. p_clean is the
			# same URL with the userinfo removed. A remote with no userinfo
			# (ssh, or an unauthenticated https URL) leaves p_cred empty and
			# p_clean identical to what arrived, so it is used unchanged.
			p_cred=$(printf '%s\n' "$p_repo" | sed -n 's|^\(https\{0,1\}://[^@/]*@[^/]*\).*|\1|p')
			p_clean=$(printf '%s\n' "$p_repo" | sed -e 's|^\(https\{0,1\}://\)[^@/]*@|\1|')
			if [ -n "$p_cred" ]; then
				mkdir -p "$(dirname "$PRIV_CRED")"
				( umask 077; printf '%s\n' "$p_cred" > "$PRIV_CRED" )
				chmod 600 "$PRIV_CRED"
				# GIT_CONFIG_* rather than `git -c`: the helper string names
				# the credential file, and while that path is not itself a
				# secret, argv is the wrong channel for anything about it.
				# The value is single-quoted because git runs a helper
				# containing spaces through a shell -- an unquoted $HOME with
				# a space in it would split into two arguments.
				GIT_CONFIG_COUNT=1
				GIT_CONFIG_KEY_0=credential.helper
				GIT_CONFIG_VALUE_0="store --file='$PRIV_CRED'"
				export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
			fi

			# NOT `run`. It echoes its whole argv to stderr, and even a clean
			# URL is worth tracing deliberately rather than by accident.
			# redact_url stays on the trace as a second line of defence: if
			# the split above ever fails to match, the credential still does
			# not reach terminal scrollback, `dotup 2>log`, or an agent
			# transcript capturing the run.
			mkdir -p "$(dirname "$PRIV_CFG")"
			if [ "$DRYRUN" -eq 1 ]; then
				printf '  + chezmoi init --apply --source %s -c %s %s\n' \
					"$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_clean")"
			else
				printf '%s  + chezmoi init --apply --source %s -c %s %s%s\n' \
					"$DIM" "$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_clean")" "$R" >&2
				chezmoi init --apply --source "$PRIV_SRC" -c "$PRIV_CFG" "$p_clean" \
					|| err "private repo init failed"
			fi

			# The exported vars above only cover this process. Record the same
			# helper in the clone's own config so a later `chezmoi update`
			# still authenticates -- the token stays in PRIV_CRED, and
			# .git/config learns only where to look for it.
			if [ -n "$p_cred" ] && [ -d "$PRIV_SRC/.git" ]; then
				git -C "$PRIV_SRC" config credential.helper \
					"store --file='$PRIV_CRED'" 2>/dev/null || :
			fi
			unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0

			# The clone lands with the caller's umask, which on a stock Ubuntu
			# is 022 -- world-readable. This tree holds ssh config, accepted
			# keys and machine identity. On a shared or multi-user box that is
			# readable by anyone with an account.
			[ ! -d "$PRIV_SRC" ] || chmod -R go-rwx "$PRIV_SRC" 2>/dev/null || :
		else err "the blob carried no PRIVATE_REPO_URL"; fi
	fi
	unset p_repo p_cred p_clean
	trap - EXIT INT TERM
}

# --------------------------------------------------------------- preflight ---
# fzf cannot come from the manifest: the picker needs it to draw the list that
# installs it. Same bootstrap problem chezmoi has. So it is fetched here, before
# any UI exists.
#
# FLOOR is exactly where verification stops, not a guess. Every release from
# 0.29 up parses every option and binding the picker uses, but cursor-on-reload
# — the property that makes ticking a list bearable — can only be measured from
# 0.44.1, where fzf's --listen API began reporting state. Below that it is
# unverifiable rather than known-broken, so we decline to rely on it.
#
# The binary is dotup's own, not yours. It lands in a cache directory and is
# invoked by absolute path — PATH is never touched. Dropping a newer fzf into
# ~/.local/bin would shadow the distro's copy for Ctrl-R, the oh-my-zsh plugin
# and every other script, which is precisely the kind of silent change the
# `invasive` flag exists to refuse. Not the repo either: a vendored binary means
# either megabytes in git or a .gitignore rule, and the public repo stays clean.
FZF_FLOOR=0.44.0
FZF_PIN=0.74.2
FZF_CACHE=${XDG_CACHE_HOME:-$HOME/.cache}/dotup
FZF=

# $1 >= $2, dotted numeric. Not `sort -V`: BSD sort on older macOS lacks it.
ver_ge() {
	awk -v a="$1" -v b="$2" 'BEGIN{
		na=split(a,A,"."); nb=split(b,B,".")
		for(i=1;i<=3;i++){ x=(i<=na)?A[i]+0:0; y=(i<=nb)?B[i]+0:0
			if(x>y) exit 0; if(x<y) exit 1 }
		exit 0 }'
}

fzf_version() { fzf --version 2>/dev/null | awk '{print $1}'; }

install_fzf() {
	case $(uname -s) in Darwin) os=darwin ;; *) os=linux ;; esac
	case $(uname -m) in
	x86_64|amd64)  arch=amd64 ;;
	aarch64|arm64) arch=arm64 ;;
	armv7l)        arch=armv7 ;;
	*) echo "dotup: unsupported arch $(uname -m); install fzf yourself" >&2; return 1 ;;
	esac
	# The release tag gained a leading v between 0.53.0 and 0.55.0. Anything we
	# would pin today is above that; the fallback keeps an older pin working.
	base=https://github.com/junegunn/fzf/releases/download
	mkdir -p "$FZF_CACHE"
	tmp=$FZF_CACHE/.fzf.$$
	echo "dotup: fetching fzf $FZF_PIN ($os/$arch) for its own use" >&2
	if   curl -sfL "$base/v$FZF_PIN/fzf-$FZF_PIN-${os}_${arch}.tar.gz" | tar xz -O fzf > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then :
	elif curl -sfL "$base/$FZF_PIN/fzf-$FZF_PIN-${os}_${arch}.tar.gz"  | tar xz -O fzf > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then :
	else rm -f "$tmp"; echo "dotup: could not fetch fzf $FZF_PIN" >&2; return 1; fi
	chmod +x "$tmp" && mv "$tmp" "$FZF_CACHE/fzf"
}

# Resolution order, cheapest first. The machine's own fzf wins when it clears
# the floor — nothing is replaced merely for being old.
ensure_fzf() {
	if [ -x "$FZF_CACHE/fzf" ] && ver_ge "$("$FZF_CACHE/fzf" --version 2>/dev/null | awk '{print $1}')" "$FZF_FLOOR"; then
		FZF=$FZF_CACHE/fzf; return 0
	fi
	if command -v fzf >/dev/null 2>&1; then
		cur=$(fzf_version)
		if [ -n "$cur" ] && ver_ge "$cur" "$FZF_FLOOR"; then
			FZF=$(command -v fzf); return 0
		fi
		echo "dotup: system fzf $cur is below the verified floor $FZF_FLOOR" >&2
	fi
	install_fzf || return 1
	FZF=$FZF_CACHE/fzf
}

# ------------------------------------------------------------------- pick ----
cmd_pick() {
	ensure_fzf || { echo "dotup: no usable fzf; use the numbered prompt" >&2; return 2; }
	[ -s "$SEL" ] || cmd_preset defaults
	# --exact is a safety property, not a preference. ^t toggles every row the
	# filter is showing, so the filter must mean exactly what it looks like.
	# Fuzzy-matching "nvidia" also matches docker, tailscale and desktop.
	cmd_render | "$FZF" --ansi --exact --no-sort --cycle --multi --layout=reverse --height=100% \
		--delimiter='\t' --with-nth=1 --pointer='>' --marker=' ' \
		--info=inline --border=none \
		--header=$'space tick  tab open  ^t tick all shown  ^a defaults  ^x none  ^o open all  enter install\n' \
		--preview "$SELF explain {2}" --preview-window='right,46%,wrap,border-left' \
		--bind "space:execute-silent($SELF toggle {2})+reload($SELF render)" \
		--bind "tab:execute-silent($SELF expand {2})+reload($SELF render)" \
		--bind "ctrl-t:select-all+execute-silent($SELF toggle {+2})+clear-selection+reload($SELF render)" \
		--bind "ctrl-a:execute-silent($SELF preset defaults)+reload($SELF render)" \
		--bind "ctrl-x:execute-silent($SELF preset none)+reload($SELF render)" \
		--bind "ctrl-o:execute-silent($SELF expand-all)+reload($SELF render)" \
		--bind 'enter:accept' > /dev/null || return 1
}

confirm() {
	[ "$ASSUME_YES" -eq 0 ] || return 0
	[ -t 0 ] || return 0
	printf '\n  install? [y/N] ' >&2
	a=''; IFS= read -r a || a=''
	case $a in y|Y|yes|YES) return 0 ;; *) say "  nothing installed."; return 1 ;; esac
}

cmd_run() {
	if [ "$UNATTENDED" -eq 1 ]; then
		# Deterministic by construction: computed fresh from the manifest, never
		# inherited from whatever a previous interactive run left in the state
		# file. "What a VM or CI run gets" has to mean the same thing twice.
		cmd_preset defaults
		cmd_plan
	else
		cmd_pick || return $?
		cmd_plan
		confirm || return 0
	fi
	rc=0
	cmd_install || rc=$?
	cmd_private || :
	return $rc
}

# ------------------------------------------------------------------ usage ----
usage() {
	cat >&2 <<-EOF
		usage: dotup [--unattended] [--print] [--yes]

		  (no flags)     the picker, then install what you ticked
		  --unattended   no UI: safe defaults, never prompts, never private
		  --print, -n    resolve everything and print the commands, install nothing
		  --yes, -y      skip the confirmation after the picker

		  private        ONLY the private tier: prompt for the endpoint and fetch.
		                 Everything already installed is left alone, so this is the
		                 way back in after a mistyped password -- no reinstall.

		plumbing, called by the fzf bindings:
		  render toggle expand expand-all preset explain plan preflight
	EOF
}

CMD=
while [ $# -gt 0 ]; do
	case $1 in
	--unattended) UNATTENDED=1; ASSUME_YES=1 ;;
	--print|-n)   DRYRUN=1 ;;
	--yes|-y)     ASSUME_YES=1 ;;
	-h|--help)    usage; exit 0 ;;
	--*)          err "unknown flag: $1"; usage; exit 2 ;;
	*)            CMD=$1; shift; break ;;
	esac
	shift
done

case ${CMD:-run} in
run)         cmd_run ;;
pick)        cmd_pick && cmd_plan ;;
install)     cmd_install ;;
private)     cmd_private ;;
render)      cmd_render ;;
toggle)      cmd_toggle "$@" ;;
expand)      cmd_expand "$@" ;;
expand-all)  cmd_expand_all ;;
preset)      cmd_preset "${1:-defaults}" ;;
explain)     cmd_explain "${1:-}" ;;
plan)        cmd_plan ;;
resolve)     resolve "${1:-}" ;;                            # test hook
preflight)   ensure_fzf && echo "using $FZF ($("$FZF" --version | awk '{print $1}'), floor $FZF_FLOOR)" ;;
fzf-path)    ensure_fzf >/dev/null 2>&1 && echo "$FZF" ;;   # test hook
vercmp)      ver_ge "$1" "$2" ;;                            # test hook
*) usage; exit 2 ;;
esac
