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

APP_NAME="nextcloud-borg-backup"
APP_VERSION="1.0.0"

CONFIG_FILE="${NCB_CONFIG:-${XDG_CONFIG_HOME:-${HOME}/.config}/${APP_NAME}/config}"

die() {
  printf 'ERROR: %s\n' "$*" >&2
  exit 1
}

warn() {
  printf 'WARN: %s\n' "$*" >&2
}

info() {
  printf '%s\n' "$*"
}

have() {
  command -v "$1" >/dev/null 2>&1
}

shell_quote() {
  printf '%q' "$1"
}

usage() {
  cat <<'EOF'
nextcloud-borg-backup - Borg backup helper for a mounted Nextcloud folder

Usage:
  nextcloud-borg-backup backup
  nextcloud-borg-backup init
  nextcloud-borg-backup list
  nextcloud-borg-backup info [ARCHIVE]
  nextcloud-borg-backup search PATTERN [ARCHIVE]
  nextcloud-borg-backup mount [ARCHIVE] [MOUNT_DIR]
  nextcloud-borg-backup umount [MOUNT_DIR]
  nextcloud-borg-backup restore [ARCHIVE] [PATH] [DEST_DIR]
  nextcloud-borg-backup check [--verify-data]
  nextcloud-borg-backup prune
  nextcloud-borg-backup status
  nextcloud-borg-backup break-lock

Environment:
  NCB_CONFIG=/path/to/config
EOF
}

load_config() {
  [[ -r "$CONFIG_FILE" ]] || die "Config not readable: $CONFIG_FILE"
  # shellcheck disable=SC1090
  source "$CONFIG_FILE"

  : "${SOURCE_DIR:=${HOME}/Nextcloud}"
  : "${REPO_DIR:=/net/thor/volume1/NetBackup/Nextcloud/borg-repo}"
  : "${ARCHIVE_PREFIX:=nextcloud}"
  : "${BORG_BIN:=borg}"
  : "${BORG_ENCRYPTION:=repokey-blake2}"
  : "${BORG_PASSPHRASE_FILE:=${XDG_CONFIG_HOME:-${HOME}/.config}/${APP_NAME}/passphrase}"
  : "${BORG_COMPRESSION:=zstd,6}"
  : "${BORG_REMOTE_PATH:=borg}"
  : "${BORG_FILES_CACHE:=}"
  : "${RETENTION_WITHIN:=4w}"
  : "${PRUNE_AFTER_BACKUP:=true}"
  : "${COMPACT_AFTER_PRUNE:=true}"
  : "${REPOSITORY_CHECK_AFTER_BACKUP:=true}"
  : "${FULL_CHECK_INTERVAL_DAYS:=7}"
  : "${FULL_CHECK_VERIFY_DATA:=false}"
  : "${ALLOW_EMPTY_SOURCE:=false}"
  : "${MIN_FREE_REPO_GB:=5}"
  : "${PRECHECK_TIMEOUT_SECONDS:=60}"
  : "${LOG_DIR:=${XDG_STATE_HOME:-${HOME}/.local/state}/${APP_NAME}/logs}"
  : "${STATE_DIR:=${XDG_STATE_HOME:-${HOME}/.local/state}/${APP_NAME}}"
  : "${RESTORE_DIR:=${HOME}/Nextcloud-Restore}"
  : "${MOUNT_DIR:=${XDG_RUNTIME_DIR:-/tmp}/${APP_NAME}-mount}"
  : "${EXCLUDE_FILE:=${XDG_CONFIG_HOME:-${HOME}/.config}/${APP_NAME}/excludes}"
  : "${EXCLUDE_CACHES:=true}"
  : "${ONE_FILE_SYSTEM:=true}"
  : "${LOCK_FILE:=${STATE_DIR}/${APP_NAME}.lock}"

  export BORG_RELOCATED_REPO_ACCESS_IS_OK="${BORG_RELOCATED_REPO_ACCESS_IS_OK:-yes}"
  export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK="${BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK:-yes}"
  export BORG_REMOTE_PATH
  [[ -n "$BORG_FILES_CACHE" ]] && export BORG_FILES_CACHE

  if [[ -f "$BORG_PASSPHRASE_FILE" ]]; then
    export BORG_PASSCOMMAND="cat $(shell_quote "$BORG_PASSPHRASE_FILE")"
  fi
}

require_command() {
  have "$1" || die "Required command missing: $1"
}

ensure_dirs() {
  mkdir -p "$LOG_DIR" "$STATE_DIR"
}

log_file_for_run() {
  local timestamp
  timestamp="$(date '+%Y-%m-%d_%H-%M-%S')"
  printf '%s/%s_%s.log' "$LOG_DIR" "$APP_NAME" "$timestamp"
}

start_logging() {
  ensure_dirs
  local log_file
  log_file="$(log_file_for_run)"
  exec > >(tee -a "$log_file") 2>&1
  info "== ${APP_NAME} ${APP_VERSION} =="
  info "-- Time: $(date -Is)"
  info "-- Config: $CONFIG_FILE"
  info "-- Log: $log_file"
}

with_lock() {
  exec 9>"$LOCK_FILE"
  flock -n 9 || die "Another ${APP_NAME} run is already active: $LOCK_FILE"
}

repo_parent() {
  dirname "$REPO_DIR"
}

check_passphrase_for_encryption() {
  case "$BORG_ENCRYPTION" in
    none|authenticated*) return 0 ;;
  esac
  [[ -r "$BORG_PASSPHRASE_FILE" ]] || die "BORG_PASSPHRASE_FILE missing/read-protected: $BORG_PASSPHRASE_FILE"
}

borg_repo_exists() {
  [[ -d "$REPO_DIR" && -f "$REPO_DIR/config" ]]
}

init_repo() {
  require_command "$BORG_BIN"
  check_passphrase_for_encryption
  mkdir -p "$(repo_parent)"
  if borg_repo_exists; then
    info "-- Borg repository already exists: $REPO_DIR"
    return 0
  fi
  info "-- Initializing Borg repository: $REPO_DIR"
  "$BORG_BIN" init --encryption="$BORG_ENCRYPTION" "$REPO_DIR"
}

timeout_cmd() {
  if have timeout; then
    timeout "$PRECHECK_TIMEOUT_SECONDS" "$@"
  else
    "$@"
  fi
}

count_source_entries() {
  timeout_cmd find "$SOURCE_DIR" -mindepth 1 -maxdepth 1 -print -quit | wc -l
}

free_repo_gb() {
  df -PBG "$(repo_parent)" | awk 'NR==2 { gsub(/G/, "", $4); print $4 }'
}

preflight() {
  require_command "$BORG_BIN"
  require_command flock
  require_command df
  require_command find

  info "-- Source: $SOURCE_DIR"
  info "-- Repository: $REPO_DIR"

  timeout_cmd test -d "$SOURCE_DIR" || die "Source directory is not reachable: $SOURCE_DIR"
  timeout_cmd test -r "$SOURCE_DIR" || die "Source directory is not readable: $SOURCE_DIR"

  local entry_count
  entry_count="$(count_source_entries | tr -d '[:space:]')"
  if [[ "$ALLOW_EMPTY_SOURCE" != "true" && "${entry_count:-0}" -eq 0 ]]; then
    die "Source directory looks empty. Refusing to create an empty backup."
  fi

  mkdir -p "$(repo_parent)"
  timeout_cmd test -w "$(repo_parent)" || die "Repository parent is not writable/reachable: $(repo_parent)"

  local free_gb
  free_gb="$(free_repo_gb)"
  info "-- Free space at repository parent: ${free_gb}G"
  [[ "${free_gb:-0}" -ge "$MIN_FREE_REPO_GB" ]] || die "Not enough free space at repository parent; need ${MIN_FREE_REPO_GB}G"

  if ! borg_repo_exists; then
    init_repo
  fi
}

create_archive() {
  local timestamp archive
  timestamp="$(date '+%Y-%m-%d_%H-%M-%S')"
  archive="${ARCHIVE_PREFIX}-${timestamp}"

  local borg_args
  borg_args=(
    create
    --stats
    --show-rc
    --compression "$BORG_COMPRESSION"
  )

  [[ "$EXCLUDE_CACHES" == "true" ]] && borg_args+=(--exclude-caches)
  [[ "$ONE_FILE_SYSTEM" == "true" ]] && borg_args+=(--one-file-system)
  [[ -f "$EXCLUDE_FILE" ]] && borg_args+=(--exclude-from "$EXCLUDE_FILE")

  info "-- Creating archive: $archive"
  (
    cd "$SOURCE_DIR"
    "$BORG_BIN" "${borg_args[@]}" "${REPO_DIR}::${archive}" .
  )
}

archive_names() {
  "$BORG_BIN" list --short --glob-archives "${ARCHIVE_PREFIX}-*" "$REPO_DIR"
}

latest_archive() {
  archive_names | tail -n 1
}

prune_repo() {
  info "-- Pruning archives with retention: keep-within $RETENTION_WITHIN"
  "$BORG_BIN" prune --list --show-rc --glob-archives "${ARCHIVE_PREFIX}-*" --keep-within "$RETENTION_WITHIN" "$REPO_DIR"
}

compact_repo() {
  info "-- Compacting repository"
  "$BORG_BIN" compact "$REPO_DIR"
}

days_since_stamp() {
  local stamp="$1"
  [[ -f "$stamp" ]] || { printf '999999\n'; return 0; }
  local now then
  now="$(date +%s)"
  then="$(stat -c %Y "$stamp")"
  printf '%s\n' "$(((now - then) / 86400))"
}

check_repo() {
  local verify_data="${1:-false}"
  if [[ "$verify_data" == "true" ]]; then
    info "-- Running full Borg check with data verification"
    "$BORG_BIN" check --verify-data "$REPO_DIR"
  else
    info "-- Running Borg repository check"
    "$BORG_BIN" check --repository-only "$REPO_DIR"
  fi
}

maybe_periodic_full_check() {
  [[ "$FULL_CHECK_VERIFY_DATA" == "true" ]] || return 0
  local stamp="${STATE_DIR}/last-full-check"
  local age_days
  age_days="$(days_since_stamp "$stamp")"
  [[ "$age_days" -ge "$FULL_CHECK_INTERVAL_DAYS" ]] || return 0
  check_repo true
  date -Is > "$stamp"
}

cmd_backup() {
  load_config
  start_logging
  with_lock
  preflight
  create_archive
  if [[ "$PRUNE_AFTER_BACKUP" == "true" ]]; then
    prune_repo
    [[ "$COMPACT_AFTER_PRUNE" == "true" ]] && compact_repo
  fi
  [[ "$REPOSITORY_CHECK_AFTER_BACKUP" == "true" ]] && check_repo false
  maybe_periodic_full_check
  info "-- Backup finished successfully"
}

cmd_list() {
  load_config
  require_command "$BORG_BIN"
  "$BORG_BIN" list "$REPO_DIR"
}

cmd_info() {
  load_config
  require_command "$BORG_BIN"
  local archive="${1:-}"
  [[ -n "$archive" ]] || archive="$(latest_archive)"
  [[ -n "$archive" ]] || die "No archive found"
  "$BORG_BIN" info "${REPO_DIR}::${archive}"
}

cmd_search() {
  load_config
  require_command "$BORG_BIN"
  local pattern="${1:-}"
  local archive="${2:-}"
  [[ -n "$pattern" ]] || die "Missing search pattern"

  if [[ -n "$archive" ]]; then
    "$BORG_BIN" list --short "${REPO_DIR}::${archive}" | grep -i -- "$pattern" || true
    return 0
  fi

  local found=0
  while IFS= read -r archive; do
    [[ -n "$archive" ]] || continue
    while IFS= read -r path; do
      printf '%s\t%s\n' "$archive" "$path"
      found=1
    done < <("$BORG_BIN" list --short "${REPO_DIR}::${archive}" | grep -i -- "$pattern" || true)
  done < <(archive_names)
  [[ "$found" -eq 1 ]] || warn "No matches found for: $pattern"
}

cmd_mount() {
  load_config
  require_command "$BORG_BIN"
  local archive="${1:-}"
  local mount_dir="${2:-$MOUNT_DIR}"
  mkdir -p "$mount_dir"
  if [[ -n "$archive" ]]; then
    info "-- Mounting archive $archive at $mount_dir"
    "$BORG_BIN" mount "${REPO_DIR}::${archive}" "$mount_dir"
  else
    info "-- Mounting repository archive overview at $mount_dir"
    "$BORG_BIN" mount "$REPO_DIR" "$mount_dir"
  fi
  info "-- Mounted at: $mount_dir"
}

cmd_umount() {
  load_config
  local mount_dir="${1:-$MOUNT_DIR}"
  if have borg; then
    "$BORG_BIN" umount "$mount_dir" 2>/dev/null && return 0
  fi
  if have fusermount3; then
    fusermount3 -u "$mount_dir"
  elif have fusermount; then
    fusermount -u "$mount_dir"
  else
    umount "$mount_dir"
  fi
}

normalize_restore_path() {
  local path="$1"
  path="${path#/}"
  path="${path#./}"
  printf '%s\n' "$path"
}

cmd_restore() {
  load_config
  require_command "$BORG_BIN"
  local archive="${1:-}"
  local restore_path="${2:-}"
  local dest_dir="${3:-}"

  [[ -n "$archive" ]] || archive="$(latest_archive)"
  [[ -n "$archive" ]] || die "No archive found"
  [[ -n "$dest_dir" ]] || dest_dir="${RESTORE_DIR}/${archive}"
  mkdir -p "$dest_dir"

  info "-- Restoring from archive: $archive"
  info "-- Destination: $dest_dir"
  (
    cd "$dest_dir"
    if [[ -n "$restore_path" ]]; then
      restore_path="$(normalize_restore_path "$restore_path")"
      "$BORG_BIN" extract "${REPO_DIR}::${archive}" "$restore_path"
    else
      "$BORG_BIN" extract "${REPO_DIR}::${archive}"
    fi
  )
  info "-- Restore finished: $dest_dir"
}

cmd_check() {
  load_config
  require_command "$BORG_BIN"
  local verify_data=false
  [[ "${1:-}" == "--verify-data" ]] && verify_data=true
  check_repo "$verify_data"
}

cmd_status() {
  load_config
  require_command "$BORG_BIN"
  info "Config: $CONFIG_FILE"
  info "Source: $SOURCE_DIR"
  info "Repository: $REPO_DIR"
  info "Retention: $RETENTION_WITHIN"
  info "Latest archive: $(latest_archive || true)"
  info
  "$BORG_BIN" info "$REPO_DIR"
}

cmd_break_lock() {
  load_config
  require_command "$BORG_BIN"
  "$BORG_BIN" break-lock "$REPO_DIR"
}

main() {
  local cmd="${1:-}"
  [[ $# -gt 0 ]] && shift || true

  case "$cmd" in
    backup) cmd_backup "$@" ;;
    init) load_config; init_repo "$@" ;;
    list) cmd_list "$@" ;;
    info) cmd_info "$@" ;;
    search) cmd_search "$@" ;;
    mount) cmd_mount "$@" ;;
    umount|unmount) cmd_umount "$@" ;;
    restore) cmd_restore "$@" ;;
    check) cmd_check "$@" ;;
    prune) load_config; prune_repo "$@"; compact_repo ;;
    status) cmd_status "$@" ;;
    break-lock|unlock) cmd_break_lock "$@" ;;
    -h|--help|help|"") usage ;;
    --version|version) printf '%s %s\n' "$APP_NAME" "$APP_VERSION" ;;
    *) usage >&2; die "Unknown command: $cmd" ;;
  esac
}

main "$@"
