#!/bin/bash
# ==============================================================================================
# ============================ Radarr Content Classification Scan ==============================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Overseerr lets any user request a movie into the wrong root folder (kids content added to
# the general Movies share, anime added to Kids_Movies, etc.) and most users never notice or
# correct it. This script reads Radarr's tracked movie list and classifies every movie as
# anime / kids-only / regular using metadata signals alone (genre, certification, studio,
# original language) — then reports where a movie's computed classification disagrees with
# the root folder it's actually sitting in, in both directions:
#
#   FORWARD  — a movie classified as anime/kids is sitting outside its dedicated root
#   REVERSE  — a movie sitting inside the kids/anime root doesn't match that classification
#
# Report-only by default — no files are moved and no Radarr API writes happen unless a
# mode flag is given. Pass --move to relocate forward misplacements, or --remove-junk to
# delete and import-exclude bad-metadata entries (see OPERATIONAL MODEL below); without
# those flags this is purely a detection tool. Every rule below was validated against
# this library's real data before being
# adopted (see master.conf comments above the curated lists) — this is not a generic
# genre-matcher, it's tuned specifically against the false-positive traps that showed up
# when testing looser rules (documented per-rule below).
#
# ==============================================================================================
# CLASSIFICATION RULES
# ==============================================================================================
#
# is_anime:
#   (genre Animation AND originalLanguage Japanese) OR studio in RADARR_ANIME_STUDIOS
#   Always wins over kids when both could apply — explicit priority, not a tiebreak.
#
# is_kids ("kids will end up watching this alone" — NOT "family movie night"):
#   not is_anime AND certification not in (R, NC-17) AND (
#     (genre Animation AND certification != PG-13)
#     OR studio in RADARR_KIDS_STUDIOS
#   )
#   Deliberately excludes bare "Family" genre and bare "G" certification — both genuinely
#   traced back to live-action films the whole household watches together (Mrs. Doubtfire,
#   Doctor Dolittle, National Treasure-style adventures, classic Westerns), not kids-only
#   content. Family movie night stays in the general Movies root by design.
#
#   The PG-13 exclusion on the Animation branch is load-bearing — without it this rule
#   catches South Park movies, Sausage Party, "9", Resident Evil: Death Island, and (via
#   the curated studio list) Warner Bros. Animation's R-rated Watchmen films, since that
#   studio makes both kids content and adult content under the same name.
#
# is_junk (bad/thin TMDb match, not a real classification problem):
#   hasFile == false AND imdbId == null AND tmdb votes < RADARR_JUNK_MIN_VOTES
#   Caught live: two fake "X-Men"/"Wolverine" entries, two fake "Silent Hill" entries, one
#   fake "The Purge" spinoff — all monitored placeholders with nothing behind them. The
#   fix for these is removal from Radarr, not blocklist+redownload — there's no release to
#   blocklist and likely nothing legitimate to redownload under that exact TMDb match.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Report pass (always):
#   check_api → check_arr_version → arr_get_tracked_data (cache-first, one call)
#   → classify every movie → report FORWARD, REVERSE and JUNK findings
#
# Remove-junk pass (--remove-junk, runs first when combined with --move):
#   One entry at a time, halting on the first failure.
#   DELETE with deleteFiles=false and addImportExclusion=true — the Radarr entry is
#   removed and blocked from re-adding, files on disk are never touched. is_junk
#   requires hasFile == false, so there is no file behind these entries anyway.
#   Verified by re-fetching and requiring a 404 before counting as removed.
#
# Move pass (--move):
#   One movie at a time, verified after each.
#   hasFile == true  → moveFiles=true, then poll the async MoveMovie command to
#                      "completed" (bounded by RADARR_MOVE_POLL_TIMEOUT) before the
#                      DB-field check — the DB flips instantly while the physical
#                      move is still queued.
#   hasFile == false → correct rootFolderPath/path and trigger MoviesSearch instead;
#                      there is nothing to move.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Report by Default, Act Only on Request
#   A bare run never calls Radarr's write API and never touches a file — every finding is
#   just a candidate. Acting on them requires an explicit --move or --remove-junk flag, so
#   the scan can be scheduled and re-run freely while the curated lists are being tuned
#   without any risk of it rearranging the library on its own.
#
# Curated Lists, Not Bare Genre/Cert Matching
#   Every signal used here failed at least once as a bare/standalone check during rule
#   development (Family genre, G certification, blanket Animation genre, bare Anime genre
#   tag, Disney+/general-platform networks) — see master.conf comments for what each
#   curated list deliberately excludes and why.
#
# Cache-First, Never a Per-Movie Call
#   Uses arr_get_tracked_data() same as radarr_cleanup.sh — Radarr's movie list already
#   embeds everything this script needs per movie, so this is a single API call (or zero,
#   if the shared cache is warm) regardless of library size.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
#   Required by the container interaction and state writes.
#
# Lock Acquisition
#   acquire_lock, plus acquire_lock "wait" around the write passes with an EXIT trap
#   releasing all locks, so an interrupted run never leaves a lock behind.
#
# Host Detection
#   detect_hosts() aliases RADARR_URL / RADARR_API_KEY / the root literals.
#
# curl + jq Dependency Check
#   Fails fast if either is missing — every classification signal is parsed with jq.
#
# Report-Only Default
#   No write happens without --move or --remove-junk.
#
# Required Var Check
#   require_var on RADARR_URL and RADARR_API_KEY before any request.
#
# API Reachability + Version Gate
#   check_api then check_arr_version against RADARR_VERSION_MAJOR. A major version bump
#   can move or rename the fields every rule depends on, so a mismatch aborts rather
#   than classifying against an unknown schema.
#
# Empty Library Abort
#   A response of 0 movies aborts — an empty list is indistinguishable from a clean
#   library and would otherwise report success during an API fault.
#
# Unconfigured Root Skip
#   A blank kids/anime root skips that category's checks rather than comparing paths
#   against an empty string.
#
# Files Never Deleted
#   Junk removal passes deleteFiles=false. Only the Radarr entry is removed, and
#   addImportExclusion=true stops it being re-added. is_junk additionally requires
#   hasFile == false, so these entries have nothing on disk in the first place.
#
# One At A Time, Stop On First Failure
#   Both write passes process one entry at a time and halt on the first failure rather
#   than continuing through the library.
#
# Post-Write Verification
#   Removal is confirmed by re-fetching and requiring a 404. Moves are confirmed by
#   re-fetching and checking rootFolderPath and hasFile. The API response alone is
#   never treated as proof.
#
# Async Move Completion Polling
#   moveFiles=true flips the DB instantly while the physical move is a separate async
#   MoveMovie command. Each move polls its own command to "completed" (bounded by
#   RADARR_MOVE_POLL_TIMEOUT) before the DB-field check, so a batch cannot report
#   everything moved while files are still queued at the old path.
#
# Junk Vote Threshold
#   RADARR_JUNK_MIN_VOTES gates junk detection alongside hasFile == false and a null
#   imdbId. All three must hold — a thin-metadata entry that actually has a file, or
#   has an IMDb ID, is never treated as junk.
#
# Post-Write Cache Refresh
#   The tracked-data cache is refreshed after writes so no other arr script reads a
#   stale rootFolderPath or a movie that no longer exists.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#   RADARR_URL / RADARR_API_KEY / RADARR_MOVIES_ROOT — existing, aliased by detect_hosts()
#   RADARR_KIDS_ROOT / RADARR_ANIME_ROOT — rootFolderPath literals as reported by the API
#     (e.g. "/kids movies", "/ext-anime-movies") — leave blank on a host with no dedicated
#     root for that category; the corresponding checks are skipped, not treated as an error.
#
# master.conf
#   RADARR_ANIME_STUDIOS / RADARR_KIDS_STUDIOS — curated studio allowlists
#   RADARR_JUNK_MIN_VOTES — TMDb vote threshold for the bad-metadata check
#   RADARR_VERSION_MAJOR  — expected API major version (reused from radarr_cleanup.sh)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
#   radarr_classification_scan.sh                — normal run, prints report
#   radarr_classification_scan.sh --log          — verbose (per-movie TRACKED-style logging)
#   radarr_classification_scan.sh --status       — show config and exit
#   radarr_classification_scan.sh --remove-junk  — delete + import-exclude bad-metadata entries
#   radarr_classification_scan.sh --move         — relocate forward misplacements (moves the
#                                                   file if one exists; for hasFile=false
#                                                   entries, just corrects rootFolderPath/path
#                                                   and triggers an immediate MoviesSearch)
#
#   --remove-junk and --move can be combined in one run — junk is cleared first, then the
#   move pass runs against the remaining (now junk-free) classification.
#
# ==============================================================================================

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

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

# --move / --remove-junk are script-local flags, not ones parse_args recognizes — check the
# raw args before they get filtered into PARSED_ARGS.
MOVE_MODE=false
REMOVE_JUNK_MODE=false
for _arg in "$@"; do
    [[ "$_arg" == "--move" ]] && MOVE_MODE=true
    [[ "$_arg" == "--remove-junk" ]] && REMOVE_JUNK_MODE=true
done
unset _arg

parse_args "$@"

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

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

detect_hosts

if [[ -z "${RADARR_URL:-}" ]] || [[ -z "${RADARR_API_KEY:-}" ]]; then
    info "Radarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
    exit 0
fi

require_var RADARR_URL
require_var RADARR_API_KEY

if [[ "$SHOW_STATUS" == true ]]; then
    echo ""
    echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
    echo "$ICON_HOST    Identity:       $MY_ID ($LOCAL_SERVER_NAME)"
    echo "$ICON_GEAR    Radarr URL:     $RADARR_URL"
    echo "$ICON_GEAR    Movies root:    $RADARR_MOVIES_ROOT"
    echo "$ICON_GEAR    General root:   ${RADARR_GENERAL_ROOT:-<not configured>}"
    echo "$ICON_GEAR    Kids root:      ${RADARR_KIDS_ROOT:-<not configured>}"
    echo "$ICON_GEAR    Anime root:     ${RADARR_ANIME_ROOT:-<not configured>}"
    echo "$ICON_GEAR    Anime studios:  ${#RADARR_ANIME_STUDIOS[@]} curated"
    echo "$ICON_GEAR    Kids studios:   ${#RADARR_KIDS_STUDIOS[@]} curated"
    echo "$ICON_GEAR    Junk min votes: ${RADARR_JUNK_MIN_VOTES:-15}"
    echo "$ICON_GEAR    Move mode:      $MOVE_MODE"
    echo "$ICON_GEAR    Remove-junk:    $REMOVE_JUNK_MODE"
    echo "━━━━━━━━━━━━━━━━━━━━━━━"
    exit 0
fi

echo ""
echo "━━━ $ICON_SYNC Fetching Radarr Library ━━━"

if ! check_api "$RADARR_URL" "Radarr" 10; then
    exit 1
fi

check_arr_version "$RADARR_URL" "$RADARR_API_KEY" "v3" "$RADARR_VERSION_MAJOR" "Radarr" || exit 1

MOVIES_RESPONSE=$(arr_get_tracked_data "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3") || {
    error "Failed to fetch movies from Radarr"
    exit 1
}

MOVIE_COUNT=$(echo "$MOVIES_RESPONSE" | jq -r 'length' 2>/dev/null)
if [[ -z "$MOVIE_COUNT" ]] || [[ "$MOVIE_COUNT" -eq 0 ]]; then
    error "API returned 0 movies — aborting"
    exit 1
fi
info "$MOVIE_COUNT movies loaded"

# ==============================================================================================
# ━━━ Classify ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Classifying ━━━"

ANIME_STUDIOS_JSON=$(printf '%s\n' "${RADARR_ANIME_STUDIOS[@]}" | jq -R . | jq -s .)
KIDS_STUDIOS_JSON=$(printf '%s\n' "${RADARR_KIDS_STUDIOS[@]}" | jq -R . | jq -s .)
JUNK_MIN_VOTES="${RADARR_JUNK_MIN_VOTES:-15}"

RESULTS=$(echo "$MOVIES_RESPONSE" | jq \
    --argjson animeStudios "$ANIME_STUDIOS_JSON" \
    --argjson kidsStudios "$KIDS_STUDIOS_JSON" \
    --arg animeRoot "${RADARR_ANIME_ROOT:-}" \
    --arg kidsRoot "${RADARR_KIDS_ROOT:-}" \
    --argjson junkMinVotes "$JUNK_MIN_VOTES" '
    def is_anime:
        (any(.genres[]?; . == "Animation") and .originalLanguage.name == "Japanese")
        or (.studio as $s | $animeStudios | index($s) != null);
    def not_adult: (.certification != "R") and (.certification != "NC-17");
    def is_kids:
        (is_anime | not) and not_adult and (
            (any(.genres[]?; . == "Animation") and .certification != "PG-13")
            or (.studio as $s | $kidsStudios | index($s) != null)
        );
    def is_junk:
        (.hasFile == false) and (.imdbId == null)
        and ((.ratings.tmdb.votes // 999999) < $junkMinVotes);

    map(
        {
            title, id, studio, certification, rootFolderPath, genres, hasFile,
            is_anime: is_anime,
            is_kids: is_kids,
            is_junk: is_junk
        } |
        . + {
            forward_anime_miss: (.is_anime and $animeRoot != "" and .rootFolderPath != $animeRoot),
            forward_kids_miss:  (.is_kids  and $kidsRoot  != "" and .rootFolderPath != $kidsRoot),
            reverse_anime_leak: ((.is_anime | not) and $animeRoot != "" and .rootFolderPath == $animeRoot and (.is_junk | not)),
            reverse_kids_leak:  ((.is_anime | not) and (.is_kids | not) and $kidsRoot != "" and .rootFolderPath == $kidsRoot
                                 and (.is_junk | not)
                                 and (.certification == "R" or .certification == "NC-17"
                                      or (.certification == "PG-13"
                                          and (any(.genres[]?; . == "Family") | not)
                                          and (any(.genres[]?; . == "Animation") | not))))
        }
    )
')

FORWARD_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_anime_miss)] | length')
FORWARD_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_kids_miss)] | length')
REVERSE_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_anime_leak)] | length')
REVERSE_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_kids_leak)] | length')
JUNK_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.is_junk)] | length')

if [[ "$ENABLE_LOGGING" == true ]]; then
    echo "$RESULTS" | jq -r '.[] | select(.forward_anime_miss or .forward_kids_miss or .reverse_anime_leak or .reverse_kids_leak or .is_junk) |
        "  [\(if .is_junk then "JUNK" elif .forward_anime_miss then "FORWARD-ANIME" elif .forward_kids_miss then "FORWARD-KIDS" elif .reverse_anime_leak then "REVERSE-ANIME" elif .reverse_kids_leak then "REVERSE-KIDS" else "?" end)] \(.title) (root: \(.rootFolderPath), studio: \(.studio // "n/a"), cert: \(.certification // "n/a"))"'
fi

# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR CLASSIFICATION SUMMARY ━━━━━"
echo "$ICON_HOST    Identity:              $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC    Movies scanned:        $MOVIE_COUNT"
echo "$ICON_TRASH   Forward — anime miss:  $FORWARD_ANIME_COUNT  (classified anime, outside ${RADARR_ANIME_ROOT:-<unconfigured>})"
echo "$ICON_TRASH   Forward — kids miss:   $FORWARD_KIDS_COUNT  (classified kids, outside ${RADARR_KIDS_ROOT:-<unconfigured>})"
echo "$ICON_WARN    Reverse — anime leak:  $REVERSE_ANIME_COUNT  (in ${RADARR_ANIME_ROOT:-<unconfigured>}, no anime signal — review, may be deliberate style placement)"
echo "$ICON_WARN    Reverse — kids leak:   $REVERSE_KIDS_COUNT  (in ${RADARR_KIDS_ROOT:-<unconfigured>}, adult-rated content)"
echo "$ICON_PROTECTED Bad metadata (junk):  $JUNK_COUNT  (hasFile=false, no imdbId, thin TMDb match — candidates for removal, not redownload)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ENABLE_LOGGING" != true ]] && echo "  (run with --log for the per-title list)"

# ==============================================================================================
# ━━━ Remove-Junk Mode ━━━
# ==============================================================================================
# Junk entries are bad/thin TMDb matches with hasFile=false — there's no release to blocklist
# (nothing was ever grabbed) and no file to delete, only a bad monitored record. The fix is
# removing the record and adding it to Radarr's import exclusion list (same mechanism
# radarr_tmdb_removed.sh already uses via RADARR_DROPPED_ADD_EXCLUSION) so the same bad TMDb
# match can't get re-added by a future Overseerr request or list sync.
if [[ "$REMOVE_JUNK_MODE" == true ]]; then
    echo ""
    echo "━━━ $ICON_TRASH Remove-Junk Mode ━━━"

    acquire_lock "wait"
    trap "_release_all_locks" EXIT

    JUNK_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select(.is_junk)]')
    JUNK_TARGET_COUNT=$(echo "$JUNK_TARGETS" | jq 'length')

    if [[ "$JUNK_TARGET_COUNT" -eq 0 ]]; then
        info "No junk entries to remove"
    else
        warn "About to remove $JUNK_TARGET_COUNT junk entries — one at a time, verifying after each"

        JUNK_REMOVED=0
        JUNK_FAILED=0

        while IFS= read -r item; do
            id=$(echo "$item" | jq -r '.id')
            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: $RADARR_API_KEY" \
                "${RADARR_URL}/api/v3/movie/${id}?deleteFiles=false&addImportExclusion=true" 2>/dev/null)

            if [[ "$http_code" != "200" && "$http_code" != "202" ]]; then
                error "  ✗ $title — API returned HTTP $http_code — stopping (review before re-running)"
                (( JUNK_FAILED++ ))
                break
            fi

            sleep 1

            # Verify — the movie should now be gone entirely (404).
            verify_code=$(curl -sf -o /dev/null -w "%{http_code}" \
                -H "X-Api-Key: $RADARR_API_KEY" \
                "${RADARR_URL}/api/v3/movie/${id}" 2>/dev/null)

            if [[ "$verify_code" == "404" ]]; then
                echo "  $ICON_SUCCESS $title — removed and excluded"
                (( JUNK_REMOVED++ ))
            else
                error "  ✗ $title — verification failed (still returns HTTP $verify_code) — stopping"
                (( JUNK_FAILED++ ))
                break
            fi
        done < <(echo "$JUNK_TARGETS" | jq -c '.[]')

        if [[ "$JUNK_REMOVED" -gt 0 ]]; then
            info "Refreshing shared tracked-data cache..."
            fresh_movies=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr")
            [[ -n "$fresh_movies" ]] && arr_cache_write "radarr" "$fresh_movies"
        fi

        echo ""
        echo "━━━━━ $ICON_SUMMARY REMOVE-JUNK SUMMARY ━━━━━"
        echo "$ICON_SUCCESS Removed: $JUNK_REMOVED"
        echo "$ICON_ERROR Failed:  $JUNK_FAILED"
        echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    fi

    _release_all_locks
    trap - EXIT
fi

# ==============================================================================================
# ━━━ Move Mode ━━━
# ==============================================================================================
# Acts on FORWARD misplacements (clear-cut: classified anime/kids, sitting in the wrong root)
# and on REVERSE-KIDS leaks (adult certification with zero Family/Animation genre sitting in
# the kids root — also clear-cut, moved back to RADARR_GENERAL_ROOT). Does NOT act on
# REVERSE-ANIME leaks — those are genuine judgment calls, since deliberate style placements
# like Castlevania/Legend of Korra legitimately live in the anime root without matching the
# anime signal — or on JUNK (those need removal from Radarr, not a file move).
#
# One movie at a time, verified after each. moveFiles=true flips the DB (rootFolderPath/
# hasFile) instantly, but the physical move is a separate async MoveMovie command Radarr's
# own MoveMovieService drains one at a time internally — same architecture that raced on the
# Sonarr side (episodeFileCount reported at the new path via API while the real files were
# still sitting at the old one, MoveSeries command queued behind ~20 others). Never confirmed
# live on the Radarr side, but the same DB-write-is-instant/move-is-async split applies, so
# each move here polls its own MoveMovie command to "completed" before the DB-field check
# runs, mirroring the Sonarr fix.
if [[ "$MOVE_MODE" == true ]]; then
    echo ""
    echo "━━━ $ICON_SYNC Move Mode — Forward Misplacements ━━━"

    acquire_lock "wait"
    trap "_release_all_locks" EXIT

    build_arr_path_map "RADARR"

    # hasFile==false entries (monitored but never downloaded) have nothing to physically
    # move — confirmed live: "Biohazard 4: Incubate" had hasFile=false even in the cache from
    # before any of this ran, not something this script broke. There's still real value in
    # fixing them, though: correct the DB pointer now (so Radarr saves to the right root
    # whenever it does find a release) and kick off an immediate search rather than waiting
    # for the next scheduled one. Junk entries are still excluded entirely — nothing to
    # search for there, they need removal instead.
    #
    # reverse_kids_leak is included here (unlike reverse_anime_leak) because its signal is
    # specifically "adult certification with zero Family/Animation genre" — there's no
    # legitimate stylistic reason for that combination to sit in a curated kids library, unlike
    # the anime side where deliberate style placements (Castlevania, Legend of Korra) are
    # common and valid. Confirmed live: the 4 titles this caught (The Addams Family, Saving
    # Mr. Banks, Dark Shadows, The DUFF) are all genuinely non-kids content, not judgment calls.
    MOVE_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select((.forward_anime_miss or .forward_kids_miss or .reverse_kids_leak) and (.is_junk | not))]')
    MOVE_COUNT=$(echo "$MOVE_TARGETS" | jq 'length')

    if [[ "$MOVE_COUNT" -eq 0 ]]; then
        info "Nothing to move"
        exit 0
    fi

    warn "About to process $MOVE_COUNT movies — one at a time, verifying after each"

    MOVED=0
    RELOCATED_SEARCH=0
    FAILED=0

    while IFS= read -r item; do
        id=$(echo "$item" | jq -r '.id')
        title=$(echo "$item" | jq -r '.title')
        is_anime_flag=$(echo "$item" | jq -r '.is_anime')
        is_forward_kids=$(echo "$item" | jq -r '.forward_kids_miss')
        had_file=$(echo "$item" | jq -r '.hasFile')
        if [[ "$is_anime_flag" == "true" ]]; then
            target_root="$RADARR_ANIME_ROOT"
        elif [[ "$is_forward_kids" == "true" ]]; then
            target_root="$RADARR_KIDS_ROOT"
        else
            target_root="$RADARR_GENERAL_ROOT"
        fi

        if [[ -z "$target_root" ]]; then
            error "  ✗ $title — target root not configured (RADARR_GENERAL_ROOT blank), skipping"
            (( FAILED++ ))
            continue
        fi

        # RESULTS only carries the reduced report fields — Radarr's PUT expects the complete
        # resource representation, so fetch a fresh full movie record to modify and send back.
        full_movie=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie/$id" "Radarr")
        if [[ -z "$full_movie" ]]; then
            error "  ✗ $title — could not fetch full movie record, skipping"
            (( FAILED++ ))
            continue
        fi

        old_path=$(echo "$full_movie" | jq -r '.path')
        folder_name="${old_path##*/}"

        # A literal "/" in the folder name would build a broken nested directory instead of
        # moving to one clean folder — bit us once already doing this by hand for Sonarr.
        if [[ "$folder_name" == *"/"* ]]; then
            error "  ✗ $title — folder name contains '/', skipping (needs manual handling)"
            (( FAILED++ ))
            continue
        fi

        new_path="${target_root}/${folder_name}"

        if [[ "$had_file" == "true" ]]; then
            info "  → $title: $old_path  →  $new_path (moving file)"
            move_qs="?moveFiles=true"
        else
            info "  → $title: $old_path  →  $new_path (no file — relocating + search)"
            move_qs=""
        fi

        updated_movie=$(echo "$full_movie" | jq --arg root "$target_root" --arg path "$new_path" \
            '.rootFolderPath = $root | .path = $path')

        http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X PUT \
            --max-time 30 \
            -H "X-Api-Key: $RADARR_API_KEY" \
            -H "Content-Type: application/json" \
            -d "$updated_movie" \
            "${RADARR_URL}/api/v3/movie/${id}${move_qs}" 2>/dev/null)

        if [[ "$http_code" != "200" && "$http_code" != "202" ]]; then
            error "  ✗ $title — API returned HTTP $http_code — stopping (review before re-running)"
            (( FAILED++ ))
            break
        fi

        # moveFiles=true flips rootFolderPath/hasFile in the DB instantly, but the actual
        # physical move is a separate async MoveMovie command that Radarr's MoveMovieService
        # drains one at a time internally — mirrors the confirmed Sonarr race (see header
        # comment above Move Mode). Poll the actual command to completion before trusting the
        # DB-field check below.
        if [[ -n "$move_qs" ]]; then
            move_cmd_id=""
            for _ in 1 2 3 4 5; do
                move_cmd_id=$(curl -sf --max-time 10 -H "X-Api-Key: $RADARR_API_KEY" \
                    "${RADARR_URL}/api/v3/command" 2>/dev/null | \
                    jq -r --argjson mid "$id" \
                        '[.[] | select(.name == "MoveMovie" and .body.movieId == $mid)] | sort_by(.id) | last | .id // empty' \
                        2>/dev/null)
                [[ -n "$move_cmd_id" ]] && break
                sleep 1
            done

            if [[ -z "$move_cmd_id" ]]; then
                error "  ✗ $title — could not locate the MoveMovie command — stopping (review before re-running)"
                (( FAILED++ ))
                break
            fi

            info "  → $title: MoveMovie command $move_cmd_id queued, waiting for completion..."
            move_status="" move_polled=0
            while [[ "$move_polled" -lt "$RADARR_MOVE_POLL_TIMEOUT" ]]; do
                move_status=$(curl -sf --max-time 10 -H "X-Api-Key: $RADARR_API_KEY" \
                    "${RADARR_URL}/api/v3/command/${move_cmd_id}" 2>/dev/null | \
                    jq -r '.status // empty' 2>/dev/null)
                [[ "$move_status" == "completed" || "$move_status" == "failed" ]] && break
                sleep 10
                (( move_polled += 10 ))
                [[ $(( move_polled % 60 )) -eq 0 ]] && log "    still moving $title... (${move_polled}s elapsed)"
            done

            if [[ "$move_status" != "completed" ]]; then
                error "  ✗ $title — MoveMovie command $move_cmd_id ended as '${move_status:-timed out after ${RADARR_MOVE_POLL_TIMEOUT}s}' — stopping"
                (( FAILED++ ))
                break
            fi
        fi

        sleep 3

        # Never trust the PUT response alone — re-fetch and confirm the change actually landed.
        verify_movie=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie/$id" "Radarr")
        verify_root=$(echo "$verify_movie" | jq -r '.rootFolderPath')
        verify_hasfile=$(echo "$verify_movie" | jq -r '.hasFile')

        if [[ "$verify_root" != "$target_root" ]]; then
            error "  ✗ $title — verification failed (root: $verify_root) — stopping"
            (( FAILED++ ))
            break
        fi

        if [[ "$had_file" == "true" ]]; then
            if [[ "$verify_hasfile" == "true" ]]; then
                echo "  $ICON_SUCCESS $title — moved and verified"
                (( MOVED++ ))
            else
                error "  ✗ $title — verification failed (root updated but hasFile now false) — stopping"
                (( FAILED++ ))
                break
            fi
        else
            search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
                --max-time 30 \
                -H "X-Api-Key: $RADARR_API_KEY" \
                -H "Content-Type: application/json" \
                -d "{\"name\":\"MoviesSearch\",\"movieIds\":[${id}]}" \
                "${RADARR_URL}/api/v3/command" 2>/dev/null)
            if [[ "$search_code" == "200" || "$search_code" == "201" ]]; then
                echo "  $ICON_SUCCESS $title — relocated, search triggered"
            else
                warn "  $title — relocated but search trigger returned HTTP $search_code (will pick up on next scheduled search)"
            fi
            (( RELOCATED_SEARCH++ ))
        fi
    done < <(echo "$MOVE_TARGETS" | jq -c '.[]')

    # arr_get_tracked_data() is cache-first — every write above changed rootFolderPath, so the
    # shared cache is now stale until the next scheduled arr_cache_prefill run (up to 30min).
    # Every other script reading this cache (cleanup, discovery, etc.) would see wrong data
    # until then — refresh it now with one more live fetch rather than leave that window open.
    if [[ "$(( MOVED + RELOCATED_SEARCH ))" -gt 0 ]]; then
        info "Refreshing shared tracked-data cache..."
        fresh_movies=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr")
        [[ -n "$fresh_movies" ]] && arr_cache_write "radarr" "$fresh_movies"
    fi

    echo ""
    echo "━━━━━ $ICON_SUMMARY MOVE SUMMARY ━━━━━"
    echo "$ICON_SUCCESS Moved (file relocated):       $MOVED"
    echo "$ICON_SUCCESS Relocated + search triggered: $RELOCATED_SEARCH"
    echo "$ICON_ERROR Failed:                       $FAILED"
    echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi

exit 0
