#!/usr/bin/env bash
set -euo pipefail
# POS: media ytsync — Incrementally sync YouTube channels/playlists into ~/Videos
# POS_SUBCMDS: add sync list remove
# POS_FLAGS: --dry-run
# POS_CONFIG: ytsync | ytsync.env | YTSYNC_VIDEOS_DIR=:Videos root for synced channels (default ~/Videos) | YTSYNC_EXTRA_ARGS=:Extra yt-dlp flags appended verbatim to every yt-dlp call (advanced)

source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
if ! command -v notify_send >/dev/null 2>&1; then
    notify_send() { return 0; }   # notify.sh absent → silent no-op (opt-in by design)
fi

# ── Global flag pre-scan ────────────────────────────────────────────
DRY_RUN=0
for _arg in "$@"; do
    if [ "$_arg" = "--dry-run" ]; then DRY_RUN=1; fi
done

# ── Deps guards (before -h|--help; yt-dlp+jq active EVEN under --dry-run,
#    because the preview IS the probe — deliberate divergence from mp3/mp4) ──
command -v yt-dlp >/dev/null 2>&1 || err "yt-dlp not found — installed by preinstall.sh (GitHub release → /usr/local/bin); run ./preinstall.sh or see DOC/howto/media.md"
command -v jq >/dev/null 2>&1 || err "jq not found (needed to parse yt-dlp probe output) — install it with: sudo apt install jq"
if [ "$DRY_RUN" -eq 0 ]; then
    command -v ffmpeg >/dev/null 2>&1 || err "ffmpeg not found (needed for MP4 merge) — install it with: sudo apt install ffmpeg"
fi

# ── Runtime config (~/.config/linux_post_install/ytsync.env) ────────
# Exported environment wins over the file; defaults come last.
_YTSYNC_CFG="$CONFIG_DIR/ytsync.env"
if [ -f "$_YTSYNC_CFG" ]; then
    while IFS= read -r _line || [ -n "$_line" ]; do
        case "$_line" in ''|\#*) continue ;; esac
        case "$_line" in
            YTSYNC_VIDEOS_DIR=*)
                _v="${_line#*=}"
                _v="${_v%\"}"; _v="${_v#\"}"; _v="${_v%\'}"; _v="${_v#\'}"
                : "${YTSYNC_VIDEOS_DIR:=$_v}"
                ;;
            YTSYNC_EXTRA_ARGS=*)
                _v="${_line#*=}"
                _v="${_v%\"}"; _v="${_v#\"}"; _v="${_v%\'}"; _v="${_v#\'}"
                : "${YTSYNC_EXTRA_ARGS:=$_v}"
                ;;
        esac
    done <"$_YTSYNC_CFG"
fi

# ── Env seams (all written VAR="${VAR:-default}") ───────────────────
YTSYNC_VIDEOS_DIR="${YTSYNC_VIDEOS_DIR:-$HOME/Videos}"   # user key AND test seam
YTSYNC_STATE_DIR="${YTSYNC_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/share}/linux_post_install/ytsync}"

REG_FILE="$YTSYNC_STATE_DIR/registry"
ARCHIVE_DIR="$YTSYNC_STATE_DIR/archive"
HIST_FILE="$YTSYNC_STATE_DIR/history.log"

BAD_URL_MSG="Not a YouTube URL — expected a channel (@handle, /c/, /user/), a playlist (?list=…), or a video link"

usage() {
    cat <<'EOF'
Usage: pos media ytsync [command] [args]

Incrementally download YouTube channels/playlists into ~/Videos.
First run asks for a channel URL; repeat runs fetch only new videos.

Commands:
  add [url]         Register a source and download it (asks for URL if omitted)
  sync [name]       Incremental sync of tracked sources (all, if no name given)
  list              Show tracked sources and their status
  remove <name>     Stop tracking a source (keeps downloaded files)

Options:
  --dry-run         Show what would be downloaded, fetch nothing
  -h, --help        This help

Layout:
  Videos/<channel>/<playlist>/<NNN> - <title>.<ext>   (playlist sources)
  Videos/<channel>/<title>.<ext>                      (channel/video sources)

Notes:
  Existing files are never overwritten; renamed/retitled videos keep their
  local filename. Unattended/scheduled use: 'pos media ytsync sync'.
  A watch link (?v=…, incl. youtu.be/<id> short links) downloads only that
  single video (--no-playlist), even when it carries &list= — never the whole
  playlist. 'remove' keeps the downloaded files AND the archive — re-adding
  the same source later resumes incrementally instead of re-downloading.

Examples:
  pos media ytsync                                  # interactive
  pos media ytsync add https://youtube.com/@SomeChannel
  pos media ytsync sync                             # cron/timer entry point
  pos media ytsync sync --dry-run                   # preview only
  pos media ytsync list

Environment:
  YTSYNC_VIDEOS_DIR   Videos root (default: $HOME/Videos)
  YTSYNC_EXTRA_ARGS   Extra yt-dlp flags appended to every download call
  Both editable via 'pos config ytsync'.
EOF
    exit 0
}

# ── Small shared helpers ────────────────────────────────────────────
_tmpfiles=()
cleanup() {
    if [ "${#_tmpfiles[@]}" -gt 0 ]; then
        rm -f "${_tmpfiles[@]}" >/dev/null 2>&1 || true
    fi
}
trap cleanup EXIT

newtmp() {
    local t
    t="$(mktemp)"
    _tmpfiles+=("$t")
    printf '%s' "$t"
}

_pretty() {   # collapse a $HOME prefix to ~ for display only
    local p="$1"
    case "$p" in
        "$HOME") printf '~' ;;
        "$HOME"/*) printf '~%s' "${p#"$HOME"}" ;;
        *) printf '%s' "$p" ;;
    esac
}

fmt_dur() {   # seconds → "4m12s" / "37s" / "1h05m"
    local s="$1"
    if [ "$s" -ge 3600 ]; then
        printf '%dh%02dm' $((s / 3600)) $(((s % 3600) / 60))
    elif [ "$s" -ge 60 ]; then
        printf '%dm%02ds' $((s / 60)) $((s % 60))
    else
        printf '%ss' "$s"
    fi
}

secs_since() {   # nanosecond start stamp → whole seconds elapsed
    local end
    end=$(_nano_now)
    echo $(((end - $1) / 1000000000))
}

is_youtube_url() {
    local u="$1"
    case "$u" in
        http://*youtube.com/* | https://*youtube.com/* | \
            http://*youtu.be/* | https://*youtu.be/* | \
            www.youtube.com/* | m.youtube.com/* | music.youtube.com/* | \
            youtube.com/* | youtu.be/* | @*)
            return 0 ;;
    esac
    return 1
}

classify_url() {   # names-one-video (v= or youtu.be/<id>) → video; list= → playlist; else channel
    local u="$1" vid=""
    case "$u" in
        *youtu.be/*)
            vid="${u#*youtu.be/}"   # short link: video id is the first path segment
            vid="${vid%%\?*}"
            vid="${vid%%\#*}"
            vid="${vid%%/*}"
            ;;
    esac
    if [ -n "$vid" ]; then
        printf 'video\n'
    elif [[ "$u" == *v=* ]]; then
        printf 'video\n'
    elif [[ "$u" == *list=* ]]; then
        printf 'playlist\n'
    else
        printf 'channel\n'
    fi
}

# ── Channel URL canonicalization ─────────────────────────────────────
# yt-dlp --flat-playlist on a bare channel URL returns the channel's
# TAB list (Videos/Live/Shorts; _type "playlist", url null, id==channel_id),
# not videos. Appending /videos makes the probe return the real videos.
canonical_channel_url() {   # add /videos to bare channel URLs; echo canonical
    local u="$1"
    case "$u" in
        @*) u="https://www.youtube.com/$u" ;;   # bare 'handle' → full URL
    esac
    [ "$(classify_url "$u")" = "channel" ] || { printf '%s' "$u"; return 0; }
    local path="${u%%\?*}"
    path="${path%%\#*}"
    path="${path%/}"
    case "${path##*/}" in
        videos | shorts | streams | live | playlists | featured | releases | podcasts | search)
            printf '%s' "$u" ;;
        *)
            printf '%s/videos' "$u" ;;
    esac
}

sanitize_component() {   # safe directory component from a resolved name
    local s="$1"
    s="${s//\//-}"
    s="$(printf '%s' "$s" | sed -E 's/^[[:space:].]+//; s/[[:space:].]+$//')"
    s="${s:0:80}"
    if [ -z "$s" ]; then s="YouTube"; fi
    printf '%s' "$s"
}

slugify() {   # unique internal key [a-z0-9][a-z0-9_-]*
    local s
    s="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
    s="$(printf '%s' "$s" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' | cut -c1-60)"
    if [ -z "$s" ]; then s="source"; fi
    case "$s" in
        [a-z0-9]*) ;;
        *) s="c$s" ;;
    esac
    printf '%s' "$s"
}

# ── Registry (machine-owned state; \x1f-delimited records) ──────────
# slug ⇥ type ⇥ url ⇥ subdir ⇥ playlist_title ⇥ added_ts
registry_entries() {
    if [ -f "$REG_FILE" ]; then
        cat "$REG_FILE"
    fi
    return 0
}

registry_count() {
    local n=""
    if [ -f "$REG_FILE" ]; then
        n="$(grep -c . "$REG_FILE" 2>/dev/null || true)"
    fi
    printf '%s' "${n:-0}"
}

registry_has_slug() {
    local s="$1" line f1
    while IFS= read -r line; do
        [ -n "$line" ] || continue
        f1="${line%%$'\x1f'*}"
        if [ "$f1" = "$s" ]; then return 0; fi
    done < <(registry_entries)
    return 1
}

unique_slug() {   # numeric -2 suffix on collision (checks registry slugs only)
    local base="$1" cand n=2
    cand="$base"
    while registry_has_slug "$cand"; do
        cand="${base}-${n}"
        n=$((n + 1))
    done
    printf '%s' "$cand"
}

registry_add_line() {   # atomic append (temp+mv)
    local line="$1" tmp
    mkdir -p "$YTSYNC_STATE_DIR"
    tmp="$(mktemp "$YTSYNC_STATE_DIR/.registry.XXXXXX")"
    _tmpfiles+=("$tmp")
    {
        if [ -f "$REG_FILE" ]; then cat "$REG_FILE"; fi
        printf '%s\n' "$line"
    } >"$tmp"
    mv "$tmp" "$REG_FILE"
}

registry_remove_slug() {   # atomic rewrite without the slug's line
    local slug="$1" tmp line f1
    mkdir -p "$YTSYNC_STATE_DIR"
    tmp="$(mktemp "$YTSYNC_STATE_DIR/.registry.XXXXXX")"
    _tmpfiles+=("$tmp")
    while IFS= read -r line || [ -n "$line" ]; do
        [ -n "$line" ] || continue
        f1="${line%%$'\x1f'*}"
        if [ "$f1" = "$slug" ]; then continue; fi
        printf '%s\n' "$line"
    done < <(registry_entries) >"$tmp"
    mv "$tmp" "$REG_FILE"
}

# ── Archive + history ───────────────────────────────────────────────
is_archived() {   # native yt-dlp archive format "<extractor> <id>"; exact-field match
    local id="$1" f="$2"
    [ -f "$f" ] || return 1
    awk -v want="$id" '$2 == want { found=1; exit } END { exit(found ? 0 : 1) }' "$f"
}

archive_count() {
    local slug="$1" n=""
    if [ -f "$ARCHIVE_DIR/$slug.txt" ]; then
        n="$(wc -l <"$ARCHIVE_DIR/$slug.txt")"
        n="$(printf '%s' "$n" | tr -d '[:space:]')"
    fi
    printf '%s' "${n:-0}"
}

append_history() {   # <date> · <name> · N new · M skipped · K failed
    mkdir -p "$YTSYNC_STATE_DIR"
    printf '%s · %s · %s new · %s skipped · %s failed\n' \
        "$(date '+%Y-%m-%d %H:%M')" "$1" "$2" "$3" "$4" >>"$HIST_FILE"
}

append_history_failed() {
    mkdir -p "$YTSYNC_STATE_DIR"
    printf '%s · %s · FAILED (probe)\n' "$(date '+%Y-%m-%d %H:%M')" "$1" >>"$HIST_FILE"
}

last_sync_of() {   # newest history line naming <display name>, date field only
    local name="$1" last=""
    if [ -f "$HIST_FILE" ]; then
        # grep may rc=1 on no match (pipefail) — || true keeps empty-string semantics
        last="$(grep -F " · $name · " "$HIST_FILE" 2>/dev/null | tail -n 1 || true)"
        last="${last%% ·*}"
    fi
    printf '%s' "${last:--}"
}

last_run_overall() {
    local last=""
    if [ -f "$HIST_FILE" ]; then
        last="$(tail -n 1 "$HIST_FILE" || true)"
        last="${last%% ·*}"
    fi
    printf '%s' "$last"
}

# ── Probe (yt-dlp --flat-playlist -J, parsed with jq) ───────────────
# spawn()-shaped UX but RETURNS failure instead of exiting: the interactive add
# flow re-prompts up to 3× and a failing source during sync must not kill the
# others' passes (spawn() exits the process on failure — unusable here).
# Spinner frames are TTY-only so no \r bytes ever reach the dispatcher tee log.
PROBE_JSON=""
PROBE_ERR=""

run_probe() {   # run_probe <url>
    local url="$1"
    url="$(canonical_channel_url "$url")"
    PROBE_JSON="$(newtmp)"
    PROBE_ERR="$(newtmp)"
    local msg="Resolving source …" start rc pid elapsed i
    start=$(_nano_now)
    yt-dlp --flat-playlist -J --no-warnings -- "$url" >"$PROBE_JSON" 2>"$PROBE_ERR" &
    pid=$!
    if [ -t 1 ]; then
        local spin=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
        i=0
        while kill -0 "$pid" 2>/dev/null; do
            printf '\r%s  %s%s %s' "$CYAN" "${spin[$i]}" "$RESET" "$msg"
            i=$(((i + 1) % ${#spin[@]}))
            sleep 0.1
        done
    else
        printf '%s\n' "$msg"
    fi
    rc=0
    wait "$pid" || rc=$?
    elapsed=$(_elapsed "$start")
    if [ "$rc" -eq 0 ]; then
        if [ -t 1 ]; then
            printf '\r%s  OK%s %s (%s)\n' "$GREEN" "$RESET" "$msg" "$elapsed"
        else
            printf '%s  OK %s (%s)\n' "$GREEN" "$msg" "$elapsed"
        fi
    else
        if [ -t 1 ]; then
            printf '\r%s  FAIL%s %s (%s)\n' "$RED" "$RESET" "$msg" "$elapsed"
        else
            printf '%s  FAIL %s (%s)\n' "$RED" "$msg" "$elapsed"
        fi
    fi
    return "$rc"
}

probe_fail_reason() {   # echoes "notfound" | "unreachable" (reads $PROBE_ERR)
    if grep -qiE 'not found|private|does not exist|404|unavailable|removed' "$PROBE_ERR" 2>/dev/null; then
        printf 'notfound\n'
    else
        printf 'unreachable\n'
    fi
}

parse_probe() {   # sets P_NAME P_OWNER P_TITLE P_KEY from $PROBE_JSON
    jq -e 'type == "object"' "$PROBE_JSON" >/dev/null 2>&1 || return 1
    P_NAME="$(jq -r '.channel // .uploader // .uploader_id // .title // ""' "$PROBE_JSON")"
    P_OWNER="$(jq -r '.channel // .uploader // .uploader_id // ""' "$PROBE_JSON")"
    P_TITLE="$(jq -r '.title // ""' "$PROBE_JSON")"
    P_KEY="$(jq -r '.uploader_id // .channel_id // .id // ""' "$PROBE_JSON")"
    return 0
}

ENTRY_IDS=()
ENTRY_TITLES=()

collect_entries() {   # flat .entries[] or a single video object (?v= URLs)
    ENTRY_IDS=()
    ENTRY_TITLES=()
    local n p id t
    n="$(jq -r '((.entries // []) | length)' "$PROBE_JSON")"
    if [ "$n" -gt 0 ]; then
        local -a pairs=()
        mapfile -t pairs < <(jq -r '.entries[] | select((.url // "") | test("watch\\?v=|youtu\\.be/|/shorts/")) | ((.id // "") + "\u001f" + (.title // ""))' "$PROBE_JSON")
        for p in "${pairs[@]}"; do
            id="${p%%$'\x1f'*}"
            t="${p#*$'\x1f'}"
            ENTRY_IDS+=("$id")
            ENTRY_TITLES+=("$t")
        done
    elif [ "$(jq -r '._type // ""' "$PROBE_JSON")" = "video" ]; then
        ENTRY_IDS+=("$(jq -r '.id // ""' "$PROBE_JSON")")
        ENTRY_TITLES+=("$(jq -r '.title // ""' "$PROBE_JSON")")
    fi
}

NEW_IDS=()
NEW_TITLES=()
NEW_POS=()
NEW_TOTAL=0
SIGNIN_COUNT=0
TOTAL_VALID=0

is_signin_skipped() {   # empty id OR "[Private"/"[Deleted"/"[Unavailable" titles
    if [ -z "$1" ]; then return 0; fi
    case "$2" in
        "[Private"* | "[Deleted"* | "[Unavailable"*) return 0 ;;
    esac
    return 1
}

compute_new_list() {   # $1 = archive file; diff entry ids against it BEFORE downloads
    local archive="$1"
    NEW_IDS=()
    NEW_TITLES=()
    NEW_POS=()
    NEW_TOTAL=0
    SIGNIN_COUNT=0
    TOTAL_VALID=0
    local i n id t pos=0
    n="${#ENTRY_IDS[@]}"
    for ((i = 0; i < n; i++)); do
        id="${ENTRY_IDS[$i]}"
        t="${ENTRY_TITLES[$i]}"
        pos=$((pos + 1))
        if is_signin_skipped "$id" "$t"; then
            SIGNIN_COUNT=$((SIGNIN_COUNT + 1))
            continue
        fi
        TOTAL_VALID=$((TOTAL_VALID + 1))
        if ! is_archived "$id" "$archive"; then
            NEW_IDS+=("$id")
            NEW_TITLES+=("$t")
            NEW_POS+=("$pos")
            NEW_TOTAL=$((NEW_TOTAL + 1))
        fi
    done
    return 0
}

# ── Per-video download (NEVER wrapped in spawn(): hours-long batches need a
#    live heartbeat; spawn captures output until completion) ────────────────
DL_ERRCAP=""
DL_RC=0

download_video() {   # <archive> <template> <video_id> <type>; stderr→$DL_ERRCAP
    local archive="$1" tpl="$2" vid="$3" vtype="$4"
    local -a dl=(
        yt-dlp
        -f "bestvideo*+bestaudio/best"
        --merge-output-format mp4
        --embed-metadata --embed-chapters
        --embed-thumbnail
        --no-overwrites
        --download-archive "$archive"
        --windows-filenames
        --trim-filenames 120
        --retries 3 --fragment-retries 3
    )
    if [ "$vtype" = "video" ]; then dl+=(--no-playlist); fi
    dl+=(--quiet --no-warnings)
    if [ -t 1 ]; then dl+=(--progress); fi
    if [ -n "${YTSYNC_EXTRA_ARGS:-}" ]; then
        local -a extra=()
        read -r -a extra <<<"$YTSYNC_EXTRA_ARGS" || true
        if [ "${#extra[@]}" -gt 0 ]; then dl+=("${extra[@]}"); fi
    fi
    dl+=(-o "$tpl" "https://www.youtube.com/watch?v=$vid")
    DL_ERRCAP="$(newtmp)"
    DL_RC=0
    "${dl[@]}" 2>"$DL_ERRCAP" || DL_RC=$?
}

# ── Failure-alarm trap (media-sync precedent), armed per pass ───────
CURRENT_SOURCE=""
arm_fail_alarm() {
    CURRENT_SOURCE="$1"
    trap 'notify_send "⚠️ ytSync FAILED for ${CURRENT_SOURCE}"' ERR
}
disarm_fail_alarm() {
    trap - ERR
    CURRENT_SOURCE=""
}

# ── Run-result accumulators ─────────────────────────────────────────
G_NEW=0
G_PRESENT=0
G_FAILED=0
G_WHOLEFAIL=0
G_SOURCES=0
NOTIFY_TITLES=()
NOTIFY_MORE=0
NOTIFY_NAMES_NEW=()
NOTIFY_NAMES_FAIL=()
NOTIFY_SRC_NEW=0
NOTIFY_DEST=""
PASS_ROWS=()

reset_globals() {
    G_NEW=0
    G_PRESENT=0
    G_FAILED=0
    G_WHOLEFAIL=0
    G_SOURCES=0
    NOTIFY_TITLES=()
    NOTIFY_MORE=0
    NOTIFY_NAMES_NEW=()
    NOTIFY_NAMES_FAIL=()
    NOTIFY_SRC_NEW=0
    NOTIFY_DEST=""
    PASS_ROWS=()
}

join_comma() {   # join_comma <array-name>
    local -n arr="$1"
    local out="" el
    for el in "${arr[@]}"; do
        if [ -z "$out" ]; then out="$el"; else out="${out}, ${el}"; fi
    done
    printf '%s' "$out"
}

send_digest() {   # noteworthy-only: new>0 or failed>0 (silent noop runs)
    if [ "$DRY_RUN" -eq 1 ]; then return 0; fi
    if [ "$G_NEW" -gt 0 ]; then
        local msg t dest
        msg="📺 ytSync — ${G_NEW} new videos: $(join_comma NOTIFY_NAMES_NEW)"
        for t in "${NOTIFY_TITLES[@]}"; do
            msg+=$'\n• '"${t}"
        done
        if [ "$NOTIFY_MORE" -gt 0 ]; then
            msg+=$'\n…and '"${NOTIFY_MORE}"' more'
        fi
        if [ "$NOTIFY_SRC_NEW" -eq 1 ]; then
            dest="$NOTIFY_DEST"
        else
            dest="$YTSYNC_VIDEOS_DIR"
        fi
        msg+=$'\n→ '"$(_pretty "$dest")"
        notify_send "$msg"
    fi
    if [ "$G_FAILED" -gt 0 ]; then
        notify_send "⚠️ ytSync — ${G_FAILED} videos failed: $(join_comma NOTIFY_NAMES_FAIL) (run 'pos media ytsync sync' to retry)"
    elif [ "$G_WHOLEFAIL" -gt 0 ]; then
        notify_send "⚠️ ytSync — ${G_WHOLEFAIL} sources failed: $(join_comma NOTIFY_NAMES_FAIL) (check connectivity; run 'pos media ytsync sync' to retry)"
    fi
    return 0
}

# ── Per-source pass engine ──────────────────────────────────────────
S_SLUG="" S_TYPE="" S_URL="" S_SUBDIR="" S_TITLE=""
R_NEW=0 R_PRESENT=0 R_FAILED=0 R_EXISTS=0 R_SIGNIN=0 R_STOPPED=""

pass_prepare() {   # fresh probe + diff; rc 0 ok / 1 wholesale failure
    R_NEW=0
    R_PRESENT=0
    R_FAILED=0
    R_EXISTS=0
    R_SIGNIN=0
    R_STOPPED=""
    CURRENT_SOURCE="${S_SUBDIR##*/}"
    if ! run_probe "$S_URL"; then
        if [ "$(probe_fail_reason)" = "notfound" ]; then
            warn "source not found or private: $S_URL — skipping"
        else
            warn "could not reach YouTube — check your connection and try again (${CURRENT_SOURCE})"
        fi
        return 1
    fi
    if ! parse_probe; then
        warn "unexpected yt-dlp probe output for ${CURRENT_SOURCE} — skipping"
        return 1
    fi
    collect_entries
    compute_new_list "$ARCHIVE_DIR/$S_SLUG.txt"
    R_SIGNIN="$SIGNIN_COUNT"
    R_PRESENT=$((TOTAL_VALID - NEW_TOTAL))
    return 0
}

print_dry_plan_for_current() {
    if [ "$R_SIGNIN" -gt 0 ]; then
        warn "${R_SIGNIN} videos require sign-in — skipped"
    fi
    printf 'Source   : %s\n' "$S_URL"
    printf 'Resolved : %s (%s · %s videos)\n' "${P_NAME:-${S_SUBDIR##*/}}" "$S_TYPE" "$TOTAL_VALID"
    printf 'Library  : %s/\n' "$(_pretty "$YTSYNC_VIDEOS_DIR/$S_SUBDIR")"
    printf 'New      : %s would be downloaded (%s already present)\n' "$NEW_TOTAL" "$R_PRESENT"
    local i shown=0 ex
    for ((i = 0; i < NEW_TOTAL; i++)); do
        if [ "$shown" -ge 5 ]; then break; fi
        if [ "$S_TYPE" = "playlist" ]; then
            ex="$(printf '%03d - %s.mp4' "${NEW_POS[$i]}" "${NEW_TITLES[$i]}")"
        else
            ex="${NEW_TITLES[$i]}.mp4"
        fi
        printf '  %s\n' "$ex"
        shown=$((shown + 1))
    done
    if [ "$NEW_TOTAL" -gt "$shown" ]; then
        printf '  … %s more\n' "$((NEW_TOTAL - shown))"
    fi
    printf 'DRY RUN complete — %s new would be fetched → %s/%s\n' \
        "$NEW_TOTAL" "$(_pretty "$YTSYNC_VIDEOS_DIR")" "$S_SUBDIR"
    return 0
}

pass_execute() {
    local name="${S_SUBDIR##*/}"
    local archive="$ARCHIVE_DIR/$S_SLUG.txt"
    local destdir="$YTSYNC_VIDEOS_DIR/$S_SUBDIR"

    if [ "$DRY_RUN" -eq 1 ]; then
        print_dry_plan_for_current
        G_SOURCES=$((G_SOURCES + 1))
        G_NEW=$((G_NEW + NEW_TOTAL))
        G_PRESENT=$((G_PRESENT + R_PRESENT))
        PASS_ROWS+=("${name} — ${NEW_TOTAL} new · ${R_PRESENT} present")
        return 0
    fi

    if [ "$R_SIGNIN" -gt 0 ]; then
        warn "${R_SIGNIN} videos require sign-in — skipped"
    fi
    log "${name} — ${NEW_TOTAL} new of ${TOTAL_VALID}"

    if [ "${#NEW_IDS[@]}" -eq 0 ]; then
        ok "Sync complete: 0 new, ${R_PRESENT} already present, 0 failed → ${destdir}"
        append_history "$name" 0 "$R_PRESENT" 0
        G_SOURCES=$((G_SOURCES + 1))
        G_PRESENT=$((G_PRESENT + R_PRESENT))
        PASS_ROWS+=("${name} — 0 new · ${R_PRESENT} present · 0 failed")
        return 0
    fi
    mkdir -p "$destdir" "$ARCHIVE_DIR"

    arm_fail_alarm "$name"
    local i total idx vid vt tpl pos tstart rc
    total="${#NEW_IDS[@]}"
    idx=0
    for ((i = 0; i < total; i++)); do
        vid="${NEW_IDS[$i]}"
        vt="${NEW_TITLES[$i]}"
        pos="${NEW_POS[$i]}"
        idx=$((idx + 1))
        echo "  [$idx/$total] $vt"
        if [ "$S_TYPE" = "playlist" ]; then
            tpl="${destdir}/$(printf '%03d' "$pos") - %(title)s.%(ext)s"
        else
            tpl="${destdir}/%(title)s.%(ext)s"
        fi
        tstart=$(_nano_now)
        download_video "$archive" "$tpl" "$vid" "$S_TYPE"
        rc="$DL_RC"
        if [ "$rc" -eq 0 ]; then
            ok "[$idx/$total] $vt ($(fmt_dur "$(secs_since "$tstart")"))"
            R_NEW=$((R_NEW + 1))
            G_NEW=$((G_NEW + 1))
            if [ "${#NOTIFY_TITLES[@]}" -lt 5 ]; then
                NOTIFY_TITLES+=("$vt")
            else
                NOTIFY_MORE=$((NOTIFY_MORE + 1))
            fi
        elif grep -qE 'No space left on device|Errno 28' "$DL_ERRCAP" 2>/dev/null; then
            warn "disk full — stopping '${name}' mid-run"
            sed -n '1,3p' "$DL_ERRCAP" | sed 's/^/    /'
            R_STOPPED="yes"
            break
        elif grep -qiE 'has already been downloaded|already been recorded' "$DL_ERRCAP" 2>/dev/null; then
            warn "exists, kept: $vt"
            ok "[$idx/$total] $vt ($(fmt_dur "$(secs_since "$tstart")"))"
            R_EXISTS=$((R_EXISTS + 1))
        else
            warn "unavailable: $vt"
            sed -n '1,3p' "$DL_ERRCAP" | sed 's/^/    /'
            R_FAILED=$((R_FAILED + 1))
            G_FAILED=$((G_FAILED + 1))
        fi
    done
    disarm_fail_alarm

    local present_total=$((R_PRESENT + R_EXISTS))
    ok "Sync complete: ${R_NEW} new, ${present_total} already present, ${R_FAILED} failed → ${destdir}"
    append_history "$name" "$R_NEW" "$present_total" "$R_FAILED"
    G_SOURCES=$((G_SOURCES + 1))
    G_PRESENT=$((G_PRESENT + present_total))
    local row="${name} — ${R_NEW} new · ${present_total} present · ${R_FAILED} failed"
    if [ -n "$R_STOPPED" ]; then row+=" · STOPPED (disk full)"; fi
    PASS_ROWS+=("$row")

    if [ "$R_NEW" -gt 0 ]; then
        NOTIFY_NAMES_NEW+=("$name")
        NOTIFY_SRC_NEW=$((NOTIFY_SRC_NEW + 1))
        NOTIFY_DEST="$destdir"
    fi
    if [ "$R_FAILED" -gt 0 ]; then
        NOTIFY_NAMES_FAIL+=("$name")
    fi
    return 0
}

# ── Add flow ────────────────────────────────────────────────────────
DERIVED_SLUG=""
DERIVED_SUBDIR=""

derive_source_fields() {   # $1=vtype $2=url; needs parsed probe globals
    local vtype="$1" url="$2"
    local name="$P_NAME" key="$P_KEY" base subdir title
    if [ -z "$name" ]; then name="$url"; fi
    if [ -n "$key" ]; then
        base="$(slugify "$key")"
    else
        base="$(slugify "$name")"
    fi
    DERIVED_SLUG="$(unique_slug "$base")"
    case "$vtype" in
        playlist)
            title="$(sanitize_component "${P_TITLE:-Playlist}")"
            if [ -n "$P_OWNER" ]; then
                subdir="$(sanitize_component "$P_OWNER")/${title}"
            else
                subdir="$title"
            fi
            ;;
        *)
            subdir="$(sanitize_component "$name")"
            ;;
    esac
    DERIVED_SUBDIR="$subdir"
}

show_resolution_screen() {   # $1=vtype $2=subdir
    printf 'Resolved : %s (%s · %s videos)\n' "${P_NAME:-unknown}" "$1" "$TOTAL_VALID"
    printf 'Library  : %s/\n' "$(_pretty "$YTSYNC_VIDEOS_DIR/$2")"
    case "$1" in
        playlist) printf '          └── numbered: <NNN> - <title>.<ext>\n' ;;
        *) printf '          └── flat videos: <title>.<ext>\n' ;;
    esac
}

finish_add() {   # $1=vtype $2=url $3=confirm|explicit — probe already done
    local vtype="$1" url="$2" mode="$3"
    collect_entries
    derive_source_fields "$vtype" "$url"
    local slug="$DERIVED_SLUG" subdir="$DERIVED_SUBDIR"
    local name="${subdir##*/}"

    if registry_has_slug "$slug"; then
        log "'$name' is already tracked — nothing to add (run 'pos media ytsync sync')"
        return 0
    fi

    compute_new_list "$ARCHIVE_DIR/$slug.txt"
    # mirror pass_prepare's counters so pass_execute's summary/digest are
    # correct on the add path too (archive may hold ids from a previous
    # tracking period — remove/re-add resume)
    R_SIGNIN="$SIGNIN_COUNT"
    R_PRESENT=$((TOTAL_VALID - NEW_TOTAL))
    show_resolution_screen "$vtype" "$subdir"

    if [ "$DRY_RUN" -eq 0 ]; then
        if [ "$mode" = "confirm" ]; then
            local ans=""
            if ! read -rp "Start download? [Y/n] " ans </dev/tty; then
                log "Cancelled — nothing changed"
                return 0
            fi
            case "$ans" in
                n | N | no | NO)
                    log "Cancelled — nothing changed"
                    return 0
                    ;;
            esac
        fi
        mkdir -p "$YTSYNC_STATE_DIR" "$ARCHIVE_DIR"
        registry_add_line "$(printf '%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s' \
            "$slug" "$vtype" "$url" "$subdir" "${P_TITLE:-}" "$(date '+%Y-%m-%d %H:%M')")"
    fi

    S_SLUG="$slug"
    S_TYPE="$vtype"
    S_URL="$url"
    S_SUBDIR="$subdir"
    S_TITLE="${P_TITLE:-}"
    if [ "$DRY_RUN" -eq 1 ]; then
        log "DRY RUN — nothing will be downloaded"
        print_dry_plan_for_current
        return 0
    fi
    pass_execute
    send_digest
    return 0
}

require_tty() {
    if [ ! -t 0 ]; then
        printf "%s\n" "[!] ytSync needs a terminal for its prompt — use 'pos media ytsync sync' for unattended runs." >&2
        return 1
    fi
    return 0
}

ASKED_URL=""
ATTEMPT=1

attempt_exhausted() {   # bumps ATTEMPT; rc 0 → exhausted (cancel), 1 → keep asking
    ATTEMPT=$((ATTEMPT + 1))
    [ "$ATTEMPT" -gt 3 ]
}

ask_url_interactive() {   # sets ASKED_URL; rc 1 = cancelled/EOF
    ATTEMPT=1
    ASKED_URL=""
    local u prompt vtype
    while :; do
        if [ "$ATTEMPT" -eq 1 ]; then
            prompt="Channel or playlist URL: "
        else
            prompt="Try again (${ATTEMPT} of 3), or press Enter to cancel: "
        fi
        u=""
        if ! read -rp "$prompt" u </dev/tty; then
            log "Cancelled — nothing changed"
            return 1
        fi
        if [ -z "$u" ]; then
            if attempt_exhausted; then
                log "Cancelled — nothing changed"
                return 1
            fi
            continue
        fi
        if ! is_youtube_url "$u"; then
            warn "$BAD_URL_MSG"
            if attempt_exhausted; then
                log "Cancelled — nothing changed"
                return 1
            fi
            continue
        fi
        vtype="$(classify_url "$u")"
        if ! run_probe "$u"; then
            if [ "$(probe_fail_reason)" = "notfound" ]; then
                warn "source not found or private: $u"
            else
                warn "could not reach YouTube — check your connection and try again"
            fi
            if attempt_exhausted; then
                log "Cancelled — nothing changed"
                return 1
            fi
            continue
        fi
        if ! parse_probe; then
            warn "unexpected yt-dlp probe output"
            if attempt_exhausted; then
                log "Cancelled — nothing changed"
                return 1
            fi
            continue
        fi
        ASKED_URL="$u"
        return 0
    done
}

cmd_add() {
    local url="${1:-}"
    reset_globals
    if [ -z "$url" ]; then
        if ! require_tty; then exit 0; fi
        if ! ask_url_interactive; then return 0; fi
        url="$ASKED_URL"
    fi
    if ! is_youtube_url "$url"; then
        warn "$BAD_URL_MSG"
        exit 1
    fi
    if ! run_probe "$url"; then
        if [ "$(probe_fail_reason)" = "notfound" ]; then
            err "source not found or private: $url"
        fi
        warn "could not reach YouTube — check your connection and try again"
        exit 1
    fi
    if ! parse_probe; then
        err "could not parse yt-dlp probe output for: $url"
    fi
    local mode="explicit"
    if [ -z "${1:-}" ]; then mode="confirm"; fi
    finish_add "$(classify_url "$url")" "$url" "$mode"
}

# ── Sync flow ───────────────────────────────────────────────────────
SYNC_LINES=()
MATCHING_SLUGS=()

resolve_sources() {   # $1=token-or-empty → SYNC_LINES[]; rc 0 ok / 1 none / 2 ambiguous
    SYNC_LINES=()
    MATCHING_SLUGS=()
    local token="$1" line slug sub
    if [ -z "$token" ]; then
        mapfile -t SYNC_LINES < <(registry_entries)
        if [ "${#SYNC_LINES[@]}" -eq 0 ]; then return 1; fi
        return 0
    fi
    while IFS= read -r line; do
        [ -n "$line" ] || continue
        IFS=$'\x1f' read -r slug _ _ sub _ _ <<<"$line"
        if [ "$token" = "$slug" ] || [ "$token" = "${sub##*/}" ]; then
            SYNC_LINES+=("$line")
            MATCHING_SLUGS+=("$slug")
        fi
    done < <(registry_entries)
    if [ "${#SYNC_LINES[@]}" -eq 0 ]; then return 1; fi
    if [ "${#SYNC_LINES[@]}" -gt 1 ]; then return 2; fi
    return 0
}

run_sync_set() {   # iterates SYNC_LINES[]; roll-up + digest; rc 1 iff wholesale failure
    if [ "$DRY_RUN" -eq 1 ]; then
        log "DRY RUN — nothing will be downloaded"
    fi
    local line
    for line in "${SYNC_LINES[@]}"; do
        IFS=$'\x1f' read -r S_SLUG S_TYPE S_URL S_SUBDIR S_TITLE rest <<<"$line"
        if pass_prepare; then
            pass_execute
        else
            G_WHOLEFAIL=$((G_WHOLEFAIL + 1))
            NOTIFY_NAMES_FAIL+=("${S_SUBDIR##*/}")
            if [ "$DRY_RUN" -eq 0 ]; then
                append_history_failed "${S_SUBDIR##*/}"
            fi
        fi
    done
    if [ "${#PASS_ROWS[@]}" -gt 1 ]; then
        printf 'Sync summary\n'
        local row
        for row in "${PASS_ROWS[@]}"; do
            printf '  %s\n' "$row"
        done
    fi
    send_digest
    if [ "$G_WHOLEFAIL" -gt 0 ]; then
        return 1
    fi
    return 0
}

cmd_sync() {
    local token="${1:-}"
    reset_globals
    local rs=0
    resolve_sources "$token" || rs=$?
    if [ "$rs" -eq 1 ]; then
        if [ -z "$token" ]; then
            log "nothing tracked yet — add a source with 'pos media ytsync add'"
            return 0
        fi
        err "unknown source '${token}' — see 'pos media ytsync list'"
    fi
    if [ "$rs" -eq 2 ]; then
        err "ambiguous '${token}' — candidates: $(join_comma MATCHING_SLUGS)"
    fi
    local src_rc=0
    run_sync_set || src_rc=$?
    return "$src_rc"
}

# ── List flow ───────────────────────────────────────────────────────
cmd_list() {
    local count
    count="$(registry_count)"
    if [ "$count" -eq 0 ]; then
        log "nothing tracked yet — add a source with 'pos media ytsync add'"
        return 0
    fi
    printf 'Tracked sources (%s)\n\n' "$count"
    local wname=4 line slug stype surl ssub stitle sts rest
    local dn dest vids last
    local -a names=() types=() vids_last=() dests=()
    while IFS= read -r line; do
        [ -n "$line" ] || continue
        IFS=$'\x1f' read -r slug stype surl ssub stitle sts rest <<<"$line"
        dn="${ssub##*/}"
        dest="$(_pretty "$YTSYNC_VIDEOS_DIR/$ssub")"
        vids="$(archive_count "$slug")"
        last="$(last_sync_of "$dn")"
        last="${last:0:10}"
        names+=("$dn")
        types+=("$stype")
        vids_last+=("${vids}|${last}")
        dests+=("$dest")
        if [ "${#dn}" -gt "$wname" ]; then wname="${#dn}"; fi
    done < <(registry_entries)

    printf '  %-*s  %-8s  %6s  %-10s  %s\n' "$wname" "NAME" "TYPE" "VIDEOS" "LAST SYNC" "DESTINATION"
    local i n vl
    for ((i = 0; i < ${#names[@]}; i++)); do
        n="${names[$i]}"
        vl="${vids_last[$i]}"
        printf '  %-*s  %-8s  %6s  %-10s  %s\n' \
            "$wname" "$n" "${types[$i]}" "${vl%%|*}" "${vl#*|}" "${dests[$i]}"
    done
    printf '\nState: %s (registry · archive/ · history.log)\n' "$(_pretty "$YTSYNC_STATE_DIR")"
    return 0
}

# ── Remove flow ─────────────────────────────────────────────────────
remove_by_line() {   # $1=registry line — drops tracking, keeps files AND archive
    local line="$1" slug stype surl ssub stitle sts rest
    IFS=$'\x1f' read -r slug stype surl ssub stitle sts rest <<<"$line"
    local name="${ssub##*/}"
    if [ "$DRY_RUN" -eq 1 ]; then
        log "would stop tracking '${name}' — files and archive stay"
        return 0
    fi
    registry_remove_slug "$slug"
    ok "Stopped tracking '${name}' — files kept in $(_pretty "$YTSYNC_VIDEOS_DIR/$ssub")"
    log "Archive kept — re-adding this source later resumes incrementally"
    return 0
}

cmd_remove() {
    local token="${1:-}"
    if [ -z "$token" ]; then
        err "usage: pos media ytsync remove <name>"
    fi
    reset_globals
    local rs=0
    resolve_sources "$token" || rs=$?
    if [ "$rs" -eq 1 ]; then
        err "unknown source '${token}' — see 'pos media ytsync list'"
    fi
    if [ "$rs" -eq 2 ]; then
        err "ambiguous '${token}' — candidates: $(join_comma MATCHING_SLUGS)"
    fi
    remove_by_line "${SYNC_LINES[0]}"
}

# ── Interactive front door ──────────────────────────────────────────
menu_render() {
    {
        printf '════════════════════════════════════════════\n'
        printf '  ytSync — YouTube channel sync\n'
        printf '════════════════════════════════════════════\n'
        local lr suffix
        lr="$(last_run_overall)"
        suffix=""
        if [ -n "$lr" ]; then suffix=" · last run ${lr}"; fi
        printf '  1) Sync all channels now   (%s tracked%s)\n' "$(registry_count)" "$suffix"
        printf '  2) Add a channel or playlist\n'
        printf '  3) List channels\n'
        printf '  4) Remove a channel\n'
        printf '  0) Exit\n'
        printf '%s\n' '--------------------------------------------'
    } >&2
}

menu_remove_flow() {
    local -a lines=() dnames=()
    mapfile -t lines < <(registry_entries)
    if [ "${#lines[@]}" -eq 0 ]; then
        log "nothing tracked yet"
        return 0
    fi
    local line slug stype surl ssub stitle sts rest i pick ans target rs=0
    for line in "${lines[@]}"; do
        IFS=$'\x1f' read -r slug stype surl ssub stitle sts rest <<<"$line"
        dnames+=("${ssub##*/}")
    done
    for i in "${!dnames[@]}"; do
        printf '  %s) %s\n' "$((i + 1))" "${dnames[$i]}" >&2
    done
    pick=""
    if ! read -rp "Remove which channel? [1-${#dnames[@]}], 0=cancel " pick </dev/tty; then
        log "Cancelled — nothing changed"
        return 0
    fi
    case "$pick" in
        0 | "" | q | Q)
            log "Cancelled — nothing changed"
            return 0
            ;;
    esac
    if [[ ! "$pick" =~ ^[0-9]+$ ]] || [ "$pick" -lt 1 ] || [ "$pick" -gt "${#dnames[@]}" ]; then
        warn "invalid choice: $pick"
        return 0
    fi
    target="${dnames[$((pick - 1))]}"
    ans=""
    if ! read -rp "Stop tracking '${target}'? Files stay in ~/Videos. [y/N]: " ans </dev/tty; then
        log "Cancelled — nothing changed"
        return 0
    fi
    case "$ans" in
        y | Y | yes | YES)
            resolve_sources "$target" || rs=$?
            if [ "$rs" -ne 0 ]; then
                warn "could not uniquely resolve '${target}'"
                return 0
            fi
            remove_by_line "${SYNC_LINES[0]}"
            ;;
        *)
            log "Cancelled — nothing changed"
            ;;
    esac
    return 0
}

interactive_empty_state() {   # zero sources → straight to the URL prompt
    while :; do
        if ! ask_url_interactive; then
            return 0
        fi
        finish_add "$(classify_url "$ASKED_URL")" "$ASKED_URL" confirm
    done
}

interactive_menu() {
    if ! require_tty; then exit 0; fi
    if [ "$(registry_count)" -eq 0 ]; then
        interactive_empty_state
        return 0
    fi
    local choice rs=0
    while :; do
        menu_render
        choice=""
        if ! read -rp "Choose: " choice </dev/tty; then
            return 0
        fi
        case "$choice" in
            1)
                reset_globals
                resolve_sources "" || rs=$?
                if [ "$rs" -eq 0 ]; then
                    run_sync_set || true
                fi
                rs=0
                ;;
            2)
                reset_globals
                if ask_url_interactive; then
                    finish_add "$(classify_url "$ASKED_URL")" "$ASKED_URL" confirm
                fi
                ;;
            3) cmd_list ;;
            4) menu_remove_flow ;;
            0 | q | Q) return 0 ;;
            *) : ;;   # unknown choice → redraw
        esac
    done
}

# ── Argument dispatch ───────────────────────────────────────────────
VERB=""
TARGET=""
while [ "$#" -gt 0 ]; do
    case "$1" in
        --dry-run) DRY_RUN=1 ;;
        -h | --help) usage ;;
        --*) err "unknown option: $1 (see 'pos media ytsync --help')" ;;
        *)
            if [ -z "$VERB" ]; then
                VERB="$1"
            elif [ -z "$TARGET" ]; then
                TARGET="$1"
            else
                err "too many arguments: $* (see 'pos media ytsync --help')"
            fi
            ;;
    esac
    shift
done

if [ -z "$VERB" ]; then
    if [ "$DRY_RUN" -eq 1 ]; then
        printf "[!] use 'add --dry-run' or 'sync --dry-run'\n" >&2
        exit 1
    fi
    interactive_menu
    exit 0
fi

case "$VERB" in
    add) cmd_add "$TARGET" ;;
    sync) cmd_sync "$TARGET" ;;
    list) cmd_list ;;
    remove) cmd_remove "$TARGET" ;;
    *) err "unknown command: $VERB (see 'pos media ytsync --help')" ;;
esac
