#!/bin/bash
# ==============================================================================================
# ========================== Arr Download Orphan Cleaner =======================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Clears orphaned completed downloads out of the SABnzbd Completed folders that Sonarr and
# Radarr import from. Anything sitting there that the arr's queue no longer references is an
# orphan — the arr will never touch it again on its own, so without this script the folder
# only ever grows.
#
# Built 2026-07-26 after exactly that: 755G of orphaned completed TV downloads (oldest from
# 2022) had silently accumulated and filled the cache pool to 89%. Every existing cleaner
# covers the library side (sonarr_cleanup.sh walks SONARR_TV_ROOT etc.) — nothing covered
# the download side. This is that missing piece, using the same triage that recovered the
# pool that day.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Runs Sonarr then Radarr, sequentially. Per arr, every top-level entry in the configured
# download dir is classified:
#
#   TRACKED    — basename matches a queue record's outputPath or title → leave it alone,
#                the arr still knows about it
#   RECENT     — mtime under DOWNLOAD_ORPHAN_AGE days → skip, may be mid-import
#   JUNK       — no video file over DOWNLOAD_ORPHAN_MIN_VIDEO_MB → delete (par2 debris,
#                samples, failed/never-extracted archives — the arr can't import these,
#                and if the content is still wanted its own missing-search re-grabs it)
#   REDUNDANT  — parse API matches it AND the library already has every episode / the
#                movie file → delete (the arr already refused it as not-an-upgrade)
#   IMPORTABLE — parse API matches it but the library is missing episodes / the movie
#                → trigger DownloadedEpisodesScan/DownloadedMoviesScan on the folder and
#                leave it; whatever imports gets swept as REDUNDANT next run, whatever
#                the arr rejects (XEM-blocked, season-span files) stays HELD for a human
#   UNMATCHED  — parse API can't match it (series/movie not in the arr) → delete. Past the
#                age gate an entry the arr cannot even name is not going to import: if the
#                title is in the library and monitored, clearing it lets the arr search a
#                copy it can actually parse; if it is not in the library, nothing is
#                tracking it and it is dead weight either way. Guarded — see Non-Empty
#                Library Requirement below
#   HELD       — IMPORTABLE entries the arr keeps refusing (XEM-blocked, season-span
#                files), and everything skipped by a guard → report only, human call
#
# The queue fetch is a hard gate: if it fails, the whole arr is skipped — with no queue
# there is no way to tell tracked from orphaned, and guessing means deleting active imports.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Classify Before Acting
#   Every entry is placed in exactly one class before anything is deleted, and each class
#   has its own justification. Nothing is removed because it merely looked unwanted — it is
#   removed because it matched a category whose deletion rationale is written down above.
#
# The Queue Is the Source of Truth
#   Tracked-vs-orphaned is decided by the arr's own queue, never inferred from filenames or
#   timestamps. If the queue cannot be read, the arr is skipped entirely rather than
#   falling back to a weaker signal — a guess here deletes an active import.
#
# Deleting Is Recoverable, Deleting Wrong Is Not
#   The classes that get deleted are ones the arr can re-acquire: junk it could never
#   import, content the library already has, and entries it cannot even name. Anything
#   whose loss would be permanent or ambiguous is held and reported for a human instead.
#
# Abnormal Volume Means Broken Input
#   The delete cap exists because the realistic failure mode is bad input, not bad logic —
#   a partial queue fetch classifies live downloads as orphans, and the only visible
#   symptom is an unusually large delete total. The cap turns that into a stop.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
#   Download folders are written by container users; removing them requires root.
#
# Lock Acquisition
#   acquire_lock "wait" with an EXIT trap releasing all locks, so an interrupted run never
#   strands a lock and the daily orchestrator is never silently skipped.
#
# Host Detection
#   detect_hosts() runs before any HOST*_-prefixed download dir is resolved.
#
# DOWNLOAD_ORPHAN_CLEANER_ENABLED Toggle
#   Master switch — exits cleanly when disabled.
#
# Download Path Restriction
#   The download dir must exist and live under /mnt/. Anything else is refused rather
#   than walked, so a blank or malformed path can never point the scan at the filesystem
#   root or a system directory.
#
# Queue Fetch Hard Gate
#   The arr is skipped entirely if its queue cannot be read. Without the queue there is no
#   way to distinguish tracked from orphaned, and guessing deletes active imports.
#
# Age Gate
#   Nothing under DOWNLOAD_ORPHAN_AGE days is touched, so an entry mid-import is never a
#   deletion candidate regardless of how it classifies.
#
# Deletion Class Restriction
#   Only JUNK, parse-verified REDUNDANT and UNMATCHED are deleted. IMPORTABLE entries and
#   anything a guard has held are reported, never removed.
#
# Non-Empty Library Requirement
#   UNMATCHED deletions require the arr to report a non-empty library. An empty or
#   restoring database answers every parse with "no match", which would condemn the whole
#   download dir. The library is queried directly rather than inferred from this run's own
#   match rate — a small batch that is legitimately all-unmatched is normal once daily runs
#   have caught up, and would otherwise read as a broken database.
#
# Delete Volume Cap
#   A run total over DOWNLOAD_ORPHAN_MAX_DELETE_GB aborts the delete pass and notifies.
#   --i-know-what-im-doing overrides it for a deliberate first run against a known backlog.
#
# Dry Run Support
#   --dry-run classifies everything and reports, deleting and importing nothing.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf (resolved per host after detect_hosts())
#
#   HOST*_SONARR_DOWNLOAD_DIR / HOST*_RADARR_DOWNLOAD_DIR
#       Host-side path to the arr's completed-download folder. Absent means that arr's
#       cleanup is skipped, not an error.
#
#   HOST*_SONARR_DOWNLOAD_CONTAINER_DIR / HOST*_RADARR_DOWNLOAD_CONTAINER_DIR
#       The same folder as the arr container sees it — used when triggering the
#       DownloadedEpisodesScan / DownloadedMoviesScan path.
#
#   SONARR_URL / SONARR_API_KEY / RADARR_URL / RADARR_API_KEY
#       Aliased by detect_hosts(). A missing URL or key skips that arr.
#
# master.conf
#
#   DOWNLOAD_ORPHAN_CLEANER_ENABLED
#       Master toggle (default: true)
#
#   DOWNLOAD_ORPHAN_AGE
#       Days before an entry is eligible at all — younger entries may be mid-import
#       (default: 7)
#
#   DOWNLOAD_ORPHAN_MIN_VIDEO_MB
#       An entry with no video file above this size is JUNK (default: 50)
#
#   DOWNLOAD_ORPHAN_MAX_DELETE_GB
#       Abort the delete pass if the run total exceeds this (default: 100)
#
#   SONARR_EXTENSIONS / RADARR_EXTENSIONS
#       Video extensions used to decide whether an entry contains real media
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
#   arr_download_orphan_cleaner.sh                          — daily orchestrator entry
#   arr_download_orphan_cleaner.sh --dry-run                — classify and report only
#   arr_download_orphan_cleaner.sh --status                 — show config and exit
#   arr_download_orphan_cleaner.sh --i-know-what-im-doing   — bypass MAX_DELETE_GB cap
#
# ==============================================================================================

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

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

parse_destructive_flags "$@"

parse_args "${FILTERED_ARGS[@]}"

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

for cmd in curl jq; do
    if ! command -v "$cmd" >/dev/null 2>&1; then
        error "$cmd not found — required"
        exit 1
    fi
done

detect_hosts

if [[ "${DOWNLOAD_ORPHAN_CLEANER_ENABLED:-false}" != true ]]; then
    info "DOWNLOAD_ORPHAN_CLEANER_ENABLED=false — skipping"
    exit 0
fi

DOWNLOAD_ORPHAN_AGE="${DOWNLOAD_ORPHAN_AGE:-7}"
DOWNLOAD_ORPHAN_MIN_VIDEO_MB="${DOWNLOAD_ORPHAN_MIN_VIDEO_MB:-50}"
DOWNLOAD_ORPHAN_MAX_DELETE_GB="${DOWNLOAD_ORPHAN_MAX_DELETE_GB:-100}"

if [[ "${SHOW_STATUS:-false}" == true ]]; then
    echo "━━━━━ $ICON_SUMMARY DOWNLOAD ORPHAN CLEANER STATUS ━━━━━"
    echo "$ICON_HOST    Identity:        $MY_ID ($LOCAL_SERVER_NAME)"
    echo "$ICON_GEAR    Enabled:         ${DOWNLOAD_ORPHAN_CLEANER_ENABLED}"
    echo "$ICON_TIME    Age gate:        ${DOWNLOAD_ORPHAN_AGE}d"
    echo "$ICON_DISK    Junk threshold:  ${DOWNLOAD_ORPHAN_MIN_VIDEO_MB}M"
    echo "$ICON_SHIELD  Delete cap:      ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G"
    for arr in SONARR RADARR; do
        dir_var="${MY_ID}_${arr}_DOWNLOAD_DIR"
        echo "$ICON_CLEAN   ${arr}: ${!dir_var:-<not configured>}"
    done
    exit 0
fi

acquire_lock "wait"
trap "_release_all_locks" EXIT

AGE_CUTOFF=$(( $(date +%s) - DOWNLOAD_ORPHAN_AGE * 86400 ))

TOTAL_DELETED=0
TOTAL_DELETED_MB=0
TOTAL_HELD=0
TOTAL_SCANS=0

echo "━━━━━ $ICON_CLEAN DOWNLOAD ORPHAN CLEANER ━━━━━"
echo "$ICON_HOST  Identity: $MY_ID ($LOCAL_SERVER_NAME)"
[[ "$DRY_RUN" == true ]] && echo "$ICON_SKIP  DRY RUN — nothing will be deleted or imported"

for arr in sonarr radarr; do
    url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY"
    arr_url="${!url_var:-}"; arr_key="${!key_var:-}"
    dir_var="${MY_ID}_${arr^^}_DOWNLOAD_DIR"
    cdir_var="${MY_ID}_${arr^^}_DOWNLOAD_CONTAINER_DIR"
    dl_dir="${!dir_var:-}"; container_dir="${!cdir_var:-}"

    if [[ -z "$arr_url" || -z "$arr_key" || -z "$dl_dir" ]]; then
        info "${arr^} download cleanup not configured on $MY_ID — skipping"
        continue
    fi
    if [[ "$dl_dir" != /mnt/* || ! -d "$dl_dir" ]]; then
        warn "${arr^} download dir invalid or missing: $dl_dir — skipping"
        continue
    fi

    echo ""
    echo "━━━ $ICON_SYNC ${arr^} — $dl_dir ━━━"

    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
    }

    if [[ "$arr" == "sonarr" ]]; then
        queue_endpoint="queue?pageSize=1000&includeUnknownSeriesItems=true"
        exts_var="SONARR_EXTENSIONS"
        scan_command="DownloadedEpisodesScan"
        library_endpoint="series"
    else
        queue_endpoint="queue?pageSize=1000&includeUnknownMovieItems=true"
        exts_var="RADARR_EXTENSIONS"
        scan_command="DownloadedMoviesScan"
        library_endpoint="movie"
    fi

    QUEUE_JSON=$(arr_api "$arr_url" "$arr_key" "v3" "$queue_endpoint" "${arr^}") || {
        error "${arr^} queue fetch failed — cannot tell tracked from orphaned, skipping this arr"
        continue
    }

    declare -A PROTECTED=()
    while IFS= read -r name; do
        [[ -n "$name" ]] && PROTECTED["$name"]=1
    done < <(echo "$QUEUE_JSON" | jq -r '.records[] | (.outputPath // empty | split("/") | last), (.title // empty)')

    eval "arr_exts=(\"\${${exts_var}[@]}\")"

    DELETE_PATHS=()
    DELETE_SIZES=()
    DELETE_LABELS=()
    SCAN_PATHS=()
    UNMATCHED_PATHS=()
    UNMATCHED_SIZES=()
    arr_tracked=0; arr_recent=0; arr_held=0; arr_delete_mb=0

    while IFS= read -r entry; do
        base="${entry##*/}"

        if [[ -n "${PROTECTED[$base]:-}" ]]; then
            arr_tracked=$((arr_tracked + 1))
            continue
        fi

        mtime=$(stat -c %Y "$entry" 2>/dev/null) || continue
        if (( mtime > AGE_CUTOFF )); then
            arr_recent=$((arr_recent + 1))
            continue
        fi

        has_video=false
        while IFS= read -r f; do
            if has_extension "$f" "${arr_exts[@]}"; then
                has_video=true
                break
            fi
        done < <(find "$entry" -type f -size +"${DOWNLOAD_ORPHAN_MIN_VIDEO_MB}"M 2>/dev/null)

        size_mb=$(du -sm "$entry" 2>/dev/null | cut -f1)
        size_mb=${size_mb:-0}

        if [[ "$has_video" == false ]]; then
            DELETE_PATHS+=("$entry")
            DELETE_SIZES+=("$size_mb")
            DELETE_LABELS+=("JUNK")
            arr_delete_mb=$((arr_delete_mb + size_mb))
            continue
        fi

        enc_title=$(jq -rn --arg t "$base" '$t|@uri')
        parse=$(arr_api "$arr_url" "$arr_key" "v3" "parse?title=${enc_title}" "${arr^}") || {
            warn "  parse failed for: $base — holding"
            arr_held=$((arr_held + 1))
            continue
        }

        if [[ "$arr" == "sonarr" ]]; then
            matched=$(echo "$parse" | jq '(.series != null) and ((.episodes | length) > 0)')
            missing=$(echo "$parse" | jq '[.episodes[]? | select(.hasFile == false)] | length')
        else
            # Radarr's parse never populates hasFile — movieFileId is the reliable signal
            matched=$(echo "$parse" | jq '.movie != null')
            missing=$(echo "$parse" | jq 'if (.movie.movieFileId // 0) > 0 then 0 else 1 end')
        fi

        if [[ "$matched" != true ]]; then
            UNMATCHED_PATHS+=("$entry")
            UNMATCHED_SIZES+=("$size_mb")
        elif (( missing == 0 )); then
            DELETE_PATHS+=("$entry")
            DELETE_SIZES+=("$size_mb")
            DELETE_LABELS+=("REDUNDANT")
            arr_delete_mb=$((arr_delete_mb + size_mb))
        else
            SCAN_PATHS+=("$base")
            arr_held=$((arr_held + 1))
        fi
    done < <(find "$dl_dir" -mindepth 1 -maxdepth 1 2>/dev/null)

    # An arr with an empty or still-restoring database answers every parse with "no match",
    # which would turn the whole download dir into UNMATCHED and delete it. Confirm the
    # library actually holds titles before trusting a no-match to mean what it says. This
    # has to be asked of the arr directly — inferring health from the run's own matches
    # fails on a small batch that is legitimately all-unmatched, which is the normal case
    # once daily runs have caught up.
    if (( ${#UNMATCHED_PATHS[@]} > 0 )); then
        library_count=$(arr_api "$arr_url" "$arr_key" "v3" "$library_endpoint" "${arr^}" | jq 'length' 2>/dev/null)
        if [[ ! "$library_count" =~ ^[0-9]+$ ]] || (( library_count == 0 )); then
            warn "  ${arr^}: library reports ${library_count:-no} titles — cannot trust 'no match', holding ${#UNMATCHED_PATHS[@]} unmatched"
            arr_held=$((arr_held + ${#UNMATCHED_PATHS[@]}))
        else
            for i in "${!UNMATCHED_PATHS[@]}"; do
                DELETE_PATHS+=("${UNMATCHED_PATHS[$i]}")
                DELETE_SIZES+=("${UNMATCHED_SIZES[$i]}")
                DELETE_LABELS+=("UNMATCHED")
                arr_delete_mb=$((arr_delete_mb + UNMATCHED_SIZES[i]))
            done
        fi
    fi

    if (( arr_delete_mb / 1024 > DOWNLOAD_ORPHAN_MAX_DELETE_GB )) && [[ "$I_KNOW" != true ]]; then
        error "${arr^}: delete total $((arr_delete_mb / 1024))G exceeds cap of ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G — aborting delete pass"
        notify "${arr^} download orphan delete total $((arr_delete_mb / 1024))G exceeds ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G cap on $(hostname) — possible partial queue data, nothing deleted. Re-run with --i-know-what-im-doing if legitimate." \
            "Download Orphan Cleaner" "warning"
        unset PROTECTED
        continue
    fi

    for i in "${!DELETE_PATHS[@]}"; do
        entry="${DELETE_PATHS[$i]}"
        if [[ "$DRY_RUN" == true ]]; then
            echo "  $ICON_SKIP would delete [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)"
        else
            echo "  $ICON_TRASH deleting [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)"
            rm -rf "$entry"
        fi
        TOTAL_DELETED=$((TOTAL_DELETED + 1))
        TOTAL_DELETED_MB=$((TOTAL_DELETED_MB + DELETE_SIZES[i]))
    done

    for base in "${SCAN_PATHS[@]}"; do
        if [[ -z "$container_dir" ]]; then
            echo "  $ICON_WARN IMPORTABLE but ${cdir_var} not set — holding: $base"
            continue
        fi
        if [[ "$DRY_RUN" == true ]]; then
            echo "  $ICON_SKIP would trigger $scan_command: $base"
        else
            echo "  $ICON_RUN triggering $scan_command: $base"
            payload=$(jq -nc --arg n "$scan_command" --arg p "$container_dir/$base" '{name: $n, path: $p, importMode: "Move"}')
            curl -sf --max-time 30 -X POST \
                -H "X-Api-Key: $arr_key" -H "Content-Type: application/json" \
                -d "$payload" "$arr_url/api/v3/command" >/dev/null \
                || warn "  $scan_command trigger failed for: $base"
            TOTAL_SCANS=$((TOTAL_SCANS + 1))
        fi
    done

    echo "  $ICON_SUMMARY ${arr^}: $arr_tracked tracked, $arr_recent recent, ${#DELETE_PATHS[@]} deleted ($((arr_delete_mb / 1024))G), ${#SCAN_PATHS[@]} import scans, $arr_held held"
    TOTAL_HELD=$((TOTAL_HELD + arr_held))
    unset PROTECTED
done

echo ""
echo "━━━━━ $ICON_DONE SUMMARY ━━━━━"
echo "$ICON_TRASH   Deleted:       $TOTAL_DELETED ($((TOTAL_DELETED_MB / 1024))G)"
echo "$ICON_RUN     Import scans:  $TOTAL_SCANS"
echo "$ICON_WARN    Held:          $TOTAL_HELD"

# Held alone never notifies — there is always something awaiting review, and on a daily
# schedule that would be a notification every morning saying nothing happened.
if [[ "$DRY_RUN" != true ]] && (( TOTAL_DELETED > 0 )); then
    notify "Download orphan cleaner on $(hostname): deleted $TOTAL_DELETED orphans ($((TOTAL_DELETED_MB / 1024))G), triggered $TOTAL_SCANS import scans, $TOTAL_HELD held for review" \
        "Download Orphan Cleaner" "normal"
fi
