#!/bin/bash
#
# shellcheck disable=SC1091,SC2086,SC2181,SC2206,SC2207
#
# Copyright (c) 2023-2026 openmediavault plugin developers
#
# This file is licensed under the terms of the GNU General Public
# License version 2. This program is licensed "as is" without any
# warranty of any kind, whether express or implied.
#
# version: 1.5.0

export LC_ALL=C.UTF-8

. /usr/share/openmediavault/scripts/helper-functions

declare -i force=0
declare -i verbose=1
declare -i i=0
declare -i j=0

declare -A seen_paths=()

# logging location
logDir="/var/log/"
logFile="${logDir}/omv-compose-backup.log"

_log()
{
  msg=${1}
  echo "[$(date +'%Y-%m-%d %H:%M:%S%z')] [composebackup] ${msg}" | tee -a ${logFile} >&2
}

extractContPath() {
  local val="$1"
   # Remove anything after a '#' (comment)
  val="${val%%#*}"
  # Remove everything through the last “}” (if there is one)
  val="${val##*\}}"
  # Remove everything before the first colon
  val="${val#*:}"
  # Remove everything after the next colon
  val="${val%%:*}"
  # Trim trailing whitespace
  val="${val%"${val##*[![:space:]]}"}"
  echo "${val}"
}

normalize_path() {
  printf '%s' "${1%/}";
}

rebuild_history_and_sizes() {
  local compose="${1}"
  local repo="${2}"
  local hist="${3}"

  local tmp_names tmp_hist tmp_sizes
  tmp_names="$(mktemp)"
  tmp_hist="$(mktemp)"
  tmp_sizes="$(mktemp)"

  # 1) Get current archive names
  if ! borg list --json "${repo}" | jq -r '.archives[].name' > "${tmp_names}"; then
    _log "WARN: borg list failed; not rebuilding vol.list.history"
    rm -f "${tmp_names}" "${tmp_hist}" "${tmp_sizes}"
    return 0
  fi

  # 2) Rebuild archive lines: ts,volnum,borg,archive
  awk -v c="${compose}" '
    function trim(s){ gsub(/\r/,"",s); gsub(/^[ \t]+|[ \t]+$/,"",s); return s }
    {
      name = trim($0)
      pat = "^" c "-v([0-9]+)-([0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})$"
      if (name ~ pat) {
        vol = name; sub("^" c "-v", "", vol); sub(/-([0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})$/, "", vol)
        ts = name; sub("^" c "-v[0-9]+-", "", ts)
        print ts "," vol ",borg," name
      }
    }
  ' "${tmp_names}" | sort -t, -k1,1 -k2,2n > "${tmp_hist}"

  # 3) Compute per-timestamp bytes by summing an archive metric
  # Choose metric: deduplicated_size (default) or original_size or compressed_size.
  local metric="${OMV_COMPOSE_BORG_SIZE_METRIC:-deduplicated_size}"
  _log "Recomputing repo_bytes using metric :: ${metric}"

  # For each archive, fetch stats and emit: ts,size
  while IFS= read -r arch; do
    # Extract timestamp from name: compose-vN-<ts>
    ts="${arch##*-v}"
    ts="${ts#*[0-9]-}"  # remove "<N>-"
    # Now ts should be YYYY-MM-DD_HH-MM-SS

    # Pull the size metric from borg info --json
    # Borg versions differ slightly; try a couple paths.
    sz="$(borg info --json "${repo}::${arch}" 2>/dev/null | jq -r .archives[0].stats.original_size 2>/dev/null)"
    # If missing/unparsable, treat as 0
    if ! [[ "${sz}" =~ ^[0-9]+$ ]]; then
      sz=0
    fi
    printf '%s,%s\n' "${ts}" "${sz}"
  done < <(cut -d, -f4 "${tmp_hist}") \
    | awk -F',' '{ sum[$1]+=$2 } END { for (t in sum) print t ",repo_bytes," sum[t] }' \
    | sort -t, -k1,1 > "${tmp_sizes}"

  # 4) Write final history file: archive lines + repo_bytes lines
  cat "${tmp_hist}" "${tmp_sizes}" > "${hist}.new"
  mv -f "${hist}.new" "${hist}"

  rm -f "${tmp_names}" "${tmp_hist}" "${tmp_sizes}"
  _log "Rebuilt vol.list.history and recomputed repo_bytes :: ${hist}"
}

prune_keep_last_runs() {
  local compose="${1}"
  local repo="${2}"
  local keep_runs="${3:-7}"

  # Get all archive names in this repo
  mapfile -t all_archives < <(borg list --json "${repo}" | jq -r '.archives[].name' | tr -d '\r')

  # Extract unique timestamps for this compose (based on name format)
  # Example: cloudcmd-v1-2026-01-27_16-53-21  -> 2026-01-27_16-53-21
  mapfile -t all_ts < <(
    printf '%s\n' "${all_archives[@]}" \
      | awk -v c="${compose}" '
          BEGIN { pat="^" c "-v[0-9]+-([0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})$" }
          {
            if ($0 ~ pat) {
              ts = $0
              sub("^" c "-v[0-9]+-", "", ts)
              print ts
            }
          }' \
      | sort -u
  )

  local total_ts="${#all_ts[@]}"
  if [ "${total_ts}" -le "${keep_runs}" ]; then
    _log "Prune (runs): found ${total_ts} run(s); <= keep_runs=${keep_runs}. Nothing to prune."
    return 0
  fi

  # Keep newest N timestamps (lexicographic sort works for this format)
  mapfile -t keep_ts < <(printf '%s\n' "${all_ts[@]}" | sort -r | head -n "${keep_runs}")

  # Build a lookup set for timestamps to keep (awk-friendly)
  local keep_file
  keep_file="$(mktemp)"
  printf '%s\n' "${keep_ts[@]}" > "${keep_file}"

  # Determine archives to delete = those whose timestamp is NOT in keep set
  mapfile -t delete_archives < <(
    printf '%s\n' "${all_archives[@]}" \
      | awk -v c="${compose}" -v kf="${keep_file}" '
          BEGIN {
            while ((getline t < kf) > 0) keep[t]=1
            close(kf)
            pat="^" c "-v[0-9]+-([0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})$"
          }
          {
            if ($0 ~ pat) {
              ts = $0
              sub("^" c "-v[0-9]+-", "", ts)
              if (!keep[ts]) print $0
            }
          }'
  )
  rm -f "${keep_file}"

  if [ "${#delete_archives[@]}" -eq 0 ]; then
    _log "Prune (runs): nothing matched for deletion."
    return 0
  fi

  _log "Prune (runs): keeping ${keep_runs} run(s); deleting ${#delete_archives[@]} archive(s)."

  # Delete archives in one borg command (pass names as args)
  # Note: borg delete accepts multiple archive names after the repo path.
  borg delete -v --list "${repo}" "${delete_archives[@]}"
}


compose="${1}"
uuid="${2}"
if [ -z "${compose}" ]; then
  _log "No compose name set.  Exiting..."
  exit 10
fi
_log "compose :: ${compose}"
_log "uuid :: ${uuid}"

# Backup backend
backupbackend="$(omv_config_get "/config/services/compose/backupbackend")"
borg_keep="$(omv_config_get "/config/services/compose/borgkeep")"
_log "Backup backend :: ${backupbackend}"

# Borg settings
export BORG_RELOCATED_REPO_ACCESS_IS_OK=yes
borg_encryption="$(omv_config_get "/config/services/compose/borgencryption")"
borg_passphrase="$(omv_config_get "/config/services/compose/borgpassphrase")"
if [ "${borg_encryption}" != "none" ] && [ -n "${borg_passphrase}" ]; then
  export BORG_PASSPHRASE="${borg_passphrase}"
fi
borg_compression="${OMV_COMPOSE_BORG_COMPRESSION:-zstd,6}"
borg_extra_args="${OMV_COMPOSE_BORG_EXTRA_ARGS:-}"

# Use a single timestamp for the whole run when borg is enabled so snapshots group cleanly.
backup_ts_run=""
if [ "${backupbackend}" = "borg" ]; then
  backup_ts_run="$(date +'%Y-%m-%d_%H-%M-%S')"
  _log "Borg keep :: ${borg_keep}"
  _log "Borg encryption :: ${borg_encryption}"
  _log "Borg compression :: ${borg_compression}"
  _log "Borg extra args :: ${borg_extra_args}"
fi

# Get docker storage path
dockerStorage=$(omv_config_get "/config/services/compose/dockerStorage")
_log "Docker storage :: ${dockerStorage}"

# get podman setting
podman=$(omv_config_get "/config/services/compose/podman")
_log "podman :: ${podman}"
if [[ "${podman}" == "1" ]]; then
  export DOCKER_HOST="unix:///run/podman/podman.sock"
else
  unset DOCKER_HOST
fi

# Get the shared folder reference and path
sfref=$(omv_config_get "/config/services/compose/sharedfolderref")
if ! omv_isuuid "${sfref}"; then
  _log "No compose sharedfolder set."
  exit 11
fi
sfpath="$(omv_get_sharedfolder_path "${sfref}")"
if [ ! -d "${sfpath}" ]; then
  _log "Shared folder directory does not exist.  Exiting..."
  exit 12
fi
sfpath="${sfpath/%\/}"
sfpath="${sfpath//\/\//\/}"
_log "Compose file path :: ${sfpath}"

# Get the backup shared folder reference and path
sfref=$(omv_config_get "/config/services/compose/backupsharedfolderref")
if ! omv_isuuid "${sfref}"; then
  _log "No backup sharedfolder set."
  exit 13
fi
backuppath="$(omv_get_sharedfolder_path "${sfref}")"
if [ ! -d "${backuppath}" ]; then
  _log "Backup shared folder directory does not exist.  Exiting..."
  exit 14
fi
backuppath="${backuppath/%\/}"
backuppath="${backuppath//\/\//\/}"
_log "Backup path :: ${backuppath}"

# set path for yml and env files
composepath="${sfpath}/${compose}"
env="${composepath}/${compose}.env"
yml="${composepath}/${compose}.yml"
ovr="${composepath}/compose.override.yml"
if [ ! -f "${yml}" ]; then
  _log "Compose file '${yml}' does not exist.  Exiting..."
  exit 15
fi
_log "Compose file :: ${yml}"

yq="/usr/local/bin/yq"
if [ ! -f "${yq}" ]; then
  _log "'${yq}' does not exist.  Exiting..."
  exit 16
fi

skipstartstop=0
if omv_isuuid "${uuid}"; then
  verbose="$(omv_config_get "/config/services/compose/jobs/job[uuid='${uuid}']/verbose")"
  skipstartstop="$(omv_config_get "/config/services/compose/jobs/job[uuid='${uuid}']/skipstartstop")"
fi
_log "Verbose :: ${verbose}"
_log "Skip container stop/start :: ${skipstartstop}"

desc="$(omv_config_get "/config/services/compose/files/file[name='${compose}']/description")"
_log "Description :: ${desc}"

# Get the backup max size
backupmaxsize=$(omv_config_get "/config/services/compose/backupmaxsize")
if [ ${backupmaxsize} -lt 1 ]; then
  _log "Backup max size is set to unlimited."
  backupmaxsize=0
fi
_log "Backup max size :: ${backupmaxsize} GB"
backupmax=$((backupmaxsize * 1024 * 1024 * 1024))

OFS=$IFS
IFS=$'\n'

# run docker compose config
confout="$(mktemp)"
omv-compose-run "${compose}" config --output "${confout}"
if [ $? -ne 0 ] || [ ! -s "${confout}" ]; then
  _log "Failed to run docker compose config.  Exiting..."
  exit 17
fi

# get services
services=($(${yq} '.services | keys | .[]' "${yml}"))

serviceVolsMap=()

OFS=$IFS
IFS=$'\n'

# build list of all container host and container paths with comments
for service in "${services[@]}"; do
  ymlVolsSvc=($(${yq} ".services.${service}.volumes" "${yml}" 2>/dev/null | sed 's/^- //' | sed 's/"//g'))
  confVolsSvc=($(${yq} eval ".services.${service}.volumes[]? | .source + \":\" + .target" "${confout}" 2>/dev/null))

  for vol in "${ymlVolsSvc[@]}"; do
    if [ -n "${vol}" ]; then
      vol2="${vol#*:}"
      vol2a="$(extractContPath "${vol}")"
      for confvol in "${confVolsSvc[@]}"; do
        confvol1="${confvol%%:*}"
        confvol2="${confvol#*:}"
        if [ "${confvol2}" = "${vol2a}" ]; then
          serviceVolsMap+=("${confvol1}:${vol2}")
          break
        fi
      done
    fi
  done

  ymlBuild=$(${yq} ".services.${service}" "${yml}" | grep 'build:' | sed 's/^build:\s*//')
  confBuild=$(${yq} eval ".services.${service}.build.context // \"\"" "${confout}")
  if [ -n "${ymlBuild}" ]; then
    serviceVolsMap+=("${confBuild}:${ymlBuild}")
  fi
done

IFS=$OFS

# create backup subdirectory
backupdir="${backuppath}/${compose}"
mkdir -pv "${backupdir}"
borgrepo="${backupdir}/borgrepo"

if [ "${backupbackend}" = "borg" ]; then
  if [ ! -d "${borgrepo}" ]; then
    _log "Initializing borg repo :: ${borgrepo}"
    mkdir -p -- "${borgrepo}"
    borg init --encryption="${borg_encryption}" "${borgrepo}" || {
      _log "Failed to initialize borg repo. Exiting..."
      exit 19
    }
  else
    # quick sanity check
    if ! borg info "${borgrepo}"; then
      _log "Existing borg repo not valid :: ${borgrepo}. Exiting..."
      exit 20
    fi
  fi
fi

# update volume list
vollist="${backupdir}/vol.list"
echo "${i},${composepath}" > "${vollist}"

# borg snapshot history (append-only). Format: ts,volnum,borg,archive
vollisthist="${backupdir}/vol.list.history"
if [ "${backupbackend}" = "borg" ]; then
  touch "${vollisthist}"
fi

# write description to file
descPath="${backupdir}/omv_file_desc.txt"
echo "${desc}" > "${descPath}"

# save status
status="$(mktemp)"
docker compose ls --all --format json | jq -r ".[] | select(.ConfigFiles | contains(\"${yml}\")) | .Status" > "${status}"
_log "status :: $(cat "${status}")"

wait_for_compose_to_exit() {
  local total_wait_s=10             # Total time to wait in seconds
  local sleep_interval_s=0.1        # Sleep interval in seconds
  local max_attempts=100            # Manually calculated as total_wait_s/sleep_interval_s
  local attempt=0

  _log "Waiting for compose to fully stop (max ${total_wait_s}s with ${sleep_interval_s}s intervals)..."

  while [ $attempt -lt $max_attempts ]; do
    current_status=$(docker compose ls --all --format json | jq -r ".[] | select(.ConfigFiles | contains(\"${yml}\")) | .Status")

    if [[ "${current_status}" == *exited* ]]; then
      _log "compose stopped: ${current_status}"
      return 0
    fi

    _log "compose still stopping (attempt $((attempt+1))/${max_attempts}): ${current_status}"
    ((attempt++))
    sleep $sleep_interval_s
  done

  _log "WARNING: compose might not be fully stopped after ${total_wait_s} seconds. Proceeding anyway..."
  return 1
}

# stop compose if running (unless configured to skip stop/start)
if [ "${skipstartstop}" = "1" ]; then
  _log "Skipping container stop/start as configured for this job."
elif grep -q "running" "${status}"; then
  omv-compose-run "${compose}" stop
  wait_for_compose_to_exit
else
  _log "${compose} is not running"
fi

# copy yml and env files and other files that use a relative path
voldir="${backupdir}/${i}"
if [[ ! -d "${voldir}" ]]; then
  _log "Create ${voldir} ..."
  mkdir -p -- "${voldir}"
fi
_log "Copy compose file(s) to ${voldir} ..."
for f in "${yml}" "${env}" "${ovr}"; do
  [[ -f "${f}" ]] || continue
  _log "Copying ${f} ..."
  cp -a -- "${f}" "${voldir}/"
done

# initialize seen paths array
seen_paths["$(normalize_path "${composepath}")"]=1

# loop through volumes
for vol in "${serviceVolsMap[@]}"; do
  force=0
  _log "Volume :: ${vol}"
  hostpath="${vol%%:*}"
  hostpath="${hostpath//\'/}"
  _log "host path :: ${hostpath}"
  contpath=$(extractContPath "${vol}")
  _log "container path :: ${contpath}"
  cmt=""
  if [[ "$vol" == *#* ]]; then
    cmt="${vol##*#}"
  fi
  _log "path comment :: ${cmt}"
  if [[ "${cmt}" == *"BACKUP"* ]]; then
    _log "Forcing for BACKUP comment :: ${vol}"
    force=1
  fi
  # check for paths to skip or force
  if [[ "${cmt}" == *"SKIP_BACKUP"* ]]; then
    _log "Skipping for SKIP_BACKUP comment :: ${vol}"
    echo
    continue
  elif [[ "${hostpath}" == @(/dev|/dev/*|/lib|/lib/*|/sys|/sys/*|./*|../*|/) ]]; then
    _log "Skipping :: ${hostpath}"
    echo
    continue
  elif [[ "${vol}" == *":ro"* ]]; then
    _log "Read only volume. Skipping :: ${hostpath}"
    echo
    continue
  elif [[ "${hostpath}" != *"/"* ]]; then
    _log "Named volume :: ${hostpath}"
    fullvolname="$(${yq} -r ".volumes.${hostpath}.name // \"\"" "${confout}" 2>/dev/null)"
    if [ -z "${fullvolname}" ]; then
      _log "Failed to retrieve full volume name. Skipping"
      continue
    fi
    _log "Full volume name :: ${fullvolname}"
    hostpath=$(docker volume inspect "${fullvolname}" --format '{{ .Mountpoint }}')
    if [ -z "${hostpath}" ]; then
      _log "Failed to retrieve named volume path. Skipping"
      continue
    fi
    if [ ! -d "${hostpath}" ]; then
      _log "Named volume path does not exist. Skipping"
      continue
    fi
    _log "Named volume path :: ${hostpath}"
  else
    _log "Backup host path :: ${hostpath}"
  fi
  if [ ! -f "${hostpath}" ] && [ ! -d "${hostpath}" ]; then
    if [ -e "${hostpath}" ]; then
      _log "Special file. Skipping..."
      echo
    else
      _log "Path does not exist. Skipping..."
      echo
    fi
    continue
  fi
  if [ ${backupmaxsize} -gt 0 ]; then
    foldersize=$(du --summarize --bytes "${hostpath}" | awk '{ print $1 }')
    _log "Folder size :: ${foldersize} bytes"
    if [ ${foldersize} -gt ${backupmax} ] && [ ${force} -eq 0 ]; then
      _log "Folder size greater than max backup size.  Skipping..."
      continue
    fi
  fi
  # check if path is already in list
  norm="$(normalize_path "${hostpath}")"
  if [[ -n "${seen_paths[${norm}]}" ]]; then
    _log "Duplicate volume path '${hostpath}' - skipping..."
    continue
  fi
  # rsync to numbered subdirectory
  ((i++))
  if [ "${backupbackend}" = "borg" ]; then
    # store each volume as a borg archive in ${backupdir}/borgrepo and
    # record the archive name in vol.list.

    if [ -d "${hostpath}" ]; then
      hostpath="${hostpath}/"
    fi

    backup_ts="${backup_ts_run}"
    archive="${compose}-v${i}-${backup_ts}"
    _log "Borg archive :: ${archive}"

    borg_args=(--compression "${borg_compression}")
    if [ ${verbose} -ne 0 ]; then
      borg_args+=(--stats)
    fi
    if [ -n "${borg_extra_args}" ]; then
      borg_args+=(${borg_extra_args})
    fi
    if [[ "${hostpath}" == */ ]] && [ "${force}" -ne 1 ]; then
      borg_args+=(--exclude "*.qcow2")
    fi
    if omv_isuuid "${uuid}"; then
      exclusions="$(omv_config_get "/config/services/compose/jobs/job[uuid='${uuid}']/excludes")"
      if [ -n "${exclusions}" ]; then
        IFS=',' read -r -a exclude_array <<< "${exclusions}"
        for item in "${exclude_array[@]}"; do
          _log "exclude: ${item}"
          borg_args+=(--exclude "${item}")
        done
      fi
    fi

    # add volume list entry
    echo "${i},${hostpath},borg,${archive}" >> "${vollist}"
    echo "${backup_ts_run},${i},borg,${archive}" >> "${vollisthist}"
    seen_paths["${norm}"]=1
    # borgbackup
    src="${hostpath%/}"
    basedir="$(dirname "${src}")"
    item="$(basename "${src}")"
    (
      cd -- "${basedir}" || exit 1
      _log "Create archive ..."
      borg create "${borg_args[@]}" "${borgrepo}::${archive}" "${item}"
    )
    echo
  else
    # rsync to numbered subdirectory
    voldir="${backupdir}/${i}"
    mkdir -pv "${voldir}"
    _log "Backup to :: ${voldir}"
    if [ ${verbose} -eq 0 ]; then
      args=(-ar)
    else
      args=(-avr)
    fi
    args+=(--delete)
    if [ -d "${hostpath}" ]; then
      hostpath="${hostpath}/"
      if [ "${force}" -ne 1 ]; then
        args+=(--exclude="*.qcow2")
      fi
    fi
    # check for exclusions
    if omv_isuuid "${uuid}"; then
      exclusions="$(omv_config_get "/config/services/compose/jobs/job[uuid='${uuid}']/excludes")"
      if [ -n "${exclusions}" ]; then
        IFS=',' read -r -a exclude_array <<< "${exclusions}"
        for item in "${exclude_array[@]}"; do
          _log "exclude: ${item}"
          args+=(--exclude="${item}")
        done
      fi
    fi
    # add volume list entry
    echo "${i},${hostpath}" >> "${vollist}"
    seen_paths["${norm}"]=1
    # rsync
    rsync "${args[@]}" "${hostpath}" "${voldir}"
    echo
  fi
done

# prune old archives and rebuild history once, after all volume archives
# for this run have been created (the borg repo is shared by all volumes
# of this compose, so these whole-repo operations only need to run once).
if [ "${backupbackend}" = "borg" ]; then
  _log "Prune old archives ..."
  prune_keep_last_runs "${compose}" "${borgrepo}" "${borg_keep}"

  _log "Rebuild history ..."
  rebuild_history_and_sizes "${compose}" "${borgrepo}" "${vollisthist}"
fi

# remove extra directories from a previous backup
# if a volume(s) were removed from the compose file.
while [ ${i} -le 100 ] && [ ${j} -le 3 ]; do
  ((i++))
  voldir="${backupdir}/${i}"
  if [ -d "${voldir}" ]; then
    _log "Removing '${voldir}' directory from previous backup that is no longer in compose file."
    rm -rf "${voldir}"
  else
    ((j++))
  fi
done

# start compose if running before backup (unless stop/start was skipped)
if [ "${skipstartstop}" != "1" ] && grep -q "running" "${status}"; then
  omv-compose-run "${compose}" start
fi

# store size of backup
spaceused="${backupdir}/space.used"
backupsize=$(du --summarize --bytes "${backupdir}" | awk '{ print $1 }')
_log "Backup size :: ${backupsize} bytes"
echo ${backupsize} > "${spaceused}"

if [ "${backupbackend}" = "borg" ] && [ -n "${backup_ts_run}" ]; then
  # Record repo size per snapshot timestamp (ts,repo_bytes,<bytes>)
  # Use backupsize you already computed (or du on "${borgrepo}" if you prefer)
  echo "${backup_ts_run},repo_bytes,${backupsize}" >> "${vollisthist}"
fi

# remove temp files
rm -f "${confout}" "${status}"

_log "Done."

exit 0
