#!/usr/bin/env bash
set -Eeuo pipefail

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

normalize_retention() {
    local variable="$1" value
    value="${!variable}"
    case "${value,,}" in
        none|unlimited) printf -v "$variable" '%s' unlimited ;;
        *) [[ "$value" =~ ^[0-9]+$ ]] || \
            die "$variable must be a non-negative integer, unlimited, or none" ;;
    esac
}

load_config() {
    : "${EXPORTER_CONFIG:?EXPORTER_CONFIG is required}"
    [[ -r "$EXPORTER_CONFIG" ]] || die "cannot read $EXPORTER_CONFIG"
    set -a
    # shellcheck disable=SC1090
    source "$EXPORTER_CONFIG"
    if [[ -n "${EXPORTER_SECRET_FILE:-}" ]]; then
        [[ -r "$EXPORTER_SECRET_FILE" ]] || die "cannot read $EXPORTER_SECRET_FILE"
        # shellcheck disable=SC1090
        source "$EXPORTER_SECRET_FILE"
    fi
    set +a
    : "${RCLONE_DESTINATION:?RCLONE_DESTINATION is required}"
    RETENTION_DAYS="${RETENTION_DAYS:-14}"
    MINIMUM_REDUNDANCY="${MINIMUM_REDUNDANCY:-2}"
    HEALTHCHECK_MAX_AGE_SECONDS="${HEALTHCHECK_MAX_AGE_SECONDS:-129600}"
    WAL_RETENTION_DAYS="${WAL_RETENTION_DAYS:-15}"
    normalize_retention RETENTION_DAYS
    normalize_retention WAL_RETENTION_DAYS
    [[ "$MINIMUM_REDUNDANCY" =~ ^[0-9]+$ ]] || \
        die "MINIMUM_REDUNDANCY must be a non-negative integer"
    [[ "$HEALTHCHECK_MAX_AGE_SECONDS" =~ ^[0-9]+$ ]] || \
        die "HEALTHCHECK_MAX_AGE_SECONDS must be a non-negative integer"
}

manifest_rows() {
    local item path
    while IFS= read -r item; do
        path="$(jq -r '.Path' <<<"$item")"
        rclone cat "${RCLONE_DESTINATION}/basebackups/${path}" | \
            jq -r '[.created_epoch,.backup_name] | @tsv'
    done < <(rclone lsjson "${RCLONE_DESTINATION}/basebackups" --recursive --files-only \
        --include '*/manifest.json' | jq -c '.[]')
}

latest_epoch() {
    local latest
    latest="$(manifest_rows | cut -f1 | sort -n | tail -1)"
    [[ -n "$latest" ]] || exit 3
    printf '%s\n' "$latest"
}

next_serial() {
    local day="$1" latest
    [[ "$day" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die "invalid serial date: $day"
    latest="$(manifest_rows | cut -f2 | sed -nE "s/^${day}-([0-9]+)(-|$).*/\\1/p" | sort -n | tail -1)"
    printf '%d\n' "$(( ${latest:-0} + 1 ))"
}

publish() {
    local stage="$1" name destination layout archive expected actual
    [[ -r "$stage/manifest.json" ]] || die "invalid stage: $stage"
    layout="$(jq -er '.artifact.layout // "files"' "$stage/manifest.json")" || die "invalid stage manifest: $stage"
    case "$layout" in
        files) [[ -r "$stage/checksums.sha256" ]] || die "invalid files stage: $stage" ;;
        archive)
            archive="$(jq -er '.artifact.file' "$stage/manifest.json")" || die "invalid archive manifest: $stage"
            expected="$(jq -er '.artifact.sha256' "$stage/manifest.json")" || die "invalid archive manifest: $stage"
            [[ "$archive" =~ ^backup\.(zip|tar\.(gz|xz|zst))$ && -r "$stage/$archive" ]] || \
                die "invalid archive stage: $stage"
            [[ "$expected" =~ ^[0-9a-f]{64}$ ]] || die "invalid archive checksum: $stage"
            actual="$(sha256sum "$stage/$archive")"; actual="${actual%% *}"
            [[ "$actual" == "$expected" ]] || die "archive checksum mismatch: $stage/$archive"
            ;;
        *) die "unsupported artifact layout: $layout" ;;
    esac
    name="$(jq -er '.backup_name' "$stage/manifest.json")"
    destination="${RCLONE_DESTINATION}/basebackups/${name}"
    rclone copy "$stage" "$destination" --exclude '/manifest.json'
    rclone copyto "$stage/manifest.json" "$destination/manifest.json"
}

retain() {
    local cutoff rows keep epoch name
    if [[ "$RETENTION_DAYS" != unlimited ]]; then
        cutoff=$(( $(date +%s) - RETENTION_DAYS * 86400 ))
        rows="$(manifest_rows | sort -nr)"
        keep=0
        while IFS=$'\t' read -r epoch name; do
            [[ -n "$epoch" ]] || continue
            keep=$((keep + 1))
            if (( keep > MINIMUM_REDUNDANCY && epoch < cutoff )); then
                rclone purge "${RCLONE_DESTINATION}/basebackups/${name}"
            fi
        done <<<"$rows"
    fi
    if [[ "$WAL_RETENTION_DAYS" != unlimited ]]; then
        rclone delete "${RCLONE_DESTINATION}/objects/wal" \
            --min-age "${WAL_RETENTION_DAYS}d" --rmdirs || true
    fi
}

put_file() {
    local source="$1" key="$2"
    [[ "$key" =~ ^[A-Za-z0-9._/-]+$ && "$key" != /* && "$key" != *..* ]] || die "invalid object key"
    rclone copyto "$source" "${RCLONE_DESTINATION}/objects/${key}"
}

get_file() {
    local key="$1" destination="$2"
    [[ "$key" =~ ^[A-Za-z0-9._/-]+$ && "$key" != /* && "$key" != *..* ]] || die "invalid object key"
    rclone copyto "${RCLONE_DESTINATION}/objects/${key}" "$destination"
}

connectivitycheck() { rclone lsd "$RCLONE_DESTINATION" >/dev/null; }

healthcheck() {
    local latest age
    latest="$(latest_epoch)" || { echo "CRITICAL: no completed exported backup"; exit 2; }
    age=$(( $(date +%s) - latest ))
    if (( age < 0 || age > HEALTHCHECK_MAX_AGE_SECONDS )); then echo "CRITICAL: latest exported backup is ${age}s old"; exit 2; fi
    echo "OK: latest exported backup is ${age}s old"
}

main() {
    load_config
    case "${1:-}" in
        latest-epoch) latest_epoch ;;
        next-serial) [[ $# -eq 2 ]] || exit 64; next_serial "$2" ;;
        publish) [[ $# -eq 2 ]] || exit 64; publish "$2" ;;
        retain) retain ;;
        connectivitycheck) connectivitycheck ;;
        healthcheck) healthcheck ;;
        put-file) [[ $# -eq 3 ]] || exit 64; put_file "$2" "$3" ;;
        get-file) [[ $# -eq 3 ]] || exit 64; get_file "$2" "$3" ;;
        *) die "unsupported command: ${1:-none}" ;;
    esac
}

main "$@"
