#!/bin/sh
# Every quoted string below is text for another script to run later, so it must
# reach that script literally.
# shellcheck disable=SC2016

# A way to keep this out of the way while building an image or debugging a
# machine, where rewriting the host boot menu is never what is wanted.
[ -e /etc/grub-biglinux-hooks.disabled ] && exit 0

# grub-btrfsd has to watch Timeshift's snapshot directory rather than snapper's.
# That setting used to be patched into the unit under /usr, which belongs to
# another package: every upgrade of it undid the change and left pacman
# reporting a modified file. A drop-in is ours and survives.
mkdir -p /etc/systemd/system/grub-btrfsd.service.d
cat >/etc/systemd/system/grub-btrfsd.service.d/timeshift.conf <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/bin/grub-btrfsd --syslog --timeshift-auto
EOF

# Inside a build chroot there is no init to talk to, and the menu below is
# generated all the same.
if [ -d /run/systemd/system ]; then
	systemctl daemon-reload
	systemctl enable grub-btrfsd.service
	systemctl restart grub-btrfsd.service
fi

# The boot menu is generated by scripts from the grub and grub-btrfs packages,
# which overwrite them on every upgrade. Everything below patches them again,
# matching on text rather than on line numbers, and the hook runs after those
# packages so the menu survives their updates.

# Give every submenu a way back to the main menu.
#
# GRUB leaves a submenu with Escape and nothing on screen says so, so the way
# out has to be an entry of its own. No command closes a single menu level:
# normal_exit needs a nested normal mode, which a submenu is not. What the
# manual does offer is configfile, which "shows a menu containing them
# immediately" - it reopens the main menu instead of closing the submenu.
#
# The gettext call and \$prefix stay unexpanded: they belong to the generator
# and to GRUB itself, and are resolved at update-grub and at boot.
back_entry="menuentry '\$(TEXTDOMAIN=grub-btrfs-timeshift TEXTDOMAINDIR=/usr/share/locale gettext \"Back to the main menu\" | grub_quote)' --class return-main --hotkey=backspace { configfile \\\$prefix/grub.cfg }"

# where is before, after or instead: whether the line is added around the line
# that matches, or replaces it. Matching is on a substring for before and after,
# and on the whole line for instead, which is what rewriting a line needs.
patch_line() {
	file=$1
	where=$2
	needle=$3
	line=$4
	grep -qF "$line" "$file" && return 0
	grep -qF "$needle" "$file" || {
		printf '%s\n' "grub-btrfs-timeshift: nothing matches in $file: $needle" >&2
		return 0
	}
	# grub-mkconfig only runs the generators that are executable, and the
	# rewritten file starts out as a plain new file.
	mode=$(stat -c %a "$file")
	NEEDLE=$needle LINE=$line WHERE=$where awk '
		ENVIRON["WHERE"] == "instead" {
			if ($0 == ENVIRON["NEEDLE"]) print ENVIRON["LINE"]; else print
			next
		}
		index($0, ENVIRON["NEEDLE"]) && ENVIRON["WHERE"] == "before" { print ENVIRON["LINE"] }
		{ print }
		index($0, ENVIRON["NEEDLE"]) && ENVIRON["WHERE"] == "after" { print ENVIRON["LINE"] }
	' "$file" >"$file.patched" &&
		mv "$file.patched" "$file" &&
		chmod "$mode" "$file"
}

# Patching has to start from the generator as its package ships it: an entry
# written by an earlier version of this script would otherwise survive next to
# the new one, and a replaced line can no longer be found to replace again.
# Our own lines all carry the package name, which is what tells the two apart.
#
# Returns non-zero when there is no clean copy to work from, which is the case
# on a machine patched before these copies existed. Skipping is the safe answer:
# the next grub upgrade brings the file back and this starts working again.
pristine=/var/lib/grub-btrfs-timeshift
restore_pristine() {
	saved=$pristine/${1##*/}
	mkdir -p "$pristine"
	if ! grep -q grub-btrfs-timeshift "$1"; then
		cp "$1" "$saved"
		return 0
	fi
	[ -f "$saved" ] || {
		printf '%s\n' "grub-btrfs-timeshift: no clean copy of $1, leaving it alone" >&2
		return 1
	}
	cat "$saved" >"$1"
}

# Advanced options for <OS>, from the grub package. The class is what gives the
# entry its icon: the theme picks the icon by class name.
if restore_pristine /etc/grub.d/10_linux; then
	patch_line /etc/grub.d/10_linux after \
		"gnulinux-advanced-\$boot_device_id'" \
		"    echo \"	$back_entry\""
	patch_line /etc/grub.d/10_linux instead \
		"    echo \"submenu '\$(gettext_printf \"Advanced options for %s\" \"\${OS}\" | grub_quote)' \\\$menuentry_id_option 'gnulinux-advanced-\$boot_device_id' {\"" \
		"    echo \"submenu '\$(gettext_printf \"Advanced options for %s\" \"\${OS}\" | grub_quote)' \\\$menuentry_id_option 'gnulinux-advanced-\$boot_device_id' --class advanced {\""
fi

if restore_pristine /etc/grub.d/41_snapshots-btrfs; then
	# Restore points: the list itself, and the kernel submenu of each snapshot.
	patch_line /etc/grub.d/41_snapshots-btrfs before \
		'if "${bindir}/${GRUB_BTRFS_SCRIPT_CHECK:-grub-script-check}"' \
		"sed -i \"1i$back_entry\" \"\$grub_btrfs_directory/grub-btrfs.new\""
	patch_line /etc/grub.d/41_snapshots-btrfs before \
		"submenu '\${title_submenu}' { echo }\"" \
		"    $back_entry"

	# Icons, again by class: the list in the main menu, each restore point in
	# the list, and the date heading inside a restore point.
	patch_line /etc/grub.d/41_snapshots-btrfs instead \
		"submenu '\${submenuname}' \${protection_authorized_users}\${unrestricted_access_submenu}{" \
		"submenu '\${submenuname}' --class snapshot \${protection_authorized_users}\${unrestricted_access_submenu}{"
	patch_line /etc/grub.d/41_snapshots-btrfs instead \
		'    entry "submenu '"'"'${title_menu}'"'"' {' \
		'    entry "submenu '"'"'${title_menu}'"'"' --class snapshot {'
	patch_line /etc/grub.d/41_snapshots-btrfs instead \
		'    submenu '"'"'${title_submenu}'"'"' { echo }"' \
		'    submenu '"'"'${title_submenu}'"'"' --class calendar { echo }"'

	# grub-btrfs lays the entries out as a table, pipes and all, and tops it
	# with a column header. A boot menu is read by people in a hurry, so the
	# fields become a plain sentence and the header goes away. Each field goes
	# through gettext: the date comes back unchanged, the type comes back
	# translated.
	#
	# The emptiness test is not decoration. grub-btrfs pads every field to the
	# width of its column heading, so a snapshot with no type arrives as
	# spaces, trim leaves nothing, and gettext answers an empty string with the
	# entire catalog header - Project-Id-Version and all - inside the menu
	# title. The -- keeps a field starting with a dash from being read as an
	# option.
	#
	# Timeshift records why it took a snapshot as a single letter in its
	# info.json, and that letter is what reached the menu: a bare "O" next to a
	# date tells nobody anything. The letters become words here, and the words
	# are what the catalogs translate. Anything else - the date column, or a
	# snapshot carrying several tags - passes through untouched.
	# Kept to one line: patch_line detects an already-applied patch with grep
	# -F, and a multi-line needle would match on any single line of itself.
	translated_field='$(field=$(trim "${var}"); case "$field" in O) field="On demand";; B) field="At boot";; H) field="Hourly";; D) field="Daily";; W) field="Weekly";; M) field="Monthly";; N/A) field="";; esac; if [ -n "$field" ]; then TEXTDOMAIN=grub-btrfs-timeshift TEXTDOMAINDIR=/usr/share/locale gettext -- "$field"; fi)'
	patch_line /etc/grub.d/41_snapshots-btrfs instead \
		'    title_menu="|" # "|" is for visuals only' \
		'    title_menu=""'
	patch_line /etc/grub.d/41_snapshots-btrfs instead \
		'    title_submenu="|" # "|" is for visuals only' \
		'    title_submenu=""'
	patch_line /etc/grub.d/41_snapshots-btrfs instead \
		'            title_menu+="${var}|"' \
		"            title_menu+=\"$translated_field   \""
	patch_line /etc/grub.d/41_snapshots-btrfs instead \
		'            title_submenu+=" $(trim "${var}") |"' \
		"            title_submenu+=\"$translated_field   \""
	patch_line /etc/grub.d/41_snapshots-btrfs instead \
		'header_menu' \
		'# header_menu - the column header means nothing without the columns'
fi

# A generator that no longer parses is not an error grub-mkconfig reports: it
# skips the file, and the menu comes out without those entries.
for generator in /etc/grub.d/10_linux /etc/grub.d/41_snapshots-btrfs; do
	[ -f "$pristine/${generator##*/}" ] || continue
	bash -n "$generator" 2>/dev/null && continue
	printf '%s\n' "grub-btrfs-timeshift: $generator broke, restoring it unpatched" >&2
	cat "$pristine/${generator##*/}" >"$generator"
done

# Firmware settings: ours is 42, so it lands after the restore points, and its
# label goes through gettext, which the stock label does not.
if [ -x /etc/grub.d/42_uefi-firmware ] && [ -x /etc/grub.d/30_uefi-firmware ]; then
	chmod -x /etc/grub.d/30_uefi-firmware
fi

# These two settings reach GRUB through the file grub-btrfs sources. They are
# removed and appended rather than substituted in place: a commented-out default
# gives a substitution nothing to match, and the setting then silently vanishes.
#
# The submenu name ends up inside single quotes in grub.cfg, and French and
# Italian translations contain apostrophes, so it needs the same escaping
# grub_quote does. Calling grub_quote is not possible here: grub-btrfs sources
# this config well before it sources grub-mkconfig_lib. The sed below is that
# function's body, spelled out.
#
# The second setting drops the description field, which without the columns
# grub-btrfs draws would read as a bare "N/A".
config=/etc/default/grub-btrfs/config
sed -i '/^GRUB_BTRFS_SUBMENUNAME=/d; /^GRUB_BTRFS_TITLE_FORMAT=/d' "$config"
cat >>"$config" <<'EOF'
GRUB_BTRFS_SUBMENUNAME="$(TEXTDOMAIN=grub-btrfs-timeshift TEXTDOMAINDIR=/usr/share/locale gettext "Restore points (snapshots)" | sed "s/'/'\\\\''/g")"
GRUB_BTRFS_TITLE_FORMAT=("date" "type")
EOF

# grub-mkconfig bakes the translations into grub.cfg, so it needs the language
# of the machine, not the one of whoever happens to be running pacman. LC_ALL
# and LC_MESSAGES both outrank LANG, and makepkg and sudo -i are among the
# things that set them.
language=$(awk -F= '$1 == "LANG" { gsub(/"/, "", $2); print $2; exit }' /etc/locale.conf 2>/dev/null)
LANG=${language:-${LANG:-C}}
unset LC_ALL LC_MESSAGES
export LANG

# The menu is written aside and only takes the place of the working one once it
# proves it can still boot something. Every failure above ends up visible here.
candidate=/boot/grub/grub.cfg.biglinux-new
if ! grub-mkconfig -o "$candidate"; then
	rm -f "$candidate"
	printf '%s\n' "grub-btrfs-timeshift: grub-mkconfig failed, keeping the current menu" >&2
	exit 0
fi

refuse() {
	printf '%s\n' "grub-btrfs-timeshift: $1, keeping the current menu" >&2
	rm -f "$candidate"
	exit 0
}

# A menu that does not parse sends GRUB to its rescue prompt. This catches the
# one way the patches above can produce that: a translated label containing an
# apostrophe, which closes the quote grub-btrfs wrapped it in.
grub-script-check "$candidate" || refuse 'the generated menu does not parse'
grep -qE '^[[:space:]]*(linux|linuxefi|linux16)[[:space:]]' "$candidate" ||
	refuse 'the generated menu has no bootable entry'
# gettext hands back the whole catalog header when it is given an empty string,
# and a snapshot with no description does exactly that. Valid script, unreadable
# menu.
! grep -qF 'Project-Id-Version' "$candidate" ||
	refuse 'the generated menu carries a catalog header'

# grub.cfg can hold password hashes, which is why grub-mkconfig writes it 0600.
# The rename is what keeps a half-written menu from ever being the live one.
chmod 0600 "$candidate"
mv -f "$candidate" /boot/grub/grub.cfg
