#!/usr/bin/env bash

#########################################################################################
#### hetzner-backup-auditor.sh — Audit backup schedules, snapshot ages, and          ####
#### retention policies for Hetzner Cloud servers via the REST API                   ####
#### Requires: bash 4+, curl, jq                                                     ####
####                                                                                 ####
#### Author: Phil Connor                                                             ####
#### Contact: contact@mylinux.work                                                   ####
#### License: MIT                                                                    ####
#### Version 1.01                                                                    ####
####                                                                                 ####
#### Usage:                                                                          ####
####   ./hetzner-backup-auditor.sh --audit                                           ####
####                                                                                 ####
#### See --help for all options.                                                     ####
#########################################################################################

set -euo pipefail

# ── Colors (pre-initialized) ─────────────────────────────────────────
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET=""

setup_colors() {
    if [[ "${COLOR:-auto}" == "never" ]]; then
        return
    fi
    if [[ "${COLOR:-auto}" == "always" ]] || [[ -t 1 ]]; then
        RED='\033[0;31m'
        GREEN='\033[0;32m'
        YELLOW='\033[0;33m'
        BLUE='\033[0;34m'
        CYAN='\033[0;36m'
        BOLD='\033[1m'
        DIM='\033[2m'
        RESET='\033[0m'
    fi
}

# ── Logging ───────────────────────────────────────────────────────────
log()     { echo -e "${BLUE}[INFO]${RESET} $*"; }
warn()    { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
err()     { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; }
die()     { err "$*"; exit 1; }

section_header() {
    echo ""
    echo -e "  ${BOLD}${CYAN}── $1 ──${RESET}"
    echo ""
}

field() {
    printf "  ${BOLD}%-22s${RESET} %s\n" "$1" "$2"
}

field_color() {
    printf "  ${BOLD}%-22s${RESET} %b\n" "$1" "$2"
}

elapsed() {
    local end_time
    end_time=$(date +%s)
    echo "$(( end_time - START_TIME ))s"
}

# ── Defaults ──────────────────────────────────────────────────────────
RUN_MODE=""
SERVER_ID=""
LABEL_SELECTOR=""
OUTPUT_FORMAT="${HBA_FORMAT:-table}"
MAX_AGE_HOURS="${HBA_MAX_AGE:-48}"
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"

# ── Credentials ───────────────────────────────────────────────────────
HCLOUD_TOKEN="${HCLOUD_TOKEN:-}"

# ── State ─────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
START_TIME=""

# ── API helpers ──────────────────────────────────────────────────────
hcloud_api() {
    local method="$1" endpoint="$2"
    shift 2
    local attempt=0 max_attempts=3

    while (( attempt < max_attempts )); do
        local http_code
        http_code=$(curl -s -o /tmp/hba_resp.json -w "%{http_code}" \
            -X "$method" \
            -H "Authorization: Bearer ${HCLOUD_TOKEN}" \
            -H "Content-Type: application/json" \
            "https://api.hetzner.cloud/v1${endpoint}" "$@")

        verbose "API ${method} ${endpoint} → HTTP ${http_code}"

        if [[ "$http_code" == "429" ]]; then
            ((attempt++)) || true
            local wait=$(( attempt * 5 ))
            warn "Rate limited — retrying in ${wait}s (attempt ${attempt}/${max_attempts})"
            sleep "$wait"
            continue
        fi

        if [[ "$http_code" =~ ^[45] ]]; then
            local errmsg
            errmsg=$(jq -r '.error.message // empty' /tmp/hba_resp.json 2>/dev/null)
            [[ -n "$errmsg" ]] && verbose "API error: ${errmsg}"
        fi

        cat /tmp/hba_resp.json
        return 0
    done

    err "API request failed after ${max_attempts} attempts: ${method} ${endpoint}"
    return 1
}

check_credentials() {
    [[ -z "$HCLOUD_TOKEN" ]] && die "HCLOUD_TOKEN not set"
}

check_deps() {
    command -v curl &>/dev/null || die "curl is required"
    command -v jq &>/dev/null || die "jq is required"
}

urlencode() {
    local string="$1"
    python3 -c "import urllib.parse; print(urllib.parse.quote('$string', safe=''))" 2>/dev/null \
        || echo "$string"
}

# ── Pagination helper ────────────────────────────────────────────────
fetch_all() {
    local endpoint="$1" key="$2"
    local page=1 per_page=50 all_data="[]"
    while true; do
        local sep="?"
        [[ "$endpoint" == *"?"* ]] && sep="&"
        local resp
        resp=$(hcloud_api GET "${endpoint}${sep}page=${page}&per_page=${per_page}")
        local page_data
        page_data=$(echo "$resp" | jq ".${key} // []" 2>/dev/null)
        local page_count
        page_count=$(echo "$page_data" | jq 'length' 2>/dev/null || echo 0)
        [[ "$page_count" -eq 0 ]] && break
        all_data=$(echo -e "${all_data}\n${page_data}" | jq -s 'add' 2>/dev/null)
        (( page_count < per_page )) && break
        ((page++)) || true
    done
    echo "$all_data"
}

# ── Age helpers ──────────────────────────────────────────────────────
iso_to_epoch() {
    date -d "$1" +%s 2>/dev/null || echo 0
}

age_hours() {
    local created_epoch="$1"
    local now
    now=$(date +%s)
    echo $(( (now - created_epoch) / 3600 ))
}

format_age() {
    local hours="$1"
    if [[ "$hours" -lt 24 ]]; then
        echo "${hours}h"
    else
        local days=$(( hours / 24 ))
        local rem=$(( hours % 24 ))
        echo "${days}d ${rem}h"
    fi
}

# ══════════════════════════════════════════════════════════════════════
#                          AUDIT
# ══════════════════════════════════════════════════════════════════════
do_audit() {
    local query="/servers?"
    [[ -n "$LABEL_SELECTOR" ]] && query="${query}label_selector=$(urlencode "$LABEL_SELECTOR")&"
    [[ -n "$SERVER_ID" ]] && query="/servers?id=${SERVER_ID}&"

    local servers
    servers=$(fetch_all "${query%&}" "servers")
    local server_count
    server_count=$(echo "$servers" | jq 'length' 2>/dev/null || echo 0)
    [[ "$server_count" -eq 0 ]] && die "No servers found"

    local snapshots
    snapshots=$(fetch_all "/images?type=snapshot" "images")

    local now
    now=$(date +%s)
    local warnings=0
    local no_backup=0
    local stale=0
    local healthy=0
    local results=""

    while IFS=$'\t' read -r sid sname sstatus backup_enabled; do
        [[ -z "$sid" ]] && continue

        # Find most recent snapshot for this server
        local latest_snap
        latest_snap=$(echo "$snapshots" | jq -r \
            --arg sid "$sid" \
            '[.[] | select(.created_from.id == ($sid | tonumber))] | sort_by(.created) | last | .created // empty' \
            2>/dev/null)

        # Find most recent backup (backup type images)
        local latest_backup_resp
        latest_backup_resp=$(hcloud_api GET "/images?type=backup&sort=created:desc&page=1&per_page=1")
        # Filter backups for this specific server
        local server_backups
        server_backups=$(fetch_all "/images?type=backup" "images")
        local latest_backup
        latest_backup=$(echo "$server_backups" | jq -r \
            --arg sid "$sid" \
            '[.[] | select(.created_from.id == ($sid | tonumber))] | sort_by(.created) | last | .created // empty' \
            2>/dev/null)

        # Determine newest protection point
        local newest=""
        local newest_type="none"
        if [[ -n "$latest_backup" && -n "$latest_snap" ]]; then
            local bepoch sepoch
            bepoch=$(iso_to_epoch "$latest_backup")
            sepoch=$(iso_to_epoch "$latest_snap")
            if [[ "$bepoch" -ge "$sepoch" ]]; then
                newest="$latest_backup"
                newest_type="backup"
            else
                newest="$latest_snap"
                newest_type="snapshot"
            fi
        elif [[ -n "$latest_backup" ]]; then
            newest="$latest_backup"
            newest_type="backup"
        elif [[ -n "$latest_snap" ]]; then
            newest="$latest_snap"
            newest_type="snapshot"
        fi

        local age_h="—"
        local status_flag="none"
        if [[ -n "$newest" ]]; then
            local nepoch
            nepoch=$(iso_to_epoch "$newest")
            age_h=$(age_hours "$nepoch")
            if [[ "$age_h" -le "$MAX_AGE_HOURS" ]]; then
                status_flag="ok"
                ((healthy++)) || true
            else
                status_flag="stale"
                ((stale++)) || true
                ((warnings++)) || true
            fi
        else
            ((no_backup++)) || true
            ((warnings++)) || true
        fi

        local backup_str="disabled"
        [[ "$backup_enabled" == "true" ]] && backup_str="enabled"

        results="${results}${sid}\t${sname}\t${sstatus}\t${backup_str}\t${newest_type}\t${age_h}\t${status_flag}\n"
    done < <(echo "$servers" | jq -r \
        '.[] | "\(.id)\t\(.name // "unknown")\t\(.status)\t\(.backup_window != null)"' \
        2>/dev/null)

    case "$OUTPUT_FORMAT" in
        json)
            jq -n \
                --argjson servers "$server_count" \
                --argjson healthy "$healthy" \
                --argjson stale "$stale" \
                --argjson no_backup "$no_backup" \
                --argjson warnings "$warnings" \
                --argjson max_age "$MAX_AGE_HOURS" \
                '{servers: $servers, healthy: $healthy, stale: $stale, no_backup: $no_backup, warnings: $warnings, max_age_hours: $max_age}'
            ;;
        prometheus)
            cat <<EOF
# HELP hetzner_backup_servers_total Total servers audited
# TYPE hetzner_backup_servers_total gauge
hetzner_backup_servers_total ${server_count}
# HELP hetzner_backup_healthy Servers with recent backup/snapshot
# TYPE hetzner_backup_healthy gauge
hetzner_backup_healthy ${healthy}
# HELP hetzner_backup_stale_total Servers with stale backup/snapshot
# TYPE hetzner_backup_stale_total gauge
hetzner_backup_stale_total ${stale}
# HELP hetzner_backup_missing_total Servers with no backup or snapshot
# TYPE hetzner_backup_missing_total gauge
hetzner_backup_missing_total ${no_backup}
# HELP hetzner_backup_warnings_total Total audit warnings
# TYPE hetzner_backup_warnings_total gauge
hetzner_backup_warnings_total ${warnings}
# HELP hetzner_backup_max_age_hours Configured max age threshold
# TYPE hetzner_backup_max_age_hours gauge
hetzner_backup_max_age_hours ${MAX_AGE_HOURS}
EOF
            ;;
        *)
            section_header "Backup Audit (max age: ${MAX_AGE_HOURS}h)"

            printf "  ${BOLD}%-10s %-18s %-10s %-10s %-10s %-8s %-8s${RESET}\n" \
                "ID" "NAME" "STATUS" "BACKUPS" "LATEST" "AGE" "RESULT"
            printf "  %s\n" "$(printf '%.0s─' {1..78})"

            echo -e "$results" | while IFS=$'\t' read -r sid sname sstatus backup_str newest_type age_h status_flag; do
                [[ -z "$sid" ]] && continue

                local result_color="$GREEN"
                local result_text="✓ ok"
                case "$status_flag" in
                    ok)    result_color="$GREEN"; result_text="✓ ok" ;;
                    stale) result_color="$YELLOW"; result_text="⚠ stale" ;;
                    none)  result_color="$RED"; result_text="✗ none" ;;
                esac

                local age_display="—"
                if [[ "$age_h" != "—" ]]; then
                    age_display=$(format_age "$age_h")
                fi

                printf "  %-10s %-18s %-10s %-10s %-10s %-8s " \
                    "$sid" "${sname:0:16}" "$sstatus" "$backup_str" "$newest_type" "$age_display"
                echo -e "${result_color}${result_text}${RESET}"
            done

            echo ""
            field "Servers:" "$server_count"
            field_color "Healthy:" "${GREEN}${healthy}${RESET}"
            if [[ "$stale" -gt 0 ]]; then
                field_color "Stale:" "${YELLOW}${stale}${RESET}"
            else
                field_color "Stale:" "${GREEN}0${RESET}"
            fi
            if [[ "$no_backup" -gt 0 ]]; then
                field_color "No backup:" "${RED}${no_backup}${RESET}"
            else
                field_color "No backup:" "${GREEN}0${RESET}"
            fi
            ;;
    esac
}

# ══════════════════════════════════════════════════════════════════════
#                          SNAPSHOTS
# ══════════════════════════════════════════════════════════════════════
do_snapshots() {
    local snapshots
    snapshots=$(fetch_all "/images?type=snapshot&sort=created:desc" "images")
    local total
    total=$(echo "$snapshots" | jq 'length' 2>/dev/null || echo 0)
    [[ "$total" -eq 0 ]] && die "No snapshots found"

    local now
    now=$(date +%s)

    case "$OUTPUT_FORMAT" in
        json)
            echo "$snapshots" | jq '[.[] | {
                id: .id, description: .description,
                size_gb: .image_size, created: .created,
                server_id: .created_from.id, server_name: .created_from.name
            }]'
            ;;
        prometheus)
            local stale_count=0
            while IFS=$'\t' read -r iid icreated; do
                [[ -z "$iid" ]] && continue
                local cepoch
                cepoch=$(iso_to_epoch "$icreated")
                local ah
                ah=$(age_hours "$cepoch")
                [[ "$ah" -gt "$MAX_AGE_HOURS" ]] && ((stale_count++)) || true
            done < <(echo "$snapshots" | jq -r '.[] | "\(.id)\t\(.created)"' 2>/dev/null)

            cat <<EOF
# HELP hetzner_backup_snapshots_total Total snapshots
# TYPE hetzner_backup_snapshots_total gauge
hetzner_backup_snapshots_total ${total}
# HELP hetzner_backup_snapshots_stale Snapshots older than threshold
# TYPE hetzner_backup_snapshots_stale gauge
hetzner_backup_snapshots_stale ${stale_count}
EOF
            ;;
        *)
            section_header "Snapshots"

            printf "  ${BOLD}%-10s %-20s %-8s %-10s %-20s %-8s${RESET}\n" \
                "ID" "DESCRIPTION" "SIZE" "SERVER" "CREATED" "AGE"
            printf "  %s\n" "$(printf '%.0s─' {1..80})"

            echo "$snapshots" | jq -r \
                '.[] | "\(.id)\t\(.description // "—")\t\(.image_size // 0)\t\(.created_from.name // "—")\t\(.created // "—")"' \
                2>/dev/null \
                | while IFS=$'\t' read -r iid idesc isize iserver icreated; do
                    local cepoch ah age_display age_color
                    cepoch=$(iso_to_epoch "$icreated")
                    ah=$(age_hours "$cepoch")
                    age_display=$(format_age "$ah")
                    age_color="$GREEN"
                    [[ "$ah" -gt "$MAX_AGE_HOURS" ]] && age_color="$YELLOW"

                    printf "  %-10s %-20s %-8s %-10s %-20s " \
                        "$iid" "${idesc:0:18}" "${isize}GB" "${iserver:0:8}" "${icreated:0:19}"
                    echo -e "${age_color}${age_display}${RESET}"
                done

            echo ""
            field "Snapshots:" "$total"
            ;;
    esac
}

# ══════════════════════════════════════════════════════════════════════
#                          BACKUPS
# ══════════════════════════════════════════════════════════════════════
do_backups() {
    local backups
    backups=$(fetch_all "/images?type=backup&sort=created:desc" "images")
    local total
    total=$(echo "$backups" | jq 'length' 2>/dev/null || echo 0)
    [[ "$total" -eq 0 ]] && die "No backups found"

    case "$OUTPUT_FORMAT" in
        json)
            echo "$backups" | jq '[.[] | {
                id: .id, description: .description,
                size_gb: .image_size, created: .created,
                server_id: .created_from.id, server_name: .created_from.name
            }]'
            ;;
        *)
            section_header "Backups"

            printf "  ${BOLD}%-10s %-20s %-8s %-10s %-20s %-8s${RESET}\n" \
                "ID" "DESCRIPTION" "SIZE" "SERVER" "CREATED" "AGE"
            printf "  %s\n" "$(printf '%.0s─' {1..80})"

            echo "$backups" | jq -r \
                '.[] | "\(.id)\t\(.description // "—")\t\(.image_size // 0)\t\(.created_from.name // "—")\t\(.created // "—")"' \
                2>/dev/null \
                | while IFS=$'\t' read -r iid idesc isize iserver icreated; do
                    local cepoch ah age_display age_color
                    cepoch=$(iso_to_epoch "$icreated")
                    ah=$(age_hours "$cepoch")
                    age_display=$(format_age "$ah")
                    age_color="$GREEN"
                    [[ "$ah" -gt "$MAX_AGE_HOURS" ]] && age_color="$YELLOW"

                    printf "  %-10s %-20s %-8s %-10s %-20s " \
                        "$iid" "${idesc:0:18}" "${isize}GB" "${iserver:0:8}" "${icreated:0:19}"
                    echo -e "${age_color}${age_display}${RESET}"
                done

            echo ""
            field "Backups:" "$total"
            ;;
    esac
}

# ══════════════════════════════════════════════════════════════════════
#                          HELP
# ══════════════════════════════════════════════════════════════════════
show_help() {
    cat <<EOF
${BOLD}${SCRIPT_NAME}${RESET} — Hetzner Backup Auditor

Audit backup schedules, snapshot ages, and retention for Hetzner Cloud
servers via the REST API.

${BOLD}MODES${RESET}
  --audit           Audit all servers for backup coverage (default)
  --snapshots       List all snapshots with age
  --backups         List all automatic backups with age

${BOLD}TARGETING${RESET}
  --server ID       Audit a specific server
  --label KEY=VAL   Filter servers by Hetzner Cloud label selector

${BOLD}OPTIONS${RESET}
  --max-age HOURS   Max acceptable backup/snapshot age (default: 48)
  --format FMT      Output: table, json, prometheus (default: table)
  --verbose         Debug output
  --no-color        Disable colored output
  --help            Show this help message

${BOLD}ENVIRONMENT VARIABLES${RESET}
  HCLOUD_TOKEN      Hetzner Cloud API token (required)
  HBA_FORMAT        Default output format
  HBA_MAX_AGE       Default max age threshold in hours (default: 48)
  VERBOSE           Enable verbose output (true/false)
  COLOR             Color mode: auto, always, never

${BOLD}EXAMPLES${RESET}
  # Audit all servers
  ${SCRIPT_NAME} --audit

  # Audit with 24-hour threshold
  ${SCRIPT_NAME} --audit --max-age 24

  # Audit servers by label
  ${SCRIPT_NAME} --audit --label env=prod

  # List all snapshots
  ${SCRIPT_NAME} --snapshots

  # List all backups
  ${SCRIPT_NAME} --backups

  # JSON output
  ${SCRIPT_NAME} --audit --format json

  # Prometheus metrics
  ${SCRIPT_NAME} --audit --format prometheus

  # Cron — daily backup audit
  0 6 * * * /usr/local/bin/hetzner-backup-auditor.sh --audit --format prometheus --no-color > /var/lib/node_exporter/textfile/hetzner_backup.prom 2>/dev/null

${BOLD}EXIT CODES${RESET}
  0   Success
  1   Runtime error
EOF
}

# ══════════════════════════════════════════════════════════════════════
#                          PARSE ARGS
# ══════════════════════════════════════════════════════════════════════
parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --audit)       RUN_MODE="audit"; shift ;;
            --snapshots)   RUN_MODE="snapshots"; shift ;;
            --backups)     RUN_MODE="backups"; shift ;;
            --server)      SERVER_ID="${2:?--server requires an ID}"; shift 2 ;;
            --label)       LABEL_SELECTOR="${2:?--label requires KEY=VALUE}"; shift 2 ;;
            --max-age)     MAX_AGE_HOURS="${2:?--max-age requires HOURS}"; shift 2 ;;
            --format)      OUTPUT_FORMAT="${2:?--format requires a value}"; shift 2 ;;
            --verbose)     VERBOSE="true"; shift ;;
            --no-color)    COLOR="never"; shift ;;
            --help|-h)     setup_colors; show_help; exit 0 ;;
            *)             die "Unknown option: $1 (see --help)" ;;
        esac
    done
}

# ══════════════════════════════════════════════════════════════════════
#                          MAIN
# ══════════════════════════════════════════════════════════════════════
main() {
    parse_args "$@"
    setup_colors

    if [[ -z "$RUN_MODE" ]]; then
        RUN_MODE="audit"
    fi

    check_deps
    check_credentials

    START_TIME=$(date +%s)

    case "$RUN_MODE" in
        audit)     do_audit ;;
        snapshots) do_snapshots ;;
        backups)   do_backups ;;
        *)         die "Unknown mode: ${RUN_MODE}" ;;
    esac

    if [[ "$OUTPUT_FORMAT" != "prometheus" ]]; then
        echo ""
        field "Duration:" "$(elapsed)"
    fi
}

main "$@"
