#!/bin/bash
#
# Artifact APPLY pipeline -- Phase 2 of the unified distribution primitive
# (docs/roadmap/updates-and-distribution.md section 8.3). The MUTATING side: it
# takes a verified artifact from the signed index and applies it reversibly, and
# can revert it. Runs ONLY under the task system (cli_artifact_commands.sh
# enqueues; the processor re-invokes with LIBREPORTAL_TASK_EXEC=1).
#
# Design contracts (all enforced below, fail-closed):
#   * Trust core == the release anchor: the index is minisign-verified against the
#     root-owned footprint key by lpFetchIndex; APPLY additionally REFUSES unless
#     that verification actually happened (LP_INDEX_SIGSTATE==verified) and the
#     payload itself is sha256-pinned by the signed index AND minisig-verified.
#     The publishers map + role gate stops a community key claiming official.
#   * The op vocabulary is a CLOSED allowlist (no run-script/exec/shell, ever).
#     An unsupported op rejects the WHOLE artifact at validation, before any write.
#   * No op VALUE/PATH may carry a shell/quote/sed metacharacter (defense in depth
#     even against a compromised-but-signed payload): _artifactSafeScalar + the
#     _artifactPathAllowed charset gate enforce the "no code-exec" contract.
#   * ALL-OR-NOTHING: every op is dry-prechecked first; one failed precondition
#     skips the whole artifact untouched (recorded, so coverage gaps are visible).
#   * Two-tier reversibility: every op records a precise inverse (the revert path);
#     a best-effort snapshot is also taken. Rollback/revert NEVER report success
#     when an inverse op failed -- they record an explicit "*-incomplete" state.
#   * Mutations write only through the de-sudo funnels, path-aware (container tree
#     -> runFileOp/runFileWrite; manager-owned configs/ -> runInstallOp/Write),
#     never raw sudo. The install tree (our own code) is off-limits to hotfixes.

# --- paths -------------------------------------------------------------------
_artifactGenDir()     { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; }
_artifactAppliedDir() { echo "$(_artifactGenDir)/applied"; }
_artifactRecordFile() { echo "$(_artifactAppliedDir)/$1.json"; }   # $1=id

# Require jq for the apply path. The TRUST core (index sig, payload sha256+sig) is
# jq-free; only walking the bounded op list / envelope fields needs structured
# parsing, and apply is a heavy, rare, privileged path where requiring jq is fine.
_artifactNeedJq() {
    command -v jq >/dev/null 2>&1 && return 0
    isError "artifact: jq is required to apply/revert artifacts (the op interpreter needs it) -- refusing."
    return 1
}

# Reject a scalar that could inject when it flows into a shell/sed/sourced-config
# context. Op values come from a signed payload, but the "no code-exec" contract
# must hold even for a compromised-but-signed payload (defense in depth).
# Bans: double-quote, single-quote, backslash, $, backtick, and any whitespace
# control char (newline/CR/tab).
_artifactSafeScalar() {
    case "$1" in
        *'"'*|*"'"*|*'\'*|*'$'*|*'`'*) return 1 ;;
    esac
    [[ "$1" == *$'\n'* || "$1" == *$'\r'* || "$1" == *$'\t'* ]] && return 1
    return 0
}

# Path-aware writer/remover: container tree -> runFileWrite/runFileOp (install user
# in rootless); manager-owned (configs/) -> runInstallWrite/runInstallOp. A
# system-scope hotfix patches configs/, which the container funnel can't write.
_artifactWriteFile() {  # stdin -> $1
    local path="$1"
    if [[ -n "${containers_dir:-}" && "$path" == "${containers_dir%/}/"* ]]; then runFileWrite "$path"
    else runInstallWrite "$path"; fi
}
_artifactRmFile() {
    local path="$1"
    if [[ -n "${containers_dir:-}" && "$path" == "${containers_dir%/}/"* ]]; then runFileOp rm -f "$path"
    else runInstallOp rm -f "$path"; fi
}

# ----------------------------------------------------------------------------
# RESOLVE -- fetch+verify the index, pull out one artifact, check the gates.
# Echoes the artifact JSON on success; non-zero (nothing usable) otherwise.
# Sets globals: _ART_INDEX (verified index json), _ART_SCOPE, _ART_APP.
# (LP_INDEX_SIGSTATE is set by lpFetchIndex; artifactApply enforces it.)
# ----------------------------------------------------------------------------
_artifactResolve() {
    local id="$1"
    lpFetchIndexInto _ART_INDEX || { isError "artifact: could not fetch/verify the index."; return 1; }

    local art; art="$(printf '%s' "$_ART_INDEX" | jq -ce --arg id "$id" '.artifacts[]? | select(.id==$id)' 2>/dev/null)"
    [[ -n "$art" ]] || { isError "artifact: id '$id' not found in the signed index."; return 1; }

    local type; type="$(jq -r '.type // empty' <<<"$art")"
    if [[ "$type" != "hotfix" ]]; then
        isError "artifact: type '$type' is not supported by this build (only 'hotfix')."; return 1
    fi

    # --- trust: publishers map + role gate ---
    local pub trust role
    pub="$(jq -r '.publisher // empty' <<<"$art")"
    trust="$(jq -r '.trust // "official"' <<<"$art")"
    role="$(printf '%s' "$_ART_INDEX" | jq -r --arg p "$pub" '.publishers[$p].role // empty' 2>/dev/null)"
    if [[ -z "$pub" || -z "$role" ]]; then
        isError "artifact: publisher '$pub' is not in the index publishers map -- refusing."; return 1
    fi
    if [[ "$trust" == "official" && "$role" != "official" ]]; then
        isError "artifact: '$id' claims trust=official but publisher '$pub' has role '$role' -- refusing."; return 1
    fi
    if [[ "$role" != "official" ]]; then
        # Community/custom would require the artifact's own canonical-bytes signature
        # against the publisher's key. Not enabled in this first-party-only build.
        isError "artifact: publisher role '$role' (non-official) is not enabled yet -- refusing."; return 1
    fi

    # --- gates (applies_when) ---
    _ART_APP="$(jq -r '.applies_when.app // empty' <<<"$art")"
    _ART_SCOPE="system"; [[ -n "$_ART_APP" ]] && _ART_SCOPE="app"

    if [[ "$_ART_SCOPE" == "app" && ! -d "${containers_dir%/}/$_ART_APP" ]]; then
        isNotice "artifact: '$id' targets '$_ART_APP' which is not installed -- not applicable."; return 2
    fi

    local minlp maxlp maxfp curlp curfp
    minlp="$(jq -r '.applies_when.min_lp // empty' <<<"$art")"
    maxlp="$(jq -r '.applies_when.max_lp // empty' <<<"$art")"
    maxfp="$(jq -r '.applies_when.max_footprint // empty' <<<"$art")"
    curlp="${CFG_LIBREPORTAL_VERSION:-$(cat "$script_dir/version" 2>/dev/null | tr -dc '0-9.')}"
    if [[ -n "$minlp" && -n "$curlp" ]] && lpVersionGt "$minlp" "$curlp"; then
        isNotice "artifact: '$id' needs LibrePortal >= $minlp (have ${curlp:-?}) -- not applicable."; return 2
    fi
    if [[ -n "$maxlp" && -n "$curlp" ]] && lpVersionGt "$curlp" "$maxlp"; then
        isNotice "artifact: '$id' applies only up to LibrePortal $maxlp (have $curlp) -- not applicable."; return 2
    fi
    if [[ -n "$maxfp" ]]; then
        curfp="$(lpInstalledFootprintVersion)"
        if (( curfp > maxfp )); then
            isNotice "artifact: '$id' applies only up to footprint $maxfp (have $curfp) -- not applicable."; return 2
        fi
    fi

    printf '%s' "$art"
}

# ----------------------------------------------------------------------------
# PAYLOAD -- download, sha256-pin (from the signed index), minisig-verify.
# REFUSES an unsigned payload (signing-not-activated) on the apply path.
# Echoes the verified payload JSON; non-zero on any failure.
# ----------------------------------------------------------------------------
_artifactFetchPayload() {
    local art="$1" base channel url want_sha sig_url tmp pf sf got kind pstate
    base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)"
    kind="$(jq -r '.payload.kind // empty' <<<"$art")"
    if [[ "$kind" != "ops" ]]; then
        isError "artifact: payload.kind '$kind' is not supported by this build (only 'ops')."; return 1
    fi
    url="$(jq -r '.payload.url // empty' <<<"$art")"
    want_sha="$(jq -r '.payload.sha256 // empty' <<<"$art")"
    sig_url="$(jq -r '.payload.sig // empty' <<<"$art")"
    [[ -n "$url" && -n "$want_sha" ]] || { isError "artifact: payload missing url/sha256 -- refusing."; return 1; }

    tmp="$(mktemp -d)"; pf="$tmp/payload.json"; sf="$tmp/payload.sig"
    case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac
    if ! _lpDownload "$url" "$pf" 2>/dev/null; then isError "artifact: payload download failed."; rm -rf "$tmp"; return 1; fi
    got="$(_lpSha256 "$pf")"
    if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH -- refusing."; rm -rf "$tmp"; return 1; fi
    if [[ -n "$sig_url" ]]; then
        case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac
        _lpDownload "$sig_url" "$sf" 2>/dev/null || true
    fi
    pstate="$(lpVerifyMinisig "$pf" "$sf")" || { rm -rf "$tmp"; return 1; }
    if [[ "$pstate" != "verified" ]]; then
        isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)."; rm -rf "$tmp"; return 1
    fi

    cat "$pf"; rm -rf "$tmp"
}

# --- path allowlist (the write-target firewall) -----------------------------
# scope:app    -> only under $containers_dir/<app>/
# scope:system -> only under $configs_dir/   (the install/code tree is OFF-LIMITS
#                 to hotfixes -- code rides signed releases; fork 1)
# Also enforces a safe-filename charset so a path can never carry a shell/quote
# metacharacter into a funnel (belt-and-braces with the runFileWrite argv fix).
_artifactPathAllowed() {
    local path="$1" scope="$2" app="$3" real root
    [[ "$path" == *".."* ]] && return 1
    [[ "$path" =~ ^[A-Za-z0-9._/@:+-]+$ ]] || return 1
    real="$(realpath -m -- "$path" 2>/dev/null)"; [[ -n "$real" ]] || return 1
    if [[ "$scope" == "app" ]]; then
        root="$(realpath -m -- "${containers_dir%/}/$app" 2>/dev/null)"
        [[ "$real" == "$root/"* ]] && return 0
        return 1
    fi
    root="$(realpath -m -- "${configs_dir%/}" 2>/dev/null)"
    [[ -n "$root" && "$real" == "$root/"* ]] && return 0
    return 1
}

# current image of an app's compose (first image: line), quotes/comment stripped.
_artifactComposeImage() {
    local app="$1" f="${containers_dir%/}/$1/docker-compose.yml"
    [[ -f "$f" ]] || return 1
    grep -m1 -E '^\s*image:' "$f" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g; s/\s+#.*$//; s/\s+$//'
}

# ----------------------------------------------------------------------------
# OP PRECHECK -- pure read; returns 0 if the op can be applied as-is, else 1 with
# a reason on stderr. This is the dry-run that enforces all-or-nothing.
# ----------------------------------------------------------------------------
_artifactOpPrecheck() {
    local op_json="$1" scope="$2" app="$3" op
    op="$(jq -r '.op // empty' <<<"$op_json")"
    case "$op" in
        set-config-key)
            local key value expect cur f
            key="$(jq -r '.key // empty' <<<"$op_json")"
            value="$(jq -r '.value // empty' <<<"$op_json")"
            [[ "$key" =~ ^CFG_[A-Z0-9_]+$ ]] || { isError "op set-config-key: bad key '$key'"; return 1; }
            _artifactSafeScalar "$value" || { isError "op set-config-key: value has an unsafe character (quote/\$/backtick/backslash/newline)"; return 1; }
            f="$(findConfigFileForOption "$key" 2>/dev/null)"
            { [[ -n "$f" ]] && grep -q "^$key=" "$f" 2>/dev/null; } || { isError "op set-config-key: key '$key' is not present -- skipping (edit-only, no create)"; return 1; }
            expect="$(jq -r 'if has("expect_current") then .expect_current else " NONE" end' <<<"$op_json")"
            if [[ "$expect" != $' NONE' ]]; then
                cur="$(grep -m1 "^$key=" "$f" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")"
                [[ "$cur" == "$expect" ]] || { isError "op set-config-key: $key is '$cur', expected '$expect' (drift)"; return 1; }
            fi
            ;;
        set-compose-image)
            local from image cur
            from="$(jq -r '.from // empty' <<<"$op_json")"
            image="$(jq -r '.image // empty' <<<"$op_json")"
            { _artifactSafeScalar "$from" && _artifactSafeScalar "$image"; } || { isError "op set-compose-image: image/from has an unsafe character"; return 1; }
            [[ "$image" =~ ^[A-Za-z0-9._:/@-]+$ ]] || { isError "op set-compose-image: '$image' is not a valid image reference"; return 1; }
            [[ -d "${containers_dir%/}/$app" ]] || { isError "op set-compose-image: app '$app' not installed"; return 1; }
            cur="$(_artifactComposeImage "$app")"
            [[ -n "$from" && "$cur" == "$from" ]] || { isError "op set-compose-image: image is '$cur', expected '$from' (drift)"; return 1; }
            ;;
        patch-file-if-checksum-matches)
            local path want got cb
            path="$(jq -r '.path // empty' <<<"$op_json")"
            _artifactPathAllowed "$path" "$scope" "$app" || { isError "op patch-file: path '$path' not in the allowlist"; return 1; }
            [[ -f "$path" ]] || { isError "op patch-file: '$path' does not exist"; return 1; }
            want="$(jq -r '.expect_sha256 // empty' <<<"$op_json")"
            got="$(_lpSha256 "$path")"
            [[ -n "$want" && "$got" == "$want" ]] || { isError "op patch-file: '$path' sha mismatch (drift) -- skipping"; return 1; }
            cb="$(jq -r '.content_b64 // empty' <<<"$op_json")"
            { [[ -n "$cb" ]] && printf '%s' "$cb" | base64 -d >/dev/null 2>&1; } || { isError "op patch-file: content_b64 missing or not valid base64"; return 1; }
            ;;
        set-data-file)
            local path cb
            path="$(jq -r '.path // empty' <<<"$op_json")"
            _artifactPathAllowed "$path" "$scope" "$app" || { isError "op set-data-file: path '$path' not in the allowlist"; return 1; }
            cb="$(jq -r '.content_b64 // empty' <<<"$op_json")"
            { [[ -n "$cb" ]] && printf '%s' "$cb" | base64 -d >/dev/null 2>&1; } || { isError "op set-data-file: content_b64 missing or not valid base64"; return 1; }
            ;;
        *)
            isError "op '$op' is not supported by this build -- rejecting the whole artifact (fail-closed)."
            return 1
            ;;
    esac
    return 0
}

# ----------------------------------------------------------------------------
# OP APPLY -- mutate, and APPEND the precise inverse op (compact JSON) to $4 (the
# undo log). Pre-images are captured BEFORE the mutation. Returns non-zero on
# failure.
# ----------------------------------------------------------------------------
_artifactOpApply() {
    local op_json="$1" scope="$2" app="$3" undo_file="$4" op
    op="$(jq -r '.op // empty' <<<"$op_json")"
    case "$op" in
        set-config-key)
            # Lossless undo: snapshot the WHOLE config file (exact bytes) and emit
            # a restore-file inverse. Avoids the lossy "re-parse the prior scalar"
            # trap (quotes / trailing #). LIFO replay handles multiple ops/file.
            local key value f prior_b64
            key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")"
            f="$(findConfigFileForOption "$key" 2>/dev/null)"
            { [[ -n "$f" && -f "$f" ]]; } || { isError "op set-config-key: config file for $key not found"; return 1; }
            prior_b64="$(base64 -w0 < "$f" 2>/dev/null)"
            updateConfigOption "$key" "$value" || return 1
            jq -cn --arg p "$f" --arg b "$prior_b64" '{op:"restore-file", path:$p, existed:true, content_b64:$b}' >> "$undo_file"
            ;;
        set-compose-image)
            local image cur f esc_img
            image="$(jq -r '.image' <<<"$op_json")"
            f="${containers_dir%/}/$app/docker-compose.yml"
            cur="$(_artifactComposeImage "$app")"
            esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')"
            runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1
            jq -cn --arg img "$cur" '{op:"set-compose-image", image:$img}' >> "$undo_file"
            ;;
        patch-file-if-checksum-matches|set-data-file)
            local path existed prior_b64 content_b64
            path="$(jq -r '.path' <<<"$op_json")"
            content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")"
            [[ -n "$content_b64" ]] || { isError "op $op: missing content_b64"; return 1; }
            if [[ -f "$path" ]]; then existed=true; prior_b64="$(base64 -w0 < "$path" 2>/dev/null)"; else existed=false; prior_b64=""; fi
            if ! printf '%s' "$content_b64" | base64 -d 2>/dev/null | _artifactWriteFile "$path"; then
                isError "op $op: write to '$path' failed"; return 1
            fi
            jq -cn --arg p "$path" --arg e "$existed" --arg b "$prior_b64" \
                '{op:"restore-file", path:$p, existed:($e=="true"), content_b64:$b}' >> "$undo_file"
            ;;
        *)
            isError "op '$op': no apply handler (should have been rejected at precheck)"; return 1
            ;;
    esac
    return 0
}

# ----------------------------------------------------------------------------
# OP UNDO -- apply one inverse op recorded by _artifactOpApply (the revert path).
# Inverse ops are idempotent (re-applying the same restore is safe), so a retried
# revert cannot corrupt state.
# ----------------------------------------------------------------------------
_artifactOpUndo() {
    local op_json="$1" app="$2" op
    op="$(jq -r '.op // empty' <<<"$op_json")"
    case "$op" in
        set-compose-image)
            local image f esc_img; image="$(jq -r '.image' <<<"$op_json")"
            f="${containers_dir%/}/$app/docker-compose.yml"
            esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')"
            runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1
            ;;
        restore-file)
            local path existed content_b64
            path="$(jq -r '.path' <<<"$op_json")"; existed="$(jq -r '.existed' <<<"$op_json")"
            content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")"
            if [[ "$existed" == "true" ]]; then
                printf '%s' "$content_b64" | base64 -d 2>/dev/null | _artifactWriteFile "$path" || return 1
            else
                _artifactRmFile "$path" || return 1
            fi
            ;;
        *) isError "undo: unknown inverse op '$op'"; return 1 ;;
    esac
    return 0
}

# Replay every inverse op in $1; "reverse" applies them LIFO (for rollback).
# Returns non-zero if ANY inverse op failed (so the caller never claims success).
_artifactReplayUndoFile() {
    local file="$1" app="$2" order="$3" rc=0
    [[ -s "$file" ]] || return 0
    local lines; mapfile -t lines < "$file"
    if [[ "$order" == "reverse" ]]; then
        local n=${#lines[@]} j
        for (( j=n-1; j>=0; j-- )); do [[ -n "${lines[j]}" ]] && { _artifactOpUndo "${lines[j]}" "$app" || rc=1; }; done
    else
        local l; for l in "${lines[@]}"; do [[ -n "$l" ]] && { _artifactOpUndo "$l" "$app" || rc=1; }; done
    fi
    return $rc
}

# Persist the applied-record (metadata + the undo log as a JSON array). Returns
# the write's exit status so the caller can react to a failed persist.
_artifactWriteRecord() {
    local id="$1" art="$2" serial="$3" undo_file="$4"
    local dir; dir="$(_artifactAppliedDir)"
    runFileOp mkdir -p "$dir" 2>/dev/null || true
    local ts; ts="$(date -Iseconds 2>/dev/null || date)"
    local undo_arr="[]"
    [[ -s "$undo_file" ]] && undo_arr="$(jq -cs '.' < "$undo_file" 2>/dev/null || echo '[]')"
    local rec
    rec="$(jq -cn --argjson art "$art" --arg ts "$ts" --arg serial "$serial" --argjson undo "$undo_arr" \
        '{id:$art.id, type:$art.type, version:($art.version//1), app:($art.applies_when.app//null),
          severity:($art.severity//"tweak"), title:($art.title//""), why:($art.why//""),
          applied_at:$ts, serial:$serial, undo:$undo}')"
    printf '%s' "$rec" | runFileWrite "$(_artifactRecordFile "$id")"
    return ${PIPESTATUS[1]}
}

# Rebuild artifacts_applied.json (the WebUI-read manifest) from applied/*.json.
_artifactRegenAppliedManifest() {
    local dir; dir="$(_artifactAppliedDir)"
    local out; out="$(_artifactGenDir)/artifacts_applied.json"
    local body="[]"
    if compgen -G "$dir/*.json" >/dev/null 2>&1; then
        body="$(jq -cs '[.[] | {id,type,app,severity,title,why,applied_at,serial,version}]' "$dir"/*.json 2>/dev/null || echo '[]')"
    fi
    local ts; ts="$(date -Iseconds 2>/dev/null || date)"
    printf '%s' "$(jq -cn --argjson a "$body" --arg ts "$ts" '{generated_at:$ts, applied:$a}')" | runFileWrite "$out"
}

# ----------------------------------------------------------------------------
# artifactApply <id> -- the full pipeline. Re-apply re-prechecks (drift guards
# skip already-applied ops cleanly).
# ----------------------------------------------------------------------------
artifactApply() {
    local id="$1"
    _artifactNeedJq || return 1
    [[ -n "$id" ]] || { isError "artifactApply: no id"; return 1; }

    isHeader "Applying hotfix: $id"

    # 0. RESOLVE + gates
    local art rc
    art="$(_artifactResolve "$id")"; rc=$?
    if [[ $rc -eq 2 ]]; then updaterRecordHistory "${_ART_APP:-}" "hotfix" "" "$id" "not-applicable" "$id" "$(_lpJsonNum "$_ART_INDEX" index_serial)" ""; return 0; fi
    [[ $rc -eq 0 && -n "$art" ]] || { updaterRecordHistory "" "hotfix" "" "$id" "rejected" "$id" "" ""; return 1; }

    local app="$_ART_APP" scope="$_ART_SCOPE" serial title
    serial="$(_lpJsonNum "$_ART_INDEX" index_serial)"
    title="$(jq -r '.title // empty' <<<"$art")"

    # GATE: never mutate from an index that was accepted only because signing
    # wasn't activated (dev/placeholder key). The read path tolerates that; apply
    # must not.
    if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then
        isError "artifact: the index is not signature-verified (signing not activated) -- refusing to APPLY."
        updaterRecordHistory "$app" "hotfix" "" "$id" "refused-unsigned" "$id" "$serial" ""
        return 1
    fi
    isNotice "$title (scope=$scope${app:+, app=$app})"

    # 4. PAYLOAD (fetch + sha256-pin + minisig-verify; refuses unsigned)
    local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; }

    local ops_count; ops_count="$(jq '.ops | length' <<<"$payload" 2>/dev/null || echo 0)"
    if ! [[ "$ops_count" =~ ^[0-9]+$ ]] || (( ops_count == 0 )); then
        isError "artifact: payload has no ops."; updaterRecordHistory "$app" "hotfix" "" "$id" "empty" "$id" "$serial" ""; return 1
    fi

    # 6a. DRY-PRECHECK ALL (all-or-nothing)
    local i op_json
    for (( i=0; i<ops_count; i++ )); do
        op_json="$(jq -c ".ops[$i]" <<<"$payload")"
        if ! _artifactOpPrecheck "$op_json" "$scope" "$app"; then
            isNotice "Precheck failed -- skipping the whole hotfix untouched (no changes made)."
            updaterRecordHistory "$app" "hotfix" "" "$id" "skipped-precheck" "$id" "$serial" ""
            return 0
        fi
    done

    # 5. SNAPSHOT (best-effort safety net; the undo log is the guaranteed path)
    if [[ "$scope" == "app" ]]; then
        isNotice "Recovery snapshot of $app..."
        backupAppStart "$app" >/dev/null 2>&1 || isNotice "No snapshot taken (no backup location?) -- relying on the precise undo log."
    else
        backupSystemConfig >/dev/null 2>&1 || isNotice "No system-config snapshot taken -- relying on the precise undo log."
    fi

    # 6b. APPLY (collect inverse ops into the undo log)
    local undo_file; undo_file="$(mktemp)"
    local applied_ok=1
    for (( i=0; i<ops_count; i++ )); do
        op_json="$(jq -c ".ops[$i]" <<<"$payload")"
        if ! _artifactOpApply "$op_json" "$scope" "$app" "$undo_file"; then applied_ok=0; break; fi
    done

    # 7. bring the app up / regenerate system config so changes take effect
    if [[ "$applied_ok" == "1" ]]; then
        if [[ "$scope" == "app" ]]; then
            dockerComposeUp "$app" >/dev/null 2>&1 || applied_ok=0
        elif declare -F webuiGenerateSystemConfigs >/dev/null 2>&1; then
            webuiGenerateSystemConfigs >/dev/null 2>&1 || applied_ok=0
        fi
    fi

    if [[ "$applied_ok" != "1" ]]; then
        # 8. AUTO-ROLLBACK -- replay the undo log LIFO; snapshot is the fallback.
        isNotice "Apply failed -- rolling back..."
        local rb_rc=0
        _artifactReplayUndoFile "$undo_file" "$app" "reverse" || rb_rc=1
        if [[ "$scope" == "app" ]]; then restoreAppStart "$app" latest "" >/dev/null 2>&1 || true; dockerComposeUp "$app" >/dev/null 2>&1 || true; fi
        rm -f "$undo_file"
        if [[ "$rb_rc" -eq 0 ]]; then
            updaterRecordHistory "$app" "hotfix" "" "$id" "rolled-back" "$id" "$serial" ""
            isError "Hotfix $id failed and was rolled back cleanly."
        else
            updaterRecordHistory "$app" "hotfix" "" "$id" "rollback-incomplete" "$id" "$serial" ""
            isError "Hotfix $id failed AND auto-rollback was incomplete -- MANUAL recovery needed (see History / the app's snapshot)."
        fi
        return 1
    fi

    # 9. RECORD (applied-record with the undo log). If we can't persist the undo
    # trail, roll back rather than leave an un-revertable change.
    if ! _artifactWriteRecord "$id" "$art" "$serial" "$undo_file"; then
        isError "Could not persist the applied-record -- rolling back to stay reversible."
        _artifactReplayUndoFile "$undo_file" "$app" "reverse" || true
        [[ "$scope" == "app" ]] && dockerComposeUp "$app" >/dev/null 2>&1 || true
        rm -f "$undo_file"
        updaterRecordHistory "$app" "hotfix" "" "$id" "record-failed-rolled-back" "$id" "$serial" ""
        return 1
    fi
    rm -f "$undo_file"
    _artifactRegenAppliedManifest
    updaterRecordHistory "$app" "hotfix" "" "$id" "applied" "$id" "$serial" "$id"
    isSuccessful "Hotfix $id applied. Reversible via: libreportal artifact revert $id"
    return 0
}

# ----------------------------------------------------------------------------
# artifactRevert <id> -- replay the applied-record's undo log (LIFO), then bring
# the app up / regen config. Only removes the record + reports success if every
# inverse op succeeded; otherwise keeps the record for retry and reports honestly.
# ----------------------------------------------------------------------------
artifactRevert() {
    local id="$1"
    _artifactNeedJq || return 1
    [[ -n "$id" ]] || { isError "artifactRevert: no id"; return 1; }
    local rf; rf="$(_artifactRecordFile "$id")"
    [[ -f "$rf" ]] || { isError "artifact: '$id' is not recorded as applied -- nothing to revert."; return 1; }

    isHeader "Reverting hotfix: $id"
    local rec; rec="$(cat "$rf")"
    local app; app="$(jq -r '.app // empty' <<<"$rec")"
    local serial; serial="$(jq -r '.serial // empty' <<<"$rec")"

    local undo_file; undo_file="$(mktemp)"
    jq -c '.undo[]?' <<<"$rec" > "$undo_file" 2>/dev/null
    local rv_rc=0
    _artifactReplayUndoFile "$undo_file" "$app" "reverse" || rv_rc=1
    rm -f "$undo_file"

    if [[ -n "$app" ]]; then dockerComposeUp "$app" >/dev/null 2>&1 || true
    else declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true; fi

    if [[ "$rv_rc" -eq 0 ]]; then
        runFileOp rm -f "$rf" 2>/dev/null || true
        _artifactRegenAppliedManifest
        updaterRecordHistory "$app" "hotfix" "$id" "" "reverted" "$id" "$serial" ""
        isSuccessful "Hotfix $id reverted."
        return 0
    fi
    updaterRecordHistory "$app" "hotfix" "$id" "" "revert-incomplete" "$id" "$serial" "$id"
    isError "Revert of $id was incomplete -- the applied-record is kept so you can retry: libreportal artifact revert $id (MANUAL recovery may be needed)."
    return 1
}

# ----------------------------------------------------------------------------
# artifactApplyAuto -- enqueue apply tasks for the auto-eligible hotfixes, gated
# by CFG_HOTFIX_AUTO (security-breakage|all|off). Called from `updater check`.
# Only acts on a VERIFIED-signed index; only artifacts with auto==true, in the
# severity policy, applicable, and not already applied. Each apply is enqueued as
# its own task (visible in the task log + History) -- never applied inline here.
# ----------------------------------------------------------------------------
artifactApplyAuto() {
    _artifactNeedJq || return 0
    local policy="${CFG_HOTFIX_AUTO:-security-breakage}"
    [[ "$policy" == "off" ]] && { isNotice "Hotfix auto-apply is off (CFG_HOTFIX_AUTO=off)."; return 0; }

    local index; lpFetchIndexInto index || { isNotice "artifact apply-auto: no index available."; return 0; }
    if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then
        isNotice "artifact apply-auto: index is unsigned (signing not activated) — not auto-applying."; return 0
    fi

    local ids id art sev app installed enqueued=0
    ids="$(printf '%s' "$index" | jq -r '.artifacts[]? | select(.auto==true and (.type=="hotfix")) | .id' 2>/dev/null)"
    while IFS= read -r id; do
        [[ -z "$id" ]] && continue
        art="$(printf '%s' "$index" | jq -ce --arg id "$id" '.artifacts[]?|select(.id==$id)' 2>/dev/null)"
        [[ -n "$art" ]] || continue
        sev="$(jq -r '.severity // "tweak"' <<<"$art")"
        case "$policy" in
            all) : ;;
            *)   [[ "$sev" == "security" || "$sev" == "breakage" ]] || continue ;;  # security-breakage (default)
        esac
        # skip if already applied
        [[ -f "$(_artifactRecordFile "$id")" ]] && continue
        # skip if app-scoped but the app isn't installed (applicable gate; full
        # gates re-checked at apply time)
        app="$(jq -r '.applies_when.app // empty' <<<"$art")"
        [[ -n "$app" && ! -d "${containers_dir%/}/$app" ]] && continue
        cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" ""
        enqueued=$((enqueued + 1))
    done <<< "$ids"

    if (( enqueued > 0 )); then isSuccessful "Queued $enqueued auto-hotfix(es) for apply (policy: $policy)."
    else isNotice "No new auto-hotfixes to apply (policy: $policy)."; fi
}
