#!/bin/bash
# ==============================================================================================
# ============================ Sonarr Content Classification Scan ==============================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Same problem as radarr_classification_scan.sh, TV side: Overseerr lets any user request a
# show into the wrong root folder (kids shows added to the general TV share, anime added to
# Kids_Tv_Shows, etc.). This script reads Sonarr's tracked series list and classifies every
# series as anime / kids-only / regular using metadata signals alone (genre, certification,
# network, original language) — then reports where a series' computed classification
# disagrees with the root folder it's actually sitting in, in both directions:
#
#   FORWARD  — a series classified as anime/kids is sitting outside its dedicated root
#   REVERSE  — a series sitting inside the kids/anime root doesn't match that classification
#
# Report-only by default. Every rule below was validated against this library's real data
# before being adopted — see the companion comment block in master.conf above the curated
# lists. Pass --move to actually act (see MOVE MODE below) — nothing writes to Sonarr unless
# that flag is given.
#
# ==============================================================================================
# CLASSIFICATION RULES — DIFFERENT FIELD MODEL THAN RADARR, NOT A COPY-PASTE
# ==============================================================================================
#
# TV metadata (TheTVDB, via Sonarr) shapes these signals differently than movie metadata
# (TMDb, via Radarr) — every difference below was confirmed live, not assumed:
#   - Sonarr has an explicit "Anime" genre tag; Radarr does not.
#   - Sonarr uses a single "network" field (TheTVDB's broadcaster), not "studio".
#   - Certification is on the US TV Parental Guidelines scale (TV-Y/TV-Y7/TV-G/TV-PG/
#     TV-14/TV-MA), not the MPAA scale — the tiers do not mean the same thing at the same
#     position (TV-G is "general audience", not "for children", unlike movie G).
#
# is_anime:
#   genre "Anime" (corroborated by Japanese language OR a Japan network — the bare tag alone
#     produced a real false positive: "Craig of the Creek", an all-American Cartoon Network
#     show, carries an "Anime" genre tag on TheTVDB for no discernible reason)
#   OR (genre Animation AND originalLanguage Japanese)
#   OR network in SONARR_ANIME_NETWORKS
#   Always wins over kids when both could apply — explicit priority, not a tiebreak.
#
# is_kids ("kids will end up watching this alone" — NOT "family show night"):
#   not is_anime AND (
#     genre "Children"                      (NOT "Family" — see below)
#     OR certification in (TV-Y, TV-Y7)     (NOT TV-G — see below)
#     OR network in SONARR_KIDS_NETWORKS
#   )
#   "Family" genre and "TV-G" certification were both tested standalone and rejected —
#   both catch general-audience live-action content the whole household watches together
#   (I Love Lucy, The Brady Bunch, Full House, Homestead Rescue), not kids-only content.
#   Blanket "Animation" genre was also tested and rejected — it's dominated on TV by adult
#   animated sitcoms (Rick and Morty, BoJack Horseman, Family Guy, South Park), unlike the
#   movie side where it's a usable (gated) signal.
#
# No junk-detection tier here (unlike Radarr) — TheTVDB's ratings/imdbId data is far
# sparser than TMDb's even for completely legitimate shows (confirmed live: "The Pussycat
# Dolls Present: The Search for the Next Doll", a real 2007 MTV show, has ratings.votes=0
# and imdbId=null) — the vote-count heuristic that works for Radarr would flag real content
# for removal here, so it's deliberately not reused. --remove-junk from the Radarr script has
# no Sonarr equivalent for the same reason.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Report pass (always):
#   check_api → check_arr_version → arr_get_tracked_data (cache-first, one call)
#   → classify every series → report FORWARD and REVERSE disagreements → exit
#
# Move pass (--move only), described in detail below:
#
# Acts on FORWARD misplacements (classified anime/kids, sitting in the wrong root) and on
# REVERSE-KIDS leaks (adult certification sitting in the kids root — moved back to
# SONARR_GENERAL_ROOT). Does NOT act on REVERSE-ANIME leaks — those are genuine judgment
# calls, since deliberate style placements (Castlevania-type Western/Chinese animation
# grouped with anime by choice) legitimately live in the anime root without matching the
# anime signal.
#
# episodeFileCount is Sonarr's equivalent of Radarr's hasFile — a series can have 0 files
# (fully monitored, nothing downloaded) even while correctly classified. Those get their
# rootFolderPath/path corrected and an immediate SeriesSearch triggered rather than a file
# move (mirrors radarr_classification_scan.sh's handling of hasFile=false movies).
#
# One series at a time, verified after each. moveFiles=true flips the DB (rootFolderPath/
# episodeFileCount) instantly, but the physical move is a separate async MoveSeries command
# Sonarr drains one at a time internally — DB fields alone can report "moved" while the real
# files are still sitting at the old path behind other queued moves (confirmed live: "Full
# House" reported episodeFileCount:192 at the new path via API while the actual 75GB/192
# files hadn't moved yet). Each move polls its own MoveSeries command to "completed" before
# the DB-field check runs, so a batch can't compound the race the way a bare sleep-and-check did.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Curated Lists, Not Bare Genre/Cert Matching — see master.conf comments for exclusions.
# Cache-First — arr_get_tracked_data() same as sonarr_cleanup.sh, single call regardless
#   of library size. Refreshed after --move writes so no other script reads stale data.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
#   Required by the container interaction and state writes.
#
# Lock Acquisition
#   acquire_lock prevents a scheduled run overlapping a manual --move.
#
# Host Detection
#   detect_hosts() aliases SONARR_URL / SONARR_API_KEY / the root literals.
#
# curl + jq Dependency Check
#   Fails fast if either is missing — every classification signal is parsed with jq,
#   and a missing jq would evaluate each signal to empty and classify nothing.
#
# Report-Only Default
#   Nothing is written to Sonarr without --move. The scan is safe to schedule and
#   safe to run repeatedly while tuning the curated lists.
#
# Required Var Check
#   require_var on SONARR_URL and SONARR_API_KEY before any request.
#
# API Reachability + Version Gate
#   check_api then check_arr_version against SONARR_VERSION_MAJOR. A major version
#   bump can move or rename the fields every rule here depends on, so a mismatch
#   aborts rather than classifying against an unknown schema.
#
# Empty Library Abort
#   A response of 0 series aborts. An empty list is indistinguishable from "nothing
#   is misplaced" and would otherwise report a clean library during an API fault.
#
# Unconfigured Root Skip
#   A blank SONARR_GENERAL_ROOT / KIDS_ROOT / ANIME_ROOT skips that category's
#   checks rather than erroring — a host with no dedicated root is a valid setup,
#   and a blank value must never be compared against as if it were a real path.
#
# One At A Time, Stop On First Failure
#   Series are moved individually and the batch halts on the first failure rather
#   than continuing. A misclassified root or a failing move is a condition to
#   review, not to repeat across the library.
#
# Async Move Completion Polling
#   moveFiles=true flips the DB instantly while the physical move is a separate
#   async MoveSeries command Sonarr drains one at a time. Each move locates its own
#   command and polls it to "completed" (bounded by SONARR_MOVE_POLL_TIMEOUT) before
#   anything else is checked. Without this a batch reports every series moved while
#   the files are still queued at the old path — and downstream orphan cleanup can
#   act on that gap.
#
# Post-Move Re-Verification
#   The PUT response is never trusted. The series is re-fetched and both
#   rootFolderPath and episodeFileCount are confirmed against expectations before
#   the move counts as successful.
#
# Reverse-Anime Leaks Excluded From Moves
#   Deliberate style placements (Western/Chinese animation grouped with anime by
#   choice) legitimately sit in the anime root. Those are reported, never moved.
#
# Post-Write Cache Refresh
#   The tracked-data cache is refreshed after --move writes so no other arr script
#   reads a stale rootFolderPath.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#   SONARR_URL / SONARR_API_KEY / SONARR_TV_ROOT — existing, aliased by detect_hosts()
#   SONARR_GENERAL_ROOT / SONARR_KIDS_ROOT / SONARR_ANIME_ROOT — rootFolderPath literals as
#     reported by the API (e.g. "/tv", "/kids tv", "/ext-anime-shows") — leave blank on a
#     host with no dedicated root for that category; the corresponding checks are skipped,
#     not treated as an error.
#
# master.conf
#   SONARR_ANIME_NETWORKS / SONARR_KIDS_NETWORKS — curated network allowlists
#   SONARR_VERSION_MAJOR — expected API major version (reused from sonarr_cleanup.sh)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
#   sonarr_classification_scan.sh          — normal run, prints report
#   sonarr_classification_scan.sh --log    — verbose (per-series list)
#   sonarr_classification_scan.sh --status — show config and exit
#   sonarr_classification_scan.sh --move   — act on forward misplacements + reverse-kids-leak
#
# ==============================================================================================

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

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

# --move is a script-local flag, not one parse_args recognizes — check the raw args before
# they get filtered into PARSED_ARGS.
MOVE_MODE=false
for _arg in "$@"; do
    [[ "$_arg" == "--move" ]] && MOVE_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 "${SONARR_URL:-}" ]] || [[ -z "${SONARR_API_KEY:-}" ]]; then
    info "Sonarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
    exit 0
fi

require_var SONARR_URL
require_var SONARR_API_KEY

if [[ "$SHOW_STATUS" == true ]]; then
    echo ""
    echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
    echo "$ICON_HOST    Identity:        $MY_ID ($LOCAL_SERVER_NAME)"
    echo "$ICON_GEAR    Sonarr URL:      $SONARR_URL"
    echo "$ICON_GEAR    TV root:         $SONARR_TV_ROOT"
    echo "$ICON_GEAR    General root:    ${SONARR_GENERAL_ROOT:-<not configured>}"
    echo "$ICON_GEAR    Kids root:       ${SONARR_KIDS_ROOT:-<not configured>}"
    echo "$ICON_GEAR    Anime root:      ${SONARR_ANIME_ROOT:-<not configured>}"
    echo "$ICON_GEAR    Anime networks:  ${#SONARR_ANIME_NETWORKS[@]} curated"
    echo "$ICON_GEAR    Kids networks:   ${#SONARR_KIDS_NETWORKS[@]} curated"
    echo "$ICON_GEAR    Move mode:       $MOVE_MODE"
    echo "━━━━━━━━━━━━━━━━━━━━━━━"
    exit 0
fi

echo ""
echo "━━━ $ICON_SYNC Fetching Sonarr Library ━━━"

if ! check_api "$SONARR_URL" "Sonarr" 10; then
    exit 1
fi

check_arr_version "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SONARR_VERSION_MAJOR" "Sonarr" || exit 1

SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || {
    error "Failed to fetch series from Sonarr"
    exit 1
}

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

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

ANIME_NETWORKS_JSON=$(printf '%s\n' "${SONARR_ANIME_NETWORKS[@]}" | jq -R . | jq -s .)
KIDS_NETWORKS_JSON=$(printf '%s\n' "${SONARR_KIDS_NETWORKS[@]}" | jq -R . | jq -s .)
# Not just the US TV-MA/TV-14 tiers — non-US certification scales use different labels for the
# same "clearly adult" tier (confirmed live: "Tomb Raider: The Legend of Lara Croft" is "16+").
ADULT_CERT_JSON='["TV-MA","TV-14","MA15+","16","16+","18","15","14"]'

RESULTS=$(echo "$SERIES_RESPONSE" | jq \
    --argjson animeNetworks "$ANIME_NETWORKS_JSON" \
    --argjson kidsNetworks "$KIDS_NETWORKS_JSON" \
    --argjson adultCert "$ADULT_CERT_JSON" \
    --arg animeRoot "${SONARR_ANIME_ROOT:-}" \
    --arg kidsRoot "${SONARR_KIDS_ROOT:-}" '
    def is_anime:
        (any(.genres[]?; . == "Anime")
            and (.originalLanguage.name == "Japanese" or (.network as $n | $animeNetworks | index($n) != null)))
        or (any(.genres[]?; . == "Animation") and .originalLanguage.name == "Japanese")
        or (.network as $n | $animeNetworks | index($n) != null);
    def is_kids:
        (is_anime | not) and (
            any(.genres[]?; . == "Children")
            or (.certification as $c | ["TV-Y","TV-Y7"] | index($c) != null)
            or (.network as $n | $kidsNetworks | index($n) != null)
        );

    map(
        {
            title, id, network, certification, rootFolderPath,
            episodeFileCount: (.statistics.episodeFileCount // 0),
            is_anime: is_anime,
            is_kids: is_kids
        } |
        . + {
            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),
            reverse_kids_leak:  ((.is_anime | not) and (.is_kids | not) and $kidsRoot != "" and .rootFolderPath == $kidsRoot
                                 and (.certification as $c | $adultCert | index($c) != null))
        }
    )
')

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')

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) |
        "  [\(if .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), network: \(.network // "n/a"), cert: \(.certification // "n/a"))"'
fi

# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY SONARR CLASSIFICATION SUMMARY ━━━━━"
echo "$ICON_HOST    Identity:              $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC    Series scanned:        $SERIES_COUNT"
echo "$ICON_TRASH   Forward — anime miss:  $FORWARD_ANIME_COUNT  (classified anime, outside ${SONARR_ANIME_ROOT:-<unconfigured>})"
echo "$ICON_TRASH   Forward — kids miss:   $FORWARD_KIDS_COUNT  (classified kids, outside ${SONARR_KIDS_ROOT:-<unconfigured>})"
echo "$ICON_WARN    Reverse — anime leak:  $REVERSE_ANIME_COUNT  (in ${SONARR_ANIME_ROOT:-<unconfigured>}, no anime signal — review, may be deliberate style placement)"
echo "$ICON_WARN    Reverse — kids leak:   $REVERSE_KIDS_COUNT  (in ${SONARR_KIDS_ROOT:-<unconfigured>}, adult-rated content)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ENABLE_LOGGING" != true ]] && echo "  (run with --log for the per-title list)"

# ==============================================================================================
# ━━━ Move Mode ━━━
# ==============================================================================================
# See MOVE MODE in the header for scope (forward + reverse-kids-leak, not reverse-anime-leak).
if [[ "$MOVE_MODE" == true ]]; then
    echo ""
    echo "━━━ $ICON_SYNC Move Mode ━━━"

    acquire_lock "wait"
    trap "_release_all_locks" EXIT

    build_arr_path_map "SONARR"

    MOVE_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select(.forward_anime_miss or .forward_kids_miss or .reverse_kids_leak)]')
    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 series — 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_files_count=$(echo "$item" | jq -r '.episodeFileCount')

        if [[ "$is_anime_flag" == "true" ]]; then
            target_root="$SONARR_ANIME_ROOT"
        elif [[ "$is_forward_kids" == "true" ]]; then
            target_root="$SONARR_KIDS_ROOT"
        else
            target_root="$SONARR_GENERAL_ROOT"
        fi

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

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

        old_path=$(echo "$full_series" | 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 — this is exactly the self-inflicted bug hit doing the
        # Fate/Zero and Fate/Stay Night moves by hand earlier this session.
        if [[ "$folder_name" == *"/"* ]]; then
            error "  ✗ $title — folder name contains '/', skipping (needs manual handling)"
            (( FAILED++ ))
            continue
        fi

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

        if [[ "$had_files_count" -gt 0 ]]; then
            info "  → $title: $old_path  →  $new_path (moving $had_files_count episode file(s))"
            move_qs="?moveFiles=true"
        else
            info "  → $title: $old_path  →  $new_path (no files — relocating + search)"
            move_qs=""
        fi

        updated_series=$(echo "$full_series" | 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: $SONARR_API_KEY" \
            -H "Content-Type: application/json" \
            -d "$updated_series" \
            "${SONARR_URL}/api/v3/series/${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/episodeFileCount in the DB instantly, but the actual
        # physical move is a separate async MoveSeries command that Sonarr drains one at a time
        # internally — confirmed live: "Full House" showed episodeFileCount:192 at the new path via
        # API while the real 75GB/192 files were still sitting at the old path, MoveSeries queued
        # behind ~20 others. The DB-field check below cannot see that: poll the actual command to
        # completion first, or a batch run can report every series "moved" while most are still
        # mid-drain.
        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: $SONARR_API_KEY" \
                    "${SONARR_URL}/api/v3/command" 2>/dev/null | \
                    jq -r --argjson sid "$id" \
                        '[.[] | select(.name == "MoveSeries" and .body.seriesId == $sid)] | 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 MoveSeries command — stopping (review before re-running)"
                (( FAILED++ ))
                break
            fi

            info "  → $title: MoveSeries command $move_cmd_id queued, waiting for completion..."
            move_status="" move_polled=0
            while [[ "$move_polled" -lt "$SONARR_MOVE_POLL_TIMEOUT" ]]; do
                move_status=$(curl -sf --max-time 10 -H "X-Api-Key: $SONARR_API_KEY" \
                    "${SONARR_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 — MoveSeries command $move_cmd_id ended as '${move_status:-timed out after ${SONARR_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.
        # This exact check is what caught the earlier race condition doing this by hand: two
        # series reported "success" while episodeFileCount had silently dropped to 0.
        verify_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series/$id" "Sonarr")
        verify_root=$(echo "$verify_series" | jq -r '.rootFolderPath')
        verify_filecount=$(echo "$verify_series" | jq -r '.statistics.episodeFileCount // 0')

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

        if [[ "$had_files_count" -gt 0 ]]; then
            if [[ "$verify_filecount" -eq "$had_files_count" ]]; then
                echo "  $ICON_SUCCESS $title — moved and verified ($verify_filecount files)"
                (( MOVED++ ))
            else
                error "  ✗ $title — verification failed (root updated but episode count $verify_filecount != expected $had_files_count) — stopping"
                (( FAILED++ ))
                break
            fi
        else
            search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
                --max-time 30 \
                -H "X-Api-Key: $SONARR_API_KEY" \
                -H "Content-Type: application/json" \
                -d "{\"name\":\"SeriesSearch\",\"seriesId\":${id}}" \
                "${SONARR_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. Refresh it now
    # rather than leave that window open for every other script reading this cache.
    if [[ "$(( MOVED + RELOCATED_SEARCH ))" -gt 0 ]]; then
        info "Refreshing shared tracked-data cache..."
        fresh_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series" "Sonarr")
        [[ -n "$fresh_series" ]] && arr_cache_write "sonarr" "$fresh_series"
    fi

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

exit 0
