#!/bin/bash
# ==============================================================================================
# ============================== DATA LAYOUT MIGRATION =========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# One-time move of everything Varaverk persists into a single rooted tree under DATA_DIR.
#
#   State_Files/              →  data/state/
#   data/*.db|.count|.tsv|.list → data/db/
#   data/ai_*                 →  data/ai/
#   data/*_tracked_cache.json →  data/cache/arr/
#   data/*.log                →  data/logs/
#   SCRIPTS_DIR/.cache/vv/d/  →  data/cache/conf/
#
# ==============================================================================================
# WHY THIS EXISTS SEPARATELY FROM conf_upgrade
# ==============================================================================================
#
# conf_upgrade adds keys the template has and the installation does not; it never rewrites a
# value the operator already has, which is exactly the behaviour you want from it and exactly
# why it cannot perform this migration. The paths being moved are existing keys — STATE_DIR,
# BANDWIDTH_LOG, AI_INDEX_DB and two dozen more — so their values would keep pointing at the old
# layout forever while the new directory variables sat beside them unused.
#
# So this rewrites those values, then moves the files to match. Both halves, or neither: a conf
# pointing at a directory the data is not in is worse than not having started.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Idempotent
#   Every step tests before acting. A second run reports "already migrated" and changes nothing,
#   which matters because the natural instinct after a partial failure is to run it again.
#
# Moves, never copies-and-deletes
#   mv within one filesystem is atomic per file, so a reader either sees the file at the old
#   path or the new one — never a half-written copy at both. Nothing is deleted; if a file
#   cannot be moved it is reported and left exactly where it is.
#
# Conf is backed up before it is rewritten
#   master.conf.bak-<stamp>, next to the original, same convention conf_upgrade uses.
#
# Refuses to run while the orchestrators might be writing
#   A watchdog that sourced conf before the rewrite and writes state after the move would put a
#   file back at the old path. The window is seconds and the damage is one stale file, but the
#   check costs nothing and the failure is silent otherwise.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# migrate_data_layout.sh --dry-run     Show what would move. Changes nothing. Do this first.
# migrate_data_layout.sh               Perform the migration.
# migrate_data_layout.sh --force       Skip the running-orchestrator check.
#
# ==============================================================================================

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONF="$ROOT/Configurations/master.conf"

DRY_RUN=false
FORCE=false
for a in "$@"; do
    case "$a" in
        --dry-run) DRY_RUN=true ;;
        --force)   FORCE=true ;;
        *) echo "Unknown argument: $a" >&2; exit 2 ;;
    esac
done

[[ -f "$CONF" ]] || { echo "[FATAL] master.conf not found at $CONF" >&2; exit 1; }

# Resolve the roots the same way load_config.sh will after this runs.
SCRIPTS_DIR="$ROOT"
DATA_DIR="$(grep -m1 -E '^\s*DATA_DIR=' "$CONF" | cut -d'"' -f2)"
DATA_DIR="${DATA_DIR//\$\{SCRIPTS_DIR\}/$SCRIPTS_DIR}"
DATA_DIR="${DATA_DIR:-$ROOT/data}"

OLD_STATE="$ROOT/State_Files"
OLD_CONFCACHE="$ROOT/.cache/vv/d"

DB_DIR="$DATA_DIR/db"
STATE_DIR="$DATA_DIR/state"
AI_DATA_DIR="$DATA_DIR/ai"
CACHE_BACKUP_DIR="$DATA_DIR/cache"
ARR_CACHE_BACKUP_DIR="$CACHE_BACKUP_DIR/arr"
CONF_CACHE_BACKUP_DIR="$CACHE_BACKUP_DIR/conf"
LOG_ARCHIVE_DIR="$DATA_DIR/logs"

moved=0; skipped=0; failed=0

say()  { printf '  %s\n' "$*"; }
step() { printf '\n━━━ %s ━━━\n' "$*"; }

# ── Guard: orchestrators mid-run ──────────────────────────────────────────────
# Scoped to THIS installation's path, not to the script names. pgrep is system-wide, and a box
# running both a production checkout and a development clone will always have one of them busy —
# matching on "Orchestrators/" alone made a dev migration abort because prod was mid-cycle, which
# is a process that cannot touch this tree's data at all. The full path is what distinguishes
# them, so that is what is matched.
if [[ "$FORCE" == false && "$DRY_RUN" == false ]]; then
    running=$(pgrep -fa "$ROOT/(Orchestrators|Watchdogs|Media|Rsync)/" 2>/dev/null \
              | grep -v "$$" | grep -v migrate_data_layout || true)
    if [[ -n "$running" ]]; then
        echo "[ABORT] A job from this installation is running — it may rewrite state mid-move:" >&2
        echo "$running" >&2
        echo "Wait for it to finish, or re-run with --force if you are sure." >&2
        exit 1
    fi
fi

# ── Move one path ─────────────────────────────────────────────────────────────
move() {
    local src="$1" dstdir="$2" base
    base="$(basename "$src")"
    [[ -e "$src" ]] || return 0
    if [[ -e "$dstdir/$base" ]]; then
        say "skip   $base — already at ${dstdir#$DATA_DIR/}/"
        ((skipped++)); return 0
    fi
    if [[ "$DRY_RUN" == true ]]; then
        say "would  $base → ${dstdir#$DATA_DIR/}/"
        ((moved++)); return 0
    fi
    mkdir -p "$dstdir" 2>/dev/null
    if mv "$src" "$dstdir/$base" 2>/dev/null; then
        say "moved  $base → ${dstdir#$DATA_DIR/}/"
        ((moved++))
    else
        say "FAILED $base — left in place"
        ((failed++))
    fi
}

# ── 1. Rewrite the conf values conf_upgrade cannot ────────────────────────────
step "Step 1: master.conf path values"
if grep -q 'STATE_DIR="\${DATA_DIR}/state"' "$CONF"; then
    say "already migrated — no conf changes needed"
else
    if [[ "$DRY_RUN" == false ]]; then
        cp "$CONF" "${CONF}.bak-$(date +%Y%m%d%H%M%S)"
        sed -i -E \
            -e 's|^(\s*STATE_DIR=)".*"|\1"${DATA_DIR}/state"|' \
            -e 's|^(\s*PERSISTENT_CONF_CACHE=)".*"|\1"${CACHE_BACKUP_DIR}/conf"|' \
            "$CONF"
        for v in ARR_SYNC_BLOCKLIST DOCKER_UPDATE_REBUILT_DAILY_FILE DOCKER_UPDATE_REBUILT_WEEKLY_FILE \
                 WATCHDOG_CONTAINER_RESTART_LOG TUNING_MONITOR_LOG LIDARR_TRACKED_COUNT_FILE \
                 LIDARR_RESCAN_DURATION_DB LIDARR_ART_MISS_CACHE LIDARR_DISCOVERY_HISTORY \
                 SONARR_DISCOVERY_HISTORY RADARR_DISCOVERY_HISTORY SONARR_TRACKED_COUNT_FILE \
                 CORRUPTION_SCAN_STATE_FILE CORRUPTION_SCAN_STRIKES_FILE RADARR_TRACKED_COUNT_FILE \
                 TRANSCODE_DAILY_LOG BANDWIDTH_LOG ARR_CLEANUP_STATS ARR_RECOVERY_STATS \
                 ARR_RECOVERY_FAILURE_COUNTS; do
            sed -i -E "s|^(\s*${v}=\")\\\$\{?DATA_DIR\}?/|\1\${DB_DIR}/|" "$CONF"
        done
        for v in AI_INDEX_DB AI_MEMORY_FILE AI_TOKEN_DB; do
            sed -i -E "s|^(\s*${v}=\")\\\$\{?DATA_DIR\}?/|\1\${AI_DATA_DIR}/|" "$CONF"
        done
        sed -i -E 's|^(\s*LIDARR_CACHE_FILE=")\$\{?DATA_DIR\}?/|\1${ARR_CACHE_BACKUP_DIR}/|' "$CONF"
        sed -i -E 's|^(\s*ZFS_REPORT_LOG=")\$\{?DATA_DIR\}?/|\1${LOG_ARCHIVE_DIR}/|' "$CONF"
        say "rewritten — backup kept beside it"
    else
        say "would rewrite STATE_DIR, PERSISTENT_CONF_CACHE and 25 file paths"
    fi
fi

# ── 2. Build the tree ─────────────────────────────────────────────────────────
step "Step 2: directory tree"
for d in "$DB_DIR" "$STATE_DIR" "$AI_DATA_DIR" "$ARR_CACHE_BACKUP_DIR" "$LOG_ARCHIVE_DIR"; do
    if [[ -d "$d" ]]; then say "exists ${d#$DATA_DIR/}"
    elif [[ "$DRY_RUN" == true ]]; then say "would create ${d#$DATA_DIR/}"
    else mkdir -p "$d" && say "created ${d#$DATA_DIR/}"
    fi
done
# The conf cache carries partner credentials and keeps its restrictive mode.
if [[ "$DRY_RUN" == false ]]; then
    mkdir -p "$CONF_CACHE_BACKUP_DIR" && chmod 700 "$CONF_CACHE_BACKUP_DIR"
    say "created cache/conf (0700)"
fi

# ── 3. State files ────────────────────────────────────────────────────────────
step "Step 3: State_Files → data/state"
if [[ -d "$OLD_STATE" ]]; then
    shopt -s nullglob dotglob
    for f in "$OLD_STATE"/*; do move "$f" "$STATE_DIR"; done
    shopt -u nullglob dotglob
    if [[ "$DRY_RUN" == false && -d "$OLD_STATE" ]]; then
        rmdir "$OLD_STATE" 2>/dev/null && say "removed empty State_Files/" \
            || say "State_Files/ not empty — left in place, inspect it"
    fi
else
    say "no State_Files/ — nothing to do"
fi

# ── 4. Sort the data root ─────────────────────────────────────────────────────
step "Step 4: sort data/ into subfolders"
classify() {
    local f="$1" base; base="$(basename "$f")"
    case "$base" in
        ai_*)                     move "$f" "$AI_DATA_DIR" ;;
        *_tracked_cache.json)     move "$f" "$ARR_CACHE_BACKUP_DIR" ;;
        *.log)                    move "$f" "$LOG_ARCHIVE_DIR" ;;
        *.db|*.count|*.tsv|*.list|*.json) move "$f" "$DB_DIR" ;;
        *)                        say "leave  $base — unclassified, left in data/" ;;
    esac
}

# Two passes, sidecars first. A SQLite database is three files, and the -wal holds committed
# transactions that have not been checkpointed into the .db yet. Move the .db first and any
# process that opens it during the gap sees a database with no write-ahead log, creates a fresh
# one at the old path, and everything still in the old -wal is lost when it is moved over the
# top. Sidecars ahead of their base closes that ordering: the worst case becomes a database
# opened without its log still sitting beside it, which SQLite handles.
shopt -s nullglob
for f in "$DATA_DIR"/*-wal "$DATA_DIR"/*-shm; do
    [[ -d "$f" ]] && continue
    classify "$f"
done
for f in "$DATA_DIR"/*; do
    # Skip the destinations by name, not everything that happens to be a directory. ai_bugs/ and
    # ai_chats/ are directories that belong under ai/, and an earlier version of this skipped
    # every directory outright — which moved neither, silently, while the PHP layer had already
    # been repointed at the new location. They were empty at the time; that was luck, not design.
    case "$(basename "$f")" in
        db|state|ai|cache|logs) continue ;;
        *-wal|*-shm)            continue ;;
    esac
    classify "$f"
done
shopt -u nullglob

# ── 5. Conf cache backup ──────────────────────────────────────────────────────
step "Step 5: conf cache backup"
if [[ -d "$OLD_CONFCACHE" ]]; then
    shopt -s nullglob dotglob
    for f in "$OLD_CONFCACHE"/*; do move "$f" "$CONF_CACHE_BACKUP_DIR"; done
    shopt -u nullglob dotglob
    [[ "$DRY_RUN" == false ]] && rmdir "$OLD_CONFCACHE" "$ROOT/.cache/vv" "$ROOT/.cache" 2>/dev/null
else
    say "no $OLD_CONFCACHE — nothing to do"
fi

# ── Summary ───────────────────────────────────────────────────────────────────
printf '\n━━━━━ SUMMARY ━━━━━\n'
printf '  %-10s %s\n' "moved:"   "$moved"
printf '  %-10s %s\n' "skipped:" "$skipped"
printf '  %-10s %s\n' "failed:"  "$failed"
[[ "$DRY_RUN" == true ]] && printf '\n  DRY RUN — nothing was changed.\n'
[[ "$failed" -gt 0 ]] && exit 1
exit 0
