@file:Suppress("TooManyFunctions") // Mirrors Player surface: ~16 methods is the API.

package com.fabledsword.minstrel.player

import android.os.Handler
import android.os.SystemClock
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import timber.log.Timber

/**
 * Integration point for UPnP transport parity. Wraps the local
 * ExoPlayer; every transport method either forwards (local route
 * active -- the default) or translates into AVTransport SOAP +
 * [RemotePlayerState] updates (UPnP route active).
 *
 * Created inside [MinstrelPlayerService]; runs on the service's main
 * looper. Network SOAP calls fire on [Dispatchers.IO]. While UPnP is
 * active, the MediaSession's reads of [Player.isPlaying] and
 * [Player.getCurrentPosition] pull from [RemotePlayerState]; the
 * wrapped ExoPlayer stays paused at the position it had when the
 * route was selected.
 *
 * Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
 * liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
 * (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
 * command (play/pause/seek/next) does NOT drop on its own: a locked phone's
 * WiFi power-save can stall a single command's socket I/O for a second or
 * two while the renderer is perfectly reachable, so commands retry on
 * transient IO failure and otherwise defer to the poll loop. The factory
 * wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
 * surface as a snackbar.
 *
 * Queue mode: OutputPickerController loads the full queue into Sonos's
 * native queue via ClearQueue + AddURIToQueue, then points the
 * transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
 * AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
 * advance natively. PollLoop keeps the local cursor aligned to the
 * track the renderer is actually playing, matched by track identity
 * (the id in GetPositionInfo's TrackURI) so it survives queue-reload
 * index drift -- see [syncLocalCursorToRemote].
 */
class MinstrelForwardingPlayer(
    private val delegate: Player,
    private val holder: ActiveUpnpHolder,
    private val remoteState: RemotePlayerState,
    private val castNetworkLock: CastNetworkLock,
    private val networkStatus: NetworkStatusController,
    private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    private val handler = Handler(delegate.applicationLooper)
    private var pollJob: Job? = null

    // Tracks consecutive non-PLAYING poll observations so a single transient
    // PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
    // flip the play/pause button. Manual pause still feels instant because it
    // bypasses the poll entirely via applyTransportPaused().
    @Volatile private var nonPlayingPollStreak = 0

    // Wall-clock of the most-recent within-track seek we issued to Sonos.
    // pollOnce uses this to suppress position overwrites for SEEK_ACK_WINDOW_MS
    // -- Sonos can take 1-2s to apply a Seek, and a poll landing inside that
    // window reports the *old* position. Without the lockout the scrubber
    // visibly jumps backwards immediately after a drag, then forwards again.
    @Volatile private var lastSeekIssuedAtMs: Long = 0L

    // Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
    // pollLoop's select{} races the delay against this channel so the next
    // pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
    // Used on activity resume (ProcessLifecycleOwner.ON_RESUME) so the UI
    // catches up to Sonos within RTT rather than the full poll cadence.
    // CONFLATED so repeated trySend's between polls don't queue up.
    private val pollTrigger = Channel<Unit>(Channel.CONFLATED)

    // External Player.Listener registry (separate from super.addListener which
    // forwards to the wrapped ExoPlayer). The wrapped player is paused with
    // no audio loaded while UPnP is active, so it never fires events for our
    // synthesized remote state -- the MediaSession's notification card and
    // lock-screen scrubber stay frozen on whatever state was last captured
    // before UPnP took over. We dual-register: super.addListener keeps the
    // listener attached to the delegate (so local-playback events still
    // reach it), AND we hold a ref here so we can directly invoke listener
    // callbacks on remote-state changes. The listener's read of isPlaying /
    // duration / position then routes through our overrides to remoteState.
    private val externalListeners = mutableListOf<Player.Listener>()

    @Volatile private var lastNotifiedIsPlaying: Boolean = false
    @Volatile private var lastNotifiedTrackIdx: Int = -1

    private val lifecycleObserver = object : DefaultLifecycleObserver {
        override fun onResume(owner: LifecycleOwner) {
            pollTrigger.trySend(Unit)
        }
    }

    init {
        scope.launch {
            holder.active.collect { active -> onActiveChanged(active) }
        }
        scope.launch {
            // When the phone's own network recovers, reconcile a held cast
            // session immediately instead of waiting up to POLL_INTERVAL_MS:
            // the renderer kept playing, so one round-trip re-confirms it and
            // resyncs the cursor/position. Only a transition *into* Healthy
            // while a route is active matters.
            var prev = networkStatus.state.value
            networkStatus.state.collect { next ->
                if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
                    pollTrigger.trySend(Unit)
                }
                prev = next
            }
        }
        // Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
        // addObserver requires it. The observer just trySend's to the channel.
        handler.post {
            ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
        }
    }

    private fun isRemote(): Boolean = holder.active.value != null

    /**
     * Returns true when selectUpnp has marked a UPnP route as the intended
     * target but loadQueueOnSonos hasn't yet wired ActiveUpnp. During this
     * window we drop transport commands silently -- they would hit Sonos's
     * stale state from a prior session and trigger restarts.
     */
    private fun isLoadingUpnp(): Boolean =
        holder.target.value != null && holder.active.value == null

    // ─── setMediaItems intercepts ──────────────────────────────────────
    // When PlayerController.setQueue replaces the queue while Sonos is the
    // active route, the wrapped delegate's queue gets the new items but
    // Sonos's native queue still holds the OLD tracks -- and the play()
    // that PlayerController fires immediately after setMediaItems would
    // resume the old Sonos queue (user reported on-device: "player view
    // updates but Sonos queue does not"). We clear active + set target
    // synchronously here so the next play() in the same IPC sequence
    // drops via isLoadingUpnp() = true; the OutputPickerController
    // observes the uiState.queue change and runs the resync (re-clears
    // Sonos's native queue + AddURIToQueue the new tracks + Play).

    override fun setMediaItems(mediaItems: List<MediaItem>) {
        super.setMediaItems(mediaItems)
        markPendingResyncIfRemote()
    }

    override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
        super.setMediaItems(mediaItems, resetPosition)
        markPendingResyncIfRemote()
    }

    override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
        super.setMediaItems(mediaItems, startIndex, startPositionMs)
        markPendingResyncIfRemote()
    }

    private fun markPendingResyncIfRemote() {
        val wasActive = holder.active.value ?: return
        Timber.w(
            "setMediaItems while UPnP active (%s) -- marking pending resync",
            wasActive.routeName,
        )
        holder.set(null)
        holder.setTarget(wasActive.routeId)
    }

    override fun play() {
        if (isLoadingUpnp()) {
            Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
            return
        }
        val active = holder.active.value
        Timber.w("ForwardingPlayer.play() active=%s", active?.routeName)
        if (active == null) {
            super.play()
        } else {
            remoteState.setPlayIntent(true)
            scope.launch {
                runCatching { retryTransport { active.avTransport.play() } }
                    .onSuccess {
                        remoteState.applyTransportPlaying()
                        notifyRemoteStateChanged()
                    }
                    .onFailure { handleTransportFailure(active, it) }
            }
        }
    }

    override fun pause() {
        if (isLoadingUpnp()) {
            Timber.w("ForwardingPlayer.pause() dropped -- UPnP loading")
            return
        }
        val active = holder.active.value
        Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName)
        if (active == null) {
            super.pause()
        } else {
            remoteState.setPlayIntent(false)
            scope.launch {
                runCatching { retryTransport { active.avTransport.pause() } }
                    .onSuccess {
                        remoteState.applyTransportPaused()
                        notifyRemoteStateChanged()
                    }
                    .onFailure { handleTransportFailure(active, it) }
            }
        }
    }

    override fun seekTo(positionMs: Long) {
        if (isLoadingUpnp()) {
            Timber.w("ForwardingPlayer.seekTo(positionMs) dropped -- UPnP loading")
            return
        }
        val active = holder.active.value
        Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName)
        if (active == null) {
            super.seekTo(positionMs)
        } else {
            lastSeekIssuedAtMs = SystemClock.elapsedRealtime()
            remoteState.applyPositionInfo(
                positionMs = positionMs,
                durationMs = remoteState.durationMs,
                trackUri = remoteState.currentTrackUri,
                trackNumber = remoteState.trackNumber,
            )
            scope.launch {
                runCatching { retryTransport { active.avTransport.seek(positionMs) } }
                    .onFailure { handleTransportFailure(active, it) }
            }
        }
    }

    /**
     * Widget-driven track change (user taps a track in the queue widget).
     * Seeks Sonos to the correct queue slot, then seeks within-track if
     * [positionMs] is non-zero.
     */
    override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
        if (isLoadingUpnp()) {
            Timber.w("ForwardingPlayer.seekTo(idx, positionMs) dropped -- UPnP loading")
            return
        }
        val active = holder.active.value
        Timber.w(
            "ForwardingPlayer.seekTo(idx=%d, %dms) active=%s",
            mediaItemIndex, positionMs, active?.routeName,
        )
        if (active == null) {
            super.seekTo(mediaItemIndex, positionMs)
            return
        }
        super.seekTo(mediaItemIndex, positionMs)
        remoteState.beginPendingTransport(
            SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
        )
        scope.launch {
            runCatching {
                retryTransport {
                    active.avTransport.seekToTrack(mediaItemIndex + 1)
                    if (positionMs > 0L) {
                        active.avTransport.seek(positionMs)
                    }
                }
            }.onFailure { handleTransportFailure(active, it) }
        }
    }

    override fun seekToNextMediaItem() {
        if (isLoadingUpnp()) {
            Timber.w("ForwardingPlayer.seekToNextMediaItem() dropped -- UPnP loading")
            return
        }
        val active = holder.active.value
        Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName)
        if (active == null) {
            super.seekToNextMediaItem()
            return
        }
        // Super first for immediate local cursor advance (UI feedback);
        // then delegate to Sonos Next. PollLoop reconciles cursor via
        // Track index if they diverge.
        super.seekToNextMediaItem()
        remoteState.beginPendingTransport(
            SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
        )
        scope.launch {
            runCatching { retryTransport { active.avTransport.next() } }
                .onFailure { handleTransportFailure(active, it) }
        }
    }

    override fun seekToPreviousMediaItem() {
        if (isLoadingUpnp()) {
            Timber.w("ForwardingPlayer.seekToPreviousMediaItem() dropped -- UPnP loading")
            return
        }
        val active = holder.active.value
        Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName)
        if (active == null) {
            super.seekToPreviousMediaItem()
            return
        }
        // Super first for immediate local cursor advance (UI feedback);
        // then delegate to Sonos Previous. PollLoop reconciles.
        super.seekToPreviousMediaItem()
        remoteState.beginPendingTransport(
            SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
        )
        scope.launch {
            runCatching { retryTransport { active.avTransport.previous() } }
                .onFailure { handleTransportFailure(active, it) }
        }
    }

    // The system media notification / lock-screen / Android Auto / Wear 'next'
    // and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
    // -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
    // *MediaItem variants the in-app transport buttons call. Un-overridden, the
    // base ForwardingPlayer forwards these to the paused local delegate -- so
    // the notification next/prev only nudged the local cursor (which the poll
    // then re-synced back to Sonos), reading as dead buttons while casting.
    // Route them through the same Sonos path as the media-item variants when a
    // UPnP route is engaged; plain local playback keeps the default behaviour.
    override fun seekToNext() {
        if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
    }

    override fun seekToPrevious() {
        if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
    }

    override fun getCurrentPosition(): Long =
        if (isRemote()) remoteState.positionMs else super.getCurrentPosition()

    override fun getDuration(): Long =
        if (isRemote()) remoteState.durationMs else super.getDuration()

    override fun isPlaying(): Boolean =
        if (isRemote()) remoteState.isPlaying else super.isPlaying()

    override fun getPlaybackState(): Int =
        if (isRemote()) Player.STATE_READY else super.getPlaybackState()

    // Mirror remote state so any consumer that gates on playWhenReady --
    // notably MediaSessionService's foreground-keepalive checks and our own
    // onTaskRemoved -- sees the remote renderer as the source of truth.
    // Without this, swiping the app away with Sonos playing would stop the
    // service, kill the poll loop, and leave Sonos orphaned.
    override fun getPlayWhenReady(): Boolean =
        if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady()

    override fun addListener(listener: Player.Listener) {
        super.addListener(listener)
        synchronized(externalListeners) { externalListeners.add(listener) }
    }

    override fun removeListener(listener: Player.Listener) {
        super.removeListener(listener)
        synchronized(externalListeners) { externalListeners.remove(listener) }
    }

    /**
     * Direct-invoke the externally-registered Player.Listeners so the
     * MediaSession's PlaybackState publisher (notification card, lock-screen
     * scrubber, BT/AVRCP, Auto, Wear OS tile) re-reads our overridden state.
     * The listeners then query isPlaying / getDuration / getCurrentPosition,
     * all of which route through to remoteState while UPnP is active.
     *
     * Posted to the player's application looper because Player.Listener
     * callbacks contract on the application thread.
     */
    private fun notifyRemoteStateChanged() {
        if (!isRemote()) return
        val playing = remoteState.isPlaying
        val trackIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
        val isPlayingChanged = playing != lastNotifiedIsPlaying
        val trackChanged = trackIdx != lastNotifiedTrackIdx
        if (!isPlayingChanged && !trackChanged) return
        lastNotifiedIsPlaying = playing
        lastNotifiedTrackIdx = trackIdx
        val snapshot = synchronized(externalListeners) { externalListeners.toList() }
        handler.post {
            for (l in snapshot) {
                if (isPlayingChanged) {
                    l.onIsPlayingChanged(playing)
                    l.onPlaybackStateChanged(Player.STATE_READY)
                }
                if (trackChanged) {
                    val item = if (trackIdx < delegate.mediaItemCount) {
                        delegate.getMediaItemAt(trackIdx)
                    } else {
                        null
                    }
                    l.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)
                }
            }
        }
    }

    override fun release() {
        pollJob?.cancel()
        scope.cancel()
        handler.post {
            ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
        }
        super.release()
    }

    /**
     * Run a transport SOAP block, retrying transport-level (IO) failures a
     * few times with backoff. A locked phone's WiFi power-save can stall the
     * first socket I/O for a second or two; a retry lets the command land once
     * WiFi wakes instead of being abandoned. A [SoapFaultException] (the
     * renderer answered and rejected the action) is NOT retried -- the device
     * is alive and retrying won't change its verdict.
     */
    private suspend fun <T> retryTransport(block: suspend () -> T): T =
        retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)

    /**
     * A transport SOAP command failed even after retries. Deliberately does
     * NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
     * of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
     * WiFi power-save can fail one command while the renderer is perfectly
     * reachable; dropping on a single command falsely kicked playback back to
     * the phone. We log, leave the poll loop running, and nudge an immediate
     * poll so the UI reconciles to Sonos's actual state -- if the renderer is
     * truly gone, the poll loop trips the drop on its own.
     */
    private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
        if (t is SoapFaultException) {
            Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
        } else {
            Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
        }
        pollTrigger.trySend(Unit)
    }

    private fun onActiveChanged(active: ActiveUpnp?) {
        pollJob?.cancel()
        nonPlayingPollStreak = 0
        // Reset notify cache so the first poll after a route flip republishes
        // playing/track state to the MediaSession even if it happens to match
        // the prior session's values numerically.
        lastNotifiedIsPlaying = false
        lastNotifiedTrackIdx = -1
        if (active != null) {
            Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
            // Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
            // about to be paused (releasing its WAKE_MODE_NETWORK locks), so
            // without this the radio power-saves on a locked screen and the
            // poll below starves -- see [CastNetworkLock].
            castNetworkLock.acquire()
            // Pause the wrapped ExoPlayer so we are not playing local audio
            // simultaneously with the remote renderer. handler.post targets the
            // application looper, so this runs on the same thread that processes
            // our override calls -- no race with the pause() override branching
            // to SOAP (holder.active is already non-null by the time this post
            // fires, but delegate.pause() bypasses the override entirely).
            handler.post { delegate.pause() }
            pollJob = scope.launch { pollLoop(active) }
        } else {
            castNetworkLock.release()
            remoteState.reset()
        }
    }

    @OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
    private suspend fun pollLoop(active: ActiveUpnp) {
        var networkDropSuppressed = false
        while (scope.isActive && holder.active.value?.routeId == active.routeId) {
            val outcome = runCatching { pollOnce(active) }
            if (outcome.isSuccess) {
                remoteState.recordPollSuccess()
                networkDropSuppressed = false
            } else if (remoteState.recordPollFailure()) {
                if (networkStatus.state.value == ServerHealth.Healthy) {
                    Timber.w("UPnP drop threshold tripped for %s", active.routeName)
                    handler.post { onDrop(active.routeName) }
                    return
                }
                networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
            }
            // Race the normal cadence against any external wake (activity
            // resume). Whichever wins continues to the next pollOnce.
            select<Unit> {
                onTimeout(POLL_INTERVAL_MS) {}
                pollTrigger.onReceive {}
            }
        }
    }

    /**
     * The poll-failure threshold tripped, but the phone's *own* network is down
     * (state is not [ServerHealth.Healthy]) -- so this is "we can't see the
     * renderer right now," not "the renderer died." A UPnP renderer streams
     * autonomously and is almost certainly still playing; meanwhile a local
     * fallback couldn't reach the server either, so dropping would only blast
     * local audio out of a pocketed phone while the speaker keeps going. Hold
     * the route and keep polling -- a successful poll once the network returns
     * reconciles to the renderer's real state. Clear the failure streak so
     * recovery re-evaluates the renderer from scratch instead of re-dropping on
     * the first post-recovery hiccup. Returns the (latched) suppression flag so
     * the rationale logs once per outage, not every tick.
     */
    private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean {
        if (!alreadySuppressed) {
            Timber.w(
                "UPnP drop suppressed for %s -- phone network %s, holding route",
                active.routeName, networkStatus.state.value,
            )
        }
        remoteState.recordPollSuccess() // clear streak; not a renderer failure
        return true
    }

    /**
     * One poll tick: read position + transport state from Sonos, apply to
     * [remoteState], and sync the local cursor to the track the renderer is
     * actually playing (see [syncLocalCursorToRemote]).
     */
    private suspend fun pollOnce(active: ActiveUpnp) {
        val info = active.avTransport.getPositionInfo()
        val now = SystemClock.elapsedRealtime()
        val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
            (now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
        // Inside the seek-ack window, keep the optimistic position we wrote in
        // seekTo -- the poll's reported position is stale until Sonos finishes
        // processing the Seek SOAP. Other fields still refresh from the poll.
        remoteState.applyPositionInfo(
            positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
            durationMs = info.trackDurationMs,
            trackUri = info.trackUri,
            trackNumber = info.track,
        )
        syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
        val transport = active.avTransport.getTransportInfo()
        when (transport.state) {
            TransportState.PLAYING -> {
                nonPlayingPollStreak = 0
                remoteState.applyTransportPlaying()
            }
            TransportState.PAUSED -> {
                nonPlayingPollStreak += 1
                if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
                    remoteState.applyTransportPaused()
                }
            }
            TransportState.STOPPED -> {
                nonPlayingPollStreak += 1
                if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
                    remoteState.applyTransportStopped()
                }
            }
            TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
        }
        notifyRemoteStateChanged()
    }

    /**
     * Align the paused local delegate cursor to the track the renderer is
     * actually playing, so the un-overridden current-item getters
     * (getCurrentMediaItem/Index -- what both the in-app player UI via
     * PlayerController.onEvents AND the MediaSession notification read) always
     * resolve to that track. This is THE single source of truth for "what's
     * playing" during a cast.
     *
     * Identity-first: match the track-id embedded in the renderer's current URI
     * against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
     * TrackRef.id), so the index-offset wobble across queue reloads / re-casts
     * can never park the cursor on a stale (pre-cast) track. Falls back to the
     * 1-based Sonos Track index only when the URI can't be resolved to a queue
     * item. Unlike the old forward-only sync this moves the cursor in BOTH
     * directions -- a renderer Previous / re-cast to a lower track pulls it back
     * too. Runs on the application looper (delegate access contract).
     *
     * Suppressed while:
     *  - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
     *    (re)uploaded, so its Track index is meaningless; and
     *  - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
     *    the override already advanced the delegate optimistically, and Sonos
     *    still reports the OLD track for a beat, so syncing now would undo the
     *    user's press. The pending deadline is honoured directly so a missed
     *    clear can't wedge the sync.
     */
    private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
        if (isLoadingUpnp()) return
        val pendingDeadline = remoteState.pendingTransportDeadlineMs
        if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
        handler.post {
            val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
            if (target != delegate.currentMediaItemIndex) {
                Timber.w(
                    "UPnP cursor sync: local=%d -> %d",
                    delegate.currentMediaItemIndex, target,
                )
                delegate.seekTo(target, 0L)
            }
        }
    }

    /**
     * Most-authoritative -> most-degraded (project rule #48): the track-id
     * parsed from the renderer's current URI wins; else the 1-based Sonos Track
     * index; else null (leave the cursor put rather than surface a wrong track).
     * Must run on the application looper -- reads the delegate timeline.
     */
    private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
        val byId = trackIdFromStreamUri(trackUri)?.let { id ->
            nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
        }
        return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
    }

    /**
     * Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
     * occurrence nearest [preferNear] so a track that appears twice in the queue
     * doesn't snap the cursor across the queue. null if the id isn't present.
     */
    private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
        var best: Int? = null
        var bestDist = Int.MAX_VALUE
        for (i in 0 until delegate.mediaItemCount) {
            if (delegate.getMediaItemAt(i).mediaId == mediaId) {
                val dist = abs(i - preferNear)
                if (dist < bestDist) {
                    best = i
                    bestDist = dist
                }
            }
        }
        return best
    }

    /** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
    private fun trackIdFromStreamUri(uri: String): String? {
        val start = uri.indexOf(TRACKS_PATH_MARKER)
        if (start < 0) return null
        val idStart = start + TRACKS_PATH_MARKER.length
        val idEnd = uri.indexOf('/', idStart)
        return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
    }

    private companion object {
        // Path segment that precedes the track-id in a stream URI
        // (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
        // back to a delegate MediaItem by identity.
        const val TRACKS_PATH_MARKER = "/api/tracks/"
        const val POLL_INTERVAL_MS = 1_000L
        const val NON_PLAYING_CONFIRM = 2
        const val SEEK_ACK_WINDOW_MS = 2_000L
        // Safety upper bound on how long the polling tick will wait for
        // Sonos to ack a user transport. The common case clears event-driven
        // when Sonos's reported Track matches the wrapped player; this only
        // kicks in if SOAP fails or Sonos drops the ack entirely.
        const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
        // Transport commands retry transient IO failures so a single WiFi
        // power-save stall (locked phone) doesn't abandon the command. 3
        // attempts x the SoapClient's 2s connect timeout + backoff bounds the
        // worst case at ~7s; a still-failing command then defers to the poll.
        const val TRANSPORT_RETRY_ATTEMPTS = 3
        const val TRANSPORT_RETRY_BACKOFF_MS = 400L
    }
}

/**
 * Retry [block] on transient transport-level ([IOException]) failures, with
 * [backoffMs] between attempts, up to [attempts] total. Anything that is not
 * an [IOException] -- notably [SoapFaultException], where the renderer
 * answered and rejected the action -- propagates immediately: the device is
 * alive, so retrying won't change its verdict. Extracted from
 * [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
 * abandon a single transport command (the bug that falsely reverted Sonos
 * playback to local audio).
 */
internal suspend fun <T> retryTransientIo(
    attempts: Int,
    backoffMs: Long,
    block: suspend () -> T,
): T {
    var attempt = 0
    while (true) {
        try {
            return block()
        } catch (io: IOException) {
            attempt += 1
            if (attempt >= attempts) throw io
            Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
            delay(backoffMs)
        }
    }
}
