#!/bin/sh
# Copyright (C) 2015  Etersoft
# Copyright (C) 2015  Vitaly Lipatov <lav@etersoft.ru>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

export LANG=C

DESCR="etertimemachine version 0.3 (c) Etersoft 2015"

# last
# -1, -2, -3
# date
# date time
# записывать выбранный способ

LAST="last"
NUMPREFIX=e

# realpath workaround
if ! which realpath 2>/dev/null >/dev/null ; then
realpath()
{
	readlink -f "$@"
}
fi

print_message()
{
	local DESC="$1"
	shift
	echo "$DESC in $(basename "$0"): $*"
}

# Print error message and stop the program
fatal()
{
	print_message Error "$@" >&2
	exit 1
}

assert_var()
{
	local i re

	for i in $@ ; do
		re=$(eval echo \$$i)
		[ -n "$re" ] || fatal "assert: $i is not exists"
	done
}


##


set_status()
{
	mkdir -p "$ROTATEDIR"
	if [ -z "$1" ] ; then
		rm "$ROTATEDIR/status" 2>/dev/null
		return
	fi
	echo "$1" >"$ROTATEDIR/status" || fatal "Can't set status"
}

get_status()
{
	[ -s "$ROTATEDIR/status" ] || return 0
	cat "$ROTATEDIR/status" || fatal "Can't read status"
}

# do not run two etertimemachine on the same dir at once
# (so the status left in the dir means the previous run was interrupted)
lock_rotatedir()
{
	mkdir -p "$ROTATEDIR" || fatal "Can't create $ROTATEDIR"
	exec 9>"$ROTATEDIR/.lock" || fatal "Can't create $ROTATEDIR/.lock"
	flock -n 9 || fatal "Another etertimemachine is working with $ROTATEDIR"
}

# finish or roll back the rotating interrupted at status "rotate"
recover_rotate()
{
	local tmp mark
	echo "Recover interrupted rotating in $ROTATEDIR ..."
	for tmp in "$ROTATEDIR"/*.tmp ; do
		[ -d "$tmp" ] || continue
		mark="${tmp%.tmp}"
		if [ -d "$ROTATEDIR/$LAST" ] && [ ! -e "$mark" ] ; then
			# copying with hard links was interrupted
			rm -rf -- "$tmp" || fatal "Can't remove $tmp"
		elif [ ! -e "$ROTATEDIR/$LAST" ] && [ -d "$mark" ] ; then
			# $LAST is already moved to $mark
			mv -- "$tmp" "$ROTATEDIR/$LAST" || fatal "Can't mv to $ROTATEDIR/$LAST"
		else
			fatal "Can't recover interrupted rotating with $tmp, fix it by hand and remove $ROTATEDIR/status"
		fi
	done
	[ -d "$ROTATEDIR/$LAST" ] || fatal "Can't recover interrupted rotating: there is no $ROTATEDIR/$LAST"
	set_status ""
}

next_number()
{
	assert_var ROTATEDIR
	local OLDNUM="$(ls -d -v -1 "$ROTATEDIR/$NUMPREFIX"* 2>/dev/null | tail -n1)"
	OLDNUM=$(basename "$OLDNUM" | sed -e "s|$NUMPREFIX||g")

	if [ -z "$OLDNUM" ] || [ "$OLDNUM" = "0" ] ; then
		echo "${NUMPREFIX}1"
	else
		local NUM=$(($OLDNUM + 1))
		[ "$NUM" = "1" ] && fatal "Can't detect number"
		echo "$NUMPREFIX$NUM"
	fi
}

get_mark()
{
	case "$1" in
		date)
			date --rfc-3339=date
			;;
		datetime|ns)
			date --rfc-3339=ns
			;;
		number)
			next_number
			;;
		*)
			fatal "Unknown method $METHOD"
	esac
}

# TODO: move to eterremove
thin_dir()
{
	# definitely we need $LAST is exists
	[ -d "$ROTATEDIR/$LAST" ] || return

	# deleting only removes dirs, so the interrupted one is just run again
	local STATUS="$(get_status)"
	[ -n "$STATUS" ] && [ "$STATUS" != "delete" ] && fatal "Previous $STATUS process was interrupted, run update or rotate to finish it"

	cd "$ROTATEDIR" || fatal
	set_status "delete"

	# "%Y-%m-%d"
	case "$THINNER" in
		log2)
			epm assure log2rotate pylog2rotate || fatal
			# TODO: check --fuzz
			OPTIONS=
			[ -n "$FORCE" ] && OPTIONS="--unsafe"
			DIRS=$(ls -1 -d -- ????-??-?? | sort | a='' log2rotate --delete $OPTIONS)
			;;
		last*)
			# keep N last dirs
			DIRS=$(ls -1 -d -- ????-??-?? 2>/dev/null | sort | head -n -"${THINNER#last}")
			;;
	esac
	echo "Follow dirs may be deleted:"
	if [ -n "$DEBUG" ] ; then
		echo
		echo "Debug mode, follow dirs may be deleted:"
		echo "$DIRS"
	else
		for i in $DIRS; do
			echo "Deleting $i ..."
			rm -rf -- "$i"
		done
	fi

	set_status ""
	cd - >/dev/null || exit
	echo "Done."

}

rotate_dir()
{
	# rotate only we have $LAST already
	[ -d "$ROTATEDIR/$LAST" ] || [ "$(get_status)" = "rotate" ] || return

	case "$(get_status)" in
		"")
			;;
		rotate)
			recover_rotate
			;;
		sync)
			fatal "Previous sync process was interrupted, run update to finish it"
			;;
		*)
			fatal "Previous $(get_status) process was interrupted, run $(get_status) to finish it"
			;;
	esac

	MARK=$(get_mark $METHOD)
	[ -n "$MARK" ] || fatal "Can't get dir name for rotating"

	# real rotate
	[ -d "$ROTATEDIR/$MARK.tmp" ] && fatal "$ROTATEDIR/$MARK.tmp already exists"

	# if $MARK already exists, we run too often
	if [ -d "$ROTATEDIR/$MARK" ] ; then
		if [ -n "$FORCE" ] ; then
			echo "Skip unneeded rotating to $MARK due --force..."
			return
		fi

		fatal "$ROTATEDIR/$MARK already exists. You run rotate too aften"
	fi

	echo "Rotating $ROTATEDIR/$LAST..."
	set_status "rotate"
	cp -al "$ROTATEDIR/$LAST" "$ROTATEDIR/$MARK.tmp" || fatal "Can't copy with hard links to $ROTATEDIR/$MARK.tmp"

	mv "$ROTATEDIR/$LAST" "$ROTATEDIR/$MARK" || fatal "Can't mv to $ROTATEDIR/$MARK"
	mv "$ROTATEDIR/$MARK.tmp" "$ROTATEDIR/$LAST" || fatal "Can't mv to $ROTATEDIR/$LAST"
	# here we have $MARK and $LAST dir with the same contents
	set_status ""
	echo "Done."
}

update_dir()
{
	local STATUS="$(get_status)"
	[ "$STATUS" = "delete" ] && fatal "Previous delete process was interrupted, run delete to finish it"

	# the interrupted sync is just continued
	# if rotate_dir will fail, we will exit via fatal
	[ "$STATUS" != "sync" ] && rotate_dir

	local RSYNC_OPTS=
	local CP_OPTS=
	if [ -n "$MAKELINK" ] ; then
		RSYNC_OPTS="--link-dest $ROTATEDIR/$LAST"
		CP_OPTS="-l"
	fi

	set_status "sync"
	if [ -d "$ROTATEDIR/$LAST" ] ; then
		echo "Syncing $BACKUPDIR to $ROTATEDIR/$LAST..."
		# Note: do not use --inplace, files in $LAST are hard links to the rotated snapshot
		# Note: do not use --safe-links, symlinks pointing outside the tree are copied as is (like cp -a does)
		rsync -av --partial --progress $RSYNC_OPTS --hard-links --delete-after "$BACKUPDIR/" "$ROTATEDIR/$LAST/" || fatal "Can't sync. Try again this command."
	else
		echo "Copying for first time..."
		cp -a $CP_OPTS "$BACKUPDIR/" "$ROTATEDIR/$LAST/" || fatal "Can't copy. Try again this command."
	fi
	set_status ""
	echo "Done."
}



COMMAND=$1
shift

METHOD="date"
if [ "$1" = "--method" ] ; then
	shift
	METHOD=$1
	case "$METHOD" in
		date|datetime|ns|number)
			;;
		*)
			fatal "Unknown method $METHOD"
	esac
	shift
fi

FORCE=
if [ "$1" = "--force" ] ; then
	shift
	FORCE=1
fi

MAKELINK=
if [ "$1" = "--link" ] ; then
	shift
	MAKELINK=1
fi

THINNER=
if [ "$1" = "--thinner" ] ; then
	shift
	THINNER=$1
	shift
fi

DEBUG=
if [ "$1" = "--debug" ] ; then
	shift
	DEBUG=1
fi


case $COMMAND in
	update)
		# from
		BACKUPDIR=$(realpath -e "$1") || exit

		# to
		ROTATEDIR=$(realpath -m "$2")
		lock_rotatedir
		update_dir
	;;
	rotate)
		[ -d "$1" ] || fatal "Run with exists dir for rotate"
		ROTATEDIR=$(realpath -e "$1") || exit
		lock_rotatedir
		[ "$(get_status)" = "rotate" ] && recover_rotate

		# for first time just create target
		if [ ! -d "$ROTATEDIR/$LAST" ] ; then
			mkdir -p "$ROTATEDIR/$LAST" || fatal "Can't create $ROTATEDIR/$LAST"
			exit
		fi

		rotate_dir
	;;
	delete)
		[ -d "$1" ] || fatal "Run with exists dir for rotate"
		ROTATEDIR=$(realpath -e "$1") || exit

		[ "$METHOD" = "date" ] || fatal "Allowed method for thin is only 'date'"
		case "$THINNER" in
			log2)
				;;
			last[1-9]|last[1-9]*[0-9])
				[ -z "$(echo "${THINNER#last}" | tr -d '0-9')" ] || fatal "Use --thinner lastN, where N is a number"
				;;
			*)
				fatal "Allowed thin algorithms for delete are 'log2' and 'lastN'"
				;;
		esac
		lock_rotatedir

		thin_dir
	;;
	-h|--help)
		echo $DESCR
		echo "Run with $0 command [options] args"

		echo
		echo "Copy/Update:"
		echo "	$ etertimemachine update /path/from /path/to"

		echo
		echo "Rotate:"
		echo "	$ etertimemachine rotate /path/to"

		echo
		echo "Delete:"
		echo "	$ etertimemachine delete --method date --thinner log2 /path/to"
		echo "	$ etertimemachine delete --method date --thinner last30 /path/to"

		echo
		echo "Options:"
		echo "	--method date|datetime|number   name convention for rotate dirs (date by default)"
		echo "	--force                         sync target even if we already rotated it this time"
		echo "	--thinner log2|lastN            method for deletion (log2 thinning or keep N last dated dirs)"
		echo "	--link                          create hard links instead of copying files to the timemachine dir"
		echo "	--debug"
	;;
	*)
		echo "$DESCR" >&2
		echo "Run with -h or --help for help" >&2
		exit 1
	;;
esac

