package com.fabledsword.minstrel.player

import android.content.ComponentName
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.resolveServerUrl
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

/**
 * UI-facing facade over Media3's [MediaController] — the IPC client to
 * [MinstrelPlayerService]'s [androidx.media3.session.MediaSession].
 *
 * ViewModels collect [uiState] (a StateFlow projection of the player's
 * current track + transport state + position) and call the
 * transport methods. The MediaController is connected asynchronously in
 * the singleton's init block; until it's ready, transport methods are
 * no-ops and uiState carries the default (empty) snapshot.
 *
 * Why a dedicated facade vs ViewModels using MediaController directly:
 * lifecycle. ViewModels come and go with their host Composables; one
 * MediaController for the process is the right shape, and centralising
 * the StateFlow projection means each ViewModel doesn't have to attach
 * its own Player.Listener.
 */
// PlayerController is the UI-side facade over the full Media3 surface
// (transport + queue + radio + lifecycle). All the public methods are
// legitimately part of one cohesive controller — splitting into
// "TransportController", "QueueController", etc. would scatter related
// state for no gain. Suppress the per-class function-count cap rather
// than fragment.
@Suppress("TooManyFunctions")
@Singleton
class PlayerController @Inject constructor(
    @ApplicationContext private val context: Context,
    @ApplicationScope private val scope: CoroutineScope,
    private val radio: RadioController,
    private val playlists: PlaylistsRepository,
    private val playerFactory: PlayerFactory,
    private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
    private val remoteState: RemotePlayerState,
) {

    /**
     * UPnP drop events surfaced from [PlayerFactory.dropEvents]. NowPlaying
     * collects this into its snackbar host so a transport / poll failure
     * during UPnP playback shows "Disconnected from <name>" to the user.
     */
    val dropEvents: SharedFlow<String> = playerFactory.dropEvents
    private val sessionToken =
        SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))

    private var mediaController: MediaController? = null

    private val uiStateInternal = MutableStateFlow(PlayerUiState())
    val uiState: StateFlow<PlayerUiState> = uiStateInternal.asStateFlow()

    /**
     * Per-error events for [com.fabledsword.minstrel.player.PlaybackErrorReporter].
     * Fires twice: once when Media3's `onPlayerError` surfaces a load
     * failure (decoder, transport, EOS), and once when the player
     * reaches STATE_READY with a duration of zero / TIME_UNSET — the
     * "track loaded but has no audio" case the user sees as the
     * player sitting frozen on a track.
     *
     * Drift #561: this was originally Channel.CONFLATED, which silently
     * dropped every emission except the latest each time the reporter
     * loop wasn't actively reading. A network blip that failed 5
     * tracks back-to-back would surface only the last failure to the
     * snackbar (the "Skipped 5 unplayable tracks" coalescing path was
     * dead code) AND only POST one playback_errors row to the admin
     * inbox instead of 5. Buffered so every burst event reaches the
     * reporter; default capacity is 64 which is well above any real
     * burst rate.
     */
    private val playbackErrorEventsChannel = Channel<PlaybackErrorEvent>(Channel.BUFFERED)
    val playbackErrorEvents: Flow<PlaybackErrorEvent> = playbackErrorEventsChannel.receiveAsFlow()

    /**
     * Tracks which queue index has already been evaluated for the
     * zero-duration / load-failed checks so STATE_READY firing
     * repeatedly (after every seek, pause/resume) doesn't re-emit
     * the same error event. Reset on each onMediaItemTransition.
     */
    private var lastEvaluatedItemIndex: Int = -1

    /**
     * #968: self-heal budget — re-pulls of a stale refreshable queue since
     * the last successful play. Capped so a still-broken refresh can't loop;
     * reset when any track loads with real audio.
     */
    private var selfHealAttempts = 0

    /**
     * Stable queue snapshot kept in sync with the player's MediaItems —
     * the player's own getMediaItem(index) returns Media3 types; we keep
     * our domain TrackRef list alongside for the UiState projection.
     */
    private var queueRefs: List<TrackRef> = emptyList()

    /**
     * Completes when [mediaController] is non-null and the listener has
     * been attached. Used by [awaitReady] so cold-boot callers like
     * [ResumeController] can wait for the IPC bind before calling
     * transport methods that would otherwise no-op silently. Drift
     * #562 caught the race where a fast restore() landed before the
     * MediaSessionService connection was up, silently dropping the
     * restored queue.
     */
    private val readyDeferred = kotlinx.coroutines.CompletableDeferred<Unit>()

    init {
        scope.launch { connectAndObserve() }
    }

    /**
     * Suspends until the MediaController binding to
     * [MinstrelPlayerService] is up and a Player.Listener is attached,
     * so a subsequent transport call (setQueue, startRadio, playNext,
     * …) will actually reach the player rather than being silently
     * swallowed by the `mediaController ?: return` guards.
     */
    suspend fun awaitReady(): Unit = readyDeferred.await()

    // ── Transport (no-op until the controller is connected) ──────────────

    // Each transport call must run on the MediaController's
    // applicationLooper; calling from a background coroutine throws
    // IllegalStateException (see PlayerController.setQueue's note).
    // UI tap handlers are already on Main so the in-place branch hits;
    // the background path only fires for cross-thread callers like
    // OutputPickerController.selectUpnp (which calls pause() after
    // handing playback off to a UPnP renderer).
    fun play() { mediaController?.let { runOnControllerThread(it) { it.play() } } }
    fun pause() { mediaController?.let { runOnControllerThread(it) { it.pause() } } }
    fun seekTo(positionMs: Long) {
        mediaController?.let { runOnControllerThread(it) { it.seekTo(positionMs) } }
    }
    fun skipToNext() {
        mediaController?.let { runOnControllerThread(it) { it.seekToNextMediaItem() } }
    }
    fun skipToPrevious() {
        mediaController?.let { runOnControllerThread(it) { it.seekToPreviousMediaItem() } }
    }

    /** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */
    fun toggleShuffle() {
        val controller = mediaController ?: return
        controller.shuffleModeEnabled = !controller.shuffleModeEnabled
    }

    /**
     * Cycles the player's repeat mode: off → all → one → off. Mirrors
     * Flutter's `cycleRepeat`. The wire order (none/all/one) matches
     * what the Now Playing icon swap expects — Lucide.Repeat for off
     * and all, Lucide.Repeat1 for one.
     */
    fun cycleRepeat() {
        val controller = mediaController ?: return
        controller.repeatMode = when (controller.repeatMode) {
            Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
            Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
            else -> Player.REPEAT_MODE_OFF
        }
    }

    /** Jump to [index] in the current queue. No-op if index is out of range. */
    fun seekToIndex(index: Int) {
        val controller = mediaController ?: return
        if (index !in queueRefs.indices) return
        controller.seekTo(index, /* positionMs = */ 0L)
        controller.play()
    }

    /**
     * Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
     * Used by the UPnP adopt path: when attaching to a renderer that's already
     * playing our queue, we sync the local cursor to the renderer's current
     * track/position so skip/seek and the UI map onto its live session — the
     * renderer keeps the audio, the local player stays paused. Unlike
     * [seekToIndex] this issues no play().
     */
    fun moveCursorTo(index: Int, positionMs: Long) {
        val controller = mediaController ?: return
        if (index !in queueRefs.indices) return
        runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
    }

    /**
     * Replace the queue with [tracks] starting at [initialIndex].
     * [source] tags the queue with its origin (e.g. "for_you") so the
     * server-side rotation reporter can advance it; carried in
     * MediaItem extras.
     *
     * [autoplay] controls whether playback starts immediately. The
     * default is true to preserve the "user pressed play on a tile"
     * UX every existing caller relies on. Drift #560: cold-boot
     * resume passes autoplay = false so the persisted queue is
     * restored without auto-starting playback after the user has
     * been away from the app — starting audio on cold launch was
     * surprising for users who had paused mid-track before
     * backgrounding.
     */
    fun setQueue(
        tracks: List<TrackRef>,
        initialIndex: Int = 0,
        source: String? = null,
        autoplay: Boolean = true,
    ) {
        val controller = mediaController ?: return
        queueRefs = tracks
        val items = tracks.map { it.toMediaItem(source) }
        // Drift #562 cold-boot resume calls this from a non-Main suspend
        // context after awaitReady() unblocks (ResumeController launches
        // on Dispatchers.Default by the time it reaches us). MediaController
        // enforces application-thread access and throws
        // IllegalStateException otherwise — post to its applicationLooper
        // if we're already there, run directly to avoid the re-dispatch
        // latency UI callers depend on.
        runOnControllerThread(controller) {
            controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
            controller.prepare()
            if (autoplay) controller.play()
        }
    }

    private fun runOnControllerThread(controller: MediaController, block: () -> Unit) {
        if (Looper.myLooper() == controller.applicationLooper) {
            block()
        } else {
            Handler(controller.applicationLooper).post(block)
        }
    }

    /**
     * Insert [track] into the queue immediately after the currently
     * playing item. Does NOT auto-play — playback state is preserved
     * (Media3's [Player.addMediaItem] doesn't disturb the current
     * window). No-op when the player isn't connected or the track has
     * no streamUrl.
     */
    fun playNext(track: TrackRef) {
        val controller = mediaController ?: return
        if (track.streamUrl.isEmpty()) return
        val insertAt = (controller.currentMediaItemIndex + 1).coerceAtLeast(0)
        queueRefs = queueRefs.toMutableList().apply { add(insertAt, track) }
        controller.addMediaItem(insertAt, track.toMediaItem(source = null))
    }

    /**
     * Append [track] to the end of the queue. Does NOT auto-play.
     * Same streamUrl + connectivity guards as [playNext].
     */
    fun enqueue(track: TrackRef) {
        val controller = mediaController ?: return
        if (track.streamUrl.isEmpty()) return
        queueRefs = queueRefs + track
        controller.addMediaItem(track.toMediaItem(source = null))
    }

    /**
     * Reorder the queue: move the item at [from] to [to], keeping the domain
     * snapshot in lock-step with the player's MediaItem timeline. Media3 emits
     * onEvents → uiState reflects the new order (and the still-playing item's
     * index). No-op on bad indices or a no-move.
     */
    fun moveInQueue(from: Int, to: Int) {
        val controller = mediaController ?: return
        if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
        queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
        controller.moveMediaItem(from, to)
    }

    /**
     * Remove the queue item at [index]. When it's the currently-playing item
     * Media3 advances to the next automatically. No-op on a bad index.
     */
    fun removeFromQueue(index: Int) {
        val controller = mediaController ?: return
        if (index !in queueRefs.indices) return
        queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
        controller.removeMediaItem(index)
    }

    /** Empty the queue and stop playback. */
    fun clearQueue() {
        val controller = mediaController ?: return
        queueRefs = emptyList()
        controller.clearMediaItems()
    }

    /**
     * Seed a fresh radio queue from [trackId]. The `source` tag is
     * "radio:<id>" so the server-side rotation reporter can
     * distinguish radio plays from album / playlist plays.
     *
     * Two modes:
     *  - Nothing in the queue: start the radio cold from track 0
     *    (the seed plays first, recommendations follow).
     *  - Queue already populated: do NOT interrupt — keep the current
     *    track playing as-is, trim every upcoming track, and append
     *    the radio results after the current item. If the seed is the
     *    currently playing track (the common "tap Start Radio on the
     *    song I'm listening to" case), drop it from the appended list
     *    so it doesn't immediately repeat. This is an intentional
     *    divergence from Flutter's `playerActions.startRadio`, which
     *    always calls `playTracks` and restarts playback from 0.
     *
     * No-op on an empty server response; throws on transport / non-2xx
     * for the caller to surface in a snackbar.
     */
    suspend fun startRadio(trackId: String) {
        val controller = mediaController ?: return
        val tracks = radio.seed(trackId)
        if (tracks.isEmpty()) return
        val source = "radio:$trackId"
        if (controller.mediaItemCount == 0) {
            setQueue(tracks, initialIndex = 0, source = source)
        } else {
            appendRadioToQueue(controller, tracks, trackId, source)
        }
    }

    /**
     * Mid-queue radio insert. The current track keeps playing; every
     * upcoming item is removed; the radio results are appended after.
     * Drops the seed from the appended list when it matches the
     * currently-playing track so it doesn't immediately repeat.
     */
    private fun appendRadioToQueue(
        controller: MediaController,
        tracks: List<TrackRef>,
        seedTrackId: String,
        source: String,
    ) {
        val currentIdx = controller.currentMediaItemIndex
        val currentTrack = queueRefs.getOrNull(currentIdx)
        val toAppend = if (currentTrack?.id == seedTrackId) tracks.drop(1) else tracks
        if (toAppend.isEmpty()) return
        val nextIdx = currentIdx + 1
        if (nextIdx < controller.mediaItemCount) {
            controller.removeMediaItems(nextIdx, controller.mediaItemCount)
        }
        queueRefs = queueRefs.take(nextIdx) + toAppend
        controller.addMediaItems(toAppend.map { it.toMediaItem(source) })
    }

    /**
     * When a STATE_READY transition for a freshly-loaded item reports
     * a zero / TIME_UNSET duration, fire a `zero_duration` error event
     * and advance past the dead track. Otherwise no-op.
     */
    private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
        // Skip during UPnP playback (active) AND during the activation load
        // window (target set, active not yet wired). ExoPlayer is intentionally
        // paused throughout both windows so its STATE_READY duration is always
        // 0 / TIME_UNSET -- without this guard we rapid-advance through the
        // entire local queue (and spam /api/playback-errors).
        val current = queueRefs.getOrNull(idx)
        val upnpEngaged = activeUpnpHolder.active.value != null ||
            activeUpnpHolder.target.value != null
        if (current == null || upnpEngaged) return
        val duration = controller.duration
        val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
        if (!isZeroDuration) {
            // A track loaded with real audio — clear the self-heal budget so
            // a later stale queue can recover again.
            selfHealAttempts = 0
            return
        }
        playbackErrorEventsChannel.trySend(
            PlaybackErrorEvent(
                trackId = current.id,
                kind = "zero_duration",
                title = current.title.ifEmpty { "Track" },
                detail = "duration=$duration",
            ),
        )
        if (controller.hasNextMediaItem()) {
            controller.seekToNextMediaItem()
        } else {
            controller.stop()
        }
    }

    /**
     * #968: the queue is fully unplayable (every track failed to load). If it
     * came from a refreshable source — a system playlist (bare-variant source)
     * or radio ("radio:<seed>") — the snapshot is probably stale (app/tab left
     * open across the daily rebuild); re-pull it and resume instead of stopping.
     * Bounded by [selfHealAttempts]. Album / artist / user-playlist / offline
     * sources have nothing to refresh — stop.
     */
    private fun selfHealOrStop(controller: MediaController) {
        val source = controller.currentMediaItem
            ?.mediaMetadata?.extras?.getString(MINSTREL_SOURCE_KEY)
        if (source == null || selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) {
            controller.stop()
            return
        }
        when {
            source.startsWith("radio:") -> {
                selfHealAttempts++
                scope.launch { selfHealRadio(source.removePrefix("radio:")) }
            }
            !source.contains(':') -> { // bare variant = refreshable system playlist
                selfHealAttempts++
                scope.launch { selfHealSystem(source) }
            }
            else -> controller.stop()
        }
    }

    private suspend fun selfHealSystem(variant: String) {
        val refs = runCatching { playlists.systemShuffle(variant).tracks.toPlayableTrackRefs() }
            .getOrDefault(emptyList())
        if (refs.isEmpty()) {
            stopOnControllerThread()
            return
        }
        setQueue(refs, initialIndex = 0, source = variant)
    }

    private suspend fun selfHealRadio(seedTrackId: String) {
        val refs = runCatching { radio.seed(seedTrackId) }.getOrDefault(emptyList())
        if (refs.isEmpty()) {
            stopOnControllerThread()
            return
        }
        setQueue(refs, initialIndex = 0, source = "radio:$seedTrackId")
    }

    private fun stopOnControllerThread() {
        val controller = mediaController ?: return
        runOnControllerThread(controller) { controller.stop() }
    }

    // ── Internal: async connect + Listener-driven UI state sync ──────────

    private suspend fun connectAndObserve() {
        val controller =
            try {
                connectController()
            } catch (
                // Service-connection failure is a "show error, move on" boundary —
                // any throwable from the Media3 IPC handshake gets surfaced via
                // playbackError. Specific-exception handling buys nothing here.
                @Suppress("TooGenericExceptionCaught") e: Throwable,
            ) {
                uiStateInternal.value =
                    uiStateInternal.value.copy(playbackError = e.message ?: "Player unavailable")
                return
            }
        mediaController = controller
        // Drift #562: signal awaitReady() callers (ResumeController et al.)
        // that the controller is bound. Listener is attached below in
        // the same coroutine so by the time downstream code runs after
        // awaitReady, the Player.Listener is wired too.
        if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
        startPositionPolling(controller)
        // Keep isUpnpLoading current between player-event fires: holder state
        // changes (setTarget / set(active)) are independent of Media3 events, so
        // onEvents alone would lag behind by up to one event cycle. This collector
        // runs for the process lifetime alongside the position poller.
        scope.launch {
            combine(
                activeUpnpHolder.target,
                activeUpnpHolder.active,
            ) { target, active -> target != null && active == null }
                .collect { isLoading ->
                    uiStateInternal.value = uiStateInternal.value.copy(isUpnpLoading = isLoading)
                }
        }
        controller.addListener(
            object : Player.Listener {
                override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
                    val current = queueRefs.getOrNull(controller.currentMediaItemIndex)
                    val title = current?.title?.takeIf { it.isNotEmpty() } ?: "Track"
                    val trackId = current?.id ?: return
                    playbackErrorEventsChannel.trySend(
                        PlaybackErrorEvent(
                            trackId = trackId,
                            kind = "load_failed",
                            title = title,
                            detail = error.message,
                        ),
                    )
                    // A failed load leaves the player IDLE; advance past the bad
                    // track and re-prepare so one unplayable item doesn't strand
                    // playback (mirrors the zero_duration skip). At the end of a
                    // fully-unplayable queue, try to self-heal a stale refreshable
                    // source before stopping.
                    if (controller.hasNextMediaItem()) {
                        controller.seekToNextMediaItem()
                        controller.prepare()
                    } else {
                        selfHealOrStop(controller)
                    }
                }

                override fun onMediaItemTransition(
                    mediaItem: androidx.media3.common.MediaItem?,
                    reason: Int,
                ) {
                    // Reset the per-item evaluation guard so the new
                    // item's STATE_READY transition gets a fresh check.
                    lastEvaluatedItemIndex = -1
                    // The track flipped -- re-anchor the position interpolator
                    // so the next polling tick treats the new track's
                    // remoteState.positionMs as fresh rather than carrying the
                    // old anchor + elapsed forward.
                    lastSeenRemotePositionMs = -1L
                }

                override fun onPlaybackStateChanged(playbackState: Int) {
                    if (playbackState != Player.STATE_READY) return
                    val idx = controller.currentMediaItemIndex
                    if (idx == lastEvaluatedItemIndex) return
                    lastEvaluatedItemIndex = idx
                    handleZeroDurationIfNeeded(controller, idx)
                }

                override fun onEvents(player: Player, events: Player.Events) {
                    val idx = player.currentMediaItemIndex
                    val current = queueRefs.getOrNull(idx)
                    val source = player.currentMediaItem
                        ?.mediaMetadata
                        ?.extras
                        ?.getString(MINSTREL_SOURCE_KEY)
                    val isUpnpLoading = activeUpnpHolder.target.value != null &&
                        activeUpnpHolder.active.value == null
                    // When UPnP is active, the wrapped ExoPlayer is paused with
                    // no real audio loaded -- player.duration / isPlaying /
                    // currentPosition all reflect that. Read from remoteState
                    // instead so an onEvents fire (e.g. activity resume) doesn't
                    // clobber the UI with zeros. Duration falls back to the
                    // wrapped player's value when Sonos hasn't reported one yet
                    // (pre-first-poll window, or Sonos still buffering) -- the
                    // wrapped ExoPlayer was prepared with the same MediaItem so
                    // it knows the real duration before any SOAP poll lands.
                    val upnpActive = activeUpnpHolder.active.value != null
                    uiStateInternal.value =
                        PlayerUiState(
                            currentTrack = current,
                            queue = queueRefs,
                            queueIndex = idx,
                            isPlaying = if (upnpActive) remoteState.isPlaying else player.isPlaying,
                            isBuffering = !upnpActive &&
                                player.playbackState == Player.STATE_BUFFERING,
                            positionMs = if (upnpActive) {
                                remoteState.positionMs
                            } else {
                                player.currentPosition
                            }.coerceAtLeast(0),
                            durationMs = effectiveDuration(
                                upnpActive,
                                remoteState.durationMs,
                                player.duration,
                                desiredIdx = idx,
                                controllerIdx = idx,
                            ),
                            bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0),
                            playbackError = player.playerError?.message,
                            currentSource = source,
                            shuffleEnabled = player.shuffleModeEnabled,
                            repeatMode = when (player.repeatMode) {
                                Player.REPEAT_MODE_ALL -> RepeatMode.ALL
                                Player.REPEAT_MODE_ONE -> RepeatMode.ONE
                                else -> RepeatMode.OFF
                            },
                            isUpnpLoading = isUpnpLoading,
                        )
                }
            },
        )
    }

    /**
     * Periodic `currentPosition` / `bufferedPosition` poller. Media3's
     * `Player.Events` callbacks only fire at boundaries (track change,
     * pause/play, buffer state), never on normal position advancement,
     * so the seek bar would otherwise sit static between events. Polls
     * at [POSITION_POLL_INTERVAL_MS]; matches the in-app cadence of
     * Spotify / Apple Music / Flutter (just_audio `positionStream` runs
     * at ~200ms; this 500ms is a battery-friendly middle ground that
     * still reads as smooth on a slider).
     *
     * The poll only patches position fields via `.copy()` so a stale
     * `onEvents` snapshot can't clobber the latest state. MediaController
     * is touched only from the main dispatcher.
     */
    private fun startPositionPolling(controller: MediaController) {
        scope.launch(Dispatchers.Main.immediate) {
            while (isActive) {
                delay(POSITION_POLL_INTERVAL_MS)
                tickPositionPoll(controller)
            }
        }
    }

    /**
     * One position-polling tick. Patches ONLY the smoothly-changing transport
     * fields (position / duration / play-pause / buffer) via `.copy()`. The
     * queue index + current track are owned solely by [onEvents], which reads
     * the authoritative delegate cursor: during a cast,
     * [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
     * the track the renderer is actually playing (matched by identity), so there
     * is exactly one writer of "what's playing" and a stale tick can never
     * revert it to the old track. (Previously the tick was a second index writer
     * that fought onEvents — the flicker-to-stale-track bug.)
     */
    private fun tickPositionPoll(controller: MediaController) {
        val upnpActive = activeUpnpHolder.active.value != null
        resolvePendingTransport(controller, upnpActive)
        val effectiveIsPlaying =
            if (upnpActive) remoteState.isPlaying else controller.isPlaying
        val effectivePosition = if (upnpActive) {
            interpolatedRemotePosition(effectiveIsPlaying)
        } else {
            controller.currentPosition
        }
        val idx = controller.currentMediaItemIndex
        val current = uiStateInternal.value
        val newPos = effectivePosition.coerceAtLeast(0)
        val newDur = effectiveDuration(
            upnpActive,
            remoteState.durationMs,
            controller.duration,
            desiredIdx = idx,
            controllerIdx = idx,
        )
        val newBuf = controller.bufferedPosition.coerceAtLeast(0)
        val somethingChanged = current.isPlaying != effectiveIsPlaying ||
            current.positionMs != newPos ||
            current.durationMs != newDur ||
            current.bufferedPositionMs != newBuf
        if (somethingChanged) {
            uiStateInternal.value = current.copy(
                isPlaying = effectiveIsPlaying,
                positionMs = newPos,
                durationMs = newDur,
                bufferedPositionMs = newBuf,
            )
        }
    }

    /**
     * Event-driven primary path: clear pending the moment Sonos's reported
     * Track matches the wrapped player's index. Safety fallback: clear on
     * deadline so we don't ignore Sonos's actual state forever if SOAP fails.
     */
    private fun resolvePendingTransport(controller: MediaController, upnpActive: Boolean) {
        if (!upnpActive || remoteState.pendingTransportDeadlineMs <= 0L) return
        val sonosIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
        val timedOut = SystemClock.elapsedRealtime() > remoteState.pendingTransportDeadlineMs
        if (sonosIdx == controller.currentMediaItemIndex || timedOut) {
            remoteState.clearPendingTransport()
        }
    }

    // ── Remote position interpolation state ──────────────────────────────
    // remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
    // SOAP poll (and only when the round-trip completes -- screen-off WiFi
    // sleep can stall it for many seconds). To keep the scrubber moving
    // smoothly we anchor each fresh reading + an elapsed-realtime stamp;
    // between updates we display anchor + elapsed when Sonos is playing.
    // A real correction lands as soon as the next poll arrives.
    @Volatile private var lastSeenRemotePositionMs: Long = -1L
    @Volatile private var positionAnchorMs: Long = 0L
    @Volatile private var positionAnchorAtRealtimeMs: Long = 0L

    private fun interpolatedRemotePosition(isPlaying: Boolean): Long {
        val raw = remoteState.positionMs
        val now = SystemClock.elapsedRealtime()
        if (raw != lastSeenRemotePositionMs) {
            lastSeenRemotePositionMs = raw
            positionAnchorMs = raw
            positionAnchorAtRealtimeMs = now
        }
        if (!isPlaying) return positionAnchorMs
        // Cap how far past the last anchor we extrapolate. After
        // MAX_INTERPOLATION_DRIFT_MS without a poll update, freeze the
        // displayed position at anchor + cap rather than projecting wildly.
        // The next successful poll re-anchors and motion resumes.
        val delta = (now - positionAnchorAtRealtimeMs).coerceAtMost(MAX_INTERPOLATION_DRIFT_MS)
        return positionAnchorMs + delta
    }

    /**
     * Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
     * to a suspend function without pulling in `kotlinx-coroutines-guava`
     * — the future's `addListener` callback is enough. `Runnable::run`
     * is a direct executor (no extra threading) since the callback only
     * unparks our continuation.
     */
    private suspend fun connectController(): MediaController =
        suspendCancellableCoroutine { cont ->
            val future = MediaController.Builder(context, sessionToken).buildAsync()
            future.addListener(
                {
                    try {
                        cont.resume(future.get())
                    } catch (
                        // ListenableFuture.get() throws ExecutionException +
                        // InterruptedException + CancellationException — handle
                        // them uniformly by forwarding to the continuation;
                        // the caller catches at the outer boundary.
                        @Suppress("TooGenericExceptionCaught") e: Throwable,
                    ) {
                        cont.resumeWithException(e)
                    }
                },
                Runnable::run,
            )
            cont.invokeOnCancellation { future.cancel(/* mayInterruptIfRunning = */ false) }
        }

    private fun TrackRef.toMediaItem(source: String?): MediaItem {
        val metadata = MediaMetadata.Builder()
            .setTitle(title)
            .setArtist(artistName)
            .setAlbumTitle(albumTitle)
            .apply {
                // Server-known duration -- gives the lock-screen / notification
                // scrubber a real total even when the wrapped ExoPlayer is
                // paused under UPnP (it never probes a duration in that state).
                if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
                if (source != null) setExtras(sourceExtras(source))
                // Point the notification / lock-screen art at the SAME album
                // cover the in-app surfaces use (TrackRef.coverUrl ->
                // /api/albums/{id}/cover). Without this, Media3 falls back to
                // whatever art is embedded in the stream's tags, which can be a
                // different image than the server's album cover. Setting
                // artworkUri here is load-bearing: MediaMetadata.populate()
                // overwrites artworkUri + artworkData as a pair, so the
                // MediaItem's URI clears any embedded artworkData ExoPlayer
                // extracts from the stream -- the cover endpoint wins on both
                // surfaces. The session's OkHttp-backed BitmapLoader (see
                // PlayerFactory) is what makes this authed placeholder URL load.
                if (coverUrl.isNotEmpty()) setArtworkUri(coverUrl.toUri())
            }
            .build()
        // Server's stream_url is a relative path (/api/tracks/{id}/stream);
        // resolve to the placeholder.invalid form so OkHttpDataSource (sharing
        // BaseUrlInterceptor) rewrites it to the live server with the auth
        // cookie. Same resolver as covers — one place for /api/* URLs.
        val resolvedUri = resolveServerUrl(streamUrl) ?: streamUrl
        return MediaItem.Builder()
            .setMediaId(id)
            .setUri(resolvedUri)
            // Key the Media3 SimpleCache by trackId (not the stream URL) so
            // cache residency can be queried per track for the cached dot +
            // offline pools, independent of URL/host rewrites.
            .setCustomCacheKey(id)
            .setMediaMetadata(metadata)
            .build()
    }

    private fun sourceExtras(source: String): Bundle =
        Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) }

    /**
     * Duration to surface to the UI. Priority: Sonos's reported duration
     * (only when UPnP active and non-zero) -> wrapped ExoPlayer's value
     * (only valid once it's probed the stream) -> TrackRef.durationSec
     * (always known from the server response). The third tier is what
     * keeps the scrubber populated when the user taps Sonos before the
     * wrapped player has had time to probe its own duration -- without
     * it, both top tiers report 0/TIME_UNSET and the field reads empty
     * until the first SOAP poll lands.
     */
    @Suppress("ReturnCount") // 3-tier fallback reads cleanest as a ladder of early returns
    private fun effectiveDuration(
        upnpActive: Boolean,
        remoteMs: Long,
        localMs: Long,
        desiredIdx: Int,
        controllerIdx: Int,
    ): Long {
        if (upnpActive && remoteMs > 0) return remoteMs
        // Tier 2 (wrapped player's probed duration) only valid when the
        // wrapped player is on the same track we're trying to show. After a
        // Sonos natural advance the polling tick updates desiredIdx from
        // Sonos's truth while controllerIdx is briefly stale -- using the
        // wrapped player's duration here would surface the old track's
        // length under the new track's title.
        if (localMs > 0 && controllerIdx == desiredIdx) return localMs
        val ref = queueRefs.getOrNull(desiredIdx) ?: return 0
        return ref.durationSec.toLong() * MS_PER_SECOND
    }

    companion object {
        const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
        private const val MS_PER_SECOND = 1_000L
        private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
    }
}

// 500ms: smooth-enough on a slider without being battery-hostile.
// Flutter (just_audio positionStream) ticks at ~200ms; Spotify / Apple
// Music sit around 250-500ms in-app. Lock-screen / notification
// surfaces are driven by Media3's MediaSession callbacks separately;
// they don't need this poll.
private const val POSITION_POLL_INTERVAL_MS = 500L

// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
// the next successful play) so a still-broken refresh can't loop.
private const val MAX_SELF_HEAL_ATTEMPTS = 1

/**
 * Structured playback failure event for [PlaybackErrorReporter]. Drives
 * both the user-facing snackbar ("Couldn't play X — skipping") and the
 * /api/playback-errors admin inbox.
 *
 * `kind` is one of "zero_duration" / "load_failed" / "stalled" matching
 * the server's CHECK constraint. `detail` is free-text (the Media3
 * error message, the observed duration value, etc.).
 */
data class PlaybackErrorEvent(
    val trackId: String,
    val kind: String,
    val title: String,
    val detail: String? = null,
)
