#!/bin/sh
# scraplinux-generation - the numbered system configurations scraplinux-rebuild leaves behind
#
#   scraplinux-generation list                 show every generation, newest first
#   scraplinux-generation create [description] record the system as it stands now
#   scraplinux-generation show [id]            what a generation contains
#   scraplinux-generation diff <a> [b]         what changed between two generations
#   scraplinux-generation switch <id>          make the system match a generation
#   scraplinux-generation delete <id>          remove one
#   scraplinux-generation prune [keep]         keep only the newest N (default 10)
#   scraplinux-generation current              print the current generation id
#
# A generation is a snapshot of what the system was declared to be:
# system.conf, the exact package versions, enabled services, and kernel.
# Every scraplinux-rebuild writes one before changing anything, so
# `scraplinux-generation switch N` can always put system.conf back and
# reconcile to it - from the shell or the boot menu.
# shellcheck shell=sh disable=SC2039

set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

GENDIR=/var/lib/scraplinux/generations
CONF=/etc/scraplinux/system.conf

if [ -t 1 ]; then
	R=$(printf '\033[0m'); B=$(printf '\033[1m')
	TEAL=$(printf '\033[38;5;178m'); MINT=$(printf '\033[38;5;79m')
	AMB=$(printf '\033[38;5;215m'); GREY=$(printf '\033[38;5;245m')
	RED=$(printf '\033[38;5;203m')
else
	R= ; B= ; TEAL= ; MINT= ; AMB= ; GREY= ; RED=
fi
note() { printf '%s::%s %s\n' "$TEAL" "$R" "$*"; }
die()  { printf '%sscraplinux-generation:%s %s\n' "$RED" "$R" "$*" >&2; exit 1; }

need_root() { [ "$(id -u)" = 0 ] || die "must be run as root"; }

# ------------------------------------------------------------------- helpers

explicit_pkgs() {
	for d in /var/lib/scraps/local/*/; do
		[ -d "$d" ] || continue
		[ "$(cat "${d}REASON" 2>/dev/null)" = "explicit" ] || continue
		basename "$d"
	done | sort
}

# name<TAB>version-release, so a generation records exactly what was on the
# machine and not just which names were asked for.
explicit_pkgs_versioned() {
	for d in /var/lib/scraps/local/*/; do
		[ -d "$d" ] || continue
		[ "$(cat "${d}REASON" 2>/dev/null)" = "explicit" ] || continue
		n=$(basename "$d")
		v=$(awk -F' *= *' '$1=="version"{print $2}' "${d}PKGINFO" 2>/dev/null)
		r=$(awk -F' *= *' '$1=="release"{print $2}' "${d}PKGINFO" 2>/dev/null)
		printf '%s\t%s-%s\n' "$n" "${v:-?}" "${r:-1}"
	done | sort
}

enabled_services() {
	for f in /etc/dinit.d/boot.d/*; do
		[ -e "$f" ] || continue
		basename "$f"
	done | sort
}

running_kernel() {
	k=$(ls /boot/vmlinuz-* 2>/dev/null | head -1)
	[ -n "$k" ] && basename "$k" || uname -r
}

# Generation ids are plain increasing integers - 1, 2, 3 - so the boot menu
# and `scraplinux rollback 3` can both talk about the same thing.
next_id() {
	max=0
	for d in "$GENDIR"/[0-9]*; do
		[ -d "$d" ] || continue
		n=$(basename "$d")
		case "$n" in *[!0-9]*) continue ;; esac
		[ "$n" -gt "$max" ] && max=$n
	done
	echo $((max + 1))
}

all_ids() {
	for d in "$GENDIR"/[0-9]*; do
		[ -d "$d" ] || continue
		n=$(basename "$d")
		case "$n" in *[!0-9]*) continue ;; esac
		echo "$n"
	done | sort -n
}

current_id() { cat "$GENDIR/current" 2>/dev/null; }

# Generation ids are plain increasing integers (see next_id) - reject anything
# else before it reaches a path, so "switch ../../etc" or "delete .." can't
# walk $GENDIR/$1 outside of $GENDIR.
gen_exists() {
	case "$1" in ''|*[!0-9]*) return 1 ;; esac
	[ -d "$GENDIR/$1" ]
}

# ------------------------------------------------------------------ commands

# Underscore-prefixed variables on purpose - no `local` in POSIX sh, and
# cmd_switch calls this mid-work, so a bare `id`/`d` would overwrite its
# caller's and switch would silently roll back to the wrong generation.
cmd_create() {
	need_root
	_c_desc=${1:-manual}
	_c_id=$(next_id)
	_c_dir="$GENDIR/$_c_id"
	mkdir -p "$_c_dir" || die "cannot create $_c_dir"

	# Not "A && B || C": if $CONF exists but cp itself fails (disk full,
	# permission trouble), that pattern falls through to the empty-file
	# branch too, silently recording an empty system.conf instead of either
	# the real one or a visible error - and a later rollback would restore
	# that empty file over the real config.
	if [ -f "$CONF" ]; then
		cp -f "$CONF" "$_c_dir/system.conf" || die "cannot copy $CONF into $_c_dir"
	else
		: >"$_c_dir/system.conf"
	fi
	explicit_pkgs           >"$_c_dir/packages"
	explicit_pkgs_versioned >"$_c_dir/packages.versioned"
	enabled_services        >"$_c_dir/services"
	running_kernel          >"$_c_dir/kernel"
	date '+%Y-%m-%d %H:%M:%S' >"$_c_dir/date"
	printf '%s\n' "$_c_desc" >"$_c_dir/description"

	echo "$_c_id" >"$GENDIR/current"
	printf '%s\n' "$_c_id"
}

cmd_list() {
	[ -d "$GENDIR" ] || { note "no generations yet"; return 0; }
	cur=$(current_id)
	ids=$(all_ids)
	[ -n "$ids" ] || { note "no generations yet"; return 0; }
	printf '%s%-5s %-20s %-8s %-9s %s%s\n' "$B" "GEN" "DATE" "PACKAGES" "SERVICES" "DESCRIPTION" "$R"
	for id in $(printf '%s\n' "$ids" | sort -rn); do
		d="$GENDIR/$id"
		mark=" "
		[ "$id" = "$cur" ] && mark="*"
		printf '%s%-4s %-20s %-8s %-9s %s\n' \
			"$mark" "$id" \
			"$(cat "$d/date" 2>/dev/null)" \
			"$(wc -l <"$d/packages" 2>/dev/null | tr -d ' ')" \
			"$(wc -l <"$d/services" 2>/dev/null | tr -d ' ')" \
			"$(cat "$d/description" 2>/dev/null)"
	done
	printf '\n%s* = current%s\n' "$GREY" "$R"
}

cmd_show() {
	id=${1:-$(current_id)}
	[ -n "$id" ] || die "no generation given and no current one recorded"
	gen_exists "$id" || die "no such generation: $id"
	d="$GENDIR/$id"
	printf '%sgeneration %s%s\n' "$B" "$id" "$R"
	printf '  date        %s\n' "$(cat "$d/date" 2>/dev/null)"
	printf '  description %s\n' "$(cat "$d/description" 2>/dev/null)"
	printf '  kernel      %s\n' "$(cat "$d/kernel" 2>/dev/null)"
	printf '\n  packages (%s):\n' "$(wc -l <"$d/packages" | tr -d ' ')"
	sed 's/^/    /' "$d/packages" 2>/dev/null
	printf '\n  services (%s):\n' "$(wc -l <"$d/services" | tr -d ' ')"
	sed 's/^/    /' "$d/services" 2>/dev/null
}

cmd_diff() {
	a=${1:?usage: scraplinux-generation diff <a> [b]}
	b=${2:-$(current_id)}
	gen_exists "$a" || die "no such generation: $a"
	gen_exists "$b" || die "no such generation: $b"
	printf '%sgeneration %s -> %s%s\n\n' "$B" "$a" "$b" "$R"

	printf '%spackages%s\n' "$B" "$R"
	# comm needs sorted input, which both files already are.
	comm -13 "$GENDIR/$a/packages" "$GENDIR/$b/packages" | sed "s/^/  ${MINT}+${R} /"
	comm -23 "$GENDIR/$a/packages" "$GENDIR/$b/packages" | sed "s/^/  ${AMB}-${R} /"

	printf '\n%sservices%s\n' "$B" "$R"
	comm -13 "$GENDIR/$a/services" "$GENDIR/$b/services" | sed "s/^/  ${MINT}+${R} /"
	comm -23 "$GENDIR/$a/services" "$GENDIR/$b/services" | sed "s/^/  ${AMB}-${R} /"
	printf '\n'
}

cmd_switch() {
	need_root
	_s_id=${1:?usage: scraplinux-generation switch <id>}
	gen_exists "$_s_id" || die "no such generation: $_s_id"
	_s_dir="$GENDIR/$_s_id"

	# Record where we are first, so switching is itself undoable. Note the
	# underscore-prefixed names above: cmd_create sets its own variables and
	# would otherwise overwrite the target this is trying to switch to.
	if [ "$(current_id)" != "$_s_id" ]; then
		cmd_create "before switching to generation $_s_id" >/dev/null
	fi

	note "switching to generation $_s_id"
	cp -f "$_s_dir/system.conf" "$CONF" || die "could not restore system.conf"

	# scraplinux-rebuild does the actual reconciling - this only decides what
	# the system should be, exactly as if the file had been edited by hand.
	if command -v scraplinux-rebuild >/dev/null 2>&1; then
		SCRAPLINUX_GEN_SWITCH=1 scraplinux-rebuild || \
			die "rebuild failed - system.conf is generation $_s_id, run scraplinux-rebuild to retry"
	fi
	echo "$_s_id" >"$GENDIR/current"
	command -v scraplinux-boot-generations >/dev/null 2>&1 && scraplinux-boot-generations || :
	note "now on generation $_s_id"
}

cmd_delete() {
	need_root
	id=${1:?usage: scraplinux-generation delete <id>}
	gen_exists "$id" || die "no such generation: $id"
	[ "$id" = "$(current_id)" ] && die "refusing to delete the current generation"
	rm -rf "$GENDIR/$id"
	note "deleted generation $id"
	command -v scraplinux-boot-generations >/dev/null 2>&1 && scraplinux-boot-generations || :
}

cmd_prune() {
	need_root
	keep=${1:-10}
	case "$keep" in *[!0-9]*|"") die "keep count must be a number" ;; esac
	cur=$(current_id)
	ids=$(all_ids | sort -rn)
	n=0
	for id in $ids; do
		n=$((n + 1))
		[ "$n" -le "$keep" ] && continue
		[ "$id" = "$cur" ] && continue
		rm -rf "$GENDIR/$id"
		printf '  removed generation %s\n' "$id"
	done
	command -v scraplinux-boot-generations >/dev/null 2>&1 && scraplinux-boot-generations || :
	note "kept the newest $keep"
}

mkdir -p "$GENDIR" 2>/dev/null || :

case "${1:-list}" in
list|ls)          shift 2>/dev/null; cmd_list ;;
create|new)       shift 2>/dev/null; cmd_create "$@" ;;
show|info)        shift 2>/dev/null; cmd_show "$@" ;;
diff)             shift 2>/dev/null; cmd_diff "$@" ;;
switch|rollback)  shift 2>/dev/null; cmd_switch "$@" ;;
delete|rm)        shift 2>/dev/null; cmd_delete "$@" ;;
prune)            shift 2>/dev/null; cmd_prune "$@" ;;
current)          current_id ;;
-h|--help|help)   sed -n '2,20p' "$0" | sed 's/^# \?//' ;;
*) die "unknown command: $1 (try --help)" ;;
esac
