#!/bin/bash

# App Updater command handler — `libreportal updater <sub>`
# ---------------------------------------------------------------------------
# Dispatched automatically by cli_initialize.sh (category -> cliHandleUpdaterCommands).
# Subcommands (the features/updater WebUI buttons route to these as tasks):
#   check            refresh the version/CVE data (runs the WebUI generator)
#   apply <app>      update one app — DISASTER-RECOVERY FIRST: snapshot the app
#                    via the backup engine, then pull + recreate; on failure,
#                    roll back to the snapshot automatically
#   apply-all [a,b]  apply to a comma-list (or every update-available app)
#   rollback <app>   restore the app's most recent pre-update snapshot
#
# State-changing subcommands use the standard task-exec split: invoked normally
# they enqueue a task (so the WebUI + CLI share locking + the audit trail);
# the task processor re-invokes them with LIBREPORTAL_TASK_EXEC=1 to do the work.

cliHandleUpdaterCommands()
{
    local sub="$initial_command2"
    local app="$initial_command3"

    case "$sub" in
        ""|"check")
            # `check auto` — the task processor's idle poll calls this every
            # ~60s; self-throttle on the age of the generated updates.json so a
            # full scan only happens once per CFG_UPDATER_SCAN_INTERVAL minutes
            # (0 disables automatic scans). A manual check / post-update rescan
            # rewrites updates.json, which resets this clock for free. A missing
            # file means never scanned -> run now (first scan needs no click).
            if [[ "$app" == "auto" ]]; then
                local scan_interval="${CFG_UPDATER_SCAN_INTERVAL:-30}"
                [[ "$scan_interval" =~ ^[0-9]+$ ]] || scan_interval=30
                (( scan_interval == 0 )) && return 0
                local scan_file="${containers_dir%/}/libreportal/frontend/data/updater/generated/updates.json"
                if [[ -f "$scan_file" ]]; then
                    local _now _last; _now=$(date +%s); _last=$(stat -c '%Y' "$scan_file" 2>/dev/null || echo 0)
                    (( _now - _last < scan_interval * 60 )) && return 0
                fi
            fi
            # Quick + safe — just regenerates the read-only data files. Source
            # the generator explicitly if the lazy loader hasn't mapped it yet
            # (new file; the array regen self-heals it on deploy, this covers
            # the gap before that).
            if ! declare -F webuiUpdaterScan >/dev/null 2>&1; then
                source "$install_scripts_dir/webui/data/generators/updater/webui_updater_scan.sh" 2>/dev/null
            fi
            webuiUpdaterScan
            # Hotfix channel: refresh the signed artifact index for the WebUI, then
            # auto-apply the eligible signed hotfixes (gated by CFG_HOTFIX_AUTO).
            if ! declare -F webuiArtifactScan >/dev/null 2>&1; then
                source "$install_scripts_dir/webui/data/generators/updater/webui_artifact_scan.sh" 2>/dev/null
            fi
            declare -F webuiArtifactScan >/dev/null 2>&1 && webuiArtifactScan
            if ! declare -F artifactApplyAuto >/dev/null 2>&1; then
                source "$install_scripts_dir/cli/commands/artifact/cli_artifact_apply.sh" 2>/dev/null
            fi
            declare -F artifactApplyAuto >/dev/null 2>&1 && artifactApplyAuto
            ;;

        "apply"|"now")
            if [[ -z "$app" ]]; then isError "Usage: libreportal updater apply <app>"; return 1; fi
            if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
                updaterApplyApp "$app"
            else
                cliTaskRun "libreportal updater apply $app" "updater_apply" "$app" ""
            fi
            ;;

        "apply-all")
            local list="$app"  # optional comma-list in $initial_command3
            if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
                updaterApplyAll "$list"
            else
                cliTaskRun "libreportal updater apply-all $list" "updater_apply_all" "updater" ""
            fi
            ;;

        "rollback")
            if [[ -z "$app" ]]; then isError "Usage: libreportal updater rollback <app>"; return 1; fi
            if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
                updaterRollbackApp "$app"
            else
                cliTaskRun "libreportal updater rollback $app" "updater_rollback" "$app" ""
            fi
            ;;

        *)
            cliShowUpdaterHelp
            ;;
    esac
}

# Update one app with disaster-recovery: snapshot -> pull -> recreate -> verify,
# auto-rolling-back on failure. Uses existing primitives (the backup CLI for the
# snapshot, docker compose for the image swap) so it shares their locking/logging.
updaterApplyApp()
{
    local app="$1"
    local app_dir="$containers_dir/$app"
    if [[ ! -d "$app_dir" ]]; then isError "App '$app' is not installed."; return 1; fi

    isHeader "Updating $app (a recovery snapshot is taken first)"

    # 1. DISASTER RECOVERY — snapshot before touching anything. Call the backup
    # function directly (we already run under LIBREPORTAL_TASK_EXEC): the CLI form
    # `backup app "$app"` parsed the app name as the ACTION, hit the dispatcher's
    # `*)` default (a notice that exits 0), so the `if !` guard passed and the app
    # was updated with NO snapshot — and rollback below was a no-op that reported
    # success. backupAppStart is the real entry point and returns 0/1 honestly.
    isNotice "Snapshotting $app before update…"
    if ! backupAppStart "$app" >/dev/null 2>&1; then
        isNotice "Pre-update snapshot did not complete cleanly — continuing is risky; aborting $app update."
        updaterRecordHistory "$app" "update" "" "" "aborted-no-snapshot"
        return 1
    fi

    # 2. Capture the current image so we can record from->to / roll back.
    local before; before="$(grep -m1 -E '^\s*image:' "$app_dir/docker-compose.yml" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')"

    # 3. Pull + recreate (uses the real, install-type-aware compose helpers).
    isNotice "Pulling new image(s) for $app…"
    if updaterComposePull "$app" && dockerComposeUp "$app" >/dev/null 2>&1; then
        local after; after="$(grep -m1 -E '^\s*image:' "$app_dir/docker-compose.yml" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')"
        updaterRecordHistory "$app" "update" "$before" "$after" "ok"
        isSuccessful "$app updated. Rollback point retained."
        webuiUpdaterScan >/dev/null 2>&1 || true
        return 0
    fi

    # 4. Failure -> automatic rollback.
    isNotice "Update of $app failed — rolling back to the pre-update snapshot…"
    updaterRollbackApp "$app" "auto"
    updaterRecordHistory "$app" "update" "$before" "" "rolled-back"
    return 1
}

updaterApplyAll()
{
    local list="$1" failures=0
    if [[ -z "$list" ]]; then
        isNotice "No app list given; nothing to do (the WebUI passes the update-available apps)."
        return 0
    fi
    local IFS=','
    for app in $list; do
        [[ -z "$app" ]] && continue
        updaterApplyApp "$app" || failures=$((failures+1))
    done
    [[ $failures -gt 0 ]] && isNotice "$failures app(s) failed and were rolled back." || isSuccessful "All requested apps updated."
}

# Roll an app back to its most recent snapshot. $2='auto' suppresses the header
# (called from the failure path of an apply).
updaterRollbackApp()
{
    local app="$1" mode="$2"
    [[ "$mode" != "auto" ]] && isHeader "Rolling $app back to its pre-update snapshot"
    # Delegate to the restore engine (latest snapshot for this app). Call the
    # function directly — the old `backup app "$app" restore latest` CLI form was
    # malformed (parsed as action="$app") so it silently did nothing yet exited 0.
    if restoreAppStart "$app" latest "" >/dev/null 2>&1; then
        dockerComposeUp "$app" >/dev/null 2>&1 || true
        [[ "$mode" != "auto" ]] && updaterRecordHistory "$app" "rollback" "" "" "rolled-back"
        isSuccessful "$app restored from its pre-update snapshot."
        return 0
    fi
    isError "Could not roll $app back automatically — restore manually from the Backups page."
    return 1
}

# Force a fresh image pull for an app (mirrors up_app.sh's install-type split).
# dockerComposeUp uses --quiet-pull which won't re-fetch a moved tag, so we pull
# explicitly first to actually pick up a new image.
updaterComposePull()
{
    local app="$1" dir="${containers_dir%/}/$1"
    [ -d "$dir" ] || return 1
    if [[ "$CFG_DOCKER_INSTALL_TYPE" == "rootless" ]]; then
        dockerCommandRunInstallUser "cd $dir && docker compose pull" >/dev/null 2>&1
    else
        ( cd "$dir" && docker compose pull >/dev/null 2>&1 )
    fi
}

# Append an entry to history.json. The "nothing silent" guarantee depends on this
# actually recording, so it is FAIL-CLOSED, not best-effort: with jq we prepend +
# cap to 200; WITHOUT jq we fall back to a brace-agnostic bash-native prepend
# (no 200-cap, the one thing jq bought) rather than silently dropping the entry.
# Args 6-8 are optional and carry the artifact channel's metadata.
updaterRecordHistory()
{
    local app="$1" action="$2" from="$3" to="$4" result="$5"
    local artifact_id="${6:-}" serial="${7:-}" undo_id="${8:-}"
    local f="$containers_dir/libreportal/frontend/data/updater/generated/history.json"
    local ts; ts="$(date -Iseconds 2>/dev/null || date)"
    [ -f "$f" ] || printf '{ "entries": [] }\n' | runFileWrite "$f"

    if command -v jq >/dev/null 2>&1; then
        local tmp; tmp="$(mktemp)"
        if jq --arg ts "$ts" --arg app "$app" --arg action "$action" --arg from "$from" --arg to "$to" \
              --arg result "$result" --arg aid "$artifact_id" --arg serial "$serial" --arg undo "$undo_id" \
            '.entries = ([{ts:$ts, app:$app, action:$action, from:$from, to:$to, result:$result, artifact_id:$aid, serial:$serial, undo_id:$undo}] + (.entries // []))[0:200]' \
            "$f" > "$tmp" 2>/dev/null; then
            runFileWrite "$f" < "$tmp"; rm -f "$tmp"; return 0
        fi
        rm -f "$tmp"
        isError "updaterRecordHistory: jq write failed for $f — using bash fallback"
    fi

    # jq absent or failed — bash-native, brace-agnostic prepend. History entries
    # are flat (scalar fields only), so splicing on the outer [ ... ] is safe.
    local entry
    entry="{\"ts\":\"$(_lpJsonEsc "$ts")\",\"app\":\"$(_lpJsonEsc "$app")\",\"action\":\"$(_lpJsonEsc "$action")\",\"from\":\"$(_lpJsonEsc "$from")\",\"to\":\"$(_lpJsonEsc "$to")\",\"result\":\"$(_lpJsonEsc "$result")\",\"artifact_id\":\"$(_lpJsonEsc "$artifact_id")\",\"serial\":\"$(_lpJsonEsc "$serial")\",\"undo_id\":\"$(_lpJsonEsc "$undo_id")\"}"
    local cur inner
    cur="$(cat "$f" 2>/dev/null)"
    inner="${cur#*[}"; inner="${inner%]*}"
    inner="$(printf '%s' "$inner" | tr -d '\n' | sed -E 's/^[[:space:]]*//; s/[[:space:]]*$//')"
    local newcontent
    if [[ -z "$inner" ]]; then newcontent="{ \"entries\": [$entry] }"
    else newcontent="{ \"entries\": [$entry, $inner] }"; fi
    local tmp2; tmp2="$(mktemp)"; printf '%s\n' "$newcontent" > "$tmp2"
    runFileWrite "$f" < "$tmp2"; rm -f "$tmp2"
    return 0
}
