#!/usr/bin/env python3
# ==============================================================================================
# ================================= Arr Cleanup ================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Delete orphaned media files not tracked by an arr (Lidarr, Radarr, Sonarr,
# or any future arr). Queries the API for all tracked file paths, walks the
# library on disk, and removes anything untracked that is old enough to be
# past the import window. Triggers an Emby library clean after each deletion
# run so ghost entries disappear immediately.
#
# Called exclusively by arr_cleanup.sh, which sources shell config and exports
# all configuration as environment variables before exec'ing this script.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Every file encountered on disk is classified into one of five categories:
#
#   TRACKED   — arr API knows this exact path → leave it alone
#   PROTECTED — matches {ARR}_PROTECTED_PATTERNS → never delete
#   ORPHAN    — media file, not tracked, older than {ARR}_ORPHAN_AGE → delete
#   JUNK      — not a tracked extension, not protected → delete regardless of age
#   RECENT    — not tracked, under {ARR}_ORPHAN_AGE → skip (may be mid-import)
#
# Arr apps generate cover art (*.jpg), metadata (*.nfo), lyrics (*.lrc), and
# subtitles (*.srt) but do NOT include these in their tracked file API response.
# Without PROTECTED classification these would be deleted — breaking the arr
# app and Emby display.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Seven gates — ALL must pass before any file is touched:
#   1. Container running and not starting/unhealthy
#   2. API reachable
#   3. API version matches {ARR}_VERSION_MAJOR in master.conf
#   4. Parent count > 0 (artists / movies / series)
#   5. Tracked file count > 0
#   6. Tracked count >= {ARR}_MIN_TRACKED_PCT % of last known (if configured)
#   7. Deletion size < {ARR}_MAX_DELETE_GB — or --i-know-what-im-doing required
#
# ==============================================================================================
# ADDING A NEW ARR
# ==============================================================================================
#
#   1. Add an entry to ARR_PROFILES below (6 values — API endpoint pattern only)
#   2. Add HOST*_<ARR>_URL, API_KEY, MEDIA_ROOT, PATH_MAP to master_host*.conf
#   3. Add <ARR>_ORPHAN_AGE, MAX_DELETE_GB, EXTENSIONS, etc. to master.conf
#   4. Add an export block to arr_cleanup.sh (copy existing block, change prefix)
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master_host*.conf (host-specific, aliased by detect_hosts in arr_cleanup.sh)
#
#   HOST*_{ARR}_URL         — arr base URL
#   HOST*_{ARR}_API_KEY     — arr API key
#   HOST*_{ARR}_MEDIA_ROOT  — host-side library root (MUSIC_ROOT / MOVIES_ROOT / TV_ROOT)
#   HOST*_{ARR}_PATH_MAP    — container path → host path translation (assoc array)
#
# master.conf (shared thresholds)
#
#   {ARR}_ORPHAN_AGE          — days before untracked file is eligible for deletion
#   {ARR}_MAX_DELETE_GB       — require --i-know-what-im-doing above this
#   {ARR}_MIN_TRACKED_PCT     — abort if tracked count drops below this % of last run
#   {ARR}_TRACKED_COUNT_FILE  — persistent baseline file path (enables gate 6)
#   {ARR}_EXTENSIONS          — media file extensions for orphan classification
#   {ARR}_PROTECTED_PATTERNS  — glob patterns never deleted (cover art, metadata, etc.)
#   {ARR}_VERSION_MAJOR       — expected arr major version for API safety check
#   {ARR}_IMPORT_SCAN_TIMEOUT — seconds to wait for pre-flight import scan (default 600)
#   {ARR}_LOCK_WARN_AGE       — override default lock warning age (large libraries)
#   ARR_CLEANUP_STATS         — stats file path (read by sunday_morning_coffee_report)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
#   arr_cleanup.sh --arr lidarr                                           — normal run
#   arr_cleanup.sh --arr lidarr --dry-run                                 — preview, no deletions
#   arr_cleanup.sh --arr lidarr --log                                     — verbose output
#   arr_cleanup.sh --arr lidarr --status                                  — show config and exit
#   arr_cleanup.sh --arr lidarr --i-know-what-im-doing                    — bypass size threshold
#   arr_cleanup.sh --arr lidarr --i-know-what-im-doing --skip-strike-list — NUCLEAR MODE
#
# NUCLEAR MODE: both flags bypass age check AND size threshold. Use when the arr
# has filled gaps and you want a clean one-pass wipe. Flag name is long and
# annoying by design.
#
# ==============================================================================================

import argparse
import atexit
import datetime
import fnmatch
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

# ── Arr profiles — only what cannot come from env vars ────────────────────────
# API endpoint patterns and labels. Everything else is config in master.conf.
ARR_PROFILES = {
    "lidarr": {
        "api_version":     "v1",
        "parent_endpoint": "artist",
        "parent_id_param": "artistId",
        "file_endpoint":   "trackFile",
        "import_scan_cmd": "DownloadedAlbumsScan",
        "parent_label":    "artists",
    },
    "radarr": {
        "api_version":     "v3",
        "parent_endpoint": "movie",
        "parent_id_param": "movieId",
        "file_endpoint":   "moviefile",
        "import_scan_cmd": "DownloadedMoviesScan",
        "parent_label":    "movies",
    },
    "sonarr": {
        "api_version":     "v3",
        "parent_endpoint": "series",
        "parent_id_param": "seriesId",
        "file_endpoint":   "episodefile",
        "import_scan_cmd": "DownloadedEpisodesScan",
        "parent_label":    "series",
    },
    # Add new arrs here. 6 values — everything else goes in master.conf.
    # "readarr": {
    #     "api_version":     "v1",
    #     "parent_endpoint": "author",
    #     "parent_id_param": "authorId",
    #     "file_endpoint":   "bookfile",
    #     "import_scan_cmd": "DownloadedBooksScan",
    #     "parent_label":    "authors",
    # },
}

# ── Output helpers ─────────────────────────────────────────────────────────────

VERBOSE = False

def log(msg):
    if VERBOSE:
        print(f"  {msg}")

def warn(msg):
    print(f"  ⚠️  {msg}")

def error(msg):
    print(f"  ❌ {msg}", file=sys.stderr)

def success(msg):
    print(f"  ✅ {msg}")

def die(msg, notify_fn=None):
    error(msg)
    if notify_fn:
        notify_fn(msg)
    sys.exit(1)

# ── Env var readers ────────────────────────────────────────────────────────────

def _env(key, default=""):
    return os.environ.get(key, default)

def _env_int(key, default=0):
    try:
        return int(os.environ.get(key, str(default)))
    except ValueError:
        return default

def _env_float(key, default=0.0):
    try:
        return float(os.environ.get(key, str(default)))
    except ValueError:
        return default

def _env_list(key):
    val = os.environ.get(key, "")
    return val.split() if val else []

def _env_bool(key):
    return os.environ.get(key, "false").lower() == "true"

# ── Lock ───────────────────────────────────────────────────────────────────────

LOCK_DIR      = _env("LOCK_DIR", "/tmp/unraid_locks")
LOCK_TIMEOUT  = _env_int("LOCK_WAIT_TIMEOUT", 30)
SCRIPT_NAME   = "arr_cleanup"

def acquire_lock(warn_age=3600):
    os.makedirs(LOCK_DIR, exist_ok=True)
    lockfile = Path(LOCK_DIR) / f"{SCRIPT_NAME}.lock"

    if lockfile.exists():
        try:
            content   = lockfile.read_text().strip()
            pid_str, locked_name = content.split(":", 1)
            pid       = int(pid_str)

            try:
                os.kill(pid, 0)
                pid_alive = True
            except (ProcessLookupError, PermissionError):
                pid_alive = False

            if not pid_alive or locked_name != SCRIPT_NAME:
                warn(f"Stale lock (PID {pid} gone) — clearing")
                lockfile.unlink(missing_ok=True)
            else:
                age = time.time() - lockfile.stat().st_mtime
                if age > warn_age:
                    warn(f"{SCRIPT_NAME} has been running for {int(age)}s — may be stuck (PID {pid})")
                print(f"  Another instance of {SCRIPT_NAME} is running — waiting up to {LOCK_TIMEOUT}s...")
                waited = 0
                while lockfile.exists() and waited < LOCK_TIMEOUT:
                    time.sleep(1)
                    waited += 1
                if lockfile.exists():
                    die(f"{SCRIPT_NAME} still locked after {LOCK_TIMEOUT}s — exiting")
        except (ValueError, OSError):
            lockfile.unlink(missing_ok=True)

    lockfile.write_text(f"{os.getpid()}:{SCRIPT_NAME}")
    atexit.register(lambda: lockfile.unlink(missing_ok=True))
    log(f"🔏 Lock acquired: {SCRIPT_NAME} (PID {os.getpid()})")

# ── HTTP helper ────────────────────────────────────────────────────────────────

def _http_get(url, api_key, timeout=30):
    req = urllib.request.Request(url, headers={"X-Api-Key": api_key})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.status, resp.read().decode()
    except urllib.error.HTTPError as e:
        return e.code, ""
    except Exception:
        return 0, ""

def _http_post(url, api_key, payload=None, timeout=30):
    data = json.dumps(payload or {}).encode()
    req  = urllib.request.Request(
        url, data=data, method="POST",
        headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.status, resp.read().decode()
    except urllib.error.HTTPError as e:
        return e.code, ""
    except Exception:
        return 0, ""

# ── Arr API ────────────────────────────────────────────────────────────────────

def arr_get(base_url, api_key, api_version, endpoint):
    url = f"{base_url}/api/{api_version}/{endpoint}"
    status, body = _http_get(url, api_key)
    if status != 200:
        error(f"API HTTP {status} for: {endpoint}")
        return None
    try:
        return json.loads(body)
    except json.JSONDecodeError:
        error(f"Failed to parse JSON for: {endpoint}")
        return None

# ── Path translation ───────────────────────────────────────────────────────────
# Replicates common.sh translate_path() — longest-prefix match wins.

def translate_path(api_path, path_map):
    best_match = ""
    best_len   = 0
    for container_path, host_path in path_map.items():
        if api_path.startswith(container_path) and len(container_path) > best_len:
            best_match = container_path
            best_len   = len(container_path)
    if best_match:
        return path_map[best_match] + api_path[best_len:]
    return api_path

# ── Container health check ─────────────────────────────────────────────────────

def check_container(name, timeout=15):
    def _inspect(fmt):
        try:
            r = subprocess.run(
                ["timeout", str(timeout), "docker", "inspect", "-f", fmt, name],
                capture_output=True, text=True,
            )
            return r.stdout.strip()
        except Exception:
            return ""

    if _inspect("{{.State.Running}}") != "true":
        return False, f"{name} is not running"

    health = _inspect("{{.State.Health.Status}}")
    if health == "healthy":
        log(f"{name} is healthy")
    elif health == "":
        log(f"{name} has no health check — proceeding")
    elif health == "starting":
        return False, f"{name} is still starting"
    elif health == "unhealthy":
        return False, f"{name} is unhealthy"
    else:
        warn(f"{name} health: {health} — proceeding with caution")

    return True, ""

# ── API reachability ───────────────────────────────────────────────────────────

def check_api(url, label, timeout=10):
    req = urllib.request.Request(url)
    try:
        urllib.request.urlopen(req, timeout=timeout)
        log(f"{label} API reachable: {url}")
        return True
    except Exception:
        error(f"{label} API not reachable: {url}")
        return False

# ── API version check ──────────────────────────────────────────────────────────

def check_arr_version(base_url, api_key, api_version, expected_major, label):
    url = f"{base_url}/api/{api_version}/system/status"
    status, body = _http_get(url, api_key, timeout=10)
    if status != 200 or not body:
        warn(f"{label} version check failed — proceeding without verification")
        return True
    try:
        data    = json.loads(body)
        version = data.get("version", "")
        major   = version.split(".")[0]
        if major == str(expected_major):
            success(f"{label} version: {version} (major {major} — tested ✅)")
            return True
        else:
            error(f"{label} version mismatch — running v{major}, tested against v{expected_major}")
            error(f"API structure may have changed — update {label.upper()}_VERSION_MAJOR in master.conf after verifying")
            return False
    except Exception:
        warn(f"{label} version check failed — could not parse response")
        return True

# ── Import scan (pre-flight) ───────────────────────────────────────────────────

def run_import_scan(base_url, api_key, api_version, scan_cmd, media_root, path_map, timeout=600, label="Arr"):
    container_root = next(
        (cp for cp, hp in path_map.items() if hp == media_root),
        None,
    )

    if container_root:
        log(f"Triggering {scan_cmd} on: {container_root}")
        payload = {"name": scan_cmd, "path": container_root}
    else:
        log(f"No path map match — triggering {scan_cmd} (all root folders)")
        payload = {"name": scan_cmd}

    url = f"{base_url}/api/{api_version}/command"
    status, body = _http_post(url, api_key, payload)
    if status not in (200, 201):
        warn(f"Could not trigger import scan (HTTP {status}) — proceeding without pre-flight")
        return

    try:
        cmd_id = json.loads(body).get("id")
    except Exception:
        cmd_id = None

    if not cmd_id:
        warn("Could not get scan command ID — proceeding without pre-flight")
        return

    print(f"  Import scan queued (command ID: {cmd_id}) — waiting for completion...")
    polled   = 0
    poll_url = f"{base_url}/api/{api_version}/command/{cmd_id}"
    while polled < timeout:
        _, body = _http_get(poll_url, api_key, timeout=10)
        try:
            state = json.loads(body).get("status", "")
        except Exception:
            state = ""

        if state == "completed":
            log("Import scan complete ✅")
            return
        if state == "failed":
            warn("Import scan reported failed — proceeding anyway")
            return

        time.sleep(10)
        polled += 10
        if polled % 60 == 0:
            log(f"  Still scanning... ({polled}s elapsed)")

    warn(f"Import scan timed out after {timeout}s — proceeding anyway")

# ── Fetch tracked paths ────────────────────────────────────────────────────────

def fetch_tracked_paths(base_url, api_key, api_version, profile, path_map, label):
    parent_endpoint = profile["parent_endpoint"]
    parent_id_param = profile["parent_id_param"]
    file_endpoint   = profile["file_endpoint"]
    parent_label    = profile["parent_label"]

    parents = arr_get(base_url, api_key, api_version, parent_endpoint)
    if parents is None:
        return None, 0, 0

    parent_ids = [p["id"] for p in parents]
    parent_count = len(parent_ids)
    if parent_count == 0:
        return None, 0, 0

    log(f"Found {parent_count} {parent_label} — fetching tracked files...")

    tracked = set()
    for i, pid in enumerate(parent_ids):
        if i > 0 and i % 100 == 0:
            log(f"Fetching files: {i}/{parent_count} {parent_label}...")
        files = arr_get(base_url, api_key, api_version, f"{file_endpoint}?{parent_id_param}={pid}")
        if not files:
            continue
        if isinstance(files, dict):
            files = [files]
        for f in files:
            api_path = f.get("path", "")
            if api_path:
                tracked.add(translate_path(api_path, path_map))

    return tracked, parent_count, len(tracked)

# ── File classification helpers ────────────────────────────────────────────────

def is_media_file(path, extensions):
    ext = Path(path).suffix.lstrip(".").lower()
    return ext in extensions

def is_protected(path, patterns):
    name = Path(path).name
    return any(fnmatch.fnmatch(name, p) for p in patterns)

# ── Notify Emby ───────────────────────────────────────────────────────────────

def notify_emby_scan(emby_url, emby_api_key, my_id):
    if not emby_url or not emby_api_key:
        log(f"Emby not configured on {my_id} — skipping library scan notification")
        return

    log("Notifying Emby to clean missing files...")
    status, body = _http_get(f"{emby_url}/ScheduledTasks", emby_api_key, timeout=15)
    if status != 200 or not body:
        warn(f"Could not reach Emby scheduled tasks API — skipping scan")
        return

    try:
        tasks = json.loads(body)
    except Exception:
        warn("Could not parse Emby tasks response")
        return

    task_id = None
    for task in tasks:
        if "Clean Missing" in task.get("Name", ""):
            task_id = task.get("Id")
            break
    if not task_id:
        for task in tasks:
            if "Scan Media Library" in task.get("Name", ""):
                task_id = task.get("Id")
                log("Clean Missing Files not found — using Scan Media Library")
                break

    if not task_id:
        warn("Could not find Emby Clean Missing Files or Scan Media Library task")
        warn("Ghost entries will persist until next Emby scan")
        return

    status, _ = _http_post(f"{emby_url}/ScheduledTasks/Running/{task_id}", emby_api_key)
    if status in (200, 204):
        warn("🎬 Emby Clean Missing Files triggered — ghost entries will be removed")
    else:
        warn(f"Emby task trigger returned HTTP {status} — ghost entries may persist")

# ── Unraid + Discord notifications ────────────────────────────────────────────

def notify(msg, subject, notify_unraid, hostname, discord_webhook):
    log(f"🔔 Notification: {subject} — {msg}")
    if notify_unraid:
        notify_script = "/usr/local/emhttp/plugins/dynamix/scripts/notify"
        if os.path.isfile(notify_script) and os.access(notify_script, os.X_OK):
            subprocess.run([notify_script, "-s", subject, "-d", msg, "-i", "warning"],
                           capture_output=True)
    if discord_webhook:
        payload = json.dumps({"content": f"🔔 **{subject}**\n{msg}"}).encode()
        req = urllib.request.Request(
            discord_webhook, data=payload, method="POST",
            headers={"Content-Type": "application/json"},
        )
        try:
            urllib.request.urlopen(req, timeout=10)
        except Exception:
            pass

# ── Format helpers ─────────────────────────────────────────────────────────────

def format_bytes(b):
    if b > 1_073_741_824:
        return f"{b / 1_073_741_824:.1f}GB"
    if b > 1_048_576:
        return f"{b / 1_048_576:.1f}MB"
    return f"{b}B"

def format_duration(secs):
    if secs >= 3600:
        return f"{secs // 3600}h{(secs % 3600) // 60}m{secs % 60}s"
    if secs >= 60:
        return f"{secs // 60}m{secs % 60}s"
    return f"{secs}s"

# ══════════════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════════════

def main():
    global VERBOSE

    parser = argparse.ArgumentParser(prog="arr_cleanup.py")
    parser.add_argument("--arr",      required=True, choices=list(ARR_PROFILES.keys()))
    parser.add_argument("--dry-run",  action="store_true")
    parser.add_argument("--log",      action="store_true")
    parser.add_argument("--status",   action="store_true")
    parser.add_argument("--i-know-what-im-doing", action="store_true", dest="i_know")
    parser.add_argument("--skip-strike-list",     action="store_true", dest="skip_strikes")
    args = parser.parse_args()

    VERBOSE   = args.log
    ARR       = args.arr
    PREFIX    = ARR.upper()
    profile   = ARR_PROFILES[ARR]
    arr_label = ARR.capitalize()

    # ── Read config from env ───────────────────────────────────────────────────
    url         = _env(f"{PREFIX}_URL")
    api_key     = _env(f"{PREFIX}_API_KEY")
    media_root  = _env(f"{PREFIX}_MEDIA_ROOT")
    extensions  = set(_env_list(f"{PREFIX}_EXTENSIONS"))
    protected   = _env_list(f"{PREFIX}_PROTECTED_PATTERNS")
    orphan_age  = _env_int(f"{PREFIX}_ORPHAN_AGE", 3)
    max_del_gb  = _env_float(f"{PREFIX}_MAX_DELETE_GB", 10.0)
    min_pct     = _env_int(f"{PREFIX}_MIN_TRACKED_PCT", 0)
    count_file  = _env(f"{PREFIX}_TRACKED_COUNT_FILE")
    ver_major   = _env(f"{PREFIX}_VERSION_MAJOR", "0")
    scan_tmout  = _env_int(f"{PREFIX}_IMPORT_SCAN_TIMEOUT", 600)
    lock_warn   = _env_int(f"{PREFIX}_LOCK_WARN_AGE", 3600)
    container   = _env(f"{PREFIX}_CONTAINER") or arr_label

    path_map_json = _env(f"{PREFIX}_PATH_MAP_JSON", "{}")
    try:
        path_map = json.loads(path_map_json)
    except json.JSONDecodeError:
        path_map = {}

    my_id         = _env("MY_ID", "HOST1")
    server_name   = _env("LOCAL_SERVER_NAME")
    notify_unraid = _env_bool("NOTIFY_UNRAID")
    emby_url      = _env("EMBY_URL")
    emby_api_key  = _env("EMBY_API_KEY")
    discord       = _env("MY_DISCORD_WEBHOOK")
    stats_file    = _env("ARR_CLEANUP_STATS")
    hostname      = _env("ARR_HOSTNAME")
    api_version   = profile["api_version"]

    def _notify(msg, subject=f"{arr_label} Cleanup"):
        notify(msg, subject, notify_unraid, hostname, discord)

    # ── Nuclear mode warning ───────────────────────────────────────────────────
    if args.i_know and args.skip_strikes and not args.dry_run:
        print()
        print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
        print("⚠️  WARNING — NUCLEAR MODE ACTIVE")
        print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
        print("  Flags:          --i-know-what-im-doing --skip-strike-list")
        print("  Strike system:  BYPASSED — deletes on first pass")
        print("  Size threshold: BYPASSED — no GB limit")
        print("  Data recovery:  NOT POSSIBLE after deletion")
        print()
        print("  Review --dry-run output before proceeding.")
        print("  You have 10 seconds to cancel (Ctrl+C)...")
        print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
        time.sleep(10)
        print("  Proceeding...")
        print()

    # ── Setup ──────────────────────────────────────────────────────────────────
    print()
    print("━━━ ⚙️  Setup ━━━")

    if not url or not api_key:
        log(f"{arr_label} not configured on {my_id} ({server_name}) — skipping")
        sys.exit(0)

    if not media_root:
        die(f"{PREFIX}_MEDIA_ROOT not set — check master_host*.conf", _notify)

    if not Path(media_root).is_dir():
        die(f"Media root not found: {media_root}", _notify)

    acquire_lock(warn_age=lock_warn)

    print(f"  {my_id} ({server_name}) — {url}")

    if args.dry_run:    warn("DRY RUN — no files will be deleted")
    if args.i_know:     warn("OVERRIDE — --i-know-what-im-doing active")
    if args.skip_strikes: warn("OVERRIDE — --skip-strike-list active — age check bypassed")

    # ── Status ─────────────────────────────────────────────────────────────────
    if args.status:
        print()
        print(f"━━━━━ 📋 STATUS ━━━━━")
        print(f"⚙️     Identity:           {my_id} ({server_name})")
        print(f"⚙️     {arr_label} URL:    {url}")
        print(f"⚙️     Media root:         {media_root}")
        print(f"⏱️     Orphan age:         {orphan_age} days")
        print(f"⚙️     Max delete:         {max_del_gb}GB (requires --i-know-what-im-doing)")
        if min_pct:
            print(f"⚙️     Min tracked %:      {min_pct}%")
        print(f"⚙️     {arr_label} ver:    v{ver_major} expected")
        print(f"⚙️     Extensions:         {' '.join(sorted(extensions))}")
        print(f"⚙️     Protected patterns: {' '.join(protected)}")
        print(f"⚙️     Dry Run:            {args.dry_run}")
        print(f"⚙️     I know:             {args.i_know}")
        print(f"⚙️     Skip strikes:       {args.skip_strikes}")
        print("━━━━━━━━━━━━━━━━━━━━━━━")
        sys.exit(0)

    # ── Safety gate 1 — container health ──────────────────────────────────────
    print()
    print("━━━ 🛡️  Safety Checks ━━━")

    ok, reason = check_container(container)
    if not ok:
        _notify(f"{arr_label} cleanup aborted on {hostname} — {reason}")
        die(f"{reason} — aborting")

    log("Safety gate 1 passed — container healthy")

    # ── Pre-flight import scan ─────────────────────────────────────────────────
    print()
    print(f"━━━ 🔄 Pre-flight: {arr_label} Import Scan ━━━")
    run_import_scan(url, api_key, api_version,
                    profile["import_scan_cmd"], media_root, path_map,
                    timeout=scan_tmout, label=arr_label)

    # ── Safety gate 2 — API reachability ──────────────────────────────────────
    print()
    print(f"━━━ 🔄 Fetching {arr_label} Tracked Files ━━━")

    if not check_api(url, arr_label):
        _notify(f"{arr_label} cleanup aborted on {hostname} — API unreachable")
        sys.exit(1)

    # ── Safety gate 3 — API version ───────────────────────────────────────────
    if not check_arr_version(url, api_key, api_version, ver_major, arr_label):
        _notify(f"{arr_label} version mismatch on {hostname} — check master.conf")
        sys.exit(1)

    # ── Fetch tracked paths ────────────────────────────────────────────────────
    print(f"  Querying {arr_label} API...")
    tracked, parent_count, tracked_count = fetch_tracked_paths(
        url, api_key, api_version, profile, path_map, arr_label,
    )

    # ── Safety gate 4 — parent count > 0 ──────────────────────────────────────
    if tracked is None or parent_count == 0:
        msg = f"API returned 0 {profile['parent_label']} — aborting to prevent mass deletion"
        _notify(f"{arr_label} cleanup aborted on {hostname} — 0 {profile['parent_label']} returned")
        die(msg)

    # ── Safety gate 5 — tracked count > 0 ────────────────────────────────────
    if tracked_count == 0:
        _notify(f"{arr_label} cleanup aborted on {hostname} — 0 tracked files returned")
        die("API returned 0 tracked files — aborting to prevent mass deletion")

    print(f"  {parent_count} {profile['parent_label']} | {tracked_count} tracked files")
    log(f"Built in-memory lookup set: {tracked_count} tracked paths")

    # ── Safety gate 6 — tracked % drop (only if configured) ──────────────────
    if count_file and min_pct > 0:
        count_path = Path(count_file)
        if count_path.exists():
            try:
                last = int(count_path.read_text().strip())
                if last > 0:
                    pct = int((tracked_count / last) * 100)
                    if pct < min_pct:
                        error(f"Tracked count dropped to {pct}% of last run ({tracked_count} vs {last})")
                        error(f"Suggests API issue — aborting to prevent mass deletion")
                        error(f"If expected (large removal) delete: {count_file}")
                        _notify(f"{arr_label} cleanup aborted on {hostname} — tracked count dropped to {pct}%")
                        sys.exit(1)
                    log(f"Tracked count: {pct}% of last run ({tracked_count} vs {last}) ✅")
            except (ValueError, OSError):
                log("Could not read previous count — skipping % check")
        else:
            log("No previous count on record — first run, saving baseline")
        try:
            count_path.write_text(str(tracked_count))
        except OSError as e:
            warn(f"Could not write tracked count file: {e}")

    # ── Scan media root ────────────────────────────────────────────────────────
    print()
    print(f"━━━ 🧹 Scanning Media Root ━━━")
    print(f"  Root: {media_root} | Orphan age: {orphan_age} days")
    print()

    start         = time.time()
    orphan_count  = junk_count = recent_count = protected_count = 0
    orphan_bytes  = junk_bytes = 0
    age_threshold = orphan_age * 86400
    now           = time.time()
    max_del_bytes = int(max_del_gb * 1_073_741_824)

    scan_roots = set(path_map.values()) | {media_root}

    all_files = []
    for root in scan_roots:
        if Path(root).is_dir():
            for fp in Path(root).rglob("*"):
                if fp.is_file():
                    all_files.append(str(fp))
    all_files = sorted(set(all_files))

    for filepath in all_files:
        if filepath in tracked:
            log(f"TRACKED: {filepath}")
            continue

        if is_protected(filepath, protected):
            log(f"🔰 PROTECTED: {filepath}")
            protected_count += 1
            continue

        try:
            st = Path(filepath).stat()
        except OSError:
            continue

        file_size = st.st_size

        if is_media_file(filepath, extensions):
            file_age = now - st.st_mtime
            if file_age < age_threshold and not args.skip_strikes:
                log(f"RECENT (skipping): {filepath}")
                recent_count += 1
                continue
            warn(f"🗑️  ORPHAN: {filepath}")
            orphan_count += 1
            orphan_bytes += file_size
        else:
            log(f"JUNK: {filepath}")
            junk_count += 1
            junk_bytes += file_size

    total_del_bytes = orphan_bytes + junk_bytes
    total_removed   = orphan_count + junk_count

    # ── Safety gate 7 — deletion size threshold ───────────────────────────────
    if total_del_bytes > max_del_bytes:
        total_human = format_bytes(total_del_bytes)
        if not args.i_know:
            print()
            error(f"Deletion would exceed {max_del_gb}GB — {total_human} would be deleted")
            error("Review ORPHAN lines above carefully before proceeding")
            error("Rerun with: --i-know-what-im-doing")
            error("To also bypass age check: add --skip-strike-list")
            _notify(f"{arr_label} cleanup halted on {hostname} — {total_human} requires --i-know-what-im-doing")
            sys.exit(1)
        else:
            warn(f"OVERRIDE — deletion is {total_human} — proceeding with --i-know-what-im-doing")

    # ── Execute deletions ──────────────────────────────────────────────────────
    if not args.dry_run:
        for filepath in all_files:
            if filepath in tracked:
                continue
            if is_protected(filepath, protected):
                continue
            try:
                st       = Path(filepath).stat()
                file_age = now - st.st_mtime
            except OSError:
                continue

            if is_media_file(filepath, extensions):
                if file_age < age_threshold and not args.skip_strikes:
                    continue

            try:
                Path(filepath).unlink()
            except OSError as e:
                error(f"Failed to delete: {filepath} — {e}")

        log("Cleaning up empty folders...")
        for root in scan_roots:
            if Path(root).is_dir():
                for d in sorted(Path(root).rglob("*"), key=lambda p: len(p.parts), reverse=True):
                    if d.is_dir():
                        try:
                            d.rmdir()
                        except OSError:
                            pass
        log("Empty folders removed")

    elapsed = int(time.time() - start)

    # ── Summary ────────────────────────────────────────────────────────────────
    orphan_human = format_bytes(orphan_bytes)
    junk_human   = format_bytes(junk_bytes)

    print()
    print(f"━━━━━ 📋 {arr_label.upper()} CLEANUP SUMMARY ━━━━━")
    print(f"🖥️     Identity:        {my_id} ({server_name})")
    print(f"🔄    Tracked:         {tracked_count} files ({parent_count} {profile['parent_label']})")
    print(f"🛡️     Protected:       {protected_count} files (cover art, metadata)")
    print(f"🗑️     Orphans:         {orphan_count} files ({orphan_human})")
    print(f"🗑️     Junk:            {junk_count} files ({junk_human})")
    print(f"⏭️     Recent skipped:  {recent_count} files (under {orphan_age} days)")
    print(f"⏱️     Duration:        {format_duration(elapsed)}")
    print()

    if args.dry_run:
        warn("DRY RUN — no files deleted")
    elif total_removed == 0:
        success("Clean — nothing to remove")
    else:
        warn(f"🏁 Removed {total_removed} files (orphans: {orphan_human} junk: {junk_human})")
        _notify(
            f"{arr_label} cleanup on {hostname} — removed {total_removed} files "
            f"(orphans: {orphan_human} junk: {junk_human})"
        )
        notify_emby_scan(emby_url, emby_api_key, my_id)

    print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")

    # ── Write stats for coffee report ─────────────────────────────────────────
    if not args.dry_run and stats_file:
        today = datetime.date.today().strftime("%Y-%m-%d")
        line  = f"{today}|{ARR}|{orphan_count}|{orphan_bytes}|{junk_count}|{junk_bytes}|{recent_count}|{tracked_count}\n"
        try:
            with open(stats_file, "a") as f:
                f.write(line)
        except OSError:
            pass


if __name__ == "__main__":
    main()
