#!/bin/sh

# Exit if an uninitialised variable is read
set -u
# Exit if a pipeline exits non-zero
set -e

VERSION="1.4.2"

if [ "$#" = "1" ] && [ "$1" = "--version" ]; then
    echo "acts version $VERSION"
    exit 0
fi

if [ "$#" = "1" ] && { [ "$1" = "-h" ] || [ "$1" = "--help" ]; }; then
    echo "usage: acts [-c /path/to/acts.conf]"
    echo
    echo "Configuration should be in /etc/acts.conf or /usr/local/etc/acts.conf,"
    echo "or given after the -c option"
    echo "See https://github.com/alexjurkiewicz/acts for more information."
    exit 0
fi

acts_conf= # Make sure it's always defined
if [ "$#" = 2 ] && [ "$1" = "-c" ]; then
    acts_conf=$2
fi

starttime=$(date +%s)

# set these so we can define the log_functions early
verbose=0
syslog=
log_error() {
    echo "Error: $*" >&2
    if [ -n "$syslog" ]; then
        logger -t "acts[$$]" -p "$syslog.warn" "level=error $*"
    fi
    exit 1
}
log_message() {
    if [ "$verbose" -ge 0 ]; then
        echo "Warning: $*" >&2
        if [ -n "$syslog" ]; then
            logger -t "acts[$$]" -p "$syslog.warn" "level=warn $*"
        fi
    fi
}
log_verbose() {
    if [ "$verbose" -ge 1 ]; then
        echo "Info: $*" >&2
        if [ -n "$syslog" ]; then
            logger -t "acts[$$]" -p "$syslog.info" "level=info $*"
        fi
    fi
}
log_debug() {
    if [ "$verbose" -ge 2 ]; then
        echo "Debug: $*" >&2
        if [ -n "$syslog" ]; then
            logger -t "acts[$$]" -p "$syslog.debug" "level=debug $*"
        fi
    fi
}

die() {
    log_error "$* action=exiting"
    exit 1
}

# PART 1: Initialisation
if [ -n "$acts_conf" ]; then
    if [ -f "$acts_conf" ]; then
        # This file isn't expected to exist--shellcheck can ignore it
        # shellcheck source=/dev/null
        . "$acts_conf"
        log_debug "load-config source=$acts_conf"
    else
        die "load-config source=$acts_conf message=\"User-specified config not found\""
    fi
elif [ -f /etc/acts.conf ]; then
    . /etc/acts.conf
    log_debug "load-config source=/etc/acts.conf"
elif [ -f /usr/local/etc/acts.conf ]; then
    . /usr/local/etc/acts.conf
    log_debug "load-config source=/usr/local/etc/acts.conf"
else
    die "load-config-error message=\"No /etc/acts.conf, /usr/local/etc/acts.conf, or -c option\""
fi
# Use default values if anything is not set
verbose="${verbose=0}"
hostname="${hostname=$(hostname -s)}"
uselocaltime="${uselocaltime=0}"
backuptargets="${backuptargets=}"
prebackupscript="${prebackupscript=}"
postbackupscript="${postbackupscript=}"
tarsnap="${tarsnap:=tarsnap}"
tarsnapbackupoptions="${tarsnapbackupoptions=}"
lockfile="${lockfile=/var/run/acts}"
dailybackups="${dailybackups:=31}"
monthlybackups="${monthlybackups:=12}"
yearlybackups="${yearlybackups:=0}"
type calltarsnap >/dev/null 2>&1 || calltarsnap() { $tarsnap "$@"; }
LANG="${LANG=en_US.UTF-8}"
export LANG

# Parameter validation
if [ -z "$backuptargets" ]; then
    die "config-error message=\"\$backuptargets is not set in acts.conf.\""
fi

if [ "$uselocaltime" = 0 ]; then
    utc="-u"
elif [ "$uselocaltime" = 1 ]; then
    utc=""
else
    die "config-error message=\"invalid \$uselocaltime in acts.conf; valid options are 0 (for UTC) or 1 (for local time).\""
fi

[ $((dailybackups)) = "$dailybackups" ]     || die "config-error message=\"Invalid \$dailybackups; must be a number.\""
[ $((monthlybackups)) = "$monthlybackups" ] || die "config-error message=\"Invalid \$monthlybackups; must be a number.\""
[ $((yearlybackups)) = "$yearlybackups" ]   || die "config-error message=\"Invalid \$yearlybackups; must be a number.\""

# Create the lock. mkdir is atomic test/set, so use that instead of the typical test -f/touch combo
if [ -d "$lockfile" ]; then
    die "acts-error message=\"$lockfile exists referencing PID $(cat "$lockfile/pid" 2>/dev/null || echo "<none>"). Hint: If acts is not already running, rm -rf $lockfile.\""
else
    if output=$(mkdir "$lockfile" 2>&1); then
        echo $$ >"$lockfile/pid"
        log_debug "message=\"Created $lockfile/ with 'pid' referencing $$\""
    else
        die "acts-error message=\"Can't create lock $lockfile: $output\""
    fi
fi

trap 'trap - INT TERM EXIT ; echo Exiting unexpectedly. >&2 ; rm -f "$lockfile/pid"; rmdir "$lockfile" ; exit 1' INT TERM EXIT

# prettyprint [command] [string]
# prettyprint archive_dirname /some/dir/path
prettyprint() {
    pp_type="$1"
    pp_str="$2"
    case "$pp_type" in
    archive_dirname)
        # /foo/bar -> '-foobar', / -> ''
        dirname=$(echo "$pp_str" | tr -d '/')
        # Use printf so that non-POSIX echo(1) doesn't think it's a flag
        case "$dirname" in
        '' | '.') echo '' ;;
        *) printf '%s%s' '-' "$dirname" ;;
        esac
        ;;
    log_dirname)
        # foo/bar -> /foo/bar, /foo/bar -> /foo/bar
        case "$pp_str" in
        /*) printf '%s'  "$pp_str" ;;
        *)  printf '/%s' "$pp_str" ;;
        esac
        ;;
    log_output)
        # Make input into one unbroken string
        echo "$pp_str" | tr '\n' " "
        ;;
    *) die "acts-prettyprint-error message=\"Unknown command '$pp_type'\"" ;;
    esac
}

# PART 2: Preparation
log_debug "Listing tarsnap archives before creating backups"
if ! archives_unsorted=$(calltarsnap --list-archives); then
    die "acts-tarsnap-error message=\"Couldn't get list of existing archives (Hint: is Tarsnap configured correctly?)\""
    exit 1
fi
archives=$(echo "$archives_unsorted" | sort -n)

# When is now?
# Instead of re-running date, be paranoid the day was microseconds away from ending
today=$(date $utc "+%Y-%m-%d_%H:%M:%S")
year=$( echo "$today" | cut -d_ -f1 | cut -d- -f1)
month=$(echo "$today" | cut -d_ -f1 | cut -d- -f2)
# day=$(echo "$today" | cut -d_ -f1 | cut -d- -f3) # unused

# Run the pre-backup script
if [ -n "$prebackupscript" ]; then
    if [ -x "$prebackupscript" ]; then
        log_verbose "run-prebackupscript script=$prebackupscript..."
        $prebackupscript
    else
        die "acts-error message=\"prebackupscript $prebackupscript is not executable!\""
    fi
fi

# PART 3: Backup
backuprc=0 # Notice any failed backups
backuplist="" # Maintain list of successfully created archives
for dir in $backuptargets; do
    log_debug "message=\"Starting backup of $dir\""
    archive_starttime=$(date +%s)
    nicedirname=$(prettyprint archive_dirname "$dir")
    logdirname=$(prettyprint log_dirname "$dir")

    dailyarchive="$hostname-daily-$today$nicedirname"
    monthlyarchive="$hostname-monthly-$today$nicedirname"
    yearlyarchive="$hostname-yearly-$today$nicedirname"

    log_verbose "backup-start  type=daily dir=/$dir name=$dailyarchive"
    # Uncontrolled expansion is bad, but we have little choice. See https://github.com/koalaman/shellcheck/wiki/Sc2086
    # shellcheck disable=SC2086
    if output="$(calltarsnap -c -f "$dailyarchive" -C / $tarsnapbackupoptions "$dir" 2>&1)"; then
        backuplist="$backuplist $dailyarchive"
    else
        log_output=$(prettyprint log_output "$output")
        log_message "backup-error type=daily dir=$logdirname output=\"$log_output\""
        backuprc=1
    fi
    archive_endtime=$(date +%s)
    log_verbose "backup-finish type=daily dir=$logdirname duration=$((archive_endtime - archive_starttime))s"

    # Determine whether other backup levels need to be created too
    # If so, copy them from $dailyarchive
    if ! echo "$archives" | grep -E -q "^$hostname-yearly-$year-.+$nicedirname$"; then
        # No yearly backup
        log_debug "message=\"Copying $logdirname daily archive to yearly archive\""
        archive_starttime=$(date +%s)
        # shellcheck disable=SC2086
        if output="$(calltarsnap -c -f "$yearlyarchive" $tarsnapbackupoptions "@@$dailyarchive" 2>&1)"; then
            backuplist="$backuplist $yearlyarchive"
        else
            log_output=$(prettyprint log_output "$output")
            log_message "copy-error  type=yearly output=\"$log_output\""
            backuprc=1
        fi
        archive_endtime=$(date +%s)
        log_verbose "copy-finish type=yearly dir=$logdirname name=$yearlyarchive duration=$((archive_endtime - archive_starttime))s"
    fi

    if ! echo "$archives" | grep -E -q "^$hostname-monthly-$year-$month-.+$nicedirname$"; then
        # No monthly backup
        log_debug "message=\"Copying $logdirname daily archive to monthly archive\""
        archive_starttime=$(date +%s)
        # shellcheck disable=SC2086
        if output="$(calltarsnap -c -f "$monthlyarchive" $tarsnapbackupoptions "@@$dailyarchive" 2>&1)"; then
            backuplist="$backuplist $monthlyarchive"
        else
            log_output=$(prettyprint log_output "$output")
            log_message "copy-error  type=monthly output=\"$log_output\""
            backuprc=1
        fi
        archive_endtime=$(date +%s)
        log_verbose "copy-finish type=monthly dir=$logdirname name=$monthlyarchive duration=$((archive_endtime - archive_starttime))s"
    fi
done
# Update the archive listing
log_debug "Listing tarsnap archives before deleting old backups"
archives=$(calltarsnap --list-archives | sort -n)

# PART 4: Trim old backups.
if [ "$backuprc" != "0" ]; then
    die "acts-tarsnap-error One of the backups failed -- not deleting old backups"
fi

prune_backups() {
    backuplevel=$1 max=$2

    if [ "$max" -gt 0 ]; then
        backupslist=$(echo "$archives" | grep -E "$hostname-$backuplevel-.+$nicedirname$" | sort -rn)
        numberofbackups=$(printf '%s' "$backupslist" | awk 'END{print NR}')
        if [ "$numberofbackups" -gt "$max" ]; then
            log_debug "message=\"More than $max $backuplevel backups, deleting the oldest\""
            echo "$backupslist" | tail -n +"$((max + 1))" | while read -r archiveprefixtodel; do
                log_verbose "message=\"Deleting backup prefix $archiveprefixtodel*\""
                echo "$archives" | grep -E "^$archiveprefixtodel" | while read -r archivetodel; do
                    log_debug "message=\"Deleting backup $archivetodel\""
                    if ! output="$(calltarsnap -d -f "$archivetodel" 2>&1)"; then
                        log_output=$(prettyprint log_output "$output")
                        log_message "delete-error type=$backuplevel output=\"$log_output\""
                    fi
                done
            done
        else
            log_debug "message=\"Found $numberofbackups $backuplevel backups; not deleting any\""
        fi
    else
        log_debug "message=\"Keeping all $backuplevel backups indefinitely; not deleting any\""
    fi
}

for dir in $backuptargets; do
    log_debug "message=\"Checking $dir for old backups to delete\""
    nicedirname=$(prettyprint archive_dirname "$dir")

    prune_backups yearly  $yearlybackups
    prune_backups monthly $monthlybackups
    prune_backups daily   $dailybackups
done

# Run the post-backup script
if [ -n "$postbackupscript" ]; then
    if [ -x "$postbackupscript" ]; then
        log_verbose "run-postbackupscript script=$postbackupscript..."
        # shellcheck disable=SC2086
        $postbackupscript $backuplist
    else
        die "acts-error message=\"postbackupscript $postbackupscript is not executable!\""
    fi
fi

trap - INT TERM EXIT
rm -f "$lockfile/pid"
rmdir "$lockfile"

endtime=$(date +%s)
log_verbose "acts-finished duration=$((endtime - starttime))s action=exiting"
exit 0
