#!/bin/bash
# Author: Christophe Casalegno / Brain 0verride
# Contact: brain@christophe-casalegno.com
# sxsync
# Version 1.12
#
# Copyright (c) 2020 Christophe Casalegno
# 
# This program is free software: you can redistribute it and/or modify
#
#    it under the terms of the GNU 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 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/>
#
# The license is available on this server here: 
# https://www.christophe-casalegno.com/licences/gpl-3.0.txt

excludedfile="${EXCLUDE_FILE:-.sxexclude.txt}"
logfile="${SXSYNC_LOG:-$HOME/sxsync.log}"
status_dir="${SXSYNC_STATUS_DIR:-}"
status_enabled="${SXSYNC_STATUS_ENABLED:-1}"
SCRIPT_VERSION="1.12"
dryrun=0
checksum=0
typesync=""
sync_user=""
sync_host=""
sync_source=""
sync_target=""
sshport=""
DRYRUN_ARGS=()
CHECKSUM_ARGS=()
EXTRA_RSYNC_ARGS=()
structured_option_mode=0

function need_arg()
{
	local opt="$1"
	local val="${2:-}"

	if [[ -z "$val" || "$val" == -* ]]
	then
		echo "Error: missing value for $opt" >&2
		exit 1
	fi
}

function need_inline_arg()
{
	local opt="$1"
	local val="$2"

	if [[ -z "$val" ]]
	then
		echo "Error: missing value for $opt" >&2
		exit 1
	fi
}

function usage()
{
	echo "sxsync version $SCRIPT_VERSION"
	echo ""
	echo "sxsync --method METHOD --user USER --host HOST --source SRC --target DST --port SSH_PORT [options]"
	echo ""
	echo "New syntax example:"
	echo "  sxsync --method retrosync --user root --host 192.168.0.1 --source /home/brain/ --target /home/chris/ --port 65022 --dry-run"
	echo ""
	echo "Short syntax example:"
	echo "  sxsync -m retrosync -u root -H 192.168.0.1 -s /home/brain/ -t /home/chris/ -p 65022 -n -c"
	echo ""
	echo "Legacy syntax (still supported):"
	echo "  sxsync retrosync root 192.168.0.1 /home/brain/ /home/chris/ 65022 [dry-run] [checksum]"
	echo ""
	echo "Optional:"
	echo " - By default, sxsync looks for the optional .sxexclude.txt list"
	echo "   It checks the current working directory, then the local sync root"
	echo "   For file syncs, sxsync uses the parent directory of the local file"
	echo " - Set EXCLUDE_FILE=myexcludes.txt to use a custom exclude list"
	echo "   If EXCLUDE_FILE is relative, sxsync also looks under the local sync root"
	echo "   If EXCLUDE_FILE is set explicitly, the file must exist"
	echo "   Legacy exclude.txt is not implicit; use EXCLUDE_FILE=exclude.txt if intentional"
	echo " - Set SXSYNC_LOG=/path/to/file to specify the log file"
	echo " - Set SXSYNC_STATUS_DIR=/path/to/dir to specify status marker directory"
	echo " - Set SXSYNC_STATUS_ENABLED=0 to disable status markers"
	echo " - --dry-run, -n (or legacy 'dry-run'): simulate the sync"
	echo " - --checksum, -c (or legacy 'checksum'): force checksum-based comparison"
	echo " - Synchronization always uses rsync --delete"
	echo " - -- [rsync args...]: pass extra rsync arguments after the sxsync options"
	echo " - --help, -h, help: show this help"
	echo " - --version, -V, -v, version: show script version"
	echo ""
	echo "Exclude file format:"
	echo "
cache/
*.log
node_modules/
"
}

function version()
{
	echo "sxsync version $SCRIPT_VERSION"
}

function init_logfile()
{
	local logdir=""

	if [[ -z "$logfile" ]]
	then
		return
	fi

	logdir="$(dirname "$logfile")"
	if ! mkdir -p "$logdir" 2>/dev/null || ! touch "$logfile" 2>/dev/null
	then
		echo "Warning: cannot write log file ($logfile), logging disabled" >&2
		logfile=""
	fi
}

function init_status_dir()
{
	if [[ "$status_enabled" = "0" || "$status_enabled" = "no" || "$status_enabled" = "false" ]]
	then
		status_enabled=0
		return
	fi

	if [[ -z "$status_dir" ]]
	then
		if [[ "${EUID:-$(id -u)}" -eq 0 ]]
		then
			status_dir="/var/log/sxsync"
		else
			status_dir="${XDG_STATE_HOME:-$HOME/.local/state}/sxsync"
		fi
	fi

	if ! mkdir -p "$status_dir" 2>/dev/null
	then
		echo "Warning: cannot create sxsync status dir ($status_dir), status disabled" >&2
		status_enabled=0
		return
	fi

	chmod 0711 "$status_dir" 2>/dev/null || true
}

function clean_status_value()
{
	printf '%s' "$1" | tr '\r\n\t' '   '
}

function write_status_file()
{
	local file="$1"
	local status="$2"
	local rc="$3"
	local method="$4"
	local src="$5"
	local dst="$6"
	local tmpfile=""
	local now_epoch=""
	local now_iso=""

	[[ "$status_enabled" = "1" ]] || return
	[[ -n "$status_dir" ]] || return

	now_epoch="$(date +%s)"
	now_iso="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
	tmpfile="$(mktemp "$status_dir/.${file}.XXXXXX" 2>/dev/null)" || return

	{
		echo "timestamp:$now_iso"
		echo "epoch:$now_epoch"
		echo "status:$(clean_status_value "$status")"
		echo "rc:$(clean_status_value "$rc")"
		echo "dry_run:$dryrun"
		echo "method:$(clean_status_value "$method")"
		echo "source:$(clean_status_value "$src")"
		echo "target:$(clean_status_value "$dst")"
		echo "peer:$(clean_status_value "$sync_user@$sync_host:$sshport")"
	} > "$tmpfile" || { rm -f "$tmpfile"; return; }

	chmod 0644 "$tmpfile" 2>/dev/null || true
	mv -f "$tmpfile" "$status_dir/$file" 2>/dev/null || rm -f "$tmpfile"
}

function write_sync_status()
{
	local TYPE="$1"
	local SRC="$2"
	local DST="$3"
	local STATUS="$4"
	local RC="$5"

	write_status_file "last-run.status" "$STATUS" "$RC" "$TYPE" "$SRC" "$DST"

	if [[ "$dryrun" -eq 1 ]]
	then
		write_status_file "last-dry-run.status" "$STATUS" "$RC" "$TYPE" "$SRC" "$DST"
		return
	fi

	if [[ "$STATUS" = "OK" ]]
	then
		write_status_file "last-success.status" "$STATUS" "$RC" "$TYPE" "$SRC" "$DST"
	else
		write_status_file "last-error.status" "$STATUS" "$RC" "$TYPE" "$SRC" "$DST"
	fi
}

function log_sync()
{
	local TYPE="$1"
	local SRC="$2"
	local DST="$3"
	local STATUS="$4"
	local RC="${5:-0}"
	local TIMESTAMP=""

	write_sync_status "$TYPE" "$SRC" "$DST" "$STATUS" "$RC"

	[[ -z "$logfile" ]] && return

	TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
	if ! { echo "$TIMESTAMP - $TYPE - $SRC -> $DST - $STATUS $([[ $dryrun -eq 1 ]] && echo "(dry-run)")" >> "$logfile"; } 2>/dev/null
	then
		echo "Warning: cannot append to log file ($logfile), logging disabled" >&2
		logfile=""
	fi
}

function build_excludes()
{
	local line=""
	local local_root=""
	local candidate=""
	local excludes_content=""
	EXCLUDES=()

	if [[ ! -f "$excludedfile" ]]
	then
		if [[ "$typesync" = "antesync" ]]
		then
			local_root="$sync_source"
		elif [[ "$typesync" = "retrosync" ]]
		then
			local_root="$sync_target"
		fi

		if [[ -n "$local_root" && "$excludedfile" != /* ]]
		then
			if [[ -f "$local_root" ]]
			then
				local_root="$(dirname "$local_root")"
			fi
			candidate="${local_root%/}/$excludedfile"
			if [[ -f "$candidate" ]]
			then
				excludedfile="$candidate"
			fi
		fi
	fi

	if [[ ! -f "$excludedfile" ]]
	then
		if [[ -n "${EXCLUDE_FILE:-}" ]]
		then
			echo "Error: configured exclude file not found: $excludedfile" >&2
			exit 1
		fi
		echo "Warning: exclude file not present: $excludedfile" >&2
		return 0
	fi

	# Read the whole list before building a destructive rsync command. A shell
	# read cannot distinguish an ordinary EOF from every underlying read error.
	if ! excludes_content="$(cat -- "$excludedfile")"
	then
		echo "Error: unable to read exclude file: $excludedfile" >&2
		return 1
	fi
	while IFS= read -r line || [[ -n "$line" ]]
	do
		line="${line%$'\r'}"
		[[ "$line" =~ ^[[:space:]]*$ ]] && continue
		[[ "$line" =~ ^[[:space:]]*# ]] && continue
		EXCLUDES+=("--exclude=$line")
	done <<< "$excludes_content"
	return 0
}

function antesync()
{
	local rc=0

	build_excludes || return 1

	if rsync -Harov "${CHECKSUM_ARGS[@]}" "${DRYRUN_ARGS[@]}" "${EXTRA_RSYNC_ARGS[@]}" --stats --progress --delete -e "ssh -p $sshport" "${EXCLUDES[@]}" -- "$sync_source" "$sync_user@$sync_host:$sync_target"
	then
		log_sync "antesync" "$sync_source" "$sync_user@$sync_host:$sync_target" "OK" "0"
	else
		rc=$?
		log_sync "antesync" "$sync_source" "$sync_user@$sync_host:$sync_target" "ERROR" "$rc"
		return "$rc"
	fi

	return 0
}


function retrosync()
{
	local rc=0

	build_excludes || return 1

	if rsync -Harov "${CHECKSUM_ARGS[@]}" "${DRYRUN_ARGS[@]}" "${EXTRA_RSYNC_ARGS[@]}" --stats --progress --delete -e "ssh -p $sshport" "${EXCLUDES[@]}" -- "$sync_user@$sync_host:$sync_source" "$sync_target"
	then
		log_sync "retrosync" "$sync_user@$sync_host:$sync_source" "$sync_target" "OK" "0"
	else
		rc=$?
		log_sync "retrosync" "$sync_user@$sync_host:$sync_source" "$sync_target" "ERROR" "$rc"
		return "$rc"
	fi

	return 0
}


args=()
if [[ $# -eq 0 ]]
then
	usage
	exit 0
fi

while [[ $# -gt 0 ]]
do
	case "$1" in
		--method=*)
			structured_option_mode=1
			need_inline_arg "--method" "${1#*=}"
			typesync="${1#*=}"
			;;
		--method|-m)
			structured_option_mode=1
			need_arg "$1" "${2:-}"
			shift
			typesync="${1:-}"
			;;
		--user=*)
			structured_option_mode=1
			need_inline_arg "--user" "${1#*=}"
			sync_user="${1#*=}"
			;;
		--user|-u)
			structured_option_mode=1
			need_arg "$1" "${2:-}"
			shift
			sync_user="${1:-}"
			;;
		--host=*)
			structured_option_mode=1
			need_inline_arg "--host" "${1#*=}"
			sync_host="${1#*=}"
			;;
		--host|-H)
			structured_option_mode=1
			need_arg "$1" "${2:-}"
			shift
			sync_host="${1:-}"
			;;
		--source=*)
			structured_option_mode=1
			need_inline_arg "--source" "${1#*=}"
			sync_source="${1#*=}"
			;;
		--source|-s)
			structured_option_mode=1
			need_arg "$1" "${2:-}"
			shift
			sync_source="${1:-}"
			;;
		--target=*)
			structured_option_mode=1
			need_inline_arg "--target" "${1#*=}"
			sync_target="${1#*=}"
			;;
		--target|-t)
			structured_option_mode=1
			need_arg "$1" "${2:-}"
			shift
			sync_target="${1:-}"
			;;
		--port=*)
			structured_option_mode=1
			need_inline_arg "--port" "${1#*=}"
			sshport="${1#*=}"
			;;
		--port|-p)
			structured_option_mode=1
			need_arg "$1" "${2:-}"
			shift
			sshport="${1:-}"
			;;
		--dry-run|-n|dry-run)
			[[ "$1" == --dry-run || "$1" == -n ]] && structured_option_mode=1
			dryrun=1
			;;
		--checksum|-c|checksum)
			[[ "$1" == --checksum || "$1" == -c ]] && structured_option_mode=1
			checksum=1
			;;
		--help|-h|help)
			usage
			exit 0
			;;
		--version|-V|-v|version)
			version
			exit 0
			;;
		--)
			shift
			while [[ $# -gt 0 ]]
			do
				EXTRA_RSYNC_ARGS+=("$1")
				shift
			done
			break
			;;
		-*)
			echo "Error: unknown option ($1)"
			usage
			exit 1
			;;
		*)
			args+=("$1")
			;;
	esac
	shift
done

if [[ ${#args[@]} -gt 0 ]]
then
	if [[ $structured_option_mode -eq 1 ]]
	then
		echo "Error: unexpected positional arguments: ${args[*]}" >&2
		exit 1
	fi
	if [[ ${#args[@]} -ne 6 ]]
	then
		echo "Error: invalid legacy arguments count (${#args[@]})"
		usage
		exit 1
	fi
	[[ -z "$typesync" ]] && typesync="${args[0]}"
	[[ -z "$sync_user" ]] && sync_user="${args[1]}"
	[[ -z "$sync_host" ]] && sync_host="${args[2]}"
	[[ -z "$sync_source" ]] && sync_source="${args[3]}"
	[[ -z "$sync_target" ]] && sync_target="${args[4]}"
	[[ -z "$sshport" ]] && sshport="${args[5]}"
fi

if [[ $dryrun -eq 1 ]]
then
	DRYRUN_ARGS=(--dry-run)
	echo "Dry-run mode enabled"
fi

if [[ $checksum -eq 1 ]]
then
	CHECKSUM_ARGS=(--checksum)
	echo "Checksum mode enabled"
fi

if [[ "$typesync" != "antesync" && "$typesync" != "retrosync" ]]
then
	echo "Error: invalid method ($typesync)"
	usage
	exit 1
fi

if [[ -z "$sync_user" || -z "$sync_host" || -z "$sync_source" || -z "$sync_target" || -z "$sshport" ]]
then
	echo "Error: invalid arguments"
	usage
	exit 1
fi

if [[ ! "$sshport" =~ ^[0-9]+$ || "$sshport" -lt 1 || "$sshport" -gt 65535 ]]
then
	echo "Error: invalid ssh port ($sshport)"
	exit 1
fi

init_logfile
init_status_dir
command -v rsync >/dev/null 2>&1 || { echo "Error: rsync is missing in \$PATH" >&2; exit 1; }
command -v ssh >/dev/null 2>&1 || { echo "Error: ssh is missing in \$PATH" >&2; exit 1; }

if [[ "$typesync" = "antesync" ]]
then
	antesync
	exit $?

elif [[ "$typesync" = "retrosync" ]]
then
	retrosync
	exit $?
fi
