#!/usr/bin/env bash
# /opt/docker/backup.sh
#
# Daily backup of all Docker service databases, named volumes, and the
# Minecraft world to /opt/backups/. Runs as root via cron at 02:00 with
# 7-day retention.
#
# Phase 1 of BACKUP-STRATEGY.md ("stop the bleeding") — repairs the
# orphaned synapse-signing-key block that was killing the script under
# `set -e` before the Minecraft section ran. Also adds structured
# logging and a sentinel `.last-success` file so silent failures are
# detectable from outside the script.
#
# A separate Phase 2 (restic playerdata snapshots every 5 min) is
# delivered by scripts/restic-backup-playerdata.sh + the systemd unit
# pair under scripts/systemd/. This file remains the safety net.
set -euo pipefail
umask 077

BACKUP_DIR="/opt/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_PATH="${BACKUP_DIR}/${TIMESTAMP}"
LOG="${BACKUP_DIR}/backup.log"
SENTINEL="${BACKUP_DIR}/.last-success"
KEEP_DAYS=7

# Track whether each backup arm succeeded so we can honour the
# sentinel contract: only stamp .last-success if the *world* (the
# critical T1 case) was captured. Other arms can fail without
# blocking the sentinel — they have their own logged FAILED lines.
MC_WORLD_OK=0

log() {
    printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"
}

mkdir -p "$BACKUP_PATH"
log "=== Backup started: ${TIMESTAMP} ==="

# ── Matrix PostgreSQL ──────────────────────────────────────────────
log "Dumping Matrix PostgreSQL..."
if docker ps --format '{{.Names}}' | grep -q '^matrix-postgres$'; then
    if docker exec matrix-postgres pg_dump -U synapse synapse \
        | gzip > "${BACKUP_PATH}/matrix-postgres-${TIMESTAMP}.sql.gz"; then
        log "  Matrix Postgres: OK ($(du -sh "${BACKUP_PATH}/matrix-postgres-${TIMESTAMP}.sql.gz" | cut -f1))"
    else
        log "  Matrix Postgres: FAILED"
    fi
else
    log "  matrix-postgres not running — skipping"
fi

# ── Rocket.Chat MongoDB ────────────────────────────────────────────
log "Dumping Rocket.Chat MongoDB..."
if docker ps --format '{{.Names}}' | grep -q '^mongodb$'; then
    if docker exec mongodb mongodump \
        -u admin -p CHANGE_ME_MONGO_ADMIN_PASSWORD \
        --authenticationDatabase admin \
        --db rocketchat \
        --archive \
        | gzip > "${BACKUP_PATH}/rocketchat-mongo-${TIMESTAMP}.archive.gz"; then
        log "  MongoDB: OK ($(du -sh "${BACKUP_PATH}/rocketchat-mongo-${TIMESTAMP}.archive.gz" | cut -f1))"
    else
        log "  MongoDB: FAILED"
    fi
else
    log "  mongodb not running — skipping"
fi

# ── Named Docker volumes ───────────────────────────────────────────
log "Backing up Docker volumes..."
for VOLUME in synapse-media rocketchat-uploads; do
    if docker volume ls --format '{{.Name}}' | grep -q "^matrix_${VOLUME}\|^rocketchat_${VOLUME}\|^${VOLUME}$"; then
        ACTUAL_VOL=$(docker volume ls --format '{{.Name}}' | grep "${VOLUME}" | head -1)
        if docker run --rm \
            -v "${ACTUAL_VOL}:/volume:ro" \
            -v "${BACKUP_PATH}:/backup" \
            alpine \
            tar czf "/backup/${VOLUME}-${TIMESTAMP}.tar.gz" -C /volume . ; then
            log "  Volume ${VOLUME}: OK"
        else
            log "  Volume ${VOLUME}: FAILED"
        fi
    else
        log "  Volume ${VOLUME}: not found — skipping"
    fi
done

# ── Config files (bind mounts) ─────────────────────────────────────
log "Backing up config directories..."
if tar czf "${BACKUP_PATH}/configs-${TIMESTAMP}.tar.gz" \
    /opt/docker/traefik/traefik.yml \
    /opt/docker/traefik/config/ \
    /opt/docker/matrix/docker-compose.yml \
    /opt/docker/matrix/element-config/ \
    /opt/docker/matrix/synapse-config/homeserver.yaml \
    /opt/docker/matrix/synapse-config/matrix.example.com.log.config \
    /opt/docker/rocketchat/docker-compose.yml \
    2>/dev/null; then
    log "  Configs: OK"
else
    log "  Configs: partial (some files missing)"
fi

# Synapse signing key — sensitive, copy out separately with tight perms.
if [ -f /opt/docker/matrix/synapse-config/matrix.example.com.signing.key ]; then
    cp /opt/docker/matrix/synapse-config/matrix.example.com.signing.key \
        "${BACKUP_PATH}/synapse-signing-key-${TIMESTAMP}.key"
    chmod 600 "${BACKUP_PATH}/synapse-signing-key-${TIMESTAMP}.key"
    log "  Synapse signing key: backed up (600)"
fi

# ── Minecraft server ───────────────────────────────────────────────
# This is the block that was missing from the deployed copy and
# corrupted by an orphaned synapse-signing-key fragment in the repo
# copy. Wrapped in a subshell so a failure here does NOT exit the
# whole script under `set -e` — we want the prune step and sentinel
# logic to still run.
log "Backing up Minecraft server..."

# tar exit codes: 0 = clean, 1 = "some files differed/changed during read"
# (NORMAL on a live MC server — chunks save while we read), 2 = fatal.
# Treat 0 and 1 as success, 2+ as failure.
tar_ok() { local rc=$1; [ "$rc" -le 1 ]; }

mc_backup() {
    if docker ps --format '{{.Names}}' | grep -q '^minecraft-mc$'; then
        # Server running — flush via rcon if mcrcon installed, then
        # tar inside the container so we get a consistent point-in-time.
        if command -v mcrcon >/dev/null 2>&1; then
            mcrcon -H 127.0.0.1 -P 25575 \
                -p "${MC_RCON_PASSWORD:-${MC_RCON_PASSWORD}}" \
                -w 1 "save-all flush" >/dev/null 2>&1 || true
        fi

        # World tar — runs inside the container. We ignore tar exit 1
        # ("file changed as we read it") because that's expected on a
        # live server and the resulting archive is still usable.
        local tar_rc=0
        docker exec minecraft-mc bash -c \
            "cd /data && tar czf /tmp/mc-world-backup-${TIMESTAMP}.tar.gz world/ world_nether/ world_the_end/" \
            >/dev/null 2>&1 || tar_rc=$?
        if tar_ok "$tar_rc" \
            && docker cp "minecraft-mc:/tmp/mc-world-backup-${TIMESTAMP}.tar.gz" "${BACKUP_PATH}/" >/dev/null 2>&1 \
            && docker exec minecraft-mc rm -f "/tmp/mc-world-backup-${TIMESTAMP}.tar.gz" >/dev/null 2>&1; then
            local sz
            sz=$(du -sh "${BACKUP_PATH}/mc-world-backup-${TIMESTAMP}.tar.gz" | cut -f1)
            if [ "$tar_rc" -eq 1 ]; then
                log "  Minecraft world: OK (${sz}) [tar exit 1 — files changed during read, expected on live server]"
            else
                log "  Minecraft world: OK (${sz})"
            fi
            MC_WORLD_OK=1
        else
            log "  Minecraft world: FAILED (tar_rc=${tar_rc})"
            # Best-effort cleanup of any half-written file inside the container.
            docker exec minecraft-mc rm -f "/tmp/mc-world-backup-${TIMESTAMP}.tar.gz" >/dev/null 2>&1 || true
        fi

        # Plugins (jars + on-disk config) — small, do this regardless
        # of world result so we always have plugin state on hand.
        # `--ignore-failed-read` suppresses spark profiler tmp files
        # (running JFR files briefly mode 600); `--warning=no-file-changed`
        # silences CoreProtect db noise in the log.
        local prc=0
        tar --ignore-failed-read --warning=no-file-changed \
            -czf "${BACKUP_PATH}/minecraft-plugins-${TIMESTAMP}.tar.gz" \
            -C /opt/docker/minecraft plugins/ >/dev/null 2>&1 || prc=$?
        if tar_ok "$prc"; then
            log "  Minecraft plugins: OK ($(du -sh "${BACKUP_PATH}/minecraft-plugins-${TIMESTAMP}.tar.gz" | cut -f1))"
        else
            log "  Minecraft plugins: FAILED (rc=${prc})"
        fi

        # Plugin DBs — copied (not dumped, all SQLite/file-based) into
        # a tagged tarball so restore is straightforward.
        local drc=0
        tar --ignore-failed-read --warning=no-file-changed \
            -czf "${BACKUP_PATH}/minecraft-dbs-${TIMESTAMP}.tar.gz" \
            -C /opt/docker/minecraft \
            homestead_data.db \
            plugins/AuthMe/authme.db \
            plugins/CoreProtect/database.db \
            plugins/LuckPerms/ \
            >/dev/null 2>&1 || drc=$?
        if tar_ok "$drc"; then
            log "  Minecraft DBs: OK ($(du -sh "${BACKUP_PATH}/minecraft-dbs-${TIMESTAMP}.tar.gz" | cut -f1))"
        else
            log "  Minecraft DBs: partial (rc=${drc} — some files may be missing)"
        fi

        # Server-side configs and access lists. Some of these files are
        # optional (eg whitelist.json absent when whitelisting is off).
        # tar reports rc=2 for missing files, so we prefilter the list.
        local cfg_files=()
        for f in server.properties purpur.yml spigot.yml bukkit.yml \
                 commands.yml help.yml permissions.yml \
                 ops.json whitelist.json banned-players.json banned-ips.json \
                 usercache.json eula.txt docker-compose.yml; do
            [ -e "/opt/docker/minecraft/$f" ] && cfg_files+=("$f")
        done
        local crc=0
        tar czf "${BACKUP_PATH}/minecraft-configs-${TIMESTAMP}.tar.gz" \
            -C /opt/docker/minecraft "${cfg_files[@]}" \
            >/dev/null 2>&1 || crc=$?
        if tar_ok "$crc"; then
            log "  Minecraft configs: OK (${#cfg_files[@]} files)"
        else
            log "  Minecraft configs: FAILED (rc=${crc})"
        fi
    else
        # Server stopped — back up everything from disk directly.
        local frc=0
        tar czf "${BACKUP_PATH}/minecraft-full-backup-${TIMESTAMP}.tar.gz" \
            -C /opt/docker/minecraft \
            world/ \
            world_nether/ \
            world_the_end/ \
            plugins/ \
            homestead_data.db \
            server.properties \
            purpur.yml \
            spigot.yml \
            bukkit.yml \
            ops.json \
            whitelist.json \
            banned-players.json \
            banned-ips.json \
            usercache.json \
            docker-compose.yml \
            >/dev/null 2>&1 || frc=$?
        if tar_ok "$frc"; then
            log "  Minecraft (full, offline): OK ($(du -sh "${BACKUP_PATH}/minecraft-full-backup-${TIMESTAMP}.tar.gz" | cut -f1))"
            MC_WORLD_OK=1
        else
            log "  Minecraft (offline): partial (rc=${frc})"
        fi
    fi
}

# Run MC arm — never let it kill the rest of the script.
if ! mc_backup; then
    log "  Minecraft arm exited non-zero — see lines above"
fi

# ── Prune old backups ──────────────────────────────────────────────
log "Pruning backups older than ${KEEP_DAYS} days..."
find "$BACKUP_DIR" -maxdepth 1 -type d -mtime "+${KEEP_DAYS}" -exec rm -rf {} + 2>/dev/null || true
find "$BACKUP_DIR" -maxdepth 1 -name "*.log" -mtime +30 -delete 2>/dev/null || true

BACKUP_SIZE=$(du -sh "$BACKUP_PATH" | cut -f1)
log "=== Backup complete: ${BACKUP_PATH} (${BACKUP_SIZE}) ==="

# ── Sentinel ───────────────────────────────────────────────────────
# Touch the sentinel only if the world (T1 case) was captured. An
# external monitor (cron on onyx, or ntfy/healthchecks once wired)
# can alert on `find /opt/backups/.last-success -mmin +1500` to catch
# silent failures within 25h of a missed daily run.
if [ "$MC_WORLD_OK" -eq 1 ]; then
    {
        printf 'last_success=%s\n' "$(date -Iseconds)"
        printf 'backup_path=%s\n'  "$BACKUP_PATH"
        printf 'backup_size=%s\n'  "$BACKUP_SIZE"
    } > "$SENTINEL"
    log "Sentinel updated: ${SENTINEL}"
else
    log "WARNING: world backup did NOT succeed — sentinel NOT updated"
fi
