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

readonly CONFIG_ROOT="${BACKMASTER_CONFIG_ROOT:-/etc/backmaster}"
readonly INSTALL_ROOT="${BACKMASTER_INSTALL_ROOT:-/usr/lib/backmaster}"
SELF="$(readlink -f "$0")"
readonly SELF
readonly BACKMASTER_VERSION="0.1.18"

log() {
    printf '%s instance=%s node=%s %s\n' "$(date --iso-8601=seconds)" \
        "${INSTANCE_NAME:-unknown}" "${NODE_NAME:-unknown}" "$*"
}

die() { log "level=error message=$*" >&2; exit 1; }

usage() {
    cat <<'USAGE'
Usage:
  backmaster run INSTANCE [--force]
  backmaster health INSTANCE
  backmaster connectivity INSTANCE
  backmaster driver INSTANCE VERB [ARG...]
  backmaster exporter INSTANCE VERB [ARG...]
  backmaster --version
USAGE
}

validate_name() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "invalid_name value=$1"; }

load_instance() {
    local requested="$1" config="${CONFIG_ROOT}/instances.d/${1}.env"
    validate_name "$requested"
    [[ -r "$config" ]] || die "missing_instance_config path=$config"
    set -a
    # shellcheck disable=SC1090
    source "$config"
    set +a

    : "${INSTANCE_NAME:?INSTANCE_NAME is required}"
    : "${NODE_NAME:?NODE_NAME is required}"
    : "${DRIVER:?DRIVER is required}"
    : "${EXPORTER:?EXPORTER is required}"
    [[ "$INSTANCE_NAME" == "$requested" ]] || die "instance_name_mismatch"
    validate_name "$DRIVER"; validate_name "$EXPORTER"
    DRIVER_EXEC="${INSTALL_ROOT}/drivers/${DRIVER}/driver"
    EXPORTER_EXEC="${INSTALL_ROOT}/exporters/${EXPORTER}/exporter"
    [[ -x "$DRIVER_EXEC" ]] || die "missing_driver path=$DRIVER_EXEC"
    [[ -x "$EXPORTER_EXEC" ]] || die "missing_exporter path=$EXPORTER_EXEC"

    MAX_AGE_SECONDS="${MAX_AGE_SECONDS:-82800}"
    CONSUL_LOCK_KEY="${CONSUL_LOCK_KEY:-service/backmaster/${INSTANCE_NAME}}"
    CONSUL_LOCK_TIMEOUT="${CONSUL_LOCK_TIMEOUT:-30s}"
    STAGING_ROOT="${STAGING_ROOT:-/var/lib/backmaster}"
    BACKMASTER_STATE_DIRECTORY="${STAGING_ROOT%/}/${INSTANCE_NAME}"
    BACKUP_NAME_MODE="${BACKUP_NAME_MODE:-daily-time}"
    BACKUP_NAME_SUFFIX_MODE="${BACKUP_NAME_SUFFIX_MODE:-hostname}"
    BACKUP_ARCHIVE_FORMAT="${BACKUP_ARCHIVE_FORMAT:-files}"
    case "$BACKUP_ARCHIVE_FORMAT" in
        files) BACKUP_ARCHIVE_COMPRESSION_LEVEL="${BACKUP_ARCHIVE_COMPRESSION_LEVEL:-0}" ;;
        zip|tar.gz|tar.xz) BACKUP_ARCHIVE_COMPRESSION_LEVEL="${BACKUP_ARCHIVE_COMPRESSION_LEVEL:-6}" ;;
        tar.zst) BACKUP_ARCHIVE_COMPRESSION_LEVEL="${BACKUP_ARCHIVE_COMPRESSION_LEVEL:-3}" ;;
        *) die "invalid_backup_archive_format value=$BACKUP_ARCHIVE_FORMAT" ;;
    esac
    case "$BACKUP_ARCHIVE_FORMAT" in
        files)
            [[ "$BACKUP_ARCHIVE_COMPRESSION_LEVEL" == 0 ]] || \
                die "archive_compression_level_requires_archive value=$BACKUP_ARCHIVE_COMPRESSION_LEVEL"
            ;;
        zip|tar.xz)
            [[ "$BACKUP_ARCHIVE_COMPRESSION_LEVEL" =~ ^[0-9]$ ]] || \
                die "invalid_archive_compression_level format=$BACKUP_ARCHIVE_FORMAT value=$BACKUP_ARCHIVE_COMPRESSION_LEVEL range=0-9"
            ;;
        tar.gz)
            [[ "$BACKUP_ARCHIVE_COMPRESSION_LEVEL" =~ ^[1-9]$ ]] || \
                die "invalid_archive_compression_level format=$BACKUP_ARCHIVE_FORMAT value=$BACKUP_ARCHIVE_COMPRESSION_LEVEL range=1-9"
            ;;
        tar.zst)
            [[ "$BACKUP_ARCHIVE_COMPRESSION_LEVEL" =~ ^([1-9]|1[0-9])$ ]] || \
                die "invalid_archive_compression_level format=$BACKUP_ARCHIVE_FORMAT value=$BACKUP_ARCHIVE_COMPRESSION_LEVEL range=1-19"
            ;;
    esac
    case "$BACKUP_NAME_MODE" in daily|daily-serial|daily-time) ;; *) die "invalid_backup_name_mode value=$BACKUP_NAME_MODE";; esac
    case "$BACKUP_NAME_SUFFIX_MODE" in
        none|hostname) ;;
        custom) : "${BACKUP_NAME_SUFFIX_VALUE:?BACKUP_NAME_SUFFIX_VALUE is required}" ;;
        *) die "invalid_backup_name_suffix_mode value=$BACKUP_NAME_SUFFIX_MODE" ;;
    esac
    export MAX_AGE_SECONDS CONSUL_LOCK_KEY CONSUL_LOCK_TIMEOUT STAGING_ROOT
    export BACKMASTER_STATE_DIRECTORY
    export DRIVER_EXEC EXPORTER_EXEC BACKUP_NAME_MODE BACKUP_NAME_SUFFIX_MODE
    export BACKUP_ARCHIVE_FORMAT BACKUP_ARCHIVE_COMPRESSION_LEVEL
    export BACKUP_NAME_SUFFIX_VALUE INSTANCE_NAME NODE_NAME DRIVER EXPORTER
}

require_core_commands() {
    local name
    for name in consul date jq find mktemp sha256sum; do
        command -v "$name" >/dev/null || die "missing_command command=$name"
    done
    [[ "$BACKUP_NAME_SUFFIX_MODE" != hostname ]] || command -v hostname >/dev/null || die "missing_command command=hostname"
    case "$BACKUP_ARCHIVE_FORMAT" in
        files) ;;
        zip) command -v zip >/dev/null || die "missing_command command=zip" ;;
        tar.gz)
            command -v tar >/dev/null || die "missing_command command=tar"
            command -v gzip >/dev/null || die "missing_command command=gzip"
            ;;
        tar.xz)
            command -v tar >/dev/null || die "missing_command command=tar"
            command -v xz >/dev/null || die "missing_command command=xz"
            ;;
        tar.zst)
            command -v tar >/dev/null || die "missing_command command=tar"
            command -v zstd >/dev/null || die "missing_command command=zstd"
            ;;
    esac
}

bundle_stage() {
    local stage="$1" temporary digest
    ARCHIVE_FILENAME=""
    ARCHIVE_SHA256=""
    [[ "$BACKUP_ARCHIVE_FORMAT" != files ]] || return 0
    case "$BACKUP_ARCHIVE_FORMAT" in
        zip) ARCHIVE_FILENAME=backup.zip ;;
        tar.gz) ARCHIVE_FILENAME=backup.tar.gz ;;
        tar.xz) ARCHIVE_FILENAME=backup.tar.xz ;;
        tar.zst) ARCHIVE_FILENAME=backup.tar.zst ;;
    esac
    temporary="$stage/.backup.partial.${ARCHIVE_FILENAME#backup.}"
    case "$BACKUP_ARCHIVE_FORMAT" in
        zip)
            (cd "$stage" && zip -q -r "-$BACKUP_ARCHIVE_COMPRESSION_LEVEL" \
                "$temporary" payload checksums.sha256) || return 1
            ;;
        tar.gz)
            (cd "$stage" && tar -cf - payload checksums.sha256) | \
                gzip "-$BACKUP_ARCHIVE_COMPRESSION_LEVEL" -c >"$temporary" || return 1
            ;;
        tar.xz)
            (cd "$stage" && tar -cf - payload checksums.sha256) | \
                xz "-$BACKUP_ARCHIVE_COMPRESSION_LEVEL" -c >"$temporary" || return 1
            ;;
        tar.zst)
            (cd "$stage" && tar -cf - payload checksums.sha256) | \
                zstd -q "-$BACKUP_ARCHIVE_COMPRESSION_LEVEL" -c >"$temporary" || return 1
            ;;
    esac
    [[ -s "$temporary" ]] || return 1
    mv -- "$temporary" "$stage/$ARCHIVE_FILENAME" || return 1
    digest="$(sha256sum "$stage/$ARCHIVE_FILENAME")" || return 1
    ARCHIVE_SHA256="${digest%% *}"
    rm -rf -- "$stage/payload" "$stage/checksums.sha256" || return 1
}

resolve_backup_name() {
    local now day base serial suffix=""
    now="$(date --utc +%Y-%m-%dT%H%M%SZ)"; day="${now%%T*}"
    case "$BACKUP_NAME_MODE" in
        daily) base="$day" ;;
        daily-time) base="$now" ;;
        daily-serial)
            serial="$($EXPORTER_EXEC next-serial "$day")" || die "exporter_next_serial_failed"
            [[ "$serial" =~ ^[1-9][0-9]*$ ]] || die "exporter_returned_invalid_serial value=$serial"
            printf -v base '%s-%03d' "$day" "$serial"
            ;;
    esac
    case "$BACKUP_NAME_SUFFIX_MODE" in
        none) ;;
        hostname) suffix="$(hostname --short)" ;;
        custom) suffix="$BACKUP_NAME_SUFFIX_VALUE" ;;
    esac
    if [[ -n "$suffix" ]]; then
        [[ "$suffix" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "invalid_backup_name_part value=$suffix"
        base="${base}-${suffix}"
    fi
    BACKUP_NAME="$base"; export BACKUP_NAME
}

is_fresh() {
    local latest status age
    set +e; latest="$($EXPORTER_EXEC latest-epoch)"; status=$?; set -e
    if (( status == 3 )); then log "level=info message=no_completed_backup"; return 1; fi
    (( status == 0 )) || die "exporter_latest_failed status=$status"
    [[ "$latest" =~ ^[0-9]+$ ]] || die "exporter_returned_invalid_epoch"
    age=$(( $(date +%s) - latest ))
    log "level=info latest_backup_epoch=$latest age_seconds=$age"
    (( age >= 0 && age < MAX_AGE_SECONDS ))
}

ready_stage() {
    local parent="${STAGING_ROOT}/${INSTANCE_NAME}"
    [[ -d "$parent" ]] || return 1
    find "$parent" -mindepth 1 -maxdepth 1 -type d -name '*.ready' -print -quit
}

export_stage() {
    local stage="$1"
    BACKUP_NAME="$(jq -er '.backup_name' "$stage/manifest.json")" || die "invalid_stage_manifest path=$stage"
    export BACKUP_NAME
    log "level=info action=export_start exporter=$EXPORTER backup_name=$BACKUP_NAME"
    "$EXPORTER_EXEC" publish "$stage"
    log "level=info action=export_complete exporter=$EXPORTER backup_name=$BACKUP_NAME"
    rm -rf -- "$stage"
}

prepare_stage() {
    local parent partial ready created
    parent="${STAGING_ROOT}/${INSTANCE_NAME}"
    if ! mkdir -p -- "$parent"; then
        die "staging_parent_create_failed path=$parent hint=run_via_systemd_or_precreate_state_directory"
    fi
    [[ -d "$parent" ]] || die "staging_parent_not_directory path=$parent"
    [[ -w "$parent" ]] || \
        die "staging_parent_not_writable path=$parent hint=run_via_systemd_or_fix_ownership"
    if ! partial="$(mktemp -d "${parent}/.${BACKUP_NAME}.partial.XXXXXX")"; then
        die "stage_temp_create_failed parent=$parent"
    fi
    if ! mkdir -- "$partial/payload"; then
        rm -rf -- "$partial"
        die "stage_payload_create_failed path=$partial/payload"
    fi
    if ! "$DRIVER_EXEC" prepare "$partial/payload" >&2; then rm -rf -- "$partial"; die "driver_prepare_failed"; fi
    (cd "$partial" && find payload -type f -print0 | sort -z | xargs -0 -r sha256sum > checksums.sha256)
    if ! bundle_stage "$partial"; then
        rm -rf -- "$partial"
        die "archive_create_failed format=$BACKUP_ARCHIVE_FORMAT"
    fi
    created="$(date +%s)"
    jq -n --arg name "$BACKUP_NAME" --arg instance "$INSTANCE_NAME" \
        --arg driver "$DRIVER" --arg node "$NODE_NAME" --argjson created "$created" \
        --arg archive_format "$BACKUP_ARCHIVE_FORMAT" \
        --arg archive_file "$ARCHIVE_FILENAME" \
        --arg archive_sha256 "$ARCHIVE_SHA256" \
        --argjson archive_level "$BACKUP_ARCHIVE_COMPRESSION_LEVEL" \
        '{schema:2,backup_name:$name,instance:$instance,driver:$driver,node:$node,created_epoch:$created} +
         if $archive_format == "files" then {artifact:{layout:"files"}}
         else {artifact:{layout:"archive",format:$archive_format,file:$archive_file,compression_level:$archive_level,sha256:$archive_sha256}}
         end' \
        >"$partial/manifest.json"
    ready="${parent}/${BACKUP_NAME}.ready"
    [[ ! -e "$ready" ]] || { rm -rf -- "$partial"; die "stage_already_exists path=$ready"; }
    mv "$partial" "$ready"
    PREPARED_STAGE="$ready"
}

run_locked() {
    local stage
    stage="$(ready_stage || true)"
    if [[ -n "$stage" ]]; then
        log "level=warning action=export_resume stage=$stage"
        export_stage "$stage"; "$EXPORTER_EXEC" retain; return
    fi
    if [[ "${FORCE_BACKUP:-0}" != 1 ]] && is_fresh; then log "level=info result=skipped reason=fresh_backup_exists"; return; fi
    resolve_backup_name
    log "level=info action=prepare_start driver=$DRIVER backup_name=$BACKUP_NAME"
    prepare_stage
    stage="$PREPARED_STAGE"
    log "level=info action=prepare_complete driver=$DRIVER stage=$stage"
    export_stage "$stage"
    "$EXPORTER_EXEC" retain
    log "level=info action=retention_complete exporter=$EXPORTER"
}

run_instance() {
    local instance="$1" option="${2:-}"
    load_instance "$instance"; require_core_commands
    case "$option" in --force) FORCE_BACKUP=1; export FORCE_BACKUP;; --locked) run_locked; return;; "") ;; *) die "unknown_argument argument=$option";; esac
    log "level=info action=lock_acquire key=$CONSUL_LOCK_KEY"
    exec consul lock -child-exit-code -shell=false -timeout="$CONSUL_LOCK_TIMEOUT" \
        -name="backmaster-${INSTANCE_NAME}-${NODE_NAME}" "$CONSUL_LOCK_KEY" \
        "$SELF" run "$INSTANCE_NAME" --locked
}

main() {
    local command="${1:-}" instance="${2:-}"
    case "$command" in
        run) [[ -n "$instance" ]] || { usage; exit 64; }; run_instance "$instance" "${3:-}" ;;
        health) load_instance "$instance"; "$DRIVER_EXEC" healthcheck; "$EXPORTER_EXEC" healthcheck ;;
        connectivity) load_instance "$instance"; "$DRIVER_EXEC" connectivitycheck; "$EXPORTER_EXEC" connectivitycheck ;;
        driver|exporter)
            [[ -n "$instance" && -n "${3:-}" ]] || { usage; exit 64; }
            load_instance "$instance"; shift 2
            if [[ "$command" == driver ]]; then exec "$DRIVER_EXEC" "$@"; else exec "$EXPORTER_EXEC" "$@"; fi
            ;;
        --version) printf 'backmaster %s\n' "$BACKMASTER_VERSION" ;;
        -h|--help) usage ;;
        *) usage >&2; exit 64 ;;
    esac
}

main "$@"
