#!/usr/bin/bash
#
# Copyright(C) 2024 @ ZENETYS
# This script is licensed under MIT License (http://opensource.org/licenses/MIT)
#

set -f
export LC_ALL=C
umask 0027
PROGNAME=${0##*/}
HOURS=
DAYS=
QUIET=
DRY_RUN=
LOCK="/dev/shm/$PROGNAME.lock"
ZLC_XZ_OPTS=( -9 )
ZLC_ENV=${ZLC_ENV:-/etc/logcenter/logcenter.conf}

function exit_usage() {
    local status=${1:-0}
    [[ "$status" != "0" ]] && exec >&2
    echo "\
Usage: $PROGNAME [-n] [-q] [-d DAYS] [-H HOURS]
Compress log files collected via syslog

Available options:
  -H, --hours   NUM     Keep n hour(s) of uncompressed files.
  -d, --days    NUM     Keep n day(s) of uncompressed files.
  -q, --quiet           Display errors only.
  -n, --dry-run         Dry-run mode, do not change anything.
  -h, --help            Display this help.

Note: days and hours to keep uncompressed are added, then converted
in minutes to be passed to find -mmin option."
    exit "$status"
}

function _log() {
    local now=$(date +%Y-%m-%dT%H:%M:%S.%3N%:z)
    local LOGGER_SEVERITY=${LOGGER_SEVERITY:-info}
    local STDOUT_LABEL=${STDOUT_LABEL:-INFO}
    [[ -t 0 ]] || logger -t "$PROGNAME[$$]" -p "$LOGGER_SEVERITY" -- "${DRY_RUN:+DRY-RUN ** }$*"
    [[ -n $QUIET && $STDOUT_LABEL == INFO ]] && return 0
    echo "$now $STDOUT_LABEL $PROGNAME: ${DRY_RUN:+DRY-RUN ** }$*"
}

function info() { LOGGER_SEVERITY=info STDOUT_LABEL=INFO _log "$@"; }
function warning() { LOGGER_SEVERITY=warning STDOUT_LABEL=WARNING _log "$@"; }
function error() { LOGGER_SEVERITY=err STDOUT_LABEL=ERROR _log "$@"; }
function fatal() { LOGGER_SEVERITY=crit STDOUT_LABEL=FATAL _log "$@"; exit 2; }

function release_lock() {
    rm "$LOCK"
}

function acquire_lock_or_fatal() {
    if ! ln -sT "/proc/$$" "$LOCK"; then
        [[ -e "$LOCK" ]] || release_lock
        ln -sT "/proc/$$" "$LOCK" ||
            fatal "Failed to acquire lock $LOCK"
    fi
}

function load_env_or_fatal() {
    # proper $PATH may be needed for sigfiled
    [[ -f /etc/profile.d/logcenter-path.sh ]] && source /etc/profile.d/logcenter-path.sh
    source "$ZLC_ENV" || fatal "Failed to source $ZLC_ENV"
    [[ -z $ZLC_ARCHIVES_DIR ]] && fatal 'Bad environment, ZLC_ARCHIVES_DIR not set'
    [[ -d $ZLC_ARCHIVES_DIR/. ]] || fatal 'Invalid ZLC_ARCHIVES_DIR, not a directory'
    [[ -n $ZLC_SIGFILED_SOCKNAME ]] && export ${!ZLC_SIGFILED_*}
    HOURS=$ZLC_ARCHIVES_UNCOMPRESSED_HOURS # may be overriden by script argument
    DAYS=$ZLC_ARCHIVES_UNCOMPRESSED_DAYS # may be overriden by script argument
    return 0
}

# stats_counters_put database src file lines words bytes ztime zbytes
function stats_counters_put() {
    local db=$1; shift
    local QSTR
    function qstr() { [[ -z $1 ]] && QSTR=NULL || QSTR="'${1//\'/\'\'}'"; }
    qstr "$1"; local src=$QSTR
    qstr "$2"; local file=$QSTR
    local fdate=NULL fhour=NULL
    {   if [[ ! -e $db ]]; then
            { mkdir -p "${db%/*}" && touch "$db"; } || return 1
            echo 'create table if not exists counters (src text,
file text primary key, fdate date, fhour hour, lines integer,
words integer, bytes integer, ztime integer, zbytes integer);
create index idx_src on counters (src);'
        fi
        if [[ ${2##*/} =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})(T([0-9]{2}))? ]]; then
            qstr "${BASH_REMATCH[1]}"; fdate=$QSTR
            [[ -n ${BASH_REMATCH[3]} ]] && fhour=$(( 10#${BASH_REMATCH[3]} ))
        fi
        printf "replace into counters values (%s,%s,%s,%s,%d,%d,%d,%d,%d);\n" \
            "$src" "$file" "$fdate" "$fhour" "$3" "$4" "$5" "$6" "$7"
    } |sqlite3 "$db"
}

[[ $UID == 0 ]] ||
    fatal 'This script must be run as root'

load_env_or_fatal

while (( $# > 0 )); do
    case "$1" in
        -H|--hours) HOURS=$2; shift ;;
        -d|--days) DAYS=$2; shift ;;
        -q|--quiet) QUIET=1 ;;
        -n|--dry-run) DRY_RUN=1 ;;
        -h|--help) exit_usage ;;
        *) exit_usage 1 ;;
    esac
    shift
done

# None of $DAYS nor $HOURS is required
[[ -n $DAYS && -n ${DAYS//[0-9]} ]] &&
    fatal 'Invalid uncompressed days, not a number'
[[ -n $HOURS && -n ${HOURS//[0-9]} ]] &&
    fatal 'Invalid uncompressed hours, not a number'

acquire_lock_or_fatal
trap release_lock EXIT

# Current date and hour are used to exclude files being currently written.
# Exclusions are made from it to handle both daily and hourly archive files.
current_date=( $(date '+%Y-%m-%d %H') ) ||
    fatal "Call to date failed, cannot to get current date, abort"

find_mmin=$(( DAYS*24*60 + HOURS*60 ))
find_mmin=$(( find_mmin == 0 ? 5 : find_mmin ))

find_opts+=(
    -L
    "$ZLC_ARCHIVES_DIR/"
    -mindepth 3
    -not \( -path "$ZLC_ARCHIVES_DIR/@*" -prune \)
    -type f
    -name "*$ZLC_SELF_ID_EXT.log"
    -not -name "${current_date[0]}${ZLC_SELF_ID_EXT}.log"
    -not -name "${current_date[0]}T${current_date[1]}${ZLC_SELF_ID_EXT}.log"
    -mmin "+$find_mmin"
)

total=0
success=0
error=0
warning=0

time_main_start=$(date +%s)

info "BEGIN TASK -- ZLC_ARCHIVES_DIR=${ZLC_ARCHIVES_DIR:-<empty>}, \
ZLC_SELF_ID_EXT=${ZLC_SELF_ID_EXT:-<empty>}, ZLC_XZ_OPTS=${ZLC_XZ_OPTS[*]:-<empty>}, \
DAYS=${DAYS:-<empty>}, HOURS=${HOURS:-<empty>}, DRY_RUN=${DRY_RUN:-<empty>}, \
QUIET=${QUIET:-<empty>}"
info "Find options: ${find_opts[*]}"

while read -r; do
    xfiletmp="$REPLY.xz.partial$RANDOM"

    info "Processing $REPLY"
    (( total++ ))

    if [[ -n $DRY_RUN ]]; then
        continue
    fi

    if [[ -n $ZLC_SIGFILED_SOCKNAME ]]; then
        # Sign the file.
        # This is best effort, as compressing the file is the main operation
        # here. Monitoring of unsigned logs must be done by another process.
        SECONDS=0
        sigdir="${REPLY%/*}/sig"
        sigfname="${REPLY##*/}${ZLC_SELF_ID_EXT}.sig"
        if mkdir -p "$ZLC_ARCHIVES_DIR/$sigdir" &&
           sigout=$(sigfiled sign "$ZLC_ARCHIVES_DIR/$REPLY" "$ZLC_ARCHIVES_DIR/$sigdir/$sigfname"); then

            info "Signing $REPLY done in ${SECONDS}s${sigout:+, $sigout}"
        else
            warning "Signing $REPLY failed${sigout:+, $sigout}"
            (( warning++ ))
        fi
    fi

    # File counters
    COUNTERS=( $(nice -n 19 wc < "$ZLC_ARCHIVES_DIR/$REPLY") )

    # Compress the file.
    SECONDS=0
    nice -n 19 xz "${ZLC_XZ_OPTS[@]}" ${QUIET:+-q} \
        < "$ZLC_ARCHIVES_DIR/$REPLY" \
        > "$ZLC_ARCHIVES_DIR/$xfiletmp"

    if (( $? == 0 )); then
        (( success++ ))

        # Add compressed duration and size
        COUNTERS+=( "$SECONDS" )
        COUNTERS+=( $(stat -c %s "$ZLC_ARCHIVES_DIR/$xfiletmp") )

        CSTRING="in ${COUNTERS[3]}s, "
        CSTRING+="${COUNTERS[0]} lines, "
        CSTRING+="${COUNTERS[1]} words, "
        CSTRING+="${COUNTERS[2]} bytes, "
        CSTRING+="${COUNTERS[4]} bytes compressed."
        info "Done compressing $REPLY $CSTRING"

        stats_counters_put "${ZLC_ARCHIVES_DIR}/@stats/stats${ZLC_SELF_ID_EXT}.db" \
            "${REPLY%%/*}" "$REPLY" "${COUNTERS[@]}"
        if (( $? != 0 )); then
            (( warning++ ))
            warning "Stats counters put failed for $REPLY"
        fi
    else
        (( error++ ))
        error "Call to xz failed, error compressing $REPLY"
        rm "$ZLC_ARCHIVES_DIR/$xfiletmp"
        continue
    fi

    # Restore user/group, perms, mtime.
    chown --reference "$ZLC_ARCHIVES_DIR/$REPLY" "$ZLC_ARCHIVES_DIR/$xfiletmp"
    if (( $? != 0 )); then
        warning "Call to chown failed on $xfiletmp"
        (( warning++ ))
    fi
    chmod --reference "$ZLC_ARCHIVES_DIR/$REPLY" "$ZLC_ARCHIVES_DIR/$xfiletmp"
    if (( $? != 0 )); then
        warning "Call to chmod failed on $xfiletmp"
        (( warning++ ))
    fi
    touch --reference "$ZLC_ARCHIVES_DIR/$REPLY" "$ZLC_ARCHIVES_DIR/$xfiletmp"
    if (( $? != 0 )); then
        warning "Call to touch (mtime) failed on $xfiletmp"
        (( warning++ ))
    fi

    # Remove the original .log file now so we minimize chances to
    # have both a .log.xz and .log at the same time on the FS.
    #
    # At worse,
    #   - if rm and mv fails, we have the .log and the .log.xz.partial
    #   - if only rm fails, we have the .log and the .log.xz
    #   - if only mv fails, we have le .log.xz.partial
    #
    # Any case, we still have the data.

    rm "$ZLC_ARCHIVES_DIR/$REPLY"
    if (( $? != 0 )); then
        warning "Call to rm failed on $REPLY"
        (( warning++ ))
    fi 

    xfile="$REPLY.xz"
    if [[ -e $ZLC_ARCHIVES_DIR/$xfile ]]; then
        for (( i = 1 ; ; i++ )); do
            if [[ ! -e $ZLC_ARCHIVES_DIR/$REPLY.$i.xz ]]; then
                xfile="$REPLY.$i.xz"
                break
            fi
        done
    fi
    mv "$ZLC_ARCHIVES_DIR/$xfiletmp" "$ZLC_ARCHIVES_DIR/$xfile"
    if (( $? != 0 )); then
        warning "Call to mv failed, could not rename $xfiletmp to ${xfile##*/}"
        (( warning++ ))
    fi

done < <(
    find "${find_opts[@]}" -printf '%P\n' |sort -V
)

time_main_stop=$(date +%s)
message="END TASK -- elapsed: $(( time_main_stop - time_main_start ))s, \
files: $total, success: $success, error: $error, warning: $warning"

if (( error > 0 )); then
    error "$message"
    exit 1
elif (( warning > 0 )); then
    warning "$message"
    exit 0
else
    info "$message"
    exit 0
fi
