#!/bin/bash
# aur-update - review, build and install updates for locally-cloned AUR packages.
#
# Decides what to rebuild by comparing the PKGBUILD's version against pacman,
# not by whether `git pull` moved HEAD, so an interrupted, declined or failed
# run is simply retried the next time round.

set -euo pipefail

AUR_DIR="${AUR_DIR:-$HOME/aur}"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/aur-update"

DRY_RUN=0
FILTERS=()
ABORT=0
RESULTS=()

usage() {
	cat <<'EOF'
usage: aur-update [--dry-run] [package...]

Sweeps every git checkout in $AUR_DIR (default ~/aur) that has a PKGBUILD:
pulls, compares the built version against pacman, shows you the diff since the
last version it installed, and on your approval builds and installs it.

  --dry-run    pull, version-check and show diffs, but never build or install
  package...   limit the sweep to these directories (default: all)

environment:
  AUR_DIR      root directory holding the package checkouts
EOF
}

while (($#)); do
	case "$1" in
	--dry-run) DRY_RUN=1 ;;
	-h | --help)
		usage
		exit 0
		;;
	-*)
		printf 'aur-update: unknown option: %s\n' "$1" >&2
		usage >&2
		exit 2
		;;
	*) FILTERS+=("$1") ;;
	esac
	shift
done

if [[ -t 1 ]]; then
	BOLD=$'\e[1m' RED=$'\e[31m' GREEN=$'\e[32m' YELLOW=$'\e[33m' BLUE=$'\e[34m' DIM=$'\e[2m' RESET=$'\e[0m'
else
	BOLD='' RED='' GREEN='' YELLOW='' BLUE='' DIM='' RESET=''
fi

say() { printf '%s\n' "$*"; }
info() { printf '%s==>%s %s\n' "$BLUE$BOLD" "$RESET$BOLD" "$*$RESET"; }
warn() { printf '%s==> WARNING:%s %s\n' "$YELLOW$BOLD" "$RESET$BOLD" "$*$RESET" >&2; }
err() { printf '%s==> ERROR:%s %s\n' "$RED$BOLD" "$RESET$BOLD" "$*$RESET" >&2; }

record() { RESULTS+=("$1|$2"); }

[[ -d $AUR_DIR ]] || {
	err "no such directory: $AUR_DIR"
	exit 2
}

# The whole point of this script is the approval prompt, so a run with nowhere
# to prompt is a run that would hang. --dry-run asks nothing, so it is allowed.
if ((!DRY_RUN)) && [[ ! -t 0 ]]; then
	err "no terminal on stdin; aur-update is interactive (use --dry-run for an unattended check)"
	exit 2
fi

SUDO_KEEPALIVE_PID=''
cleanup() {
	[[ -n $SUDO_KEEPALIVE_PID ]] && kill "$SUDO_KEEPALIVE_PID" 2>/dev/null
	return 0
}
trap cleanup EXIT

# mysql and php74 build for long enough to outlive sudo's timestamp, which
# would otherwise leave a password prompt waiting in an empty room.
start_sudo_keepalive() {
	sudo -v || {
		err "could not acquire sudo"
		exit 1
	}
	while true; do
		sudo -n true 2>/dev/null || exit
		sleep 50
		kill -0 "$$" 2>/dev/null || exit
	done &
	SUDO_KEEPALIVE_PID=$!
}

# makepkg --packagelist prints one absolute path per subpackage, named
# <pkgname>-[<epoch>:]<pkgver>-<pkgrel>-<arch><PKGEXT>. Splitting from the
# right is the only safe way to recover the name: ttf-lilex-variable and
# backlog.md both contain the field separators.
parse_pkgfile() {
	local base=${1##*/}
	base=${base%.pkg.tar*}
	local nvr=${base%-*} # drop arch
	local rel=${nvr##*-}
	local nv=${nvr%-*}
	local ver=${nv##*-}
	PKG_NAME=${nv%-*}
	PKG_FULLVER="$ver-$rel"
}

# Returns 0 when at least one non-debug subpackage is missing or out of date.
# -debug packages are excluded from the decision: makepkg lists one for every
# package, but an arch=any package never produces it, so requiring it would
# rebuild ttf-lilex on every single run.
needs_update() {
	local -n _list=$1
	local f outdated=1
	NEEDS_REASON=''
	for f in "${_list[@]}"; do
		[[ -n $f ]] || continue
		parse_pkgfile "$f"
		[[ $PKG_NAME == *-debug ]] && continue
		local installed
		installed=$(pacman -Q "$PKG_NAME" 2>/dev/null | awk '{print $2}') || true
		if [[ -z $installed ]]; then
			NEEDS_REASON="$PKG_NAME not installed (would install $PKG_FULLVER)"
			outdated=0
		elif [[ $(vercmp "$installed" "$PKG_FULLVER") -lt 0 ]]; then
			NEEDS_REASON="$PKG_NAME $installed -> $PKG_FULLVER"
			outdated=0
		fi
	done
	return $outdated
}

process_pkg() {
	local dir=$1 path="$AUR_DIR/$dir"

	info "$dir"

	# --- 1. reset to a pristine checkout -------------------------------
	# makepkg leaves src/, pkg/, downloaded sources and (for VCS packages) a
	# rewritten pkgver behind; left alone, the next run sees a dirty tree and
	# never pulls again. Nothing in $AUR_DIR is meant to hold local edits.
	if ! git -C "$path" reset --hard --quiet || ! git -C "$path" clean -ffdx --quiet; then
		err "$dir: could not clean worktree"
		record failed "$dir"
		return 0
	fi

	# --- 2. pull (informational only; it is not the update trigger) ------
	local prepull
	prepull=$(git -C "$path" rev-parse HEAD)
	if ! git -C "$path" pull --ff-only --quiet; then
		err "$dir: git pull failed"
		record failed "$dir"
		return 0
	fi

	# --- 3. VCS packages: pkgver is a lie until makepkg computes it ------
	if grep -qE '^[[:space:]]*pkgver\(\)' "$path/PKGBUILD"; then
		if ((DRY_RUN)); then
			warn "$dir: VCS package; fetching sources to resolve pkgver (this rewrites its PKGBUILD)"
		else
			say "${DIM}resolving pkgver from sources...${RESET}"
		fi
		if ! (cd "$path" && makepkg -o --nodeps --noconfirm >/dev/null); then
			err "$dir: could not fetch sources to resolve pkgver"
			record failed "$dir"
			return 0
		fi
	fi

	# --- 4. version check ------------------------------------------------
	local list
	if ! list=$(cd "$path" && makepkg --packagelist 2>/dev/null); then
		err "$dir: could not read package list (broken PKGBUILD?)"
		record failed "$dir"
		return 0
	fi
	local pkgfiles=()
	mapfile -t pkgfiles <<<"$list"

	if ! needs_update pkgfiles; then
		say "${GREEN}up to date${RESET}"
		record uptodate "$dir"
		return 0
	fi
	say "$BOLD$NEEDS_REASON$RESET"

	# --- 5. diff since the version currently installed -------------------
	local statefile="$STATE_DIR/$dir" base=$prepull head reviewed=0
	head=$(git -C "$path" rev-parse HEAD)
	if [[ -f $statefile ]]; then
		local saved
		saved=$(<"$statefile")
		if git -C "$path" cat-file -e "$saved^{commit}" 2>/dev/null; then
			base=$saved
			[[ $saved == "$head" ]] && reviewed=1
		fi
	fi

	if ((reviewed)); then
		# The state file proves this exact commit was reviewed and installed
		# before, so a pkgrel-only rebuild needs no second approval.
		say "${DIM}no new commits since the last version installed; rebuilding reviewed code${RESET}"
	else
		if [[ $base == "$head" ]]; then
			# Nothing to diff, and no state file to vouch for this code:
			# the commits landed outside this script. Ask anyway rather
			# than install unreviewed code silently.
			warn "$dir: no reviewable diff (commits predate this script); nothing to show"
		else
			# The whole repo, not just the PKGBUILD: .install scripts and
			# patches run too, and approving only the PKGBUILD is theatre.
			git -C "$path" diff --stat "$base" "$head"
			say ''
			git -C "$path" diff "$base" "$head"
			say ''
		fi
		if ((DRY_RUN)); then
			say "${YELLOW}[dry-run] would prompt to build and install${RESET}"
			record would "$dir"
			return 0
		fi
		local reply
		read -r -p "${BOLD}Build and install ${dir}? [y/N/q]${RESET} " reply
		case "$reply" in
		[yY]) ;;
		[qQ])
			say "aborting"
			ABORT=1
			record declined "$dir"
			return 0
			;;
		*)
			say "skipped"
			record declined "$dir"
			return 0
			;;
		esac
	fi

	if ((DRY_RUN)); then
		say "${YELLOW}[dry-run] would build and install${RESET}"
		record would "$dir"
		return 0
	fi

	# --- 6. build --------------------------------------------------------
	if ! (cd "$path" && makepkg -sfc --noconfirm); then
		err "$dir: build failed (artifacts left in place)"
		record failed "$dir"
		return 0
	fi

	# --- 7. install -------------------------------------------------------
	# Install whatever the build actually produced: -debug files are real for
	# mysql and absent for ttf-lilex, and this tells the two apart.
	local built=() f
	for f in "${pkgfiles[@]}"; do
		[[ -n $f && -f $f ]] && built+=("$f")
	done
	if ((${#built[@]} == 0)); then
		err "$dir: build reported success but produced no packages"
		record failed "$dir"
		return 0
	fi
	if ! sudo pacman -U --needed --noconfirm "${built[@]}"; then
		err "$dir: install failed (artifacts left in place)"
		record failed "$dir"
		return 0
	fi

	# --- 8. record and clean up ------------------------------------------
	# Written only now, so the state file always means "the commit whose build
	# is actually installed on this machine".
	printf '%s\n' "$head" >"$statefile"
	rm -f "${built[@]}"

	say "${GREEN}installed${RESET}"
	record updated "$dir"
	return 0
}

main() {
	mkdir -p "$STATE_DIR"
	((DRY_RUN)) || start_sudo_keepalive

	local dir path
	for path in "$AUR_DIR"/*/; do
		((ABORT)) && break
		dir=${path%/}
		dir=${dir##*/}

		if ((${#FILTERS[@]})); then
			local want=0 f
			for f in "${FILTERS[@]}"; do [[ $f == "$dir" ]] && want=1; done
			((want)) || continue
		fi

		if [[ ! -d $path/.git || ! -f $path/PKGBUILD ]]; then
			record notapkg "$dir"
			continue
		fi

		process_pkg "$dir"
		say ''
	done

	if ((${#FILTERS[@]})); then
		local f found
		for f in "${FILTERS[@]}"; do
			found=0
			for r in "${RESULTS[@]}"; do [[ ${r#*|} == "$f" ]] && found=1; done
			((found)) || warn "no such package directory: $f"
		done
	fi

	# --- summary ----------------------------------------------------------
	local updated=0 would=0 declined=0 failed=0 uptodate=0 skipped=0
	local r status name
	printf '%s\n' "${BOLD}=== summary ===${RESET}"
	for r in "${RESULTS[@]}"; do
		status=${r%%|*}
		name=${r#*|}
		case "$status" in
		updated)
			printf '  %s%-24s installed%s\n' "$GREEN" "$name" "$RESET"
			((++updated))
			;;
		would)
			printf '  %s%-24s would update%s\n' "$YELLOW" "$name" "$RESET"
			((++would))
			;;
		declined)
			printf '  %-24s declined\n' "$name"
			((++declined))
			;;
		failed)
			printf '  %s%-24s FAILED%s\n' "$RED" "$name" "$RESET"
			((++failed))
			;;
		uptodate)
			printf '  %s%-24s up to date%s\n' "$DIM" "$name" "$RESET"
			((++uptodate))
			;;
		notapkg)
			printf '  %s%-24s skipped (no .git/PKGBUILD)%s\n' "$DIM" "$name" "$RESET"
			((++skipped))
			;;
		esac
	done
	printf '%s\n' "${BOLD}${updated} updated, ${would} would update, ${declined} declined, ${failed} failed, ${uptodate} up to date, ${skipped} skipped${RESET}"

	# Declining is a normal outcome, not an error; only real breakage exits hot.
	((failed == 0))
}

main
