#!/bin/bash
# ==============================================================================================
# ================================= Play State Sync ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Syncs watched/played state and resume positions across all configured Emby
# and Jellyfin servers. Newest timestamp wins — no data is ever lost.
#
# Users are matched by name (case-insensitive). If a user exists on some servers
# but not others, those servers are skipped for that user — no errors, no partial
# syncs from unrelated accounts.
#
# Items are matched by external provider IDs:
#   Movies   → IMDb ID, then TMDB ID
#   Episodes → TVDB ID + season + episode number
#   Audio    → MusicBrainz Track ID
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# For each matched item across ≥2 servers:
#   1. Compare LastPlayedDate across all servers that have a play record.
#   2. The server with the newest LastPlayedDate is authoritative.
#   3. Push that server's state (Played, PlayCount, LastPlayedDate,
#      PlaybackPositionTicks) to every other server.
#   4. Servers with no record for that item also receive the state.
#
# Resume positions (partial plays, not marked Played):
#   Synced by comparing PlaybackPositionTicks when LastPlayedDate is absent.
#   The higher tick count wins.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Newest Timestamp Wins
#   No merge logic, no conflict resolution — the server with the most recent
#   LastPlayedDate is simply authoritative. Simple rules produce predictable
#   outcomes users can reason about.
#
# No Data Loss
#   The sync only pushes state forward — it never clears a Played flag or
#   resets a resume position to zero. A watch record on any server always
#   propagates outward, never disappears.
#
# Provider ID Matching
#   Items are matched by external IDs (IMDb, TVDB, MusicBrainz), not by
#   title or file path. This makes matching robust across library reorganisation,
#   renames, and multi-server path differences.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
#   The probe fingerprint is written under STATE_DIR, which is not user-writable.
#   Without root the fingerprint silently fails to persist and the change probe
#   never suppresses anything.
#
# jq Dependency Check
#   Exits if jq is missing. All API response parsing and the epoch comparisons
#   depend on it — without jq every comparison would silently evaluate empty.
#
# PLAY_SYNC_ENABLED Gate
#   Exits cleanly when disabled; no partial runs.
#
# PLAY_SYNC_REMOTE Gate
#   When false, only this host's own servers are synced. Remote hosts are skipped
#   before any network call is attempted.
#
# Partnership Gate
#   Remote hosts are skipped when PARTNERSHIP_ENABLED=false. Local Emby↔Jellyfin
#   sync still runs — a dormant partnership does not disable local work.
#
# Tailscale Resolution Guard
#   A remote host whose Tailscale IP cannot be resolved is skipped rather than
#   contacted at its literal localhost URL, which would otherwise point the sync
#   at this host's own server and cross-contaminate state.
#
# Placeholder Credential Guard
#   Servers whose API key is empty or still a placeholder are dropped from the
#   list before any request is made.
#
# Per-Server Reachability
#   Unreachable servers are skipped individually; one offline server does not
#   abort the entire sync.
#
# User Match Required
#   A user missing from a server is skipped for that server. State is never
#   written to an unrelated account that happens to exist there.
#
# Forward-Only Writes
#   The sync only pushes state forward — it never clears a Played flag or resets
#   a resume position. The worst outcome of a bad comparison is a no-op, not
#   erased watch history.
#
# Lock Acquisition
#   acquire_lock prevents concurrent runs racing on the same items during the
#   30 minute critical window. --wait switches from skip to wait for manual runs.
#
# Probe Staleness Ceiling
#   PLAY_SYNC_PROBE_MAX_AGE_HOURS forces a full comparison regardless of the
#   hash. Fetches happen every run either way, so the probe can only skip
#   per-item processing — it can never cause a change to be missed outright.
#
# Dry Run Support
#   --dry-run performs all comparisons and writes no state.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
#   HOST*_TRANSCODE_SERVERS  "Name|URL|APIKey|type" entries per host (emby/jellyfin)
#                            Read directly for every HOST[0-9]+ defined — this script
#                            deliberately does NOT call detect_hosts(), because it needs
#                            every host's servers, not just this one's. Self is identified
#                            by comparing HOST* values against hostname -s.
#                            Remote host URLs have localhost rewritten to their Tailscale IP.
#
# master.conf
#
#   PLAY_SYNC_ENABLED        Master toggle (default: true)
#   PLAY_SYNC_REMOTE         Sync across all hosts via Tailscale (default: true)
#                            false = local servers only (this host's Emby + Jellyfin)
#   PLAY_SYNC_TYPES          Comma-separated item types to sync (default: Movie,Episode —
#                            Audio excluded, music library too large; favorites handled separately)
#   PLAY_SYNC_PROBE          Skip all per-item processing when no play/resume/favorite
#                            state changed since the last successful run (default: true).
#                            The raw API responses are hashed and compared against the
#                            fingerprint stored in STATE_DIR — fetches still happen every
#                            run, so nothing can be missed.
#   PLAY_SYNC_PROBE_MAX_AGE_HOURS  Force a full comparison when the stored fingerprint is
#                            older than this many hours regardless of the hash (default: 24)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# play_state_sync.sh
#     Sync all matched users across all configured servers.
#
# play_state_sync.sh --dry-run
#     Show what would be synced without writing any state.
#
# play_state_sync.sh --status
#     Show configured servers, reachability, and user counts.
#
# play_state_sync.sh --full
#     Bypass the change probe — always run the full comparison.
#
# play_state_sync.sh --wait
#     Wait for an in-progress run to finish instead of exiting. For manual runs
#     that would otherwise be skipped by the every-30-minute scheduled pass.
#
# play_state_sync.sh --log
#     Verbose output — show each item comparison.
#
# ==============================================================================================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"

# ── Handle --full and --wait before parse_args ────────────────────────────────
FULL_SYNC=false
LOCK_MODE="strict"
_FILTERED=()
for _a in "$@"; do
    if [[ "$_a" == "--full" ]]; then
        FULL_SYNC=true
    elif [[ "$_a" == "--wait" ]]; then
        LOCK_MODE="wait"
    else
        _FILTERED+=("$_a")
    fi
done
parse_args "${_FILTERED[@]}"

# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
# The probe fingerprint lives under STATE_DIR — without root it silently fails to persist
# and the change probe can never suppress a run.
if [[ "$EUID" -ne 0 ]]; then
    error "Must be run as root"
    exit 1
fi

[[ "${PLAY_SYNC_ENABLED:-true}" != "true" ]] && echo "Play state sync disabled" && exit 0

SYNC_TYPES="${PLAY_SYNC_TYPES:-Movie,Episode}"
FAV_TYPES="${PLAY_SYNC_FAV_TYPES:-MusicArtist,MusicAlbum,Movie,Series}"
log "$ICON_GEAR Config: types=${SYNC_TYPES} favs=${FAV_TYPES} remote=${PLAY_SYNC_REMOTE:-true} probe=${PLAY_SYNC_PROBE:-true}"

command -v jq >/dev/null 2>&1 || { error "jq is required but not installed"; exit 1; }

acquire_lock "$LOCK_MODE"

# ── Build server list across ALL hosts ────────────────────────────────────────
# All HOST*_TRANSCODE_SERVERS arrays are loaded into env by load_config.sh.
# For remote hosts, localhost in the URL is rewritten to their Tailscale IP.
declare -a SRV_NAME SRV_URL SRV_KEY SRV_TYPE
_srv_count=0
_my_hostname=$(hostname -s)

_add_server() {
    local name="$1" url="$2" key="$3" type="$4"
    [[ -z "$url" || -z "$key" ]] && return
    [[ "$key" == "YOUR_API_KEY"* || "$key" == "placeholder"* ]] && return
    SRV_NAME[$_srv_count]="$name"
    SRV_URL[$_srv_count]="$url"
    SRV_KEY[$_srv_count]="$key"
    SRV_TYPE[$_srv_count]="$type"
    (( _srv_count++ ))
}

for _varname in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
    _num="${_varname//[^0-9]/}"
    _host_hostname="${!_varname}"
    [[ -z "$_host_hostname" ]] && continue

    _is_me=false
    [[ "${_host_hostname,,}" == "${_my_hostname,,}" ]] && _is_me=true

    # Skip remote hosts when PLAY_SYNC_REMOTE=false
    if [[ "$_is_me" == false && "${PLAY_SYNC_REMOTE:-true}" != "true" ]]; then
        log "$_host_hostname — remote sync disabled (PLAY_SYNC_REMOTE=false), skipping"
        continue
    fi

    # Skip remote hosts when partnership is inactive
    if [[ "$_is_me" == false && "${PARTNERSHIP_ENABLED:-false}" != "true" ]]; then
        log "$_host_hostname — partnership inactive (PARTNERSHIP_ENABLED=false), skipping"
        continue
    fi

    # Resolve Tailscale IP for remote hosts
    _ts_ip=""
    if [[ "$_is_me" == false ]]; then
        _ts_ip=$(resolve_tailscale_ip "$_host_hostname")
        if [[ -z "$_ts_ip" ]]; then
            log "$_host_hostname — Tailscale IP not found, skipping"
            continue
        fi
    fi

    # Load this host's TRANSCODE_SERVERS array
    _srv_arr_name="HOST${_num}_TRANSCODE_SERVERS"
    eval "_host_entries=(\"\${${_srv_arr_name}[@]}\")"
    [[ "${#_host_entries[@]}" -eq 0 ]] && continue

    for _entry in "${_host_entries[@]}"; do
        IFS='|' read -r _name _url _key _type <<< "$_entry"
        [[ "$_type" == "emby" || "$_type" == "jellyfin" ]] || continue
        # For remote hosts rewrite localhost/127.0.0.1 → Tailscale IP
        if [[ "$_is_me" == false ]]; then
            _url="${_url//localhost/$_ts_ip}"
            _url="${_url//127.0.0.1/$_ts_ip}"
        fi
        _add_server "${_name} (${_host_hostname})" "$_url" "$_key" "$_type"
    done
done

if [[ "$_srv_count" -lt 2 ]]; then
    error "Need at least 2 media servers configured — found $_srv_count"
    exit 1
fi

# ==============================================================================================
# ━━━ API Helpers ━━━
# ==============================================================================================
_api_get() {
    local url="$1" key="$2" endpoint="$3"
    curl -sf --max-time 30 \
        -H "X-Emby-Token: $key" \
        "${url%/}/${endpoint}" 2>/dev/null
}

_api_post() {
    local url="$1" key="$2" endpoint="$3" data="${4:-}"
    if [[ -n "$data" ]]; then
        curl -sf --max-time 30 -s -o /dev/null -w "%{http_code}" -X POST \
            -H "X-Emby-Token: $key" \
            -H "Content-Type: application/json" \
            -d "$data" \
            "${url%/}/${endpoint}" 2>/dev/null
    else
        curl -sf --max-time 30 -s -o /dev/null -w "%{http_code}" -X POST \
            -H "X-Emby-Token: $key" \
            "${url%/}/${endpoint}" 2>/dev/null
    fi
}

# Ticks → seconds (1 tick = 100ns, 10_000_000 ticks = 1s)
_ticks_to_sec() {
    echo $(( ${1:-0} / 10000000 ))
}

# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
    echo ""
    echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC STATUS ━━━━━"
    echo "$ICON_GEAR  Remote sync: ${PLAY_SYNC_REMOTE:-true}"
    echo "$ICON_GEAR  Change probe: ${PLAY_SYNC_PROBE:-true}"
    echo "$ICON_GEAR  Item types:  $SYNC_TYPES"
    echo ""
    for i in $(seq 0 $(( _srv_count - 1 ))); do
        echo "$ICON_HOST  [${SRV_TYPE[$i]}] ${SRV_NAME[$i]}  (${SRV_URL[$i]})"
        _users=$(_api_get "${SRV_URL[$i]}" "${SRV_KEY[$i]}" "Users" 2>/dev/null | jq -r '.[].Name' 2>/dev/null | wc -l)
        if [[ "$_users" -gt 0 ]]; then
            echo "    $ICON_DONE Reachable — $_users user(s)"
            _api_get "${SRV_URL[$i]}" "${SRV_KEY[$i]}" "Users" 2>/dev/null \
                | jq -r '.[].Name' 2>/dev/null | while read -r n; do echo "      · $n"; done
        else
            echo "    $ICON_ERROR Unreachable or no users"
        fi
    done
    echo "━━━━━━━━━━━━━━━━━━━━━━━"
    exit 0
fi

# ==============================================================================================
# ━━━ Main Sync ━━━
# ==============================================================================================
echo "━━━ $ICON_SYNC Play State Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
[[ "$DRY_RUN" == true ]]  && warn "DRY RUN — no state will be written"
[[ "$FULL_SYNC" == true ]] && log "Full sync mode — change probe bypassed"

START=$(date +%s)
TOTAL_SYNCED=0
TOTAL_SKIPPED=0
TOTAL_ERRORS=0

# ── Step 1: Fetch users from each server ─────────────────────────────────────
declare -A SRV_USERS   # idx → JSON array string of users

log "Fetching users..."
for i in $(seq 0 $(( _srv_count - 1 ))); do
    _resp=$(_api_get "${SRV_URL[$i]}" "${SRV_KEY[$i]}" "Users")
    if [[ -z "$_resp" ]]; then
        warn "${SRV_NAME[$i]} — unreachable, skipping"
        SRV_USERS[$i]=""
        continue
    fi
    SRV_USERS[$i]="$_resp"
    _count=$(echo "$_resp" | jq 'length' 2>/dev/null || echo 0)
    log "${SRV_NAME[$i]} — $_count user(s)"
done

# ── Step 2: Build cross-server user map ──────────────────────────────────────
# lowercase_name → "server_idx:user_id server_idx:user_id ..."
declare -A USER_MAP

for i in $(seq 0 $(( _srv_count - 1 ))); do
    [[ -z "${SRV_USERS[$i]}" ]] && continue
    while IFS=$'\t' read -r uid uname; do
        [[ -z "$uid" || -z "$uname" ]] && continue
        lname="${uname,,}"
        if [[ -n "${USER_MAP[$lname]}" ]]; then
            USER_MAP[$lname]+=" ${i}:${uid}"
        else
            USER_MAP[$lname]="${i}:${uid}"
        fi
    done < <(echo "${SRV_USERS[$i]}" | jq -r '.[] | [.Id, .Name] | @tsv' 2>/dev/null)
done

# ── Step 2.3: Fetch play/resume/favorite state for every matched user ────────
# Raw responses are cached for the sync passes below and hashed for the change
# probe. Fetching costs seconds — the per-item comparison is what costs minutes,
# so it only runs when a response actually changed since the last successful run.
declare -A RESP_STATE   # "lname|si" → deduplicated played+resumable items JSON
declare -A RESP_FAV     # "lname|si|ftype" → favorites response JSON

IFS=',' read -ra _fav_type_list <<< "$FAV_TYPES"

for lname in "${!USER_MAP[@]}"; do
    read -ra _pairs <<< "${USER_MAP[$lname]}"
    [[ "${#_pairs[@]}" -lt 2 ]] && continue

    for _pair in "${_pairs[@]}"; do
        IFS=':' read -r _si _uid <<< "$_pair"

        # All played items — no limit, covers both date-stamped and batch-marked (null date) entries
        _endpoint="Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending"
        _resp=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" "$_endpoint")
        if [[ -z "$_resp" ]]; then
            warn "  ${SRV_NAME[$_si]} — failed to fetch items for $lname"
            RESP_STATE["$lname|$_si"]=""
        else
            # Resume positions (not yet marked played)
            _endpoint2="Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&SortBy=DatePlayed&SortOrder=Descending&Filters=IsResumable"
            _resp2=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" "$_endpoint2")

            # Combine and deduplicate by Id
            [[ -z "$_resp2" ]] && _resp2='{"Items":[]}'
            RESP_STATE["$lname|$_si"]=$(printf '%s\n%s' "$_resp" "$_resp2" | jq -s \
                '[.[0].Items // [], .[1].Items // []] | add // [] | unique_by(.Id)' 2>/dev/null)
        fi

        for _ftype in "${_fav_type_list[@]}"; do
            RESP_FAV["$lname|$_si|$_ftype"]=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
                "Users/${_uid}/Items?Recursive=true&IncludeItemTypes=${_ftype}&Filters=IsFavorite&Fields=ProviderIds")
        done
    done
done

# ── Step 2.4: Change probe — skip the comparison when nothing changed ────────
# Failed fetches hash as empty strings, so reachability transitions also read
# as changes and trigger a full pass once the server comes back.
PROBE_FILE="$STATE_DIR/play_state_sync_probe"
CUR_HASH=$(
    {
        while IFS= read -r _k; do
            printf '%s:' "$_k"; printf '%s' "${RESP_STATE[$_k]}" | md5sum
        done < <(printf '%s\n' "${!RESP_STATE[@]}" | sort)
        while IFS= read -r _k; do
            printf '%s:' "$_k"; printf '%s' "${RESP_FAV[$_k]}" | md5sum
        done < <(printf '%s\n' "${!RESP_FAV[@]}" | sort)
    } | md5sum | awk '{print $1}'
)

if [[ "${PLAY_SYNC_PROBE:-true}" == "true" && "$FULL_SYNC" == false && "$DRY_RUN" == false && -f "$PROBE_FILE" ]]; then
    _prev_hash=$(sed -n '1p' "$PROBE_FILE" 2>/dev/null)
    _prev_epoch=$(sed -n '2p' "$PROBE_FILE" 2>/dev/null)
    [[ "$_prev_epoch" =~ ^[0-9]+$ ]] || _prev_epoch=0
    _probe_max_age=$(( ${PLAY_SYNC_PROBE_MAX_AGE_HOURS:-24} * 3600 ))
    if [[ "$CUR_HASH" == "$_prev_hash" ]] && (( $(date +%s) - ${_prev_epoch:-0} < _probe_max_age )); then
        END=$(date +%s)
        echo ""
        echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC SUMMARY ━━━━━"
        echo "$ICON_TIME  Duration:  $(format_duration $(( END - START )))"
        echo "$ICON_DONE  No play/resume/favorite changes since last sync — comparison skipped"
        success "Done ✅"
        exit 0
    fi
fi

# ── Step 2.5: Pre-build provider ID → item ID lookup map ─────────────────────
# AnyProviderIdEquals is broken in Jellyfin 10.11+ (ignores the filter entirely).
# Pre-fetching all items' provider IDs once and using a local map avoids the broken
# per-item API call and is faster overall.
declare -A PROV_LOOKUP   # "si|tvdb.{id}" | "si|imdb.{id}" | "si|mb.{id}" → item_id

# Jellyfin's mixed-type query (Movie,Episode,Audio) changes sort order unpredictably,
# pushing items to positions far beyond the first pages. Query each type separately so
# each list sorts within its own type and items appear where expected.
_PROV_PAGE=2000
# Combine play state types + fav types for the map build (deduplicated)
declare -A _prov_seen_dedup
_prov_types=()
IFS=',' read -ra _prov_combined <<< "${SYNC_TYPES},${FAV_TYPES}"
for _t in "${_prov_combined[@]}"; do
    [[ -n "${_prov_seen_dedup[$_t]:-}" ]] && continue
    _prov_seen_dedup[$_t]=1; _prov_types+=("$_t")
done
unset _prov_seen_dedup _prov_combined

for _psi in $(seq 0 $(( _srv_count - 1 ))); do
    [[ -z "${SRV_URL[$_psi]}" ]] && continue
    log "Building provider ID map for ${SRV_NAME[$_psi]}..."
    for _prov_type in "${_prov_types[@]}"; do
        _prov_start=0
        _prov_total=-1
        while true; do
            _page=$(curl -sf --max-time 60 \
                -H "X-Emby-Token: ${SRV_KEY[$_psi]}" \
                "${SRV_URL[$_psi]%/}/Items?Recursive=true&IncludeItemTypes=${_prov_type}&Fields=ProviderIds&Limit=${_PROV_PAGE}&StartIndex=${_prov_start}" 2>/dev/null)
            [[ -z "$_page" ]] && break
            [[ "$_prov_total" -lt 0 ]] && _prov_total=$(echo "$_page" | jq '.TotalRecordCount // 0' 2>/dev/null || echo 0)
            _prov_count=$(echo "$_page" | jq '.Items | length' 2>/dev/null || echo 0)
            [[ "$_prov_count" -eq 0 ]] && break
            while IFS=$'\t' read -r _pid _ptype _ptvdb _pimdb _ptmdb _pmbtrack _pmbartist _pmbalbum; do
                [[ "$_pimdb"     != "null" && -n "$_pimdb"     ]] && PROV_LOOKUP["${_psi}|imdb.${_pimdb}"]="$_pid"
                [[ "$_ptmdb"     != "null" && -n "$_ptmdb"     ]] && PROV_LOOKUP["${_psi}|tmdb.${_ptmdb}"]="$_pid"
                [[ "$_pmbtrack"  != "null" && -n "$_pmbtrack"  ]] && PROV_LOOKUP["${_psi}|mb.${_pmbtrack}"]="$_pid"
                [[ "$_pmbartist" != "null" && -n "$_pmbartist" ]] && PROV_LOOKUP["${_psi}|mb.artist.${_pmbartist}"]="$_pid"
                [[ "$_pmbalbum"  != "null" && -n "$_pmbalbum"  ]] && PROV_LOOKUP["${_psi}|mb.album.${_pmbalbum}"]="$_pid"
                # tvdb: namespace by type so Series and Episode IDs don't collide
                if [[ "$_ptvdb" != "null" && -n "$_ptvdb" ]]; then
                    case "$_ptype" in
                        Series)  PROV_LOOKUP["${_psi}|tvdb.series.${_ptvdb}"]="$_pid" ;;
                        Episode) PROV_LOOKUP["${_psi}|tvdb.${_ptvdb}"]="$_pid" ;;
                        *)       PROV_LOOKUP["${_psi}|tvdb.${_ptvdb}"]="$_pid" ;;
                    esac
                fi
            done < <(echo "$_page" | jq -r '.Items[] | [
                .Id,
                .Type,
                (.ProviderIds.Tvdb // "null"),
                (.ProviderIds.Imdb // "null"),
                (.ProviderIds.Tmdb // "null"),
                (.ProviderIds.MusicBrainzTrackId // "null"),
                (.ProviderIds.MusicBrainzArtistId // "null"),
                (.ProviderIds.MusicBrainzAlbumId // "null")
            ] | @tsv' 2>/dev/null)
            _prov_start=$(( _prov_start + _prov_count ))
            [[ "$_prov_total" -gt 0 && "$_prov_start" -ge "$_prov_total" ]] && break
        done
    done
done

# ── Step 3: Sync per matched user ────────────────────────────────────────────
for lname in "${!USER_MAP[@]}"; do
    read -ra _pairs <<< "${USER_MAP[$lname]}"

    # Skip users only on one server
    [[ "${#_pairs[@]}" -lt 2 ]] && log "  $lname — only on 1 server, skipping" && continue

    echo ""
    echo "── User: $lname (${#_pairs[@]} server(s)) ──"

    # Build per-server user context
    declare -A U_IDX U_UID
    for _pair in "${_pairs[@]}"; do
        IFS=':' read -r _si _ui <<< "$_pair"
        U_IDX["$_si"]="$_si"
        U_UID["$_si"]="$_ui"
    done

    # ── Fetch played items from each server for this user ────────────────────
    # Key: provider_id_string → sorted list of (epoch, srv_idx, item_id, play_count, ticks, played)
    declare -A ITEM_MAP   # provider_key → JSON per-server data

    for _si in "${!U_IDX[@]}"; do
        _combined="${RESP_STATE[$lname|$_si]:-}"
        [[ -z "$_combined" ]] && continue

        _count=$(echo "$_combined" | jq 'length' 2>/dev/null || echo 0)
        log "  ${SRV_NAME[$_si]} — $_count item(s) with state for $lname"

        # Build item lookup by provider key
        while IFS=$'\t' read -r iid itype season ep imdb tmdb tvdb mbtrack played ticks lplayed pcount epoch; do
            # Build canonical provider key
            _pkey=""
            case "$itype" in
                Movie)
                    [[ "$imdb" != "null" && -n "$imdb" ]] && _pkey="imdb:${imdb}"
                    [[ -z "$_pkey" && "$tmdb" != "null" && -n "$tmdb" ]] && _pkey="tmdb:movie:${tmdb}"
                    ;;
                Episode)
                    [[ "$tvdb" != "null" && -n "$tvdb" && "$season" != "null" && "$ep" != "null" ]] && \
                        _pkey="tvdb:ep:${tvdb}:s${season}e${ep}"
                    ;;
                Audio)
                    [[ "$mbtrack" != "null" && -n "$mbtrack" ]] && _pkey="mb:track:${mbtrack}"
                    ;;
            esac
            [[ -z "$_pkey" ]] && continue

            _epoch="${epoch:-0}"
            _entry="${_si}|${iid}|${played}|${pcount}|${ticks}|${_epoch}|${lplayed}"

            if [[ -n "${ITEM_MAP[$_pkey]}" ]]; then
                ITEM_MAP[$_pkey]+=$'\n'"$_entry"
            else
                ITEM_MAP[$_pkey]="$_entry"
            fi

        done < <(echo "$_combined" | jq -r '.[] | [
            .Id,
            .Type,
            (.ParentIndexNumber // "null" | tostring),
            (.IndexNumber // "null" | tostring),
            (.ProviderIds.Imdb // "null"),
            (.ProviderIds.Tmdb // "null"),
            (.ProviderIds.Tvdb // "null"),
            (.ProviderIds.MusicBrainzTrackId // "null"),
            (.UserData.Played // false | tostring),
            (.UserData.PlaybackPositionTicks // 0 | tostring),
            (.UserData.LastPlayedDate // "null"),
            (.UserData.PlayCount // 0 | tostring),
            ((.UserData.LastPlayedDate // null) | if . == null then "0"
             else (((sub("\\.[0-9]+"; "") | sub("[+-][0-9]{2}:?[0-9]{2}$"; "Z")
                     | if endswith("Z") then . else . + "Z" end
                     | fromdateiso8601)? // 0) | tostring) end)
        ] | @tsv' 2>/dev/null)
    done

    # ── Compare and sync ─────────────────────────────────────────────────────
    for _pkey in "${!ITEM_MAP[@]}"; do
        # Collect all server entries for this item
        declare -A E_EPOCH E_PLAYED E_PCOUNT E_TICKS E_IID E_LPLAYED E_SIDX
        _has_entries=false

        while IFS='|' read -r _si _iid _played _pcount _ticks _epoch _lplayed; do
            [[ -z "$_si" ]] && continue
            E_SIDX[$_si]="$_si"
            E_IID[$_si]="$_iid"
            E_PLAYED[$_si]="$_played"
            E_PCOUNT[$_si]="$_pcount"
            E_TICKS[$_si]="$_ticks"
            E_EPOCH[$_si]="$_epoch"
            E_LPLAYED[$_si]="$_lplayed"
            _has_entries=true
        done <<< "${ITEM_MAP[$_pkey]}"

        [[ "$_has_entries" == false ]] && continue

        # Find the authoritative server: newest LastPlayedDate epoch
        # Tie-break: higher PlayCount, then higher Ticks, then Played=true
        # Init at -1 so servers with epoch=0 (batch-marks with null LastPlayedDate) can win
        _auth_si=""
        _auth_epoch=-1
        _auth_pcount=-1
        _auth_ticks=-1
        _auth_pf="false"

        for _si in "${!E_SIDX[@]}"; do
            _e="${E_EPOCH[$_si]:-0}"
            _pc="${E_PCOUNT[$_si]:-0}"
            _tk="${E_TICKS[$_si]:-0}"
            _pf="${E_PLAYED[$_si]:-false}"
            # Played=true is the primary key — a played server always beats a non-played server
            # regardless of epoch. A resumable item with a newer LastPlayedDate must not become
            # authority over a played item, as pushing resume ticks to a played server resets
            # the played status on some Emby/Jellyfin versions.
            if ( [[ "$_pf" == "true" ]] && [[ "$_auth_pf" != "true" ]] ) || \
               ( [[ "$_pf" == "$_auth_pf" ]] && [[ "$_e" -gt "$_auth_epoch" ]] ) || \
               ( [[ "$_pf" == "$_auth_pf" ]] && [[ "$_e" -eq "$_auth_epoch" ]] && [[ "$_pc" -gt "$_auth_pcount" ]] ) || \
               ( [[ "$_pf" == "$_auth_pf" ]] && [[ "$_e" -eq "$_auth_epoch" ]] && [[ "$_pc" -eq "$_auth_pcount" ]] && [[ "$_tk" -gt "$_auth_ticks" ]] ); then
                _auth_si="$_si"
                _auth_epoch="$_e"
                _auth_pcount="$_pc"
                _auth_ticks="$_tk"
                _auth_pf="$_pf"
            fi
        done

        [[ -z "$_auth_si" ]] && continue

        _auth_played="${E_PLAYED[$_auth_si]}"
        _auth_lplayed="${E_LPLAYED[$_auth_si]}"
        _auth_pcount="${E_PCOUNT[$_auth_si]}"
        _auth_ticks="${E_TICKS[$_auth_si]}"

        # Push to servers with older state OR no state at all
        for _si in "${!U_IDX[@]}"; do
            [[ "$_si" == "$_auth_si" ]] && continue
            _their_epoch="${E_EPOCH[$_si]:-0}"
            _their_played="${E_PLAYED[$_si]:-false}"

            # Skip if they already have the same/newer state
            _skip=false
            if [[ "$_auth_played" == "true" ]]; then
                # Both servers already have this played — nothing to propagate regardless of dates.
                # Date-based comparison caused a ping-pong: syncing without a DatePlayed param lets
                # the target server stamp the current time, making it the new authority next cycle.
                [[ "$_their_played" == "true" ]] && _skip=true
            else
                # Resume only: skip if target already has same or more ticks.
                # Allow 5-second tolerance (50_000_000 ticks) — Emby may round tick values slightly
                # differently on read, causing an exact-match check to miss and re-sync every cycle.
                _tick_gap=$(( ${_auth_ticks:-0} - ${E_TICKS[$_si]:-0} ))
                [[ "$_tick_gap" -le 50000000 && "${E_TICKS[$_si]:-0}" -gt 0 && "$_their_played" == "false" ]] && _skip=true
            fi
            if [[ "$_skip" == true ]]; then
                log "  SKIP  $_pkey → ${SRV_NAME[$_si]} already up to date"
                (( TOTAL_SKIPPED++ ))
                continue
            fi

            _uid="${U_UID[$_si]}"
            _iid="${E_IID[$_si]:-}"   # might not exist on this server yet

            # Find item ID on target server by provider key if not in our map
            if [[ -z "$_iid" ]]; then
                _ptype="${_pkey%%:*}"
                case "$_ptype" in
                    imdb)
                        _pval="${_pkey#imdb:}"
                        _iid="${PROV_LOOKUP[${_si}|imdb.${_pval}]:-}"
                        ;;
                    tmdb)
                        _pval="${_pkey#tmdb:movie:}"
                        _iid="${PROV_LOOKUP[${_si}|tmdb.${_pval}]:-}"
                        ;;
                    tvdb)
                        # pkey format: tvdb:ep:{tvdb_id}:s{season}e{ep}
                        _tvdb_num="${_pkey#tvdb:ep:}"; _tvdb_num="${_tvdb_num%%:*}"
                        _iid="${PROV_LOOKUP[${_si}|tvdb.${_tvdb_num}]:-}"
                        ;;
                    mb)
                        _pval="${_pkey#mb:track:}"
                        _iid="${PROV_LOOKUP[${_si}|mb.${_pval}]:-}"
                        ;;
                esac
                [[ -z "$_iid" ]] && log "  SKIP  $_pkey → ${SRV_NAME[$_si]} item not found on server" && continue
            fi

            log "  SYNC  $_pkey → ${SRV_NAME[$_si]} (auth: ${SRV_NAME[$_auth_si]}, epoch: $_auth_epoch)"

            if [[ "$DRY_RUN" == true ]]; then
                echo "  DRY RUN: would sync $_pkey → ${SRV_NAME[$_si]} user=$lname played=$_auth_played date=$_auth_lplayed"
                (( TOTAL_SYNCED++ ))
                continue
            fi

            # Write state to target server
            if [[ "$_auth_played" == "true" ]]; then
                # Mark as played with date
                _date_param=""
                if [[ "$_auth_lplayed" != "null" && -n "$_auth_lplayed" ]]; then
                    # PlayedItems expects DatePlayed in yyyyMMddHHmmss (no separators, no timezone)
                    _lp="${_auth_lplayed%.*}"
                    _lp="${_lp%Z}"
                    _date_param="?DatePlayed=${_lp//[-T:]/}"
                fi
                _http=$(_api_post "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
                    "Users/${_uid}/PlayedItems/${_iid}${_date_param}")
                if [[ "$_http" == "200" || "$_http" == "201" ]]; then
                    success "  ✓  $_pkey → ${SRV_NAME[$_si]} marked played"
                    (( TOTAL_SYNCED++ ))
                else
                    warn "  ✗  $_pkey → ${SRV_NAME[$_si]} failed (HTTP ${_http:-err})"
                    (( TOTAL_ERRORS++ ))
                fi
            else
                # Sync resume position only — never push ticks to a server that already
                # has this item marked played. Writing ticks via UserData can reset Played=false.
                if [[ "${E_PLAYED[$_si]:-false}" == "true" ]]; then
                    log "  SKIP  $_pkey → ${SRV_NAME[$_si]} already played, not overwriting with resume ticks"
                    (( TOTAL_SKIPPED++ ))
                    continue
                fi
                _ticks_int=$(( ${_auth_ticks:-0} ))
                if [[ "$_ticks_int" -gt 0 ]]; then
                    _payload="{\"PlaybackPositionTicks\":${_ticks_int}}"
                    _http=$(_api_post "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
                        "Users/${_uid}/Items/${_iid}/UserData" "$_payload")
                    if [[ "$_http" == "200" || "$_http" == "204" ]]; then
                        success "  ✓  $_pkey → ${SRV_NAME[$_si]} resume synced ($(_ticks_to_sec "$_ticks_int")s)"
                        (( TOTAL_SYNCED++ ))
                    else
                        warn "  ✗  $_pkey → ${SRV_NAME[$_si]} resume sync failed (HTTP ${_http:-err})"
                        (( TOTAL_ERRORS++ ))
                    fi
                fi
            fi
        done

        unset E_EPOCH E_PLAYED E_PCOUNT E_TICKS E_IID E_LPLAYED E_SIDX
    done

    unset ITEM_MAP U_IDX U_UID
done

# ==============================================================================================
# ━━━ Favorite Sync ━━━
# ==============================================================================================
# Union semantics — if favorited on any server, sync to all others. Never unmarks.
# Covers MusicArtist, MusicAlbum, Movie, Series (configured via PLAY_SYNC_FAV_TYPES).
FAV_TOTAL_SYNCED=0
FAV_TOTAL_SKIPPED=0
FAV_TOTAL_ERRORS=0

for lname in "${!USER_MAP[@]}"; do
    read -ra _pairs <<< "${USER_MAP[$lname]}"
    [[ "${#_pairs[@]}" -lt 2 ]] && continue

    declare -A UF_IDX UF_UID
    for _pair in "${_pairs[@]}"; do
        IFS=':' read -r _si _ui <<< "$_pair"
        UF_IDX["$_si"]="$_si"
        UF_UID["$_si"]="$_ui"
    done

    declare -A FAV_MAP   # provider_key → space-separated "si:iid" pairs

    for _si in "${!UF_IDX[@]}"; do
        _uid="${UF_UID[$_si]}"
        for _ftype in "${_fav_type_list[@]}"; do
            _resp="${RESP_FAV[$lname|$_si|$_ftype]:-}"
            [[ -z "$_resp" ]] && continue
            _fcount=$(echo "$_resp" | jq '.Items | length' 2>/dev/null || echo 0)
            [[ "$_fcount" -eq 0 ]] && continue
            log "  ${SRV_NAME[$_si]} — $_fcount ${_ftype} favorite(s) for $lname"

            while IFS=$'\t' read -r _iid _itype _pimdb _ptmdb _ptvdb _pmbartist _pmbalbum _pmbtrack; do
                _pkey=""
                case "$_itype" in
                    Movie)
                        [[ "$_pimdb" != "null" && -n "$_pimdb" ]] && _pkey="imdb:${_pimdb}"
                        [[ -z "$_pkey" && "$_ptmdb" != "null" && -n "$_ptmdb" ]] && _pkey="tmdb:movie:${_ptmdb}"
                        ;;
                    Series)
                        [[ "$_ptvdb" != "null" && -n "$_ptvdb" ]] && _pkey="tvdb:series:${_ptvdb}"
                        ;;
                    MusicArtist)
                        [[ "$_pmbartist" != "null" && -n "$_pmbartist" ]] && _pkey="mb:artist:${_pmbartist}"
                        ;;
                    MusicAlbum)
                        [[ "$_pmbalbum" != "null" && -n "$_pmbalbum" ]] && _pkey="mb:album:${_pmbalbum}"
                        ;;
                    Audio)
                        [[ "$_pmbtrack" != "null" && -n "$_pmbtrack" ]] && _pkey="mb:track:${_pmbtrack}"
                        ;;
                esac
                [[ -z "$_pkey" ]] && continue

                if [[ -n "${FAV_MAP[$_pkey]:-}" ]]; then
                    FAV_MAP[$_pkey]+=" ${_si}:${_iid}"
                else
                    FAV_MAP[$_pkey]="${_si}:${_iid}"
                fi
            done < <(echo "$_resp" | jq -r '.Items[] | [
                .Id, .Type,
                (.ProviderIds.Imdb // "null"),
                (.ProviderIds.Tmdb // "null"),
                (.ProviderIds.Tvdb // "null"),
                (.ProviderIds.MusicBrainzArtistId // "null"),
                (.ProviderIds.MusicBrainzAlbumId // "null"),
                (.ProviderIds.MusicBrainzTrackId // "null")
            ] | @tsv' 2>/dev/null)
        done
    done

    for _pkey in "${!FAV_MAP[@]}"; do
        declare -A _fhave _fiid
        for _entry in ${FAV_MAP[$_pkey]}; do
            IFS=':' read -r _si _iid <<< "$_entry"
            _fhave[$_si]=true
            _fiid[$_si]="$_iid"
        done

        for _si in "${!UF_IDX[@]}"; do
            [[ "${_fhave[$_si]:-false}" == "true" ]] && (( FAV_TOTAL_SKIPPED++ )) && continue
            _uid="${UF_UID[$_si]}"

            # Look up item ID on target server via PROV_LOOKUP
            _iid=""
            _ptype="${_pkey%%:*}"
            case "$_ptype" in
                imdb)  _iid="${PROV_LOOKUP[${_si}|imdb.${_pkey#imdb:}]:-}" ;;
                tmdb)  _iid="${PROV_LOOKUP[${_si}|tmdb.${_pkey#tmdb:movie:}]:-}" ;;
                tvdb)  _iid="${PROV_LOOKUP[${_si}|tvdb.series.${_pkey#tvdb:series:}]:-}" ;;
                mb)
                    _mbsub="${_pkey#mb:}"
                    case "${_mbsub%%:*}" in
                        artist) _iid="${PROV_LOOKUP[${_si}|mb.artist.${_mbsub#artist:}]:-}" ;;
                        album)  _iid="${PROV_LOOKUP[${_si}|mb.album.${_mbsub#album:}]:-}" ;;
                        track)  _iid="${PROV_LOOKUP[${_si}|mb.${_mbsub#track:}]:-}" ;;
                    esac
                    ;;
            esac

            if [[ -z "$_iid" ]]; then
                log "  SKIP  FAV $_pkey → ${SRV_NAME[$_si]} not in library"
                continue
            fi

            log "  FAV   $_pkey → ${SRV_NAME[$_si]} ($lname)"
            if [[ "$DRY_RUN" == false ]]; then
                _http=$(_api_post "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
                    "Users/${_uid}/FavoriteItems/${_iid}")
                if [[ "$_http" == "200" || "$_http" == "201" ]]; then
                    success "  ✓  FAV $_pkey → ${SRV_NAME[$_si]}"
                    (( FAV_TOTAL_SYNCED++ ))
                else
                    warn "  ✗  FAV $_pkey → ${SRV_NAME[$_si]} failed (HTTP ${_http:-err})"
                    (( FAV_TOTAL_ERRORS++ ))
                fi
            else
                echo "  DRY RUN: would favorite $_pkey → ${SRV_NAME[$_si]} user=$lname"
                (( FAV_TOTAL_SYNCED++ ))
            fi
        done

        unset _fhave _fiid
    done

    unset FAV_MAP UF_IDX UF_UID
done

# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC SUMMARY ━━━━━"
echo "$ICON_TIME  Duration:  $(format_duration $(( END - START )))"
echo "$ICON_DONE  Synced:    $TOTAL_SYNCED"
[[ "$TOTAL_SKIPPED" -gt 0 ]] && echo "$ICON_RUNNING  Skipped:   $TOTAL_SKIPPED (already current)"
[[ "$TOTAL_ERRORS"  -gt 0 ]] && echo "$ICON_ERROR  Errors:    $TOTAL_ERRORS"
echo ""
echo "  Favorites — synced: $FAV_TOTAL_SYNCED  skipped: $FAV_TOTAL_SKIPPED  errors: $FAV_TOTAL_ERRORS"

TOTAL_ALL_ERRORS=$(( TOTAL_ERRORS + FAV_TOTAL_ERRORS ))

# Fingerprint is the PRE-sync state — our own writes above changed the targets,
# so the next run does one more full pass and then settles into probe skips.
# Re-hashing post-sync instead would swallow plays that landed mid-run.
if [[ "$DRY_RUN" == false && "$TOTAL_ALL_ERRORS" -eq 0 ]]; then
    printf '%s\n%s\n' "$CUR_HASH" "$START" > "$PROBE_FILE" 2>/dev/null || true
fi

if [[ "$DRY_RUN" == true ]]; then
    warn "DRY RUN — no changes written"
    exit 0
elif [[ "$TOTAL_ALL_ERRORS" -eq 0 ]]; then
    success "Done ✅"
    exit 0
else
    warn "Done with $TOTAL_ALL_ERRORS error(s)"
    exit 1
fi
