#!/bin/bash
#
# firefly-update - update a Firefly III instance on Debian.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <https://www.gnu.org/licenses/>.

set -euo pipefail
# Loud failure: without this the script died silently mid-update and left a
# half-built dir that looked like success. Now it names the failing line.
trap 'echo "FAILED at line $LINENO (last command exited $?)" >&2' ERR

WORKDIR=${WORKDIR:-/var/www}
# Directory name of the live instance under $WORKDIR. Override with INSTANCE=...
INSTANCE=${INSTANCE:-piggy}

LIVE="${WORKDIR}/${INSTANCE}"
OLD="${WORKDIR}/${INSTANCE}-old"
UPDATED="${WORKDIR}/${INSTANCE}-updated"
BACKUPDIR=${BACKUPDIR:-${WORKDIR}/firefly-backups}

usage() {
	cat <<EOF
usage: $0 [-v|--version <tag>] [--restore] [-h|--help]

Update the Firefly III instance \$INSTANCE (default piggy) in \$WORKDIR
(default /var/www).

  -v, --version <tag>   Install this release tag instead of autodiscovering
                        the latest (e.g. -v 6.1.0).
  --restore             Roll back: swap $OLD back to live and overlay the
                        newest DB backup from $BACKUPDIR. Runs instead of
                        an update.
  -h, --help            Show this help and exit.
EOF
}

restore() {
	[ -d "$OLD" ] || { echo "no backup files at $OLD to restore from" >&2; exit 1; }
	# Backup names are database-YYYYMMDD-HHMMSS.sqlite, so lexical sort == time
	# order; pick the last. Glob avoids parsing ls output (breaks on aliases).
	shopt -s nullglob
	backups=("${BACKUPDIR}"/database-*.sqlite)
	shopt -u nullglob
	[ ${#backups[@]} -gt 0 ] || { echo "no DB backup found in $BACKUPDIR" >&2; exit 1; }
	newdb=$(printf '%s\n' "${backups[@]}" | sort | tail -n1)

	echo "restoring files from $OLD and database from $newdb"
	cd "$WORKDIR" || { echo "cannot cd to $WORKDIR" >&2; exit 1; }
	rm -rf "${LIVE}-broken"
	mv "$LIVE" "${LIVE}-broken"
	mv "$OLD" "$LIVE"
	cp "$newdb" "${LIVE}/storage/database/database.sqlite"

	chown -R www-data:www-data "$LIVE"
	chmod -R 775 "${LIVE}/storage"
	service apache2 restart
	echo "restored. broken version saved at ${LIVE}-broken (remove once verified)"
}

# Optional -v/--version <tag> overrides autodiscovery of the latest release.
latestversion=""
dorestore=""
while [ $# -gt 0 ]; do
	case "$1" in
		-v|--version)
			latestversion="${2:-}"
			[ -n "$latestversion" ] || { echo "$1 requires a value" >&2; exit 1; }
			shift 2
			;;
		--version=*)
			latestversion="${1#*=}"
			shift
			;;
		--restore)
			dorestore=1
			shift
			;;
		-h|--help)
			usage
			exit 0
			;;
		*)
			echo "unknown argument: $1" >&2
			usage >&2
			exit 1
			;;
	esac
done

if [ -n "$dorestore" ]; then
	restore
	exit 0
fi

for dep in curl unzip php; do
	command -v "$dep" >/dev/null || { echo "required command not found: $dep" >&2; exit 1; }
done

# Modify next line to where your firefly-iii instance is installed to.
cd "$WORKDIR" || { echo "cannot cd to $WORKDIR" >&2; exit 1; }

# Timestamped DB backup before anything touches the live instance. Survives the
# next run (unlike $OLD, which is removed below). Override dir with BACKUPDIR=...
mkdir -p "$BACKUPDIR"
backup="${BACKUPDIR}/database-$(date +%Y%m%d-%H%M%S).sqlite"
cp "${LIVE}/storage/database/database.sqlite" "$backup"
echo "backed up database to $backup"

# Remove old version of firefly-iii from a previous run.
rm -rf "$OLD"

# Get latest version of firefly unless --version was given.
if [ -z "$latestversion" ]; then
	latestversion=$(curl -s https://api.github.com/repos/firefly-iii/firefly-iii/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")')
	if [ -z "$latestversion" ]; then
		echo "could not determine latest firefly-iii version (curl failed or rate-limited)" >&2
		exit 1
	fi
fi
# Release tags carry a leading "v" (e.g. v6.6.5). Normalize so both -v 6.6.5
# and -v v6.6.5 work, and so the autodiscovered tag matches the asset name.
case "$latestversion" in v*) tag="$latestversion" ;; *) tag="v$latestversion" ;; esac
echo "installing firefly-iii $tag"

# Install from the official release zip, NOT composer create-project. The
# composer/Packagist dist ships source only: it lacks the webpack-compiled
# frontend bundles (public/v1/js/app.js etc.), which breaks the UI (no graphs,
# broken pages). The release zip is prebuilt and is the artifact Firefly's
# install docs point at. Asset name is FireflyIII-<tag>.zip, tag incl. the "v".
rm -rf "$UPDATED"
mkdir -p "$UPDATED"
zip="${WORKDIR}/firefly-${tag}.zip"
base="https://github.com/firefly-iii/firefly-iii/releases/download/${tag}/FireflyIII-${tag}.zip"
echo "downloading $base"
curl -fL -o "$zip" "$base"

# Verify the archive against the release SHA256 if available and sha256sum is
# present. A truncated/corrupt download otherwise extracts garbage.
if command -v sha256sum >/dev/null && curl -fLs -o "${zip}.sha256" "${base}.sha256"; then
	# The published file is "<hash>  FireflyIII-<tag>.zip"; check against our path.
	expected=$(cut -d' ' -f1 "${zip}.sha256")
	actual=$(sha256sum "$zip" | cut -d' ' -f1)
	rm -f "${zip}.sha256"
	if [ "$expected" != "$actual" ]; then
		echo "checksum mismatch: expected $expected, got $actual" >&2
		rm -f "$zip"
		exit 1
	fi
	echo "checksum OK"
fi

# Extract the full archive, storage skeleton included. That skeleton provides
# storage/framework/{cache,views,sessions}, which Laravel needs to boot; without
# them artisan dies with a misleading "Target class [auth] does not exist". Live
# user data (uploads, exports, DB) is overlaid on top below.
unzip -q "$zip" -d "$UPDATED"
rm -f "$zip"

# Validate the extracted tree before proceeding. A partial extract must not be
# allowed to replace the live install. $OLD does not exist yet at this point,
# so failing here leaves the live instance untouched (ERR trap + set -e).
for item in artisan bootstrap vendor public/v1/js/app.js; do
	[ -e "${UPDATED}/${item}" ] || { echo "extracted install incomplete, missing: $item" >&2; exit 1; }
done

# Carry over config and user data, overlaying onto the extracted skeleton.
# cp -a of dir/. copies contents incl. dotfiles and does not fail on an empty
# source. mkdir -p is belt-and-suspenders should a future zip omit a dir.
cp "${LIVE}/.env" "${UPDATED}/.env"
mkdir -p "${UPDATED}/storage/upload" "${UPDATED}/storage/export" "${UPDATED}/storage/database"
cp -a "${LIVE}/storage/upload/." "${UPDATED}/storage/upload/"
cp -a "${LIVE}/storage/export/." "${UPDATED}/storage/export/"
# SQLite DB is a file; copy it so migrate runs on real data, not the fresh
# empty database.sqlite the new install ships with.
cp "${LIVE}/storage/database/database.sqlite" "${UPDATED}/storage/database/database.sqlite"

cd "$UPDATED" || { echo "cannot cd to $UPDATED" >&2; exit 1; }
rm -rf bootstrap/cache/*
php artisan cache:clear

# Reconcile Passport OAuth migrations. Passport renames its migration files
# between versions (e.g. 2018_..._create_oauth_auth_codes -> 2016_...), so a
# carried-over DB has the TABLES but the new filenames look "pending", and
# migrate then tries CREATE TABLE on an existing table and aborts. For every
# oauth migration whose table already exists but whose filename isn't recorded,
# insert a migrations row so migrate skips it. Missing tables are left for
# migrate to create normally. Requires sqlite3.
db="${UPDATED}/storage/database/database.sqlite"
if command -v sqlite3 >/dev/null; then
	batch=$(( $(sqlite3 "$db" "SELECT COALESCE(MAX(batch),0) FROM migrations;") + 1 ))
	for f in database/migrations/*oauth*; do
		[ -e "$f" ] || continue
		name=$(basename "$f" .php)
		tbl=$(grep -oP "Schema::create\('\K[^']+" "$f" | head -1)
		[ -n "$tbl" ] || continue
		[ -n "$(sqlite3 "$db" "SELECT 1 FROM migrations WHERE migration='$name';")" ] && continue
		if [ -n "$(sqlite3 "$db" "SELECT 1 FROM sqlite_master WHERE type='table' AND name='$tbl';")" ]; then
			sqlite3 "$db" "INSERT INTO migrations (migration,batch) VALUES ('$name',$batch);"
			echo "reconciled existing oauth table: $tbl ($name)"
		fi
	done
else
	echo "warning: sqlite3 not found, skipping oauth migration reconcile" >&2
fi

# Upgrade sequence per the official self-managed upgrade docs, with --force
# (non-interactive) and the oauth reconcile above. laravel-passport-keys is the
# Firefly-specific passport step, not the generic passport:install.
php artisan migrate --force --seed
php artisan cache:clear
php artisan view:clear
php artisan firefly-iii:upgrade-database
php artisan firefly-iii:laravel-passport-keys

# Swap next version in. set -e above aborts before this if any step failed,
# so a broken build never replaces the live install.
cd "$WORKDIR"
mv "$LIVE" "$OLD"
mv "$UPDATED" "$LIVE"

cd "$LIVE" || { echo "cannot cd to $LIVE" >&2; exit 1; }
php artisan cache:clear

# Fix rights, restart apache2.
chown -R www-data:www-data "$LIVE"
chmod -R 775 "${LIVE}/storage"
service apache2 restart
