#!/bin/bash
#
# bashclub-zsync-timer: systemd timers for bashclub-zsync, one per configuration /etc/bashclub/<name>.conf
# (units bashclub-zsync@<name>.timer / .service; schedule, user and log file as drop-ins)

PATH="/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin"
prog=$(basename "$0")
conf_dir=${ZSYNC_CONF_DIR:-/etc/bashclub}
unit_dir=${ZSYNC_UNIT_DIR:-/etc/systemd/system}
cron_dirs=${ZSYNC_CRON_DIRS:-/etc/crontab /etc/cron.d /var/spool/cron/crontabs /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly}
marker="# bashclub-zsync-timer: migrated to bashclub-zsync@"

usage() {
    cat >&2 << EOF
usage: $prog list
       $prog enable <name> [<OnCalendar>] [--user <user>] [--log <file>]
       $prog disable <name>
       $prog migrate [--dry-run]

    <name>          configuration /etc/bashclub/<name>.conf, timer bashclub-zsync@<name>.timer
    <OnCalendar>    schedule (systemd.time), e.g. daily, hourly, "*-*-* 23:00", "Mon..Fri *-*-* 06..22:00/15"
                    default: keep the current schedule, for a new timer daily
    --user          run as this user instead of root (operation without root, see README)
    --log           append the output to this file instead of the journal
    migrate         take over cron jobs that run bashclub-zsync -c /etc/bashclub/<name>.conf:
                    schedule, user and log file, comment out the cron line, enable the timer
EOF
    exit 1
}

die() { echo "$prog: $*" >&2; exit 1; }

# checks a schedule with systemd-analyze (if available)
valid_calendar() {
    if command -v systemd-analyze > /dev/null; then
        systemd-analyze calendar "$1" > /dev/null 2>&1
    fi
}

#### cron schedule (5 fields or @keyword) -> OnCalendar, empty if it cannot be converted exactly
# fields: minute hour day-of-month month day-of-week; a-b -> a..b, */n -> start/n, lists stay lists
cron_field() {
    local value=$1 start=$2 part out=() from to
    [[ $value == "*" ]] && { echo "*"; return 0; }
    IFS=',' read -ra parts <<< "$value"
    for part in "${parts[@]}"; do
        if [[ $part =~ ^\*/([0-9]+)$ ]]; then out+=("$start/${BASH_REMATCH[1]}")
        elif [[ $part =~ ^([0-9]+)/([0-9]+)$ ]]; then out+=("${BASH_REMATCH[1]}/${BASH_REMATCH[2]}")
        elif [[ $part =~ ^([0-9]+)-([0-9]+)$ ]]; then out+=("${BASH_REMATCH[1]}..${BASH_REMATCH[2]}")
        elif [[ $part =~ ^[0-9]+$ ]]; then out+=("$part")
        else return 1
        fi
    done
    (IFS=','; echo "${out[*]}")
}
cron_dow() {
    local value=${1,,} part out=() names=(sun mon tue wed thu fri sat sun) a b
    [[ $value == "*" ]] && return 0
    IFS=',' read -ra parts <<< "$value"
    for part in "${parts[@]}"; do
        for n in 0 1 2 3 4 5 6 7; do part=${part//${names[$n]}/$n}; done
        if [[ $part =~ ^([0-7])-([0-7])$ ]]; then
            a=${names[${BASH_REMATCH[1]}]^}; b=${names[${BASH_REMATCH[2]}]^}; out+=("$a..$b")
        elif [[ $part =~ ^[0-7]$ ]]; then out+=("${names[$part]^}")
        else return 1
        fi
    done
    (IFS=','; echo "${out[*]}")
}
cron_to_calendar() {
    case $1 in
        # scripts in /etc/cron.{hourly,daily,weekly,monthly}: times of run-parts in the Debian /etc/crontab
        run-parts:hourly) echo "*-*-* *:17:00"; return ;; run-parts:daily) echo "*-*-* 06:25:00"; return ;;
        run-parts:weekly) echo "Sun *-*-* 06:47:00"; return ;; run-parts:monthly) echo "*-*-01 06:52:00"; return ;;
        @hourly) echo hourly; return ;; @daily|@midnight) echo daily; return ;; @weekly) echo weekly; return ;;
        @monthly) echo monthly; return ;; @yearly|@annually) echo yearly; return ;; @*) return 1 ;;
    esac
    local min hour dom mon dow d m h mi
    read -r min hour dom mon dow <<< "$1"
    mi=$(cron_field "$min" 0) && h=$(cron_field "$hour" 0) && d=$(cron_field "$dom" 1) && m=$(cron_field "$mon" 1) \
        && dow=$(cron_dow "$dow") || return 1
    # minutes with two digits for single values (systemd accepts both, the output is easier to read)
    [[ $mi =~ ^[0-9]$ ]] && mi=0$mi
    echo "${dow:+$dow }*-$m-$d $h:$mi:00"
}

#### drop-ins: schedule, user, log file
write_dropins() {
    local name=$1 calendars=$2 user=$3 log=$4 c
    mkdir -p "$unit_dir/bashclub-zsync@$name.timer.d" "$unit_dir/bashclub-zsync@$name.service.d"
    if [[ -n $calendars ]]; then
        {
            echo "# written by $prog"
            echo "[Timer]"
            echo "OnCalendar="
            while read -r c; do [[ -n $c ]] && echo "OnCalendar=$c"; done <<< "$calendars"
        } > "$unit_dir/bashclub-zsync@$name.timer.d/schedule.conf"
    fi
    if [[ -n $user ]]; then
        if [[ $user == root ]]; then
            rm -f "$unit_dir/bashclub-zsync@$name.service.d/user.conf"
        else
            printf '# written by %s\n[Service]\nUser=%s\n' "$prog" "$user" > "$unit_dir/bashclub-zsync@$name.service.d/user.conf"
        fi
    fi
    if [[ -n $log ]]; then
        printf '# written by %s\n[Service]\nStandardOutput=append:%s\nStandardError=inherit\n' "$prog" "$log" \
            > "$unit_dir/bashclub-zsync@$name.service.d/log.conf"
    fi
}

enable_timer() {
    local name=$1
    systemctl daemon-reload
    systemctl enable --now "bashclub-zsync@$name.timer" > /dev/null 2>&1 || die "enabling bashclub-zsync@$name.timer failed"
    echo "bashclub-zsync@$name.timer enabled, next run: $(systemctl show -p NextElapseUSecRealtime --value "bashclub-zsync@$name.timer")"
}

cmd_enable() {
    local name=$1 calendar= user= log=
    shift || usage
    while [ $# -gt 0 ]; do
        case $1 in
            --user) user=$2; shift ;;
            --log) log=$2; shift ;;
            *) calendar=$1 ;;
        esac
        shift
    done
    [[ -f $conf_dir/$name.conf ]] || die "configuration $conf_dir/$name.conf not found"
    if [[ -n $calendar ]] && ! valid_calendar "$calendar"; then die "invalid schedule: $calendar"; fi
    if [[ -n $user ]] && ! id "$user" > /dev/null 2>&1; then die "user $user does not exist"; fi
    write_dropins "$name" "$calendar" "$user" "$log"
    enable_timer "$name"
}

cmd_disable() {
    [[ -n $1 ]] || usage
    systemctl disable --now "bashclub-zsync@$1.timer" > /dev/null 2>&1
    echo "bashclub-zsync@$1.timer disabled (schedule kept in $unit_dir/bashclub-zsync@$1.timer.d/)"
}

cmd_list() {
    local conf name state schedule
    printf '%-28s %-9s %-40s %s\n' CONFIGURATION TIMER SCHEDULE "CRON JOBS"
    for conf in "$conf_dir"/*.conf; do
        [[ -f $conf ]] || continue
        name=$(basename "$conf" .conf)
        state=$(systemctl is-enabled "bashclub-zsync@$name.timer" 2>/dev/null || true)
        schedule=$(systemctl show -p TimersCalendar --value "bashclub-zsync@$name.timer" 2>/dev/null \
            | grep -o 'OnCalendar=[^;]*' | sed 's/OnCalendar=//' | paste -sd',')
        printf '%-28s %-9s %-40s %s\n' "$name" "${state:--}" "${schedule:--}" "$(cron_jobs | awk -F'\t' -v c="$conf" '$4 == c' | grep -vc "^#")"
    done
}

#### cron jobs that run bashclub-zsync: file<TAB>line number<TAB>schedule<TAB>config<TAB>user<TAB>log file
cron_jobs() {
    local file user n line rest schedule conf log
    for file in $(find $cron_dirs -maxdepth 1 -type f 2>/dev/null); do
        n=0
        while IFS= read -r line || [[ -n $line ]]; do
            ((n++))
            [[ $line =~ ^[[:space:]]*# ]] && continue
            [[ $line == *bashclub-zsync* ]] || continue
            [[ $line =~ -c[[:space:]]+([^[:space:]]+) ]] || continue
            conf=${BASH_REMATCH[1]}
            if [[ $file =~ /cron\.(hourly|daily|weekly|monthly)/ ]]; then
                schedule=run-parts:${BASH_REMATCH[1]}; rest=$line
            elif [[ $line =~ ^[[:space:]]*(@[a-z]+)[[:space:]]+(.*)$ ]]; then
                schedule=${BASH_REMATCH[1]}; rest=${BASH_REMATCH[2]}
            elif [[ $line =~ ^[[:space:]]*([^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+)[[:space:]]+(.*)$ ]]; then
                schedule=${BASH_REMATCH[1]}; rest=${BASH_REMATCH[2]}
            else
                continue
            fi
            # system crontabs have a user field, user crontabs are named after the user
            if [[ $schedule == run-parts:* ]]; then user=root
            elif [[ $file == /etc/crontab || $file == */cron.d/* ]]; then user=${rest%%[[:space:]]*}
            else user=$(basename "$file"); fi
            log=
            if [[ $line =~ \>\>?[[:space:]]*(/[^[:space:]]+) ]]; then log=${BASH_REMATCH[1]}; fi
            printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$file" "$n" "$schedule" "$conf" "$user" "$log"
        done < "$file"
    done
}

cmd_migrate() {
    local dry=0 file n schedule conf user log name calendar todo=() names=()
    [[ $1 == --dry-run ]] && dry=1
    while IFS=$'\t' read -r file n schedule conf user log; do
        name=$(basename "$conf" .conf)
        if [[ $(dirname "$conf") != "$conf_dir" ]]; then
            echo "skip  $file:$n: $conf is not in $conf_dir (the timer needs $conf_dir/<name>.conf)"; continue
        fi
        if ! calendar=$(cron_to_calendar "$schedule") || ! valid_calendar "$calendar"; then
            echo "skip  $file:$n: schedule '$schedule' cannot be converted, create the timer manually: $prog enable $name <OnCalendar>"; continue
        fi
        echo "$([ $dry -eq 1 ] && echo plan || echo do)  $file:$n -> bashclub-zsync@$name.timer OnCalendar=$calendar user=$user${log:+ log=$log}"
        todo+=("$file"$'\t'"$n"$'\t'"$name"$'\t'"$calendar"$'\t'"$user"$'\t'"$log")
    done < <(cron_jobs)
    if [ ${#todo[@]} -eq 0 ]; then echo "no cron jobs of bashclub-zsync to migrate"; return 0; fi
    [ $dry -eq 1 ] && { echo "dry run, nothing changed"; return 0; }

    # one timer per configuration, several cron lines become several OnCalendar entries
    local entry calendars
    for entry in "${todo[@]}"; do
        IFS=$'\t' read -r file n name calendar user log <<< "$entry"
        [[ " ${names[*]} " == *" $name "* ]] || names+=("$name")
    done
    for name in "${names[@]}"; do
        calendars=$(printf '%s\n' "${todo[@]}" | awk -F'\t' -v n="$name" '$3 == n { print $4 }')
        entry=$(printf '%s\n' "${todo[@]}" | awk -F'\t' -v n="$name" '$3 == n' | head -1)
        IFS=$'\t' read -r file n name calendar user log <<< "$entry"
        write_dropins "$name" "$calendars" "$user" "$log"
    done
    # comment out the cron lines (from the end, line numbers stay valid)
    for entry in $(printf '%s\n' "${todo[@]}" | sort -t$'\t' -k1,1 -k2,2nr | tr '\t' '|'); do
        IFS='|' read -r file n name calendar user log <<< "$entry"
        sed -i "${n}s|^|$marker$name.timer: |" "$file"
    done
    for name in "${names[@]}"; do enable_timer "$name"; done
}

case $1 in
    list) cmd_list ;;
    enable) shift; cmd_enable "$@" ;;
    disable) shift; cmd_disable "$@" ;;
    migrate) shift; cmd_migrate "$@" ;;
    *) usage ;;
esac
