#!/usr/bin/env bash

#########################################################################################
#### route53-dns-backup.sh — Export AWS Route 53 zones to BIND zone file format      ####
#### Supports diff, S3 upload, retention, and zone restore                           ####
#### Requires: bash 4+, aws-cli v2, jq                                              ####
####                                                                                 ####
#### Author: Phil Connor                                                             ####
#### Contact: contact@mylinux.work                                                   ####
#### License: MIT                                                                    ####
#### Version 1.00                                                                    ####
####                                                                                 ####
#### Usage:                                                                          ####
####   ./route53-dns-backup.sh --export                                              ####
####   ./route53-dns-backup.sh --export --s3-bucket my-dns-backups                   ####
####                                                                                 ####
#### See --help for all options.                                                     ####
#########################################################################################

set -euo pipefail

# ── Defaults ──────────────────────────────────────────────────────────
AWS_REGION="${AWS_REGION:-us-east-1}"
BACKUP_DIR="${R53_BACKUP_DIR:-$HOME/route53-backups}"
S3_BUCKET="${R53_S3_BUCKET:-}"
S3_PREFIX="${R53_S3_PREFIX:-route53-backups}"
RETAIN_DAYS="${R53_RETAIN_DAYS:-90}"
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"

# ── State ─────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
RUN_MODE=""
ZONE_ID=""
RESTORE_FILE=""
DRY_RUN="false"
DIFF_MODE="false"
OUTPUT_DIR=""
TOTAL_ZONES=0
TOTAL_RECORDS=0
START_TIME=""

# ── Colors ────────────────────────────────────────────────────────────
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET=""
setup_colors() {
    if [[ "$COLOR" == "never" ]]; then
        return
    fi
    if [[ "$COLOR" == "auto" && ! -t 1 ]]; then
        return
    fi
    RED="\033[0;31m"
    GREEN="\033[0;32m"
    YELLOW="\033[0;33m"
    BLUE="\033[0;34m"
    BOLD="\033[1m"
    DIM="\033[2m"
    RESET="\033[0m"
}

# ── Helpers ───────────────────────────────────────────────────────────
die()         { printf "%b\n" "${RED}[ERROR]${RESET} $*" >&2; exit 1; }
log_info()    { printf "%b\n" "${GREEN}[INFO]${RESET} $*"; }
log_warn()    { printf "%b\n" "${YELLOW}[WARN]${RESET} $*"; }
log_verbose() { [[ "$VERBOSE" == "true" ]] && printf "%b\n" "${DIM}[DEBUG]${RESET} $*" || true; }

usage() {
    cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS]

Export AWS Route 53 hosted zones to BIND zone file format.
Supports diff, S3 upload, local retention, and zone restore.

Modes:
  --export                   Export hosted zones to BIND format
  --restore                  Restore records from a BIND zone file

Options:
  --zone-id ID               Target a specific hosted zone (default: all)
  --diff                     Compare with most recent previous backup
  --s3-bucket BUCKET         Upload backups to S3
  --s3-prefix PREFIX         S3 key prefix (default: route53-backups)
  --file PATH                BIND zone file for restore
  --dry-run                  Preview restore without applying
  --output-dir DIR           Local backup directory (default: ~/route53-backups)
  --retain-days N            Delete local backups older than N days (default: 90)
  --verbose                  Debug output
  --no-color                 Disable colored output
  --help                     Show this help

Environment Variables:
  AWS_PROFILE                AWS CLI profile
  AWS_REGION                 AWS region (default: us-east-1)
  R53_BACKUP_DIR             Backup directory (default: ~/route53-backups)
  R53_S3_BUCKET              S3 bucket for uploads
  R53_S3_PREFIX              S3 key prefix (default: route53-backups)
  R53_RETAIN_DAYS            Local retention days (default: 90)

Examples:
  $SCRIPT_NAME --export
  $SCRIPT_NAME --export --diff --s3-bucket my-dns-backups
  $SCRIPT_NAME --export --zone-id Z0123456789ABCDEFGHIJ
  $SCRIPT_NAME --restore --zone-id Z0123456789ABCDEFGHIJ --file backup.zone --dry-run
EOF
    exit 0
}

# ── Dependency Check ──────────────────────────────────────────────────
check_deps() {
    local missing=()
    for cmd in aws jq; do
        if ! command -v "$cmd" &>/dev/null; then
            missing+=("$cmd")
        fi
    done
    if [[ ${#missing[@]} -gt 0 ]]; then
        die "Missing required commands: ${missing[*]}"
    fi
    log_verbose "Dependencies OK: aws, jq"
}

# ── Argument Parsing ──────────────────────────────────────────────────
parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --export)       RUN_MODE="export"; shift ;;
            --restore)      RUN_MODE="restore"; shift ;;
            --zone-id)      ZONE_ID="${2:?--zone-id requires a value}"; shift 2 ;;
            --diff)         DIFF_MODE="true"; shift ;;
            --s3-bucket)    S3_BUCKET="${2:?--s3-bucket requires a value}"; shift 2 ;;
            --s3-prefix)    S3_PREFIX="${2:?--s3-prefix requires a value}"; shift 2 ;;
            --file)         RESTORE_FILE="${2:?--file requires a value}"; shift 2 ;;
            --dry-run)      DRY_RUN="true"; shift ;;
            --output-dir)   OUTPUT_DIR="${2:?--output-dir requires a value}"; shift 2 ;;
            --retain-days)  RETAIN_DAYS="${2:?--retain-days requires a value}"; shift 2 ;;
            --verbose)      VERBOSE="true"; shift ;;
            --no-color)     COLOR="never"; shift ;;
            --help)         usage ;;
            *)              die "Unknown option: $1" ;;
        esac
    done

    [[ -z "$RUN_MODE" ]] && die "Specify --export or --restore. See --help."

    if [[ "$RUN_MODE" == "restore" ]]; then
        [[ -z "$ZONE_ID" ]] && die "--restore requires --zone-id"
        [[ -z "$RESTORE_FILE" ]] && die "--restore requires --file"
        [[ ! -f "$RESTORE_FILE" ]] && die "File not found: $RESTORE_FILE"
    fi
}

# ── AWS Identity ──────────────────────────────────────────────────────
get_account_id() {
    aws sts get-caller-identity --query 'Account' --output text 2>/dev/null || echo "unknown"
}

# ── List Hosted Zones ─────────────────────────────────────────────────
list_hosted_zones() {
    if [[ -n "$ZONE_ID" ]]; then
        aws route53 get-hosted-zone --id "$ZONE_ID" --output json 2>/dev/null | \
            jq '[{Id: .HostedZone.Id, Name: .HostedZone.Name, Private: .HostedZone.Config.PrivateZone, RecordCount: .HostedZone.ResourceRecordSetCount}]'
    else
        local zones="[]"
        local marker=""
        while true; do
            local cmd=(aws route53 list-hosted-zones --output json --max-items 100)
            [[ -n "$marker" ]] && cmd+=(--starting-token "$marker")

            local result
            result=$("${cmd[@]}" 2>/dev/null)

            zones=$(echo "$zones" "$result" | jq -s '.[0] + [.[1].HostedZones[] | {Id: .Id, Name: .Name, Private: .Config.PrivateZone, RecordCount: .ResourceRecordSetCount}]')

            marker=$(echo "$result" | jq -r '.NextToken // empty')
            [[ -z "$marker" ]] && break
        done
        echo "$zones"
    fi
}

# ── Export Zone to BIND Format ────────────────────────────────────────
export_zone() {
    local zone_id="$1"
    local zone_name="$2"
    local is_private="$3"
    local output_file="$4"

    # Strip trailing dot for display
    local display_name="${zone_name%.}"

    log_verbose "Exporting zone: $display_name ($zone_id)"

    # Fetch all records
    local records
    records=$(aws route53 list-resource-record-sets \
        --hosted-zone-id "$zone_id" \
        --output json 2>/dev/null)

    local record_count
    record_count=$(echo "$records" | jq '.ResourceRecordSets | length')

    # Write BIND zone file
    {
        echo "; Zone file for $display_name"
        echo "; Exported from AWS Route 53 on $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
        echo "; Hosted Zone ID: $zone_id"
        [[ "$is_private" == "true" ]] && echo "; Private hosted zone"
        echo ";"
        echo "\$ORIGIN ${zone_name}"
        echo ""

        # Process each record set
        echo "$records" | jq -r '.ResourceRecordSets[] | @json' | while IFS= read -r rr; do
            local rr_name rr_type rr_ttl
            rr_name=$(echo "$rr" | jq -r '.Name')
            rr_type=$(echo "$rr" | jq -r '.Type')
            rr_ttl=$(echo "$rr" | jq -r '.TTL // 300')

            # Handle ALIAS records (not supported in BIND)
            local alias_target
            alias_target=$(echo "$rr" | jq -r '.AliasTarget.DNSName // empty')
            if [[ -n "$alias_target" ]]; then
                echo "; ALIAS $rr_name $rr_type → $alias_target (Route 53 alias — no BIND equivalent)"
                continue
            fi

            # Standard resource records
            echo "$rr" | jq -r '.ResourceRecords[]?.Value // empty' | while IFS= read -r value; do
                [[ -z "$value" ]] && continue
                printf "%-40s %s  IN  %-8s %s\n" "$rr_name" "$rr_ttl" "$rr_type" "$value"
            done
        done
    } > "$output_file"

    local private_label=""
    [[ "$is_private" == "true" ]] && private_label=" (private)"

    printf "  %b %s (%s) — %d records → %s%s\n" \
        "${GREEN}✓${RESET}" "$display_name" "${zone_id##*/}" "$record_count" \
        "$(basename "$output_file")" "$private_label"

    TOTAL_ZONES=$((TOTAL_ZONES + 1))
    TOTAL_RECORDS=$((TOTAL_RECORDS + record_count))

    # Rate limiting — Route 53 API allows 5 req/s
    sleep 0.3
}

# ── Diff Against Previous Backup ──────────────────────────────────────
diff_zone() {
    local zone_name="$1"
    local current_file="$2"
    local backup_base="$3"

    local display_name="${zone_name%.}"
    local zone_filename
    zone_filename=$(sanitize_filename "$zone_name")

    # Find the most recent previous backup
    local prev_dir=""
    local today_dir
    today_dir=$(basename "$(dirname "$current_file")")

    if [[ -d "$backup_base" ]]; then
        prev_dir=$(find "$backup_base" -maxdepth 1 -mindepth 1 -type d \
            -not -name "$today_dir" | sort -r | head -1)
    fi

    if [[ -z "$prev_dir" || ! -f "$prev_dir/$zone_filename" ]]; then
        log_verbose "No previous backup found for $display_name — skipping diff"
        return
    fi

    local prev_file="$prev_dir/$zone_filename"

    # Filter out comments and blank lines for comparison
    local diff_output
    diff_output=$(diff \
        <(grep -v '^;' "$prev_file" | grep -v '^\$' | grep -v '^$' | sort) \
        <(grep -v '^;' "$current_file" | grep -v '^\$' | grep -v '^$' | sort) \
        2>/dev/null || true)

    if [[ -z "$diff_output" ]]; then
        echo "No changes in $display_name"
    else
        echo ""
        printf "%bChanges detected in %s:%b\n" "$YELLOW" "$display_name" "$RESET"
        echo "$diff_output" | while IFS= read -r line; do
            case "$line" in
                ">"*)  printf "  %b+ %s%b\n" "$GREEN" "${line:2}" "$RESET" ;;
                "<"*)  printf "  %b- %s%b\n" "$RED" "${line:2}" "$RESET" ;;
            esac
        done
    fi
}

# ── S3 Upload ─────────────────────────────────────────────────────────
upload_to_s3() {
    local local_dir="$1"
    local date_stamp
    date_stamp=$(basename "$local_dir")

    local s3_path="s3://${S3_BUCKET}/${S3_PREFIX}/${date_stamp}/"

    log_info "Uploading to $s3_path"

    aws s3 cp "$local_dir/" "$s3_path" \
        --recursive \
        --sse AES256 \
        --quiet \
        2>/dev/null

    log_info "Upload complete: $s3_path"
}

# ── Local Retention ───────────────────────────────────────────────────
enforce_retention() {
    local backup_base="$1"
    local days="$2"

    [[ ! -d "$backup_base" ]] && return

    local deleted=0
    while IFS= read -r dir; do
        [[ -z "$dir" ]] && continue
        log_verbose "Deleting old backup: $dir"
        rm -rf "$dir"
        deleted=$((deleted + 1))
    done < <(find "$backup_base" -maxdepth 1 -mindepth 1 -type d -mtime +"$days" 2>/dev/null)

    [[ $deleted -gt 0 ]] && log_info "Deleted $deleted backup(s) older than $days days"
}

# ── Restore Zone ──────────────────────────────────────────────────────
restore_zone() {
    local zone_id="$1"
    local file="$2"

    log_info "Restoring records to zone $zone_id from $file"
    [[ "$DRY_RUN" == "true" ]] && log_warn "DRY-RUN mode — no changes will be applied"

    local changes='{"Changes":[]}'
    local record_count=0

    while IFS= read -r line; do
        # Skip comments, directives, and blank lines
        [[ "$line" =~ ^[[:space:]]*$ ]] && continue
        [[ "$line" =~ ^[[:space:]]*\; ]] && continue
        [[ "$line" =~ ^\$ ]] && continue

        # Parse BIND record: name ttl IN type value
        local rr_name rr_ttl rr_type rr_value
        read -r rr_name rr_ttl _ rr_type rr_value <<< "$line"

        [[ -z "$rr_type" || -z "$rr_value" ]] && continue

        # Skip SOA and NS records (Route 53 manages these)
        [[ "$rr_type" == "SOA" || "$rr_type" == "NS" ]] && continue

        local change
        change=$(jq -n \
            --arg action "UPSERT" \
            --arg name "$rr_name" \
            --arg type "$rr_type" \
            --argjson ttl "$rr_ttl" \
            --arg value "$rr_value" \
            '{Action: $action, ResourceRecordSet: {Name: $name, Type: $type, TTL: $ttl, ResourceRecords: [{Value: $value}]}}')

        changes=$(echo "$changes" | jq --argjson c "$change" '.Changes += [$c]')
        record_count=$((record_count + 1))

        if [[ "$DRY_RUN" == "true" ]]; then
            printf "  %b[DRY-RUN]%b UPSERT %s %s %s → %s\n" "$YELLOW" "$RESET" "$rr_name" "$rr_ttl" "$rr_type" "$rr_value"
        fi
    done < "$file"

    if [[ $record_count -eq 0 ]]; then
        log_warn "No restorable records found in $file"
        return
    fi

    if [[ "$DRY_RUN" == "true" ]]; then
        log_info "DRY-RUN: $record_count record(s) would be upserted"
        return
    fi

    # Batch changes (Route 53 allows up to 1000 per request)
    local batch_size=500
    local total_changes
    total_changes=$(echo "$changes" | jq '.Changes | length')
    local batches=$(( (total_changes + batch_size - 1) / batch_size ))

    for ((i = 0; i < batches; i++)); do
        local offset=$((i * batch_size))
        local batch
        batch=$(echo "$changes" | jq "{Comment: \"Restore from backup\", Changes: .Changes[$offset:$((offset + batch_size))]}")

        log_verbose "Submitting batch $((i + 1))/$batches ($batch_size records)"

        aws route53 change-resource-record-sets \
            --hosted-zone-id "$zone_id" \
            --change-batch "$batch" \
            --output text \
            2>/dev/null

        sleep 0.5
    done

    log_info "Restored $record_count record(s) to zone $zone_id"
}

# ── Filename Sanitization ─────────────────────────────────────────────
sanitize_filename() {
    local name="$1"
    name="${name%.}"
    echo "${name}.zone"
}

# ── Main: Export ──────────────────────────────────────────────────────
run_export() {
    local account_id
    account_id=$(get_account_id)
    local date_stamp
    date_stamp=$(date -u '+%Y-%m-%d')

    local target_dir="${OUTPUT_DIR:-$BACKUP_DIR}/$date_stamp"
    mkdir -p "$target_dir"

    echo ""
    printf "%bRoute 53 DNS Backup%b\n" "$BOLD" "$RESET"
    echo "Account: $account_id"
    echo "Time:    $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
    if [[ -n "$ZONE_ID" ]]; then
        echo "Mode:    export (zone $ZONE_ID)"
    else
        echo "Mode:    export (all zones)"
    fi
    echo ""
    echo "Exporting hosted zones..."

    local zones
    zones=$(list_hosted_zones)

    local zone_count
    zone_count=$(echo "$zones" | jq 'length')

    if [[ "$zone_count" -eq 0 ]]; then
        log_warn "No hosted zones found"
        return
    fi

    for ((i = 0; i < zone_count; i++)); do
        local z_id z_name z_private
        z_id=$(echo "$zones" | jq -r ".[$i].Id" | sed 's|/hostedzone/||')
        z_name=$(echo "$zones" | jq -r ".[$i].Name")
        z_private=$(echo "$zones" | jq -r ".[$i].Private")

        local zone_file
        zone_file="$target_dir/$(sanitize_filename "$z_name")"

        export_zone "$z_id" "$z_name" "$z_private" "$zone_file"

        # Diff if requested
        if [[ "$DIFF_MODE" == "true" ]]; then
            diff_zone "$z_name" "$zone_file" "${OUTPUT_DIR:-$BACKUP_DIR}"
        fi
    done

    echo ""
    printf "Backup saved to: %b%s%b\n" "$BLUE" "$target_dir" "$RESET"
    echo "  $TOTAL_ZONES zones, $TOTAL_RECORDS records total"

    # S3 upload
    if [[ -n "$S3_BUCKET" ]]; then
        echo ""
        upload_to_s3 "$target_dir"
    fi

    # Local retention
    enforce_retention "${OUTPUT_DIR:-$BACKUP_DIR}" "$RETAIN_DAYS"
}

# ── Main: Restore ─────────────────────────────────────────────────────
run_restore() {
    restore_zone "$ZONE_ID" "$RESTORE_FILE"
}

# ── Entry Point ───────────────────────────────────────────────────────
main() {
    START_TIME=$(date +%s)
    setup_colors
    parse_args "$@"
    check_deps

    case "$RUN_MODE" in
        export)  run_export ;;
        restore) run_restore ;;
        *)       die "Unknown mode: $RUN_MODE" ;;
    esac

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

main "$@"
