#!/usr/bin/env bash
#
# FileArk Manual Cloud Transfer — Bash + rclone
# Google Drive <-> Microsoft OneDrive, through bounded local batches.
#
# Copyright (c) 2026 FileArk
# SPDX-License-Identifier: MIT
#
# This program is intentionally copy-only. It never calls rclone move, sync,
# delete, purge, or rmdirs. Source files are never deleted.
# Guide: https://www.fileark.net/en/blog/manual-cloud-transfer-bash-rclone

set -Eeuo pipefail
IFS=$'\n\t'

VERSION="1.0.0"
GIB=$((1024 * 1024 * 1024))

SOURCE=""
DESTINATION=""
DESTINATION_FOLDER="FileArk Manual Transfer"
STAGING_DIR="./.fileark-rclone-staging"
STATE_FILE="./.fileark-rclone-verified.txt"
BATCH_GIB=10
BATCH_FILES=250
LOCAL_RESERVE_GIB=10
DESTINATION_RESERVE_GIB=5
TRANSFERS=4
CHECKERS=8
DRY_RUN=0
DEMO=0

usage() {
  cat <<'EOF'
FileArk Manual Cloud Transfer — Bash + rclone

Usage:
  ./fileark-cloud-transfer.sh \
    --source gdrive: \
    --destination onedrive: \
    [options]

Required:
  --source REMOTE:              Configured rclone source remote and optional path
  --destination REMOTE:         Configured rclone destination remote

Options:
  --destination-folder PATH     Destination folder (default: FileArk Manual Transfer)
  --staging-dir PATH            Bounded local staging directory
  --state-file PATH             Verified-file checkpoint
  --batch-gib NUMBER            Maximum known bytes staged per batch
  --batch-files NUMBER          Maximum files per batch
  --local-reserve-gib NUMBER    Local free space kept untouched
  --destination-reserve-gib N   Destination free space kept untouched
  --transfers NUMBER            Concurrent rclone transfers
  --checkers NUMBER             Concurrent rclone checkers
  --dry-run                     Inventory and preflight without copying
  --demo                        Print a network-free example run
  --version                     Print version
  --help                        Show this help

The script uses only copy/check/list/about operations. It never deletes source
objects. Existing destination files are not overwritten; they must pass the
same full-byte verification before they can be checkpointed.
EOF
}

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

log() {
  printf '%s  %s\n' "$(date '+%H:%M:%S')" "$*"
}

human_bytes() {
  local bytes="${1:-0}"
  awk -v bytes="$bytes" 'BEGIN {
    split("B KiB MiB GiB TiB PiB", units, " ")
    value = bytes + 0
    unit = 1
    while (value >= 1024 && unit < 6) {
      value /= 1024
      unit++
    }
    printf "%.1f %s", value, units[unit]
  }'
}

canonical_remote() {
  local value="${1%/}"
  [[ "$value" == *:* ]] || die "rclone remote must contain a colon: $value"
  printf '%s' "$value"
}

join_remote() {
  local root="${1%/}"
  local child="${2#/}"
  if [[ -n "$child" ]]; then
    printf '%s/%s' "$root" "$child"
  else
    printf '%s' "$root"
  fi
}

validate_relative_path() {
  local path="$1"
  [[ -n "$path" ]] || die "Inventory returned an empty path"
  [[ "$path" != /* ]] || die "Unsafe absolute source path: $path"
  [[ "/$path/" != *"/../"* ]] || die "Unsafe parent traversal in source path: $path"
  [[ "$path" != *$'\r'* && "$path" != *$'\n'* && "$path" != *$'\t'* ]] ||
    die "This Bash edition cannot safely represent tabs/newlines in a filename; use the Python edition"
}

local_free_bytes() {
  df -Pk "$STAGING_DIR" | awk 'NR == 2 {print $4 * 1024}'
}

remote_free_bytes() {
  local remote="$1"
  local about
  if ! about="$(rclone about "$remote" --json 2>/dev/null)"; then
    printf '%s' "-1"
    return
  fi
  jq -r '
    if (.free | type) == "number" then .free
    elif (.total | type) == "number" and (.used | type) == "number"
      then (.total - .used)
    else -1
    end
  ' <<<"$about"
}

require_capacity() {
  local label="$1"
  local free="$2"
  local required="$3"
  local reserve="$4"
  local needed=$((required + reserve))
  if (( free < 0 )); then
    log "$label does not expose a finite quota; provider enforcement remains active"
    return
  fi
  (( free >= needed )) ||
    die "$label too small: $(human_bytes "$free") free, $(human_bytes "$needed") required including reserve"
  log "$label: $(human_bytes "$free") free; $(human_bytes "$reserve") reserve  PASS"
}

is_completed() {
  local path="$1"
  [[ -s "$STATE_FILE" ]] && grep -Fqx -- "$path" "$STATE_FILE"
}

record_completed() {
  local path="$1"
  printf '%s\n' "$path" >>"$STATE_FILE"
}

prune_staging_file() {
  local relative="$1"
  local target="$STAGING_DIR/$relative"
  rm -f -- "$target"
  local parent
  parent="$(dirname "$target")"
  while [[ "$parent" != "$STAGING_DIR" && "$parent" == "$STAGING_DIR/"* ]]; do
    rmdir -- "$parent" 2>/dev/null || break
    parent="$(dirname "$parent")"
  done
}

transfer_batch() {
  local batch_number="$1"
  local batch_list="$2"
  local batch_bytes="$3"
  local batch_count="$4"
  local destination_root="$5"
  local local_free destination_free

  local_free="$(local_free_bytes)"
  destination_free="$(remote_free_bytes "$DESTINATION")"
  require_capacity "Local staging" "$local_free" "$batch_bytes" "$((LOCAL_RESERVE_GIB * GIB))"
  require_capacity "Destination" "$destination_free" "$batch_bytes" "$((DESTINATION_RESERVE_GIB * GIB))"
  log "Batch $batch_number: $batch_count files, $(human_bytes "$batch_bytes") known size"

  if (( DRY_RUN )); then
    return
  fi

  log "Batch $batch_number: copy source -> local staging"
  rclone copy "$SOURCE" "$STAGING_DIR" \
    --files-from-raw "$batch_list" \
    --ignore-existing \
    --transfers "$TRANSFERS" \
    --checkers "$CHECKERS" \
    --retries 6 \
    --low-level-retries 20 \
    --create-empty-src-dirs \
    --stats 10s

  log "Batch $batch_number: verify source -> local staging (full bytes)"
  rclone check "$SOURCE" "$STAGING_DIR" \
    --files-from-raw "$batch_list" \
    --one-way \
    --download \
    --checkers "$CHECKERS"

  log "Batch $batch_number: copy staging -> destination (no overwrite)"
  rclone copy "$STAGING_DIR" "$destination_root" \
    --files-from-raw "$batch_list" \
    --ignore-existing \
    --transfers "$TRANSFERS" \
    --checkers "$CHECKERS" \
    --retries 6 \
    --low-level-retries 20 \
    --create-empty-src-dirs \
    --stats 10s

  log "Batch $batch_number: verify staging -> destination (full bytes)"
  rclone check "$STAGING_DIR" "$destination_root" \
    --files-from-raw "$batch_list" \
    --one-way \
    --download \
    --checkers "$CHECKERS"

  while IFS= read -r relative || [[ -n "$relative" ]]; do
    validate_relative_path "$relative"
    record_completed "$relative"
    prune_staging_file "$relative"
    log "VERIFIED  $relative"
  done <"$batch_list"
}

run_demo() {
  cat <<'EOF'
FileArk Manual Cloud Transfer 1.0.0 — DEMO (no network calls)
Safety mode: COPY ONLY. No move, sync, purge, or source delete.
Source: onedrive:        Destination: gdrive:
Inventory: 1,842 files, 768.4 GiB
Destination: 1.7 TiB free; reserve: 5.0 GiB               PASS
Local staging: 82.1 GiB free; reserve: 10.0 GiB           PASS
[batch 1] copy     source -> staging      214 files / 9.8 GiB
[batch 1] check    source -> staging      full bytes      VERIFIED
[batch 1] copy     staging -> destination resumable
[batch 1] check    staging -> destination full bytes      VERIFIED
Checkpoint: 214 verified paths saved in .fileark-rclone-verified.txt
Source files deleted: 0
EOF
}

while (( $# )); do
  case "$1" in
    --source) SOURCE="${2:-}"; shift 2 ;;
    --destination) DESTINATION="${2:-}"; shift 2 ;;
    --destination-folder) DESTINATION_FOLDER="${2:-}"; shift 2 ;;
    --staging-dir) STAGING_DIR="${2:-}"; shift 2 ;;
    --state-file) STATE_FILE="${2:-}"; shift 2 ;;
    --batch-gib) BATCH_GIB="${2:-}"; shift 2 ;;
    --batch-files) BATCH_FILES="${2:-}"; shift 2 ;;
    --local-reserve-gib) LOCAL_RESERVE_GIB="${2:-}"; shift 2 ;;
    --destination-reserve-gib) DESTINATION_RESERVE_GIB="${2:-}"; shift 2 ;;
    --transfers) TRANSFERS="${2:-}"; shift 2 ;;
    --checkers) CHECKERS="${2:-}"; shift 2 ;;
    --dry-run) DRY_RUN=1; shift ;;
    --demo) DEMO=1; shift ;;
    --version) printf '%s\n' "$VERSION"; exit 0 ;;
    --help|-h) usage; exit 0 ;;
    *) die "Unknown argument: $1 (use --help)" ;;
  esac
done

if (( DEMO )); then
  run_demo
  exit 0
fi

command -v rclone >/dev/null || die "rclone is required: https://rclone.org/install/"
command -v jq >/dev/null || die "jq is required for destination quota checks"
command -v awk >/dev/null || die "awk is required"

[[ -n "$SOURCE" && -n "$DESTINATION" ]] || die "--source and --destination are required"
[[ "$SOURCE" != "$DESTINATION" ]] || die "Source and destination must differ"
[[ "$BATCH_GIB" =~ ^[0-9]+$ && "$BATCH_GIB" -gt 0 ]] || die "--batch-gib must be a positive integer"
[[ "$BATCH_FILES" =~ ^[0-9]+$ && "$BATCH_FILES" -gt 0 ]] || die "--batch-files must be a positive integer"
[[ "$LOCAL_RESERVE_GIB" =~ ^[0-9]+$ ]] || die "--local-reserve-gib must be a non-negative integer"
[[ "$DESTINATION_RESERVE_GIB" =~ ^[0-9]+$ ]] || die "--destination-reserve-gib must be a non-negative integer"
[[ "$TRANSFERS" =~ ^[0-9]+$ && "$TRANSFERS" -gt 0 ]] || die "--transfers must be positive"
[[ "$CHECKERS" =~ ^[0-9]+$ && "$CHECKERS" -gt 0 ]] || die "--checkers must be positive"

SOURCE="$(canonical_remote "$SOURCE")"
DESTINATION="$(canonical_remote "$DESTINATION")"
DESTINATION_ROOT="$(join_remote "$DESTINATION" "$DESTINATION_FOLDER")"
mkdir -p "$STAGING_DIR" "$(dirname "$STATE_FILE")"
touch "$STATE_FILE"

work_dir="$(mktemp -d "${TMPDIR:-/tmp}/fileark-rclone.XXXXXX")"
trap 'rm -rf -- "$work_dir"' EXIT
inventory="$work_dir/inventory.tsv"

log "Safety mode: COPY ONLY. Source deletion is not implemented."
log "Scanning $SOURCE"
rclone lsf "$SOURCE" \
  --recursive \
  --files-only \
  --format "sp" \
  --separator $'\t' >"$inventory"

pending_bytes=0
pending_files=0
while IFS=$'\t' read -r size relative || [[ -n "${relative:-}" ]]; do
  [[ -n "${relative:-}" ]] || continue
  validate_relative_path "$relative"
  [[ "$size" =~ ^[0-9]+$ ]] || die "Unexpected size for $relative: $size"
  if ! is_completed "$relative"; then
    pending_bytes=$((pending_bytes + size))
    pending_files=$((pending_files + 1))
  fi
done <"$inventory"

log "Pending inventory: $pending_files files, $(human_bytes "$pending_bytes")"
require_capacity \
  "Destination" \
  "$(remote_free_bytes "$DESTINATION")" \
  "$pending_bytes" \
  "$((DESTINATION_RESERVE_GIB * GIB))"

if (( pending_files == 0 )); then
  log "Nothing to transfer. Source files deleted: 0"
  exit 0
fi

batch_limit=$((BATCH_GIB * GIB))
batch_number=1
batch_bytes=0
batch_count=0
batch_list="$work_dir/batch-$batch_number.txt"
: >"$batch_list"

while IFS=$'\t' read -r size relative || [[ -n "${relative:-}" ]]; do
  [[ -n "${relative:-}" ]] || continue
  is_completed "$relative" && continue

  if (( batch_count > 0 )) &&
    (( batch_count >= BATCH_FILES || batch_bytes + size > batch_limit )); then
    transfer_batch "$batch_number" "$batch_list" "$batch_bytes" "$batch_count" "$DESTINATION_ROOT"
    batch_number=$((batch_number + 1))
    batch_bytes=0
    batch_count=0
    batch_list="$work_dir/batch-$batch_number.txt"
    : >"$batch_list"
  fi

  printf '%s\n' "$relative" >>"$batch_list"
  batch_bytes=$((batch_bytes + size))
  batch_count=$((batch_count + 1))
done <"$inventory"

if (( batch_count > 0 )); then
  transfer_batch "$batch_number" "$batch_list" "$batch_bytes" "$batch_count" "$DESTINATION_ROOT"
fi

if (( DRY_RUN )); then
  log "Dry run complete. No files were downloaded or uploaded."
else
  log "Transfer complete. Verified paths: $(wc -l <"$STATE_FILE" | tr -d ' ')"
fi
log "Source files deleted: 0"
