#!/bin/bash
# Delbackups bash script -- Delete recursive backups by his label
# Based on name format like: "rootDir-%Y-%m-%d_%H:%M"
# note that separators `-`, `:` or `_` could by replaced by any non number
# character:  "rootDir-%Y-%m-%d %Hh%M", "rootDir_%Y.%m.%d %H-%M"
# (C) 2000-2024 F-Hauri - http://www.f-hauri.ch
# Version: 0.1.12 -- Last update: Thu Feb 27 14:52:56 CET 2025
# Licensed under terms of LGPL v3. www.gnu.org
# Goal of this is to keep N backup by kind of consideration:

# shellcheck disable=SC2059 # No vars in printf... dtFmt, fmt[ltr], bkNamFmt
shopt -s extglob

nWeeks=15 nMonths=18 nDays=12 nHalfHours=72 nYears=30

base="base" root="." QUIET=false statbackups=false bkNamFmt='' suf='' slPath=''

usage() {
    cat <<-EOF
	Usage: ${0##*/} [OPTIONS] [/path/to/root] [baseDir]
	    -Y Int	Number of Years to keep [$nYears]
	    -M Int	Number of Months to keep [$nMonths]
	    -W Int	Number of Weeks to keep [$nWeeks]
	    -D Int	Number of Days to keep [$nDays]
	    -H Int	Number of Half hours to keep [$nHalfHours]
	    -q 	     	Quiet (disabled by -s)
	    -s		Disk usage Stats (\`du\` could take lot of time) implie ! -q
	    -r Path	Path to root of backups [$root]
	    -b Dir	Base dir name [$base]
	    -h		show This
	    -x		suffiX or eXtension [$suf]
	    -l Path	Create symlinks into Path (will be destroyed)
	    -R		Show sample Rsync script
	    -[y|n]	Answer for ultime question: Do delete unwanted backups?
	Note: [-r] and [-b] switches are ignored if submited as 1st and 2nd arguments.
	EOF
}
readCmdLine(){
    local opt OPTARG OPTIND
    local -A 'iStrs=([Y]=nYears [W]=nWeeks [M]=nMonths [D]=nDays
		[H]=nHalfHours [l]=slPath [r]=root [b]=base [x]=suf )'
    while getopts "ynqshRY:M:W:D:H:r:b:x:l:" opt; do
        case $opt in
            [YMWDHrbxl] )
		# shellcheck disable=SC2154 # Referenced but not assigned.
		printf -v "${iStrs["$opt"]}" %s "$OPTARG" ;;
	    q ) QUIET=true ;;
	    s ) statbackups=true;QUIET=false ;;
            h ) usage;exit 0 ;;
            R ) declare -f backupSample;exit 0 ;;
	    [yn] ) [[ -v doDelete ]] && die '-y and -n are exclusive!'
		   doDelete=$opt;;
        esac
    done
    shift $((OPTIND-1))
    [[ $1 ]] && [[ -d $1 ]] && root=$1
    [[ $2 ]] && [[ -d $root/$2$suf ]] && base=$2    
}
backupSample(){
    : '
	Short sample showing how rsync will maintain $base up to date.
    '
    local base=base root="/path/to/backups/root/" suf='' cpylink
    local dateFmt="${base}_%Y-%m-%d_%Hh%M"
    cd "$root" || exit 1

    test -d "$base$suf" || exit 1
    cpylink=$(date -r "$base$suf" +"$dateFmt")
    test -d "$cpylink" || cp -al "$base$suf" "$cpylink"

    : 'Filsystem create and mount snapshot at Source!'
    ssh source <<< "lvcreate -s -L 10G -n Snap Grp/Vol;
                    mount /dev/Grp/Snap path/to/source/;
                    touch path/to/source/"

    rsync -ax ... source:path/to/source/. "$base/".

    ssh source <<<'umount path/to/source/;lvremove -f Grp/Snap'
}
die() {
    printf >&2 '%s ERROR: %s\n' "${0##*/}" "$*"
    exit 1
}
human() {
    local _hu=0 _v=${1}0000 _abr=("${abr[@]}")
    for ((; ${#_v}>7; _v>>=10,_hu++)) { :;}
	printf -v "$2" %.3f%s \
	       "${_v:0:$((${#_v}-4))}.${_v:$((${#_v}-4))}" "${_abr[_hu]}"
}
doYMReStr() {
    local Cm C8y i
    local -a restr=() month=()
    for i in {1..12}; do
	TZ=UTC printf -v "month[i]" "%(%b)T" $((29000000+i*86400*32))
    done
    printf -v Cm "%(%Y%m)T" "$now"
    C8y=${Cm::4}
    for ((i=nYears;i--;)); do
	printf -v "descr[Y$C8y]" "%s" "$C8y"
	restr+=("$C8y")
	((C8y-=1))
    done
    restr=("${restr[*]}");
    printf -v "reStrs[Y]" "%s" "${restr// /|}"
    
    restr=()
    for ((i=nMonths;i--;)) ;do
	printf -v "descr[M$Cm]" "%s %s" "${month[10#${Cm:4}]}" "${Cm::4}"
	restr+=("$Cm")
	((Cm=(Cm-1)%100?Cm-1:Cm-89))
    done
    restr=("${restr[*]}");
    printf -v "reStrs[M]" "%s" "${restr// /|}"
}
doReStr() {
    local ltr lhs rhs refmt dscfmt
    local -a restr=()
    IFS=\; read -r ltr lhs rhs refmt dscfmt <<<"$1"
    for ((i=lhs;i<=now;i+=rhs)) ;do
	printf -v o "%($refmt)T" $i
	printf -v "descr[$ltr$o]" "%($dscfmt)T" $i
    restr+=("$o")
    done
    restr=("${restr[*]}");
    printf -v "reStrs[$ltr]" "%s" "${restr// /|}"
}
buildReStrs() {
    printf -v now "%(%s)T" -1
    doYMReStr
    doReStr 'W;now-604800*nWeeks;604800;%Y%W;#%W %Y'
    doReStr 'D;now-86400*nDays;86400;%Y%m%d;%d %b %y'
    doReStr 'H;(now-1800*nHalfHours)/1800*1800;1800;%Y%m%d%H%M;%d %b %H:%M'
}
selthisfile() {
    local letter="$1" file="$2" seconds="$3" this
    local -n expr="reStrs[$letter]"
    local -A fmt=([Y]='%(%Y)T' [M]='%(%Y%m)T' [W]='%(%Y%W)T'
		  [D]='%(%Y%m%d)T' [H]='%(%Y%m%d%H%M)T');
    [ "$letter" = "H" ] && seconds=$(( seconds / 1800 * 1800 + 1800 ))
    printf -v this "${fmt[$letter]}" "$seconds"
    [ -z "${this//*($expr)}" ] && selfiles[$letter$this]=$file
}
rootsSelection() {
    local sedCmd='\([0-9]\+\)' fileAsSec thisfile kind allRoots dtFmt
    printf -v sedCmd \
	's/^%s[^0-9]%s[^0-9]%s[^0-9]%s[^0-9]%s[^0-9]%s/\\1\\/\\2\/\\3 \\4:\\5/'\
	"$base" "$sedCmd"{,,,,}
    allRoots=(
	"$base"[^0-9][0-9][0-9][0-9][0-9][^0-9][0-9][0-9][^0-9][0-9][0-9][^0-9][0-9][0-9][^0-9][0-9][0-9]
    )
    [[ -d ${allRoots[0]} ]] ||
	die "Can't find backups matching '${allRoots[0]}'"
    dtFmt=${allRoots[0]/$base/%s}
    dtFmt=${dtFmt/+([0-9])/%(%Y}
    dtFmt=${dtFmt/+([0-9])/%m}
    dtFmt=${dtFmt/+([0-9])/%d}
    dtFmt=${dtFmt/+([0-9])/%H}
    dtFmt=${dtFmt/+([0-9])/%M)T}
    
    bkNamFmt=${dtFmt//%?/%s}
    bkNamFmt=${bkNamFmt//%s[^-][^%]/%s}
    
    while read -r fileAsSec ;do
	  printf -v thisfile '%(%Y %m %d %H %M)T' "$fileAsSec"
	  printf -v "result[${thisfile// }]" "$dtFmt" "$base" "$fileAsSec"
	  timeStamp["$thisfile"]=fileAsSec
	  for kind in Y M W D H; do
	      selthisfile "$kind" "$thisfile" "$fileAsSec"
	  done
    done < <(
	    printf %s\\n "$base"[^0-9][0-9][0-9][0-9][0-9][^0-9][0-9][0-9][^0-9][0-9][0-9][^0-9][0-9][0-9][^0-9][0-9][0-9] |
		sed "$sedCmd" |
		date -f - +%s
	)
}
unwantedRoots(){
    local kind
    for kind in "${!selfiles[@]}"; do
	unset "result[${selfiles["$kind"]// }]"
	keeped["${selfiles[$kind]// }"]+="$kind "
    done
    
    $QUIET || printf 'KEEPED: %d \e[34m(%d)\e[0m, DELETE: %d\n' \
	   "${#keeped[@]}" "${#selfiles[@]}" ${#result[@]}
}
printOut() {
    if $statbackups; then
	declare -i APPEARFD REALFD
	doRunStatBackup
    fi
    local cnt=() asizes=() tasiz='' trsiz='' crtdname tkind fmt selidxs tdsc
    local -i totrsize=0 totasize=0
    selidxs=("${!selfiles[@]}")
    for tkind in Yearly Monthly Weekly Daily HalfHourly; do
	order=(${selidxs[@]/#[^${tkind::1}]*})
	(( ${#order[@]} )) || continue
	printf -v fmt '[10#%s]=%%s ' ${order[@]/#${tkind::1}}
	printf -v fmt "$fmt" ${order[@]}
	local -a order="($fmt)"
	local -a cnted=("${!order[@]}")
	printf '%s (%d):\n' $tkind ${#order[@]}
	for i in "${!cnted[@]}"; do
	    printf -v sfr %s%02d "${tkind:0:1}" "${cnted[i]}"
	    tdsc=${descr[$sfr]}
	    kpr=${selfiles[$sfr]// }
	    OLANG=$LANG; LANG=C; u8c=${#tdsc}; LANG=$OLANG
	    [ -v "cnt[kpr]" ] && c=34 || c=0
	    read -ra selfile <<<"${selfiles[$sfr]}"
	    if $statbackups; then
		printf -v crtdname "$bkNamFmt" "$base" "${selfile[@]}"
		if [ -v "cnt[kpr]" ]; then
		    rsize=0
		    trsiz=0b
		    asize=${asizes[kpr]}
		    human "$asize" tasiz
		else
		    read -ru "$APPEARFD" asize dir1
		    read -ru "$REALFD" rsize dir2
		    asizes[kpr]=$asize
		    human "$asize" tasiz
 		    human "$rsize" trsiz
		    ((totasize+=asize))
		    ((totrsize+=rsize))
		    [[ $dir1 == "$dir2" ]]&&[[ $dir1 == "$crtdname" ]]||printf \
			"\e[31mWARNING Crt vs DU don't match '%s'-'%s-'%s''\n"\
			"$crtdname" "$dir1" "$dir2"
		fi
		printf '\e[%dm  %s/%s/%s %s:%s  %1s  %*s %9s %9s %s%02d [ %s]\e[0m\n' \
		       "$c" "${selfile[@]}" "${cnt[kpr]:-+}" \
		       $((u8c-${#tdsc}+12)) "$tdsc" "$tasiz" "$trsiz" \
		      "${tkind:0:1}" $((${#cnted[@]}-i-1)) "${keeped[$kpr]}"
	    else
		printf '\e[%dm  %s/%s/%s %s:%s  %1s  %*s %s%02d [ %s]\e[0m\n' \
		       "$c" "${selfile[@]}" "${cnt[kpr]:-+}" \
		       $((u8c-${#tdsc}+12)) "$tdsc" "${tkind:0:1}"  $((${#cnted[@]}-i-1)) \
		       "${keeped[$kpr]}"
	    fi
	    cnt[kpr]=' '
	done
    done
    if $statbackups; then
	read -ru "$APPEARFD" asize dir1
	read -ru "$REALFD" rsize dir2
	[[ $dir1 == "$dir2" ]] && [[ $dir2 == "$base$suf" ]] ||
	    printf '\e[31mWARNING: "%s" == "%s" == "%s" not match!\e[0m\n' \
		   "$dir1" "$dir2" "$base$suf"
	human "$asize" tasiz
	human "$rsize" trsiz
	printf '%-14s %30s %9s\n' "$base$suf" "$tasiz" "$trsiz"
	((totasize+=asize))
	((totrsize+=rsize))
	
	human "$totasize" tasiz
	human "$totrsize" trsiz
	printf 'Total %39s %9s\n' "$tasiz" "$trsiz"
    fi
}
doRunStatBackup() {
    local chk=() cmd1='du -cks ' cmd2='' kind order i fname
    for kind in Y M W D H; do
	order=()
	for i in "${!selfiles[@]}"; do
	    [ "${i:0:1}" = "$kind" ] && {
		if [ -z "${chk[${selfiles[$i]// }]}" ]; then
		    chk[${selfiles["$i"]// }]='-'
		    order[${selfiles["$i"]// }]=${selfiles["$i"]}
		fi
	    }
	done
	for i in "${order[@]}"; do
	    read -ra fname <<< "$i"
	    printf -v i "$bkNamFmt" "$base" "${fname[@]}"
	    cmd1+="'$i' "
	    cmd2+="du -ks '$i';"
	done
    done    
    cmd1+="'$base$suf' "
    cmd2+="du -ks '$base$suf';"
    exec {REALFD}< <( eval "$cmd1" )
    exec {APPEARFD}< <( eval "$cmd2" )
}
ask4delete() {
    local loop answer
    echo -n "Delete ${#result[@]} unwanted backups (y/n)? "
    loop=true
    while $loop; do
	read -srn1 answer
	[ "$answer" ] && [ -z "${answer#[yn]}" ] && loop=false
    done
    case $answer in
	y ) echo Yes; doDelete=y ;;
	* ) echo No;  doDelete=n ;;
    esac
}
doSymLinks() {
    [[ -d $slPath ]] || die "Path to SymLinks '$slPath/' don't exist."
    rm -fR "${slPath:?}"/*
    local crtdname tkind i selidxs order linkname fmt
    selidxs=("${!selfiles[@]}")
    for tkind in Yearly Monthly Weekly Daily HalfHourly; do
	mkdir "$slPath/$tkind"
	order=(${selidxs[@]/#[^${tkind::1}]*})
	printf -v fmt '[10#%s]=%%s ' ${order[@]/#${tkind::1}}
	printf -v fmt "$fmt" ${order[@]}
	local -a order="($fmt)"
	order=(${order[@]})
	for i in ${!order[@]}; do
	    read -ra selfile <<<"${selfiles["${order[i]}"]}"
	    printf -v crtdname "$bkNamFmt" "$base" "${selfile[@]}"
	    printf -v linkname '%s/%s/%02d-%(%a_%d_%b_%Y-%H%M)T' \
		   "$slPath" "$tkind" $(( ${#order[@]} -1 - i )) \
		   ${timeStamp["${selfile[@]}"]}
	    ln -s "$PWD/$crtdname" "$linkname" &&
		touch -d "@${timeStamp[${selfile[@]}]}" -h "$linkname"
	done
    done
}

readCmdLine "$@"

cd "$root" || die "Can't cd to '$root'."
[[ -d $base$suf ]] || die "Can't find '$base$suf' dir."
export -a abr=(K M G T P E Z Y)

# shellcheck disable=SC2034 # reStrs appears unused
declare -A reStrs=() descr=()
buildReStrs

declare -A selfiles
declare -iA timeStamp
declare -a result=() keeped=()
rootsSelection
unwantedRoots

! $QUIET && printOut

[[ $slPath ]] && doSymLinks

if [ "${#result[@]}" -eq 0 ]; then
    $QUIET || echo "Nothing to do!"
    exit 0
fi

$QUIET || fold -s ${COLUMNS+-w} ${COLUMNS} < <(echo "${result[@]#$base-}")

! [[ -v doDelete ]] && ask4delete

if [[ $doDelete == y ]]; then
    $QUIET || echo 'RUN Job: delete' ${#result[@]}, keep ${#keeped[@]}.
    if ! rm -fr "${result[@]}"; then
	find "${result[@]}" ! -perm /u+w -exec chmod +w {} +
	rm -fr "${result[@]}"
    fi
else
    $QUIET || echo 'Doing nothing.'
fi
