#!/bin/bash
# LibrePortal relocate — move an installed LibrePortal's roots to another disk.
#
#   sudo libreportal-relocate --system-dir=/mnt/ssd/libreportal-system
#   sudo libreportal-relocate --containers-dir=/mnt/big/libreportal-containers
#   sudo libreportal-relocate --system-dir=... --containers-dir=... --dry-run
#
# WHY THIS IS NOT A BUTTON IN THE WEBUI
#
# Moving a root means re-baking the paths inside the root-owned helpers in
# /usr/local/lib/libreportal/, the systemd unit, and the WebUI's own compose
# bind-mounts. Those helpers have their paths baked at install precisely so the
# manager cannot redirect a privileged operation by editing something it owns
# (see scripts/system/libreportal-storage and docs/roadmap/storage-locations.md
# §3). A helper that re-baked the other helpers from a caller-supplied path
# would hand the manager the entire trust boundary those helpers defend.
#
# So this is a root-run command, deliberately NOT in the manager's scoped
# sudoers — same category as libreportal-uninstall. A human with real root runs
# it; the WebUI can only tell you the command.
#
# ORDER OF OPERATIONS — the source is never removed until the destination is
# verified AND the rebaked footprint is in place, so an interrupted run leaves a
# working install behind rather than half a one.

set -u

[[ $EUID -eq 0 ]] || { echo "libreportal-relocate: must run as root" >&2; exit 1; }

LIB_DIR="/usr/local/lib/libreportal"
STORAGE_REGISTRY="$LIB_DIR/storage.roots"
MARKER=".libreportal-storage"

RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; BOLD=$'\033[1m'; NC=$'\033[0m'
say()  { echo "${BOLD}==${NC} $*"; }
ok()   { echo "  ${GREEN}✓${NC} $*"; }
warn() { echo "  ${YELLOW}!${NC} $*"; }
die()  { echo "  ${RED}✗${NC} $*" >&2; exit 1; }

DRY=0
NEW_SYSTEM=""
NEW_CONTAINERS=""
for a in "$@"; do
    case "$a" in
        --system-dir=*)     NEW_SYSTEM="${a#*=}" ;;
        --containers-dir=*) NEW_CONTAINERS="${a#*=}" ;;
        --dry-run)          DRY=1 ;;
        -h|--help)
            sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
            exit 0 ;;
        *) die "unknown argument: $a" ;;
    esac
done
[[ -n "$NEW_SYSTEM$NEW_CONTAINERS" ]] || die "nothing to do — pass --system-dir and/or --containers-dir"

# ---- current layout, read from the installed footprint ----------------------
CUR_SYSTEM=$(grep -m1 '^LP_SYSTEM_DIR="' "$LIB_DIR/libreportal" 2>/dev/null | cut -d'"' -f2)
CUR_CONTAINERS=$(grep -m1 '^LP_CONTAINERS_DIR="' "$LIB_DIR/libreportal" 2>/dev/null | cut -d'"' -f2)
[[ -n "$CUR_SYSTEM" && "$CUR_SYSTEM" != *"__"* ]] || die "cannot read the current system root from $LIB_DIR/libreportal — is LibrePortal installed?"
[[ -n "$CUR_CONTAINERS" && "$CUR_CONTAINERS" != *"__"* ]] || die "cannot read the current containers root"

MANAGER=$(grep -m1 '^CHECK_USER="' "$LIB_DIR/libreportal" 2>/dev/null | cut -d'"' -f2)
[[ -z "$MANAGER" || "$MANAGER" == *"__"* ]] && MANAGER="libreportal"

UNIT="/etc/systemd/system/libreportal.service"
[[ -f "$UNIT" ]] || UNIT="/lib/systemd/system/libreportal.service"

# ---- validation, before anything is touched ---------------------------------
# Validates, and on success leaves the normalised path in VALIDATED.
#
# Deliberately NOT called in a command substitution: `die` runs `exit`, and
# inside $( ) that only kills the subshell — the caller sails on with an empty
# variable and every refusal below silently becomes "proceed". That is exactly
# what happened the first time this was tested, and it is the difference between
# a refusal and an unintended relocation.
VALIDATED=""
_validate_target() {
    local what="$1" cur="$2" new="$3"
    VALIDATED=""
    [[ "$new" == /* ]] || die "$what must be an absolute path (got '$new')"
    [[ "$new" == *..* ]] && die "$what must not contain '..'"
    new="${new%/}"
    [[ "$new" == "$cur" ]] && die "$what is already $cur"
    case "$new" in
        /|/etc|/etc/*|/usr|/usr/*|/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/lib64|/lib64/*|/boot|/boot/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|/root|/root/*|/tmp|/tmp/*)
            die "refusing $what '$new' — inside a protected system path" ;;
    esac
    # Same emptiness rule as libreportal-storage: we only ever take a directory
    # that has nothing to lose.
    if [[ -e "$new" ]]; then
        [[ -d "$new" ]] || die "$what '$new' exists and is not a directory"
        local e
        shopt -s nullglob dotglob
        for e in "$new"/*; do
            case "${e##*/}" in lost+found|"$MARKER") continue ;; esac
            shopt -u nullglob dotglob
            die "$what '$new' is not empty — pick an empty directory"
        done
        shopt -u nullglob dotglob
    fi
    # Space, with headroom.
    local need avail parent="${new%/*}"; [[ -z "$parent" ]] && parent="/"
    need=$(du -sk "$cur" 2>/dev/null | awk '{print $1}')
    avail=$(df -Pk "$parent" 2>/dev/null | awk 'NR==2 {print $4}')
    if [[ -n "$need" && -n "$avail" ]] && (( avail < need + need / 10 )); then
        die "not enough space for $what: need ~$(( (need + need/10) / 1024 )) MiB, have $(( avail / 1024 )) MiB"
    fi
    VALIDATED="$new"
}

say "Checking"
if [[ -n "$NEW_SYSTEM" ]]; then
    _validate_target "the system root" "$CUR_SYSTEM" "$NEW_SYSTEM"
    NEW_SYSTEM="$VALIDATED"
fi
if [[ -n "$NEW_CONTAINERS" ]]; then
    _validate_target "the containers root" "$CUR_CONTAINERS" "$NEW_CONTAINERS"
    NEW_CONTAINERS="$VALIDATED"
fi

# The three roots must not nest.
FIN_SYSTEM="${NEW_SYSTEM:-$CUR_SYSTEM}"
FIN_CONTAINERS="${NEW_CONTAINERS:-$CUR_CONTAINERS}"
[[ "$FIN_SYSTEM" == "$FIN_CONTAINERS"/* || "$FIN_CONTAINERS" == "$FIN_SYSTEM"/* ]] \
    && die "the system and containers roots must not nest ($FIN_SYSTEM / $FIN_CONTAINERS)"

ok "system root:     ${CUR_SYSTEM}${NEW_SYSTEM:+  ->  $NEW_SYSTEM}"
ok "containers root: ${CUR_CONTAINERS}${NEW_CONTAINERS:+  ->  $NEW_CONTAINERS}"

HELPER_SRC="$FIN_SYSTEM/install/scripts/system"
[[ -d "$CUR_SYSTEM/install/scripts/system" ]] \
    || die "cannot find the helper sources at $CUR_SYSTEM/install/scripts/system — a release install is required to re-bake them"

if (( DRY )); then
    echo ""
    say "Dry run — nothing was changed"
    echo "  would stop:      libreportal.service, the WebUI container"
    echo "  would copy:      $CUR_SYSTEM${NEW_SYSTEM:+ -> $NEW_SYSTEM}"
    [[ -n "$NEW_CONTAINERS" ]] && echo "                   $CUR_CONTAINERS -> $NEW_CONTAINERS"
    echo "  would re-bake:   $LIB_DIR/libreportal{,-ownership,-appcfg,-crowdsec,-storage,-svc,-dns,-ssh-access,-socket,-bininstall}"
    echo "  would rewrite:   $UNIT"
    echo "                   $FIN_CONTAINERS/libreportal/docker-compose.yml"
    echo "  would remove the old tree only after all of the above verified"
    exit 0
fi

# ---- 1. stop -----------------------------------------------------------------
say "Stopping LibrePortal"
systemctl stop libreportal.service >/dev/null 2>&1 || true
ok "task processor stopped"

INSTALL_USER=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$CUR_SYSTEM/configs/general/general_docker_install" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}')
INSTALL_USER="${INSTALL_USER:-dockerinstall}"
_docker() {
    local uid; uid=$(id -u "$INSTALL_USER" 2>/dev/null) || return 1
    sudo -u "$INSTALL_USER" \
        XDG_RUNTIME_DIR="/run/user/$uid" \
        DOCKER_HOST="unix:///run/user/$uid/docker.sock" \
        docker "$@" 2>/dev/null
}
if [[ -f "$CUR_CONTAINERS/libreportal/docker-compose.yml" ]]; then
    ( cd "$CUR_CONTAINERS/libreportal" && _docker compose down ) >/dev/null 2>&1 || true
    ok "WebUI container stopped"
fi

# ---- 2. copy (never move: the source must survive a failure) -----------------
_copy_tree() {
    local from="$1" to="$2" what="$3"
    say "Copying $what"
    mkdir -p "$to" || die "could not create $to"
    cp -a --reflink=auto "$from/." "$to/" || die "copy of $what failed — nothing has been removed; the install is still at $from"
    local a b
    a=$(find "$from" -mindepth 1 2>/dev/null | wc -l)
    b=$(find "$to"   -mindepth 1 2>/dev/null | wc -l)
    [[ "$a" == "$b" ]] || die "verification failed for $what ($a entries at source, $b at destination) — source left intact at $from"
    ok "$what copied and verified ($a entries)"
}
[[ -n "$NEW_SYSTEM" ]]     && _copy_tree "$CUR_SYSTEM"     "$NEW_SYSTEM"     "the system tree"
[[ -n "$NEW_CONTAINERS" ]] && _copy_tree "$CUR_CONTAINERS" "$NEW_CONTAINERS" "the containers tree"

# ---- 3. re-bake the root-owned footprint -------------------------------------
say "Re-baking the root-owned helpers"
BACKUP_DIR="/usr/local/lib/libreportal/.relocate-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
for h in libreportal-ownership libreportal-dns libreportal-ssh-access libreportal-socket \
         libreportal-svc libreportal-bininstall libreportal-appcfg libreportal-crowdsec \
         libreportal-storage; do
    src="$HELPER_SRC/$h"
    [[ -f "$src" ]] || { warn "$h has no source — left as-is"; continue; }
    [[ -f "$LIB_DIR/$h" ]] && cp -a "$LIB_DIR/$h" "$BACKUP_DIR/$h"
    tmp=$(mktemp)
    sed -e "s/__MANAGER__/${MANAGER}/g" \
        -e "s#__SYSTEM_DIR__#${FIN_SYSTEM}#g" \
        -e "s#__CONTAINERS_DIR__#${FIN_CONTAINERS}#g" \
        -e "s#__BACKUPS_DIR__#$(grep -m1 '^LP_BACKUPS_DIR="' "$LIB_DIR/libreportal" 2>/dev/null | cut -d'"' -f2)#g" \
        "$src" > "$tmp"
    if bash -n "$tmp" 2>/dev/null; then
        install -m 0755 -o root -g root "$tmp" "$LIB_DIR/$h"
        ok "$h"
    else
        rm -f "$tmp"; die "re-baked $h is malformed — aborting; originals are in $BACKUP_DIR"
    fi
    rm -f "$tmp"
done

# The CLI wrapper carries the roots too.
if [[ -f "$LIB_DIR/libreportal" ]]; then
    cp -a "$LIB_DIR/libreportal" "$BACKUP_DIR/libreportal"
    tmp=$(mktemp)
    sed -e "s#^LP_SYSTEM_DIR=\".*\"#LP_SYSTEM_DIR=\"${FIN_SYSTEM}\"#" \
        -e "s#^LP_CONTAINERS_DIR=\".*\"#LP_CONTAINERS_DIR=\"${FIN_CONTAINERS}\"#" \
        "$LIB_DIR/libreportal" > "$tmp"
    bash -n "$tmp" 2>/dev/null || { rm -f "$tmp"; die "re-baked CLI wrapper is malformed — originals in $BACKUP_DIR"; }
    install -m 0755 -o root -g root "$tmp" "$LIB_DIR/libreportal"
    rm -f "$tmp"
    ok "CLI wrapper"
fi

# ---- 4. systemd unit + compose ----------------------------------------------
if [[ -f "$UNIT" ]]; then
    cp -a "$UNIT" "$BACKUP_DIR/$(basename "$UNIT")"
    sed -i -e "s#LP_SYSTEM_DIR=[^[:space:]\"]*#LP_SYSTEM_DIR=${FIN_SYSTEM}#g" \
           -e "s#LP_CONTAINERS_DIR=[^[:space:]\"]*#LP_CONTAINERS_DIR=${FIN_CONTAINERS}#g" \
           -e "s#${CUR_SYSTEM}#${FIN_SYSTEM}#g" \
           -e "s#${CUR_CONTAINERS}#${FIN_CONTAINERS}#g" "$UNIT"
    systemctl daemon-reload >/dev/null 2>&1 || true
    ok "systemd unit"
fi

COMPOSE="$FIN_CONTAINERS/libreportal/docker-compose.yml"
if [[ -f "$COMPOSE" ]]; then
    cp -a "$COMPOSE" "$BACKUP_DIR/docker-compose.yml"
    sed -i -e "s#${CUR_SYSTEM}#${FIN_SYSTEM}#g" \
           -e "s#${CUR_CONTAINERS}#${FIN_CONTAINERS}#g" "$COMPOSE"
    ok "WebUI compose bind-mounts"
fi

# ---- 5. ownership on the new trees -------------------------------------------
say "Reconciling ownership"
"$LIB_DIR/libreportal-ownership" reconcile >/dev/null 2>&1 && ok "ownership reconciled" \
    || warn "ownership reconcile reported a problem — check 'libreportal system verify' after this"

# ---- 6. start, and only then remove the old trees -----------------------------
say "Starting LibrePortal"
systemctl start libreportal.service >/dev/null 2>&1 || warn "could not start libreportal.service"
if [[ -f "$COMPOSE" ]]; then
    ( cd "$FIN_CONTAINERS/libreportal" && _docker compose up -d ) >/dev/null 2>&1 \
        && ok "WebUI container started" || warn "WebUI container did not start — check 'libreportal webui logs'"
fi

echo ""
say "Done"
[[ -n "$NEW_SYSTEM" ]]     && echo "  LibrePortal now lives in $NEW_SYSTEM"
[[ -n "$NEW_CONTAINERS" ]] && echo "  App data now lives in    $NEW_CONTAINERS"
echo ""
echo "  The previous copies were left in place on purpose — verify the WebUI"
echo "  works, then remove them yourself:"
[[ -n "$NEW_SYSTEM" ]]     && echo "    rm -rf $CUR_SYSTEM"
[[ -n "$NEW_CONTAINERS" ]] && echo "    rm -rf $CUR_CONTAINERS"
echo ""
echo "  Pre-relocation copies of the root-owned files: $BACKUP_DIR"
