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

# =====================================================
# save_batocera.sh
# Author: pacmaN (altbox.de)
# Release date: 2026-01-22
#
# This script creates automated backups of a Batocera
# system's save data and relevant configuration files
# via SSH.
#
# Features:
# - Backs up savegames and memory cards for multiple systems
#   (PSX, PS2, SNES, N64, GameCube, Wii, etc.)
# - Includes RetroArch / Libretro savestates
#   (limited to the newest states per game)
# - Includes PCSX2 savestates (.p2s)
# - Excludes savestate preview images and temporary files
# - Includes RTC files required by certain games
# - Includes Xbox (xemu) virtual hard drive and EEPROM
# - Creates timestamped backup snapshots
# - Keeps only the newest backup snapshots (configurable)
# - Uses an index file for fast incremental backups
# - Can be safely executed via cron
# - Supports a --force flag to bypass the time-based limit
#
# A new backup is only created if the latest backup
# is older than the configured number of days,
# unless the script is executed with --force.
#
# The script is designed for use in private home networks.
# SSH credentials must be configured at the top of the script
# before running.
# =====================================================

# -------------------------
# CONFIG
# -------------------------
BATOCERA_HOST="batocera.local"
BATOCERA_USER="root"
BATOCERA_PASS="YOUR_PASSWORD_HERE"
BATOCERA_PORT="22"

BASE_DIR="$HOME/batocera"
BACKUPS_DIR="$BASE_DIR/backups"
INDEX_FILE="$BASE_DIR/index.files"
META_FILE="$BASE_DIR/index.meta"

KEEP_LAST=3
STATE_LIMIT=5
MIN_DAYS_BETWEEN_BACKUPS=7

# Prune big/irrelevant subtrees inside /userdata/saves
PRUNE_DIRS=( "windows" "steam" "flatpak" "windows_installers" "kodi" )

# -------------------------
# ARGS (ROBUST)
# -------------------------
FORCE=0

usage() {
  cat <<'USAGE'
Usage: save_batocera.sh [--force|-f] [--help]
  --force, -f      Bypass the MIN_DAYS_BETWEEN_BACKUPS gate
  --help,  -h      Show this help
USAGE
}

# Parse all args (force can be anywhere), handle --force=1, strip CR
for arg in "$@"; do
  arg="${arg//$'\r'/}"         # in case something injects CR (Windows/CRLF etc.)
  key="${arg%%=*}"
  val="${arg#*=}"

  case "$key" in
    --force|-f)
      # allow plain --force OR --force=1/true/yes
      if [[ "$arg" == "--force" || "$arg" == "-f" ]]; then
        FORCE=1
      else
        case "${val,,}" in
          1|true|yes|y|on) FORCE=1 ;;
          0|false|no|n|off) FORCE=0 ;;
          *) FORCE=1 ;; # be permissive: --force=whatever still means force
        esac
      fi
      ;;
    --help|-h)
      usage
      exit 0
      ;;
    "")
      ;;
    *)
      echo "Unknown arg: $arg" >&2
      usage >&2
      exit 2
      ;;
  esac
done

# -------------------------
# REQUIREMENTS
# -------------------------
need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1" >&2; exit 2; }; }
need rsync
need ssh
need sshpass
need date
need awk
need ls
need rm
need wc
need tr
need find
need head
need sort
need sed
need mkdir
need stat

mkdir -p "$BASE_DIR" "$BACKUPS_DIR"

SSH_OPTS=(
  -p "$BATOCERA_PORT"
  -o "ConnectTimeout=30"
  -o "StrictHostKeyChecking=accept-new"
)

ssh_run() {
  sshpass -p "$BATOCERA_PASS" ssh "${SSH_OPTS[@]}" "${BATOCERA_USER}@${BATOCERA_HOST}" "$@"
}

# =====================================================
# RETENTION (ROBUST)
# Always keeps the newest KEEP_LAST folders by name (YYYYMMDD_HHMMSS sorts correctly)
# =====================================================
prune_old_backups() {
  local list
  list="$(find "$BACKUPS_DIR" -mindepth 1 -maxdepth 1 -type d -printf "%f\n" 2>/dev/null | sort -r || true)"
  [[ -n "${list:-}" ]] || return 0

  echo "$list" | tail -n +"$((KEEP_LAST + 1))" | while read -r bn; do
    [[ -n "${bn:-}" ]] || continue
    rm -rf -- "$BACKUPS_DIR/$bn"
  done
}

# =====================================================
# 7-DAY GATE
# Uses filesystem mtime of the newest backup directory (no date parsing)
# =====================================================
latest_backup_dir_path() {
  find "$BACKUPS_DIR" -mindepth 1 -maxdepth 1 -type d -printf "%T@ %p\n" 2>/dev/null \
    | sort -nr | head -n 1 | awk '{print $2}' || true
}

latest_backup_age_seconds() {
  local last now last_ts
  last="$(latest_backup_dir_path)"
  [[ -n "${last:-}" ]] || { echo 999999999; return; }

  now="$(date +%s)"
  last_ts="$(stat -c %Y "$last" 2>/dev/null || echo 0)"
  [[ "${last_ts:-0}" -gt 0 ]] || { echo 999999999; return; }

  echo $(( now - last_ts ))
}

# =====================================================
# INDEX
# =====================================================
validate_index() {
  [[ -f "$META_FILE" && -f "$INDEX_FILE" ]] || return 1
  local files
  files="$(awk -F= '$1=="files"{print $2}' "$META_FILE" 2>/dev/null || echo 0)"
  [[ "${files:-0}" -gt 0 ]]
}

build_index() {
  local tmp_non tmp_state tmp_all
  tmp_non="$(mktemp)"
  tmp_state="$(mktemp)"
  tmp_all="$(mktemp)"
  trap 'rm -f "$tmp_non" "$tmp_state" "$tmp_all"' RETURN

  local prune_expr=""
  for d in "${PRUNE_DIRS[@]}"; do
    prune_expr+=" -path /userdata/saves/$d -prune -o"
  done

  local remote_script
  remote_script=$(cat <<EOF
set -e

echo "__NON_STATE__"
if [ -d /userdata/saves ]; then
  find /userdata/saves $prune_expr -type f \\( \
    -iname "*.srm" -o -iname "*.sav" -o \
    -iname "*.mcr" -o -iname "*.mcd" -o \
    -iname "*.ps2" -o -iname "*.psu" -o \
    -iname "*.gci" -o -iname "MemoryCard*.raw" -o \
    -iname "*.rtc" -o \
    -path "/userdata/saves/xbox/xemu_eeprom.bin" -o \
    -path "/userdata/saves/xbox/xbox_hdd.qcow2" \
  \\) -print
fi

echo "__STATE__"
if [ -d /userdata/saves ]; then
  find /userdata/saves $prune_expr -type f \\( \
    -iname "*.state" -o -iname "*.state.auto" -o -iname "*.state[0-9]*" -o \
    -iname "*.p2s" \
  \\) ! -iname "*.png" ! -iname "*.backup" -exec sh -c '
    for f in "\$@"; do
      t=\$(stat -c %Y "\$f" 2>/dev/null || date -r "\$f" +%s 2>/dev/null || echo 0)
      printf "%s\\t%s\\n" "\$t" "\$f"
    done
  ' sh {} +
fi
EOF
)

  ssh_run "$remote_script" > "$tmp_all" 2>/dev/null || true

  awk '
    $0=="__NON_STATE__"{mode=1;next}
    $0=="__STATE__"{mode=2;next}
    mode==1{print > NON}
    mode==2{print > STA}
  ' NON="$tmp_non" STA="$tmp_state" "$tmp_all"

  : > "$INDEX_FILE"
  local count=0

  while IFS= read -r path; do
    [[ -n "${path:-}" ]] || continue
    case "$path" in /userdata/*) ;; *) continue ;; esac
    rel="${path#/}"
    printf "%s\0" "$rel" >> "$INDEX_FILE"
    count=$((count+1))
  done < "$tmp_non"

  sort -nr "$tmp_state" \
    | awk -F'\t' -v limit="$STATE_LIMIT" '
        function mkkey(p,   k) {
          k=p
          sub(/\.state\.auto$/, "", k)
          sub(/\.state[0-9]+$/, "", k)
          sub(/\.state$/, "", k)
          sub(/\.p2s$/, "", k)
          return k
        }
        NF>=2 {
          p=$2
          key=mkkey(p)
          if (!(key in c)) c[key]=0
          if (c[key] < limit) {
            c[key]++
            print p
          }
        }
      ' \
    | while IFS= read -r path; do
        [[ -n "${path:-}" ]] || continue
        case "$path" in /userdata/*) ;; *) continue ;; esac
        rel="${path#/}"
        printf "%s\0" "$rel" >> "$INDEX_FILE"
        count=$((count+1))
      done

  local ts
  ts="$(date -Iseconds)"
  printf "timestamp=%s\nfiles=%s\n" "$ts" "$count" > "$META_FILE"
}

backup_file_count() {
  find "$1" -type f -size +0c 2>/dev/null | wc -l | tr -d ' '
}

# =====================================================
# MAIN
# =====================================================

# Gate: do nothing if newest backup is younger than MIN_DAYS_BETWEEN_BACKUPS (unless --force)
if [[ "$FORCE" -ne 1 ]]; then
  age_s="$(latest_backup_age_seconds)"
  if [[ "$age_s" -lt $((MIN_DAYS_BETWEEN_BACKUPS * 86400)) ]]; then
    days_old=$(( age_s / 86400 ))
    echo "Skip: latest backup is only ${days_old} day(s) old (< ${MIN_DAYS_BETWEEN_BACKUPS}). Use --force to run anyway." >&2
    prune_old_backups
    exit 0
  fi
else
  echo "Force enabled: bypassing ${MIN_DAYS_BETWEEN_BACKUPS}-day gate." >&2
fi

# Connectivity check
ssh_run "true" >/dev/null 2>&1 || exit 1

# Ensure index exists
if ! validate_index; then
  build_index
fi
validate_index || { echo "Index empty - no savefiles found." >&2; exit 1; }

# Create new snapshot
TS="$(date +%Y%m%d_%H%M%S)"
SNAP="$BACKUPS_DIR/$TS"
mkdir -p "$SNAP"

# Backup indexed files with total progress line
rsync -aH --numeric-ids \
  --files-from="$INDEX_FILE" --from0 --relative \
  --info=progress2 \
  -e "sshpass -p '$BATOCERA_PASS' ssh ${SSH_OPTS[*]}" \
  "${BATOCERA_USER}@${BATOCERA_HOST}:/" \
  "$SNAP/"

# Also store batocera.conf + PCSX2 configs (silent if missing)
rsync -aH --numeric-ids --info=progress2 \
  -e "sshpass -p '$BATOCERA_PASS' ssh ${SSH_OPTS[*]}" \
  "${BATOCERA_USER}@${BATOCERA_HOST}:/userdata/system/batocera.conf" \
  "$SNAP/batocera.conf" 2>/dev/null || true

rsync -aH --numeric-ids --info=progress2 \
  -e "sshpass -p '$BATOCERA_PASS' ssh ${SSH_OPTS[*]}" \
  "${BATOCERA_USER}@${BATOCERA_HOST}:/userdata/system/configs/PCSX2/" \
  "$SNAP/pcsx2_configs/" 2>/dev/null || true

# Validate snapshot (must contain files under userdata)
cnt="$(backup_file_count "$SNAP/userdata")"
if [[ "${cnt:-0}" -le 0 ]]; then
  rm -rf -- "$SNAP"
  echo "Backup empty -> discarded." >&2
  exit 1
fi

# Retention
prune_old_backups
exit 0