#!/bin/bash
# ==============================================================================================
# ============================ Arr Corruption Scan ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Scans Sonarr's and Radarr's tracked video files for corrupt headers (ffprobe-based, same
# detection method as the third-party Healarr tool) and, in --remediate mode, deletes the bad
# file from the owning arr and explicitly triggers a search to replace it.
#
# Built after Healarr crashed mid-scan on a genuine Go concurrency bug (unsynchronized
# map access when multiple corruption events land at once — confirmed via its own crash
# log, not fixable from our side). The core idea (scan → delete → re-search) isn't hard to
# replicate; the fix here is architectural: this script processes one file at a time,
# strictly sequential, so the race condition that killed Healarr can't happen — there's
# nothing running concurrently to race.
#
# Sonarr-only originally (2026-07-18/19); Radarr/Movies coverage added 2026-07-21 as a second
# arr in the same per-file scan/strike/remediate loop, not a separate script — the detection,
# strike, and state-file logic is identical, only the API shape (episodefile vs moviefile,
# EpisodeSearch vs MoviesSearch) differs. Radarr's moviefile list is fetched batched
# (movieId=... query params, BATCH_SIZE at a time) rather than off the movie list's embedded
# .movieFile alone — Radarr supports a second tracked file per movie (alternate editions/
# extras) that never shows up there, same gap radarr_cleanup.sh hit and fixed 2026-07-19;
# reusing that batched-fetch shape here instead of the simpler single-file read so a
# corruption scan doesn't silently skip every alternate edition in the library.
#
# ==============================================================================================
# WHY A SEPARATE CONTAINER FOR FFPROBE
# ==============================================================================================
#
# Neither Sonarr nor Radarr bundle ffprobe. ffprobe runs via `docker exec` into a
# different container that does — confirmed live 2026-07-18:
#   Jellyfin        — working ffprobe, mounts every share Emby does (Tv_Shows, Movies, kids/
#                      anime shares, standup) as of 2026-07-18
#   Emby            — mounts everything too, but its bundled ffprobe binary is broken
#                      (2017-dated, fails to exec — likely a missing dynamic linker
#                      dependency, not something to fix here)
# Jellyfin is what's configured (HOST*_FFPROBE_CONTAINER).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Runs Sonarr then Radarr, sequentially, never parallel — one arr failing/unconfigured never
# blocks the other. Within each arr, one file at a time, in this order per file:
#   1. Skip if unchanged (mtime+size) since the last time it verified clean — state file
#      avoids re-probing the entire library every run, which would take far too long at
#      this library size (90k+ tracked files).
#   2. ffprobe via `docker exec` into FFPROBE_CONTAINER. Empty stderr + exit 0 = clean.
#      Anything else = corrupt (same signature as Healarr: "Invalid data found when
#      processing input", EBML header errors, etc.)
#   3. Report-only by default. --remediate additionally:
#        a. DELETE the specific episodefile/moviefile record via the arr's API
#        b. Verify hasFile flipped false (never trust the DELETE response alone)
#        c. Explicitly trigger EpisodeSearch/MoviesSearch for that episode/movie — this is
#           deliberate, not left to the arr's own background missing-search cycle, because
#           that cycle skips unmonitored items entirely. An explicit search call does not
#           have that restriction (confirmed live: two unmonitored episodes Healarr healed
#           both still got successfully re-grabbed via this exact same kind of search call,
#           logged in Sonarr's history as "UserInvokedSearch").
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Detect Always, Act Only on Request
#   A bare run probes and reports. Deleting a file the arr believes it has is a
#   destructive act, so it requires --remediate explicitly. The scan can be scheduled
#   weekly and read without any risk of it removing media on its own.
#
# One Probe Result Is Not Evidence
#   ffprobe can fail for reasons that have nothing to do with the file — a mid-write
#   import, an NFS blip, a container restart. Corruption must be observed
#   CORRUPTION_SCAN_STRIKE_LIMIT times consecutively before remediation acts, and a
#   single clean re-probe resets the counter.
#
# Skip-Cache Over Re-Probing
#   At 90k+ tracked files a full re-probe every run is not viable. Files unchanged by
#   mtime and size since they last verified clean are skipped, so each run spends its
#   time on what actually changed rather than re-proving the library from scratch.
#
# Delete the Record, Let the Arr Re-Acquire
#   Remediation removes the file record and explicitly triggers a search. The arr is
#   left to obtain a good copy through its normal path — this script never tries to
#   repair a file in place.
#
# Explicit Search, Not the Background Cycle
#   The re-search is triggered directly rather than left to the arr's own missing-search
#   cycle, because that cycle skips unmonitored items entirely and would silently leave
#   an unmonitored corrupt file deleted and never replaced.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
#   docker exec into the ffprobe container requires root.
#
# Lock Acquisition
#   acquire_lock prevents overlapping runs. Two instances would both probe and could
#   both count a strike against the same file, reaching the limit in half the intended
#   number of observations.
#
# Host Detection
#   detect_hosts() aliases the arr URLs, API keys and HOST*_FFPROBE_CONTAINER.
#
# jq Dependency Check
#   Fails fast if jq is missing — the tracked-file lists and every hasFile verification
#   are parsed with it.
#
# Report-Only Default
#   Nothing is deleted without --remediate.
#
# ffprobe Configuration Check
#   Exits cleanly if FFPROBE_CONTAINER / FFPROBE_BIN are unconfigured for this host.
#
# ffprobe Container Health Check
#   check_container_health() verifies the container is running and healthy before any
#   probing. Every probe is a docker exec into it — if it is stopped or unhealthy every
#   exec fails, every file reads as corrupt, and two consecutive runs would clear the
#   strike limit and hand --remediate the whole library to delete.
#
# API Reachability + Version Gate
#   check_api then check_arr_version per arr. A version mismatch skips that arr rather
#   than issuing deletes against an API whose file-record endpoints may have moved.
#
# Per-Arr Isolation
#   Sonarr and Radarr run sequentially, and one failing, unconfigured or version-
#   mismatched arr never blocks the other.
#
# Unmapped Path Skip
#   Files whose arr-side path cannot be mapped into the ffprobe container's mount
#   namespace are skipped and counted, never probed through a wrong path and never
#   treated as corrupt because the probe could not see them.
#
# Strike Threshold
#   CORRUPTION_SCAN_STRIKE_LIMIT consecutive corrupt detections are required before
#   --remediate deletes anything. A transient ffprobe failure cannot trigger a delete,
#   and a clean re-probe clears the counter.
#
# Post-Delete Verification
#   The DELETE response is never trusted. hasFile is re-checked and must have flipped
#   false before the re-search is issued, so a failed delete never leaves the arr
#   searching for something it still believes it has.
#
# Targeted Deletion
#   Only the specific episodefile/moviefile record for the corrupt file is removed —
#   never the series, movie, or any sibling file.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#   SONARR_URL / SONARR_API_KEY, RADARR_URL / RADARR_API_KEY — existing, aliased by
#   detect_hosts(). Either arr missing its URL/key is skipped, not fatal.
#   HOST*_FFPROBE_CONTAINER — container name with a working ffprobe binary
#   HOST*_FFPROBE_BIN       — full path to that binary inside the container
#   HOST*_FFPROBE_PATH_MAP  — host path prefix → that container's internal path prefix
#                             (separate from the arrs' own path maps — the ffprobe
#                             container almost certainly mounts shares differently)
#
# master.conf
#   CORRUPTION_SCAN_STATE_FILE   — path to the clean-file skip-cache (default in DATA_DIR),
#                                  shared across both arrs — keyed by host path, which never
#                                  collides between a Sonarr and a Radarr share
#   CORRUPTION_SCAN_STRIKES_FILE — path to the consecutive-corrupt-detection counter (default
#                                  in DATA_DIR), keyed by host path
#   CORRUPTION_SCAN_STRIKE_LIMIT — consecutive corrupt detections required before --remediate
#                                  acts on a file (default 2) — guards against a one-off
#                                  ffprobe hiccup (mid-write file, NFS blip) triggering an
#                                  unnecessary delete+re-search. Resets on a clean re-probe.
#   SONARR_VERSION_MAJOR / RADARR_VERSION_MAJOR — reused from sonarr_cleanup.sh/
#                                  radarr_cleanup.sh for the API version check
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
#   arr_corruption_scan.sh                  — report-only, scans everything not yet
#                                              verified clean (Sonarr then Radarr)
#   arr_corruption_scan.sh --remediate      — delete + re-search on every corrupt file found
#   arr_corruption_scan.sh --limit=50       — cap EACH arr to 50 newly-probed files this run
#                                              (state file makes repeat runs cheap regardless,
#                                              but useful for a bounded first test)
#   arr_corruption_scan.sh --log            — verbose (prints every clean file too)
#   arr_corruption_scan.sh --status         — show config and exit
#   arr_corruption_scan.sh --filter=Becker  — only consider paths containing this substring
#                                              (testing/targeting a specific show/movie; state
#                                              file and everything else behaves normally)
#
# ==============================================================================================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

source "$SCRIPT_DIR/../load_config.sh"

# --remediate / --limit are script-local, not recognized by parse_args — check raw args
# before they get filtered.
REMEDIATE=false
SCAN_LIMIT=0
PATH_FILTER=""
for _arg in "$@"; do
    case "$_arg" in
        --remediate) REMEDIATE=true ;;
        --limit=*)   SCAN_LIMIT="${_arg#*=}" ;;
        --filter=*)  PATH_FILTER="${_arg#*=}" ;;
    esac
done
unset _arg

parse_args "$@"

# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
    error "Must be run as root"
    exit 1
fi

if ! command -v curl >/dev/null 2>&1; then
    error "curl not found — required for arr API calls"
    exit 1
fi

if ! command -v jq >/dev/null 2>&1; then
    error "jq not found — required for JSON parsing"
    exit 1
fi

if ! command -v docker &>/dev/null; then
    error "Docker command not found"
    exit 1
fi

acquire_lock "wait"
TMP_DIR="/tmp/arr_corruption_scan_$$"
mkdir -p "$TMP_DIR"
trap "_release_all_locks; rm -rf $TMP_DIR" EXIT

detect_hosts

# FFPROBE_* aren't part of the shared arr alias set in detect_hosts() — resolve them here,
# same eval-based pattern build_arr_path_map() uses for the associative array.
FFPROBE_CONTAINER_VAR="${MY_ID}_FFPROBE_CONTAINER"
FFPROBE_CONTAINER="${!FFPROBE_CONTAINER_VAR:-}"
FFPROBE_BIN_VAR="${MY_ID}_FFPROBE_BIN"
FFPROBE_BIN="${!FFPROBE_BIN_VAR:-}"

declare -A FFPROBE_PATH_MAP=()
_fp_map_var="${MY_ID}_FFPROBE_PATH_MAP"
eval "for key in \"\${!${_fp_map_var}[@]}\"; do
    FFPROBE_PATH_MAP[\"\$key\"]=\"\${${_fp_map_var}[\$key]}\"
done"
unset _fp_map_var

if [[ -z "$FFPROBE_CONTAINER" || -z "$FFPROBE_BIN" ]]; then
    error "FFPROBE_CONTAINER/FFPROBE_BIN not configured on $MY_ID — skipping"
    exit 0
fi

# Every probe is a docker exec into this container. If it is stopped or unhealthy, every
# exec fails, every file reads as corrupt, and two such runs would clear the strike limit
# and hand --remediate an entire library to delete. Abort before probing anything.
check_container_health "$FFPROBE_CONTAINER" "${DOCKER_TIMEOUT:-30}" "Arr Corruption Scan"

CORRUPTION_SCAN_STATE_FILE="${CORRUPTION_SCAN_STATE_FILE:-$DATA_DIR/corruption_scan_state.tsv}"
mkdir -p "$(dirname "$CORRUPTION_SCAN_STATE_FILE")"
touch "$CORRUPTION_SCAN_STATE_FILE"

CORRUPTION_SCAN_STRIKES_FILE="${CORRUPTION_SCAN_STRIKES_FILE:-$DATA_DIR/corruption_scan_strikes.tsv}"
CORRUPTION_SCAN_STRIKE_LIMIT="${CORRUPTION_SCAN_STRIKE_LIMIT:-2}"
mkdir -p "$(dirname "$CORRUPTION_SCAN_STRIKES_FILE")"
touch "$CORRUPTION_SCAN_STRIKES_FILE"

# Thin wrappers around common.sh's wd_state_get/wd_state_set — same shape as
# stability_watchdog.sh's get_strikes/set_strikes/increment_strikes/reset_strikes, keyed here
# by host path instead of a watchdog check name. Requires repeat corrupt detections across
# separate scan runs before --remediate acts, so a one-off ffprobe hiccup (mid-write file,
# NFS blip) can't trigger an unnecessary delete+re-search on its own.
get_scan_strikes() {
    wd_state_get "$1" "$CORRUPTION_SCAN_STRIKES_FILE"
}
set_scan_strikes() {
    wd_state_set "$1" "$2" "$CORRUPTION_SCAN_STRIKES_FILE"
}
increment_scan_strikes() {
    local current
    current=$(get_scan_strikes "$1")
    [[ -z "$current" ]] && current=0
    (( current++ ))
    set_scan_strikes "$1" "$current"
    echo "$current"
}
reset_scan_strikes() {
    local current
    current=$(get_scan_strikes "$1")
    [[ -n "$current" && "$current" != "0" ]] && set_scan_strikes "$1" 0
}

# Per-arr API shape differences — everything else in the scan/strike/remediate loop below is
# identical between Sonarr and Radarr.
declare -A ARR_FILE_ENDPOINT=(   [sonarr]="episodefile" [radarr]="moviefile" )
declare -A ARR_PARENT_ENDPOINT=( [sonarr]="episode"     [radarr]="movie" )
declare -A ARR_SEARCH_COMMAND=(  [sonarr]="EpisodeSearch" [radarr]="MoviesSearch" )
declare -A ARR_SEARCH_ID_FIELD=( [sonarr]="episodeIds"    [radarr]="movieIds" )

if [[ "$SHOW_STATUS" == true ]]; then
    echo ""
    echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
    echo "$ICON_HOST    Identity:          $MY_ID ($LOCAL_SERVER_NAME)"
    for arr in sonarr radarr; do
        url_var="${arr^^}_URL"
        echo "$ICON_GEAR    ${arr^} URL:        ${!url_var:-not configured}"
    done
    echo "$ICON_GEAR    FFprobe container: $FFPROBE_CONTAINER"
    echo "$ICON_GEAR    FFprobe binary:    $FFPROBE_BIN"
    echo "$ICON_GEAR    FFprobe path map:  ${#FFPROBE_PATH_MAP[@]} entries"
    echo "$ICON_GEAR    State file:        $CORRUPTION_SCAN_STATE_FILE"
    echo "$ICON_GEAR    Strike limit:      $CORRUPTION_SCAN_STRIKE_LIMIT"
    echo "$ICON_GEAR    Remediate:         $REMEDIATE"
    echo "$ICON_GEAR    Scan limit:        ${SCAN_LIMIT:-unlimited} (per arr)"
    echo "$ICON_GEAR    Path filter:       ${PATH_FILTER:-none}"
    echo "━━━━━━━━━━━━━━━━━━━━━━━"
    exit 0
fi

echo ""
[[ "$REMEDIATE" == true ]] && warn "REMEDIATE MODE — corrupt files will be deleted and re-searched" \
    || info "Report-only — pass --remediate to act"

echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
check_container_health "$FFPROBE_CONTAINER" 15 "Corruption Scan"

# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================

# Translates a host filesystem path to FFPROBE_CONTAINER's internal path via prefix match
# against FFPROBE_PATH_MAP. Empty output (return 1) means this file's share isn't covered
# by the ffprobe container yet — caller must skip, not guess.
ffprobe_translate_path() {
    local host_path="$1" prefix
    for prefix in "${!FFPROBE_PATH_MAP[@]}"; do
        if [[ "$host_path" == "$prefix"/* ]]; then
            echo "${FFPROBE_PATH_MAP[$prefix]}${host_path#$prefix}"
            return 0
        fi
    done
    return 1
}

# Probes one file. Echoes "clean" or "corrupt:<reason>". Never trusts a truncated/garbled
# stderr as automatically corrupt — only a real non-empty ffprobe stderr counts.
probe_file() {
    local host_path="$1" container_path output
    container_path=$(ffprobe_translate_path "$host_path") || { echo "unmapped"; return; }
    output=$(docker exec "$FFPROBE_CONTAINER" "$FFPROBE_BIN" -v error "$container_path" 2>&1)
    if [[ -z "$output" ]]; then
        echo "clean"
    else
        echo "corrupt:${output//$'\n'/ }"
    fi
}

# Appends one arr_api() call's output to a batch file, but ONLY on success. arr_api() prints
# its own error message via error() (a plain `echo`, i.e. stdout, not stderr) on any non-200
# response — appending its raw output unconditionally means a single failed batch call (one
# bad seriesId/movieId batch out of hundreds) mixes a plain-text error line into what's
# otherwise a stream of valid JSON arrays, and `jq -s` then fails to parse the WHOLE file,
# turning one bad batch into zero usable files for the entire arr. Confirmed live 2026-07-21:
# Sonarr seriesId=650 returned HTTP 404 (stale/deleted series reference) mid-walk, and that
# single 404's error text corrupted the full 138MB/1177-series concatenated batch, silently
# zeroing out the whole Sonarr scan for that run. Capturing output first and gating the
# append on the actual exit code isolates one bad call to just that call.
_arr_api_append_on_success() {
    local out
    out=$(arr_api "$1" "$2" "$3" "$4" "$5" 2>/dev/null)
    [[ $? -eq 0 ]] && echo "$out" >> "$6"
}

# Fetches every movie's file(s) via Radarr's moviefile endpoint, batched (movieId=X repeated
# query param, BATCH_SIZE at a time — a single whole-library request 414s, confirmed live by
# radarr_cleanup.sh 2026-07-19). Deliberately not just the movie list's embedded .movieFile —
# that only ever has the primary file, missing Radarr's second-tracked-file-per-movie feature
# (alternate editions/extras). Echoes the raw moviefile JSON array (id/movieId/path per item).
fetch_radarr_items() {
    local movies_now
    movies_now=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr" 2>/dev/null)

    local _ids=() _id _qs="" _batch_count=0
    local BATCH_SIZE=200   # 250 confirmed working live 2026-07-19 by radarr_cleanup.sh, margin kept
    mapfile -t _ids < <(echo "$movies_now" | jq -r '.[] | select(.hasFile==true) | .id' 2>/dev/null)

    local all_tmp; all_tmp=$(mktemp)
    for _id in "${_ids[@]}"; do
        _qs+="movieId=${_id}&"
        (( _batch_count++ ))
        if [[ "$_batch_count" -ge "$BATCH_SIZE" ]]; then
            _arr_api_append_on_success "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" "$all_tmp"
            _qs=""
            _batch_count=0
        fi
    done
    if [[ -n "$_qs" ]]; then
        _arr_api_append_on_success "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?${_qs%&}" "Radarr" "$all_tmp"
    fi

    jq -s 'add // []' "$all_tmp" 2>/dev/null
    rm -f "$all_tmp"
}

# ==============================================================================================
# ━━━ Load clean-file state (skip cache) ━━━
# ==============================================================================================
declare -A CLEAN_STATE
while IFS=$'\t' read -r _s_path _s_stamp; do
    [[ -n "$_s_path" ]] && CLEAN_STATE["$_s_path"]="$_s_stamp"
done < "$CORRUPTION_SCAN_STATE_FILE"
unset _s_path _s_stamp
info "Loaded ${#CLEAN_STATE[@]} previously-verified-clean entries"

# Merges a fresh-clean-stamps temp file into the persistent state file, newest wins per path —
# reading fresh entries first (before the old base file) means the first occurrence tac/awk
# keeps is always the newest one for any path re-verified this run. Called once per arr
# (immediately after that arr's scan, not batched to the very end of the whole script) so a
# hard-exit partway through the NEXT arr — check_container_health()/check_arr_version() both
# exit 1 directly on a real failure, not just return — can never wipe out the previous arr's
# already-computed clean state for this run.
merge_clean_state() {
    local fresh_tmp="$1" state_tmp
    state_tmp=$(mktemp)
    cat "$fresh_tmp" "$CORRUPTION_SCAN_STATE_FILE" | awk -F'\t' '!seen[$1]++' | sort > "$state_tmp"
    mv "$state_tmp" "$CORRUPTION_SCAN_STATE_FILE"
}

TOTAL_SCANNED=0
TOTAL_CORRUPT=0
TOTAL_REMEDIATED=0
TOTAL_REMEDIATE_FAILED=0
declare -A ARR_SCANNED ARR_SKIPPED_CACHED ARR_SKIPPED_UNMAPPED ARR_CORRUPT ARR_STRIKE_HELD ARR_REMEDIATED ARR_REMEDIATE_FAILED

for arr in sonarr radarr; do
    url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY"
    # Named arr_url/arr_key, not url/key — build_arr_path_map() below uses a non-local
    # `for key in ...` loop internally (iterating FFPROBE/path-map prefixes) and would
    # silently clobber a plain $key with its last loop value otherwise. Confirmed live
    # 2026-07-21: this exact collision fed a path-map prefix ("/ext-anime-shows") to Sonarr's
    # API calls as the X-Api-Key header instead of the real key, making every Sonarr call
    # this loop made fail with 401 while looking like a connectivity problem.
    arr_url="${!url_var:-}"; arr_key="${!key_var:-}"

    if [[ -z "$arr_url" || -z "$arr_key" ]]; then
        info "${arr^} not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
        continue
    fi

    echo ""
    echo "  ${arr^} — $arr_url"

    build_arr_path_map "${arr^^}"

    check_container_health "${arr^}" 15 "Corruption Scan"
    ver_var="${arr^^}_VERSION_MAJOR"
    check_arr_version "$arr_url" "$arr_key" "v3" "${!ver_var}" "${arr^}" || {
        warn "${arr^} version check failed — skipping this arr"
        continue
    }

    # ──────────────────────────────────────────────────────────────────────────────────────
    # Fetch tracked files, normalized to {path, file_id, parent_id, title} regardless of arr —
    # everything past this point is arr-agnostic.
    # ──────────────────────────────────────────────────────────────────────────────────────
    echo ""
    echo "━━━ $ICON_SYNC Fetching ${arr^} Tracked Files ━━━"

    if [[ "$arr" == "sonarr" ]]; then
        # Prefer the shared per-episode-file cache written by sonarr_cleanup.sh (has
        # id/episodeId/seriesId/path already) — falls back to a live per-series walk only on
        # a genuine miss.
        RAW_ITEMS=$(arr_get_cached_items "sonarr" 14400)
        if [[ -z "$RAW_ITEMS" || "$RAW_ITEMS" == "null" ]]; then
            info "No fresh cached episode-file data — fetching live (this is the slow path)"
            SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$arr_url" "$arr_key" "v3") || {
                error "Failed to fetch series from Sonarr — skipping this arr"
                continue
            }
            SERIES_IDS=$(echo "$SERIES_RESPONSE" | jq -r '.[].id')
            all_tmp=$(mktemp)
            while IFS= read -r sid; do
                [[ -z "$sid" ]] && continue
                _arr_api_append_on_success "$arr_url" "$arr_key" "v3" "episodefile?seriesId=${sid}" "Sonarr" "$all_tmp"
            done <<< "$SERIES_IDS"
            RAW_ITEMS=$(jq -s 'add // []' "$all_tmp" 2>/dev/null)
            rm -f "$all_tmp"
            arr_item_cache_write "sonarr" "$RAW_ITEMS"
        fi
        ITEMS=$(echo "$RAW_ITEMS" | jq -c \
            '[.[] | {path, file_id:.id, parent_id:.episodeId, title:(.sceneName // .relativePath // .path)}]')
    else
        RAW_ITEMS=$(arr_get_cached_items "radarr" 14400)
        if [[ -z "$RAW_ITEMS" || "$RAW_ITEMS" == "null" ]]; then
            info "No fresh cached movie-file data — fetching live (this is the slow path)"
            RAW_ITEMS=$(fetch_radarr_items)
            arr_item_cache_write "radarr" "$RAW_ITEMS"
        fi
        ITEMS=$(echo "$RAW_ITEMS" | jq -c \
            '[.[] | {path, file_id:.id, parent_id:.movieId, title:(.sceneName // .relativePath // .path)}]')
    fi

    ITEM_COUNT=$(echo "$ITEMS" | jq 'length' 2>/dev/null)
    if [[ -z "$ITEM_COUNT" || "$ITEM_COUNT" -eq 0 ]]; then
        error "0 tracked files for ${arr^} — skipping this arr"
        continue
    fi
    info "$ITEM_COUNT tracked files"

    # ──────────────────────────────────────────────────────────────────────────────────────
    # Scan
    # ──────────────────────────────────────────────────────────────────────────────────────
    echo ""
    echo "━━━ $ICON_CLEAN Scanning ━━━"

    SCANNED=0
    SKIPPED_CACHED=0
    SKIPPED_UNMAPPED=0
    CORRUPT_COUNT=0
    STRIKE_HELD=0
    REMEDIATED=0
    REMEDIATE_FAILED=0

    FRESH_CLEAN_TMP=$(mktemp)

    while IFS= read -r item; do
        api_path=$(echo "$item" | jq -r '.path')
        file_id=$(echo "$item" | jq -r '.file_id')
        parent_id=$(echo "$item" | jq -r '.parent_id')

        host_path=$(translate_path "$api_path")
        [[ -f "$host_path" ]] || continue
        [[ -n "$PATH_FILTER" && "$host_path" != *"$PATH_FILTER"* ]] && continue

        stamp="$(stat -c '%Y:%s' "$host_path" 2>/dev/null)"
        [[ -z "$stamp" ]] && continue

        if [[ "${CLEAN_STATE[$host_path]:-}" == "$stamp" ]]; then
            (( SKIPPED_CACHED++ ))
            continue
        fi

        (( SCANNED++ ))
        if [[ "$SCAN_LIMIT" -gt 0 && "$SCANNED" -gt "$SCAN_LIMIT" ]]; then
            (( SCANNED-- ))
            break
        fi

        result=$(probe_file "$host_path")

        if [[ "$result" == "unmapped" ]]; then
            (( SKIPPED_UNMAPPED++ ))
            [[ "$ENABLE_LOGGING" == true ]] && warn "  ? $host_path — no FFPROBE_PATH_MAP entry covers this share"
            continue
        fi

        if [[ "$result" == "clean" ]]; then
            reset_scan_strikes "$host_path"
            echo -e "${host_path}\t${stamp}" >> "$FRESH_CLEAN_TMP"
            [[ "$ENABLE_LOGGING" == true ]] && echo "  $ICON_SUCCESS $host_path"
            continue
        fi

        # corrupt:<reason>
        reason="${result#corrupt:}"
        (( CORRUPT_COUNT++ ))
        strikes=$(increment_scan_strikes "$host_path")
        echo "  $ICON_ERROR CORRUPT: $host_path (strike $strikes/$CORRUPTION_SCAN_STRIKE_LIMIT)"
        [[ "$ENABLE_LOGGING" == true ]] && echo "      $reason"

        if [[ "$REMEDIATE" != true ]]; then
            continue
        fi

        if (( strikes < CORRUPTION_SCAN_STRIKE_LIMIT )); then
            warn "  $host_path — strike $strikes/$CORRUPTION_SCAN_STRIKE_LIMIT, not yet remediating (needs repeat confirmation)"
            (( STRIKE_HELD++ ))
            continue
        fi
        reset_scan_strikes "$host_path"

        title=$(echo "$item" | jq -r '.title')

        http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
            --max-time 15 -H "X-Api-Key: $arr_key" \
            "${arr_url}/api/v3/${ARR_FILE_ENDPOINT[$arr]}/${file_id}" 2>/dev/null)

        if [[ "$http_code" != "200" ]]; then
            error "  ✗ $title — delete failed (HTTP $http_code)"
            (( REMEDIATE_FAILED++ ))
            continue
        fi

        sleep 2
        verify_hasfile=$(arr_api "$arr_url" "$arr_key" "v3" "${ARR_PARENT_ENDPOINT[$arr]}/${parent_id}" "${arr^}" 2>/dev/null \
            | jq -r '.hasFile // "unknown"')

        if [[ "$verify_hasfile" != "false" ]]; then
            error "  ✗ $title — deleted but hasFile still '$verify_hasfile' — not searching, needs review"
            (( REMEDIATE_FAILED++ ))
            continue
        fi

        search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
            --max-time 30 -H "X-Api-Key: $arr_key" -H "Content-Type: application/json" \
            -d "{\"name\":\"${ARR_SEARCH_COMMAND[$arr]}\",\"${ARR_SEARCH_ID_FIELD[$arr]}\":[${parent_id}]}" \
            "${arr_url}/api/v3/command" 2>/dev/null)

        if [[ "$search_code" == "200" || "$search_code" == "201" ]]; then
            echo "  $ICON_SUCCESS $title — deleted, verified, search triggered"
            (( REMEDIATED++ ))
        else
            warn "  $title — deleted and verified, but search trigger returned HTTP $search_code"
            (( REMEDIATE_FAILED++ ))
        fi
    done < <(echo "$ITEMS" | jq -c '.[]')

    merge_clean_state "$FRESH_CLEAN_TMP"
    rm -f "$FRESH_CLEAN_TMP"

    ARR_SCANNED[$arr]=$SCANNED
    ARR_SKIPPED_CACHED[$arr]=$SKIPPED_CACHED
    ARR_SKIPPED_UNMAPPED[$arr]=$SKIPPED_UNMAPPED
    ARR_CORRUPT[$arr]=$CORRUPT_COUNT
    ARR_STRIKE_HELD[$arr]=$STRIKE_HELD
    ARR_REMEDIATED[$arr]=$REMEDIATED
    ARR_REMEDIATE_FAILED[$arr]=$REMEDIATE_FAILED

    (( TOTAL_SCANNED += SCANNED ))
    (( TOTAL_CORRUPT += CORRUPT_COUNT ))
    (( TOTAL_REMEDIATED += REMEDIATED ))
    (( TOTAL_REMEDIATE_FAILED += REMEDIATE_FAILED ))
done

# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY CORRUPTION SCAN SUMMARY ━━━━━"
echo "$ICON_HOST    Identity:            $MY_ID ($LOCAL_SERVER_NAME)"
for arr in sonarr radarr; do
    [[ -z "${ARR_SCANNED[$arr]:-}" ]] && continue
    echo ""
    echo "  ${arr^}:"
    echo "    $ICON_SYNC    Newly scanned:       ${ARR_SCANNED[$arr]}"
    echo "    $ICON_SUCCESS Skipped (cached):    ${ARR_SKIPPED_CACHED[$arr]}"
    echo "    $ICON_WARN    Skipped (unmapped):  ${ARR_SKIPPED_UNMAPPED[$arr]}"
    echo "    $ICON_ERROR   Corrupt found:       ${ARR_CORRUPT[$arr]}"
    if [[ "$REMEDIATE" == true ]]; then
        echo "    $ICON_WARN    Held (strikes):      ${ARR_STRIKE_HELD[$arr]}"
        echo "    $ICON_SUCCESS Remediated:          ${ARR_REMEDIATED[$arr]}"
        echo "    $ICON_ERROR   Remediation failed:  ${ARR_REMEDIATE_FAILED[$arr]}"
    fi
done
echo ""
echo "  Total:"
echo "    $ICON_SYNC    Newly scanned:       $TOTAL_SCANNED"
echo "    $ICON_ERROR   Corrupt found:       $TOTAL_CORRUPT"
if [[ "$REMEDIATE" == true ]]; then
    echo "    $ICON_SUCCESS Remediated:          $TOTAL_REMEDIATED"
    echo "    $ICON_ERROR   Remediation failed:  $TOTAL_REMEDIATE_FAILED"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

exit 0
