@file:Suppress("TooManyFunctions") // 5 MediaRouter.Callback overrides inflate the count
package com.fabledsword.minstrel.player.output

import android.content.Context
import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Snapshot of the audio output route state. [current] is the live
 * route audio is being delivered to. [available] is every route the
 * picker knows about -- MediaRouter system routes merged with
 * UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn
 * "Phone speaker" pinned first, everything else alphabetical.
 */
data class RouteSnapshot(
    val current: OutputRoute,
    val available: List<OutputRoute>,
)

/**
 * Hilt-singleton facade over [MediaRouter] + [UpnpDiscoveryController].
 * Owns the callback lifecycle — default passive behavior at process
 * start (route list updates without forcing Bluetooth scans), upgrades
 * to active discovery while the picker sheet is open so newly-paired
 * devices appear promptly. The [routesState] StateFlow projects the
 * current route + sorted available list as a [RouteSnapshot]; the
 * picker ViewModel collects it.
 *
 * Selection branches on [OutputRoute.Protocol]:
 *  - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
 *    wired, Bluetooth)
 *  - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
 *    [StreamTokenProvider.mint], drive the discovered renderer with
 *    AVTransport.SetAVTransportURI + Play, pause local playback so
 *    audio yields to the network speaker
 *  - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
 *    reserved for follow-up slices; ignored for now.
 *
 * Mirrors the OutputPickerController role described in
 * docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md
 * and the UPnP extensions in
 * docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
 */
@Singleton
class OutputPickerController @Inject constructor(
    @ApplicationContext private val context: Context,
    @ApplicationScope private val scope: CoroutineScope,
    private val upnpDiscovery: UpnpDiscoveryController,
    private val playerController: PlayerController,
    private val playerFactory: PlayerFactory,
    private val streamTokens: StreamTokenProvider,
    private val activeUpnpHolder: ActiveUpnpHolder,
    private val remoteState: RemotePlayerState,
    private val okHttp: OkHttpClient,
) {
    private val mediaRouter = MediaRouter.getInstance(context)

    private val selector = MediaRouteSelector.Builder()
        .addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
        .build()

    /**
     * Internal projection of the live MediaRouter snapshot. Refreshed
     * on every Callback event; combined downstream with UPnP routes
     * into the public [routesState].
     */
    private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())

    private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
    private val selectUpnpMutex = Mutex()

    val routesState: StateFlow<RouteSnapshot> = combine(
        systemRoutesInternal,
        upnpDiscovery.routes,
        upnpDiscovery.sonosTopology,
        selectedUpnpRouteIdInternal,
    ) { sys, upnp, _, upnpSelected ->
        val suppressed = upnpDiscovery.nonCoordinatorMemberUdns()
        val visibleUpnp = upnp
            .filter { it.id.bareUdn() !in suppressed } // suppressed set is bare UDNs
            .map { OutputRoute.fromUpnpRoute(it) }
        val merged = sys.available + visibleUpnp
        val current = if (upnpSelected != null) {
            merged.firstOrNull { it.id == upnpSelected } ?: sys.current
        } else {
            sys.current
        }
        RouteSnapshot(current = current, available = sortRoutes(merged))
    }.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)

    private val callback = object : MediaRouter.Callback() {
        override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
            refresh()

        override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) =
            refresh()

        override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) =
            refresh()

        override fun onRouteSelected(
            router: MediaRouter,
            route: MediaRouter.RouteInfo,
            reason: Int,
        ) = refresh()

        override fun onRouteUnselected(
            router: MediaRouter,
            route: MediaRouter.RouteInfo,
            reason: Int,
        ) = refresh()
    }

    // Last-synced queue identity. Used by observeQueueChangesForSonosResync
    // to detect when the user has mutated the queue (full replacement,
    // playNext insert, or radio-append) while UPnP is active and apply the
    // minimum-incremental set of Sonos SOAP operations to bring its native
    // queue back in sync. Stored as the full id list (not a join-key) so we
    // can run the longest-common-prefix / common-suffix diff.
    private var lastSyncedQueueIds: List<String>? = null

    // Idle-revert timer. Armed when playback is paused/stopped on a UPnP
    // route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
    // non-playing. Cancelled on resume, route change, or explicit disconnect.
    // See idleRevertAction for the arm/cancel decision.
    private var idleRevertJob: Job? = null

    init {
        // Two-arg addCallback registers with no discovery flag —
        // androidx.mediarouter 1.7.0's default passive behavior:
        // route-list updates flow through onRouteAdded/Removed/Changed
        // without forcing Bluetooth scans. (There is no
        // CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
        mediaRouter.addCallback(selector, callback)
        // When MinstrelForwardingPlayer reports the active UPnP route has
        // dropped (the poll loop's consecutive-failure threshold tripping --
        // the sole drop arbiter; a failed transport command no longer drops),
        // clear the UPnP selection state and fall back to local ExoPlayer at
        // the last-known remote position. This mirrors selectSystem's disconnect
        // path but skips the Stop SOAP since the device is already unreachable.
        scope.launch {
            playerFactory.dropEvents.collect { handleRemoteDrop() }
        }
        scope.launch { observeQueueChangesForSonosResync() }
        scope.launch { observeIdleRevertWhileUpnp() }
        scope.launch { observeSelectedRouteDisappearance() }
    }

    /**
     * Revert the UPnP selection to the phone when the selected renderer leaves
     * discovery (powered off / off the LAN) while we are NOT actively casting
     * to it. Without this the selection could pin to a ghost route, so a later
     * "play" would target a speaker that's gone. An *active* cast is left alone
     * -- its own poll loop arbitrates liveness and won't drop on a mere
     * discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
     * post-picker-scan prune; this reacts to the resulting routes change.
     */
    private suspend fun observeSelectedRouteDisappearance() {
        upnpDiscovery.routes.collect { routes ->
            val selected = selectedUpnpRouteIdInternal.value
            val present = routes.any { it.id == selected }
            val casting = activeUpnpHolder.active.value?.routeId == selected
            if (selected != null && !present && !casting) {
                Timber.w(
                    "Output: selected UPnP route %s left discovery; reverting to phone",
                    selected,
                )
                selectedUpnpRouteIdInternal.value = null
            }
        }
    }

    /**
     * Arm/cancel the idle-revert timer as playback state changes while a
     * UPnP route is engaged. "Engaged" = active OR target set, so the brief
     * selectUpnp/resync window (active momentarily null, target set) doesn't
     * spuriously fire. The arm/cancel choice is the pure idleRevertAction.
     */
    private suspend fun observeIdleRevertWhileUpnp() {
        playerController.uiState.collect { state ->
            val engaged = activeUpnpHolder.active.value != null ||
                activeUpnpHolder.target.value != null
            val armed = idleRevertJob?.isActive == true
            when (idleRevertAction(engaged, state.isPlaying, armed)) {
                IdleRevertAction.ARM ->
                    idleRevertJob = scope.launch {
                        delay(IDLE_REVERT_MS)
                        revertToPhoneOnIdle()
                    }
                IdleRevertAction.CANCEL -> {
                    idleRevertJob?.cancel()
                    idleRevertJob = null
                }
                IdleRevertAction.IGNORE -> Unit
            }
        }
    }

    /**
     * Timer fired: the operator paused on a UPnP route and never resumed.
     * Stop the renderer (best-effort -- it's reachable, unlike a drop),
     * clear the UPnP overlay so routesState falls back to the phone, and
     * seek local ExoPlayer to the last remote position. No play(): the
     * operator was paused, so we revert routing silently. Guarded by the
     * selectUpnpMutex so it can't race selectUpnp/resync.
     */
    private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
        if (remoteState.isPlaying) return@withLock // resumed at the boundary
        val active = activeUpnpHolder.active.value
        val capturedPositionMs = remoteState.positionMs
        runCatching { active?.avTransport?.stop() }
            .onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
        activeUpnpHolder.set(null)
        activeUpnpHolder.setTarget(null)
        selectedUpnpRouteIdInternal.value = null
        idleRevertJob = null
        playerController.seekTo(capturedPositionMs)
    }

    /**
     * When the user plays a different playlist while Sonos is active,
     * PlayerController.setQueue replaces the local queue but Sonos's
     * native queue still holds the OLD tracks. MinstrelForwardingPlayer's
     * setMediaItems override clears holder.active + sets target so the
     * imminent play() call drops (drops via isLoadingUpnp() = true). Then
     * this collector observes the uiState.queue change and re-runs
     * loadQueueOnSonos to push the new tracks to Sonos.
     *
     * Discrimination: selectUpnp's initial-load path doesn't change
     * uiState.queue (the queue was already populated before route
     * selection), so this collector doesn't fire during that window. Only
     * a fresh setQueue from PlayerController bumps the joined-ids key.
     */
    private suspend fun observeQueueChangesForSonosResync() {
        playerController.uiState.collect { state ->
            val newIds = state.queue.map { it.id }
            val oldIds = lastSyncedQueueIds
            if (newIds == oldIds) return@collect
            lastSyncedQueueIds = newIds
            // Route can be in target (setMediaItems-induced clearing already
            // ran in ForwardingPlayer) OR in active (queue changed via
            // addMediaItem / removeMediaItems etc. which don't hit the
            // markPending hook).
            val routeId = activeUpnpHolder.target.value
                ?: activeUpnpHolder.active.value?.routeId
                ?: return@collect
            if (state.queue.isEmpty()) {
                Timber.w("Sonos resync skipped: empty queue (clearing target)")
                activeUpnpHolder.setTarget(null)
                return@collect
            }
            scope.launch {
                resyncSonosQueue(routeId, oldIds.orEmpty(), state.queue, state.queueIndex)
            }
        }
    }

    /**
     * Bring Sonos's native queue back in sync with the local queue after a
     * mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue
     * + AddURIToQueue at the insertion point) so playback continues without
     * interruption -- that's what playNext / radio-append want. Falls back to
     * the full removeAllTracks + reload path when the diff implies the current
     * Sonos track was deleted (e.g. user switched playlists), which is what
     * the user-reported "Sonos queue does not update" bug needed.
     */
    private suspend fun resyncSonosQueue(
        routeId: String,
        oldIds: List<String>,
        newQueue: List<TrackRef>,
        newCurrentIndex: Int,
    ) = selectUpnpMutex.withLock {
        val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
        val transport = upnpDiscovery.transportFor(routeId)
        if (upnpRoute == null || transport == null) {
            Timber.w(
                "Sonos resync: route or transport gone for %s, dropping to local",
                routeId,
            )
            activeUpnpHolder.setTarget(null)
            selectedUpnpRouteIdInternal.value = null
            return@withLock
        }
        val handledIncrementally = runCatching {
            tryIncrementalResync(transport, oldIds, newQueue)
        }.getOrElse { e ->
            Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
            false
        }
        if (handledIncrementally) {
            // Active was never cleared on the incremental path; clear any
            // target that markPendingResyncIfRemote set (it didn't, for
            // incremental cases that don't go through setMediaItems, but
            // belt-and-suspenders).
            activeUpnpHolder.setTarget(null)
            return@withLock
        }
        // Full rebuild: ensure active is cleared so transport calls drop
        // (markPendingResyncIfRemote may already have done this on the
        // setMediaItems path).
        if (activeUpnpHolder.active.value != null) {
            activeUpnpHolder.set(null)
            activeUpnpHolder.setTarget(routeId)
        }
        val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
        val rendering = renderingClientFor(routeId)
        Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name)
        runCatching {
            loadQueueOnSonos(transport, outputRoute, newQueue, newCurrentIndex)
            activeUpnpHolder.set(
                ActiveUpnp(
                    routeId = routeId,
                    routeName = outputRoute.name,
                    avTransport = transport,
                    rendering = rendering,
                ),
            )
            activeUpnpHolder.setTarget(null)
        }.onFailure { e ->
            Timber.w(e, "Sonos resync (full) failed -- dropping to local")
            activeUpnpHolder.setTarget(null)
            selectedUpnpRouteIdInternal.value = null
        }
    }

    /**
     * Diff-based incremental Sonos queue sync. Returns true when the new
     * queue can be produced from the old one with a remove-then-insert at
     * the same middle slice -- the common-prefix and common-suffix portions
     * stay untouched, and the current Sonos track must lie in the preserved
     * prefix (otherwise the diff would orphan playback). Returns false to
     * signal the caller to fall back to a full reload.
     */
    private suspend fun tryIncrementalResync(
        transport: AVTransportClient,
        oldIds: List<String>,
        newQueue: List<TrackRef>,
    ): Boolean {
        val newIds = newQueue.map { it.id }
        if (oldIds == newIds) return true
        val prefixLen = commonPrefixLength(oldIds, newIds)
        val suffixLen = commonSuffixLength(
            oldIds.subList(prefixLen, oldIds.size),
            newIds.subList(prefixLen, newIds.size),
        )
        val removedCount = oldIds.size - prefixLen - suffixLen
        val addedCount = newIds.size - prefixLen - suffixLen
        // Sonos's current track number is 1-based; compare against the
        // preserved-prefix range as 0-based. If the current track is in
        // the removed slice, incremental can't preserve playback -- caller
        // falls back to full rebuild.
        val currentSonosIdx0 = remoteState.trackNumber - 1
        val canApply = currentSonosIdx0 in 0 until prefixLen
        if (canApply) {
            applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
        } else {
            Timber.w(
                "Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
                currentSonosIdx0,
                prefixLen,
            )
        }
        return canApply
    }

    private suspend fun applyQueueDiff(
        transport: AVTransportClient,
        newQueue: List<TrackRef>,
        prefixLen: Int,
        removedCount: Int,
        addedCount: Int,
    ) {
        if (removedCount > 0) {
            Timber.w(
                "Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
                prefixLen + 1,
                removedCount,
            )
            transport.removeTrackRangeFromQueue(
                startingIndex = prefixLen + 1,
                numberOfTracks = removedCount,
            )
        }
        if (addedCount == 0) return
        Timber.w(
            "Sonos incremental: AddURIToQueue x%d starting at position %d",
            addedCount,
            prefixLen + 1,
        )
        for (i in 0 until addedCount) {
            val ref = newQueue[prefixLen + i]
            val token = streamTokens.mint(ref.id)
            transport.addURIToQueue(
                uri = token.url,
                mime = token.mime,
                title = token.title,
                enqueuedURIPosition = prefixLen + i + 1,
            )
            if (i > 0) delay(EXTEND_THROTTLE_MS)
        }
    }

    private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
        val limit = minOf(a.size, b.size)
        for (i in 0 until limit) {
            if (a[i] != b[i]) return i
        }
        return limit
    }

    private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
        val limit = minOf(a.size, b.size)
        for (i in 0 until limit) {
            if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
        }
        return limit
    }

    /**
     * Called when the active UPnP route drops unexpectedly (the poll loop's
     * consecutive-failure threshold tripping). Captures the last remote
     * position + play state, clears UPnP selection, and resumes local ExoPlayer at
     * the same point. Skips the Stop SOAP (device already unreachable).
     * The snackbar is handled independently by the NowPlaying surface
     * collecting the same [PlayerFactory.dropEvents] via PlayerController.
     */
    private fun handleRemoteDrop() {
        val capturedPositionMs = remoteState.positionMs
        // Resume on the operator's last play-intent, not the observed remote
        // state: after a pause, isPlaying is false, but a failed play() that
        // triggered this drop means the operator DID ask to play -- honor it.
        val wasPlayingRemote = remoteState.lastPlayIntent
        activeUpnpHolder.set(null)
        activeUpnpHolder.setTarget(null)
        selectedUpnpRouteIdInternal.value = null
        playerController.seekTo(capturedPositionMs)
        if (wasPlayingRemote) playerController.play()
    }

    /**
     * Upgrade callback registration to active discovery — call when
     * the picker sheet opens so newly-paired Bluetooth devices
     * surface within a few seconds, and fire an SSDP M-SEARCH burst
     * for UPnP renderers. Idempotent: re-registering with a
     * new flag set replaces the prior registration in MediaRouter.
     */
    fun upgradeDiscovery() {
        mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
        upnpDiscovery.upgradeDiscovery()
    }

    /**
     * Downgrade callback registration back to default passive behavior
     * — call when the picker sheet closes so we don't keep Bluetooth
     * scanning on for battery cost. Two-arg overload = no flag =
     * passive. Same idempotent re-registration semantics. The UPnP
     * side has no symmetric downgrade (passive SSDP NOTIFY listen is
     * always on); the call is preserved for API parity.
     */
    fun downgradeDiscovery() {
        mediaRouter.addCallback(selector, callback)
        upnpDiscovery.downgradeDiscovery()
    }

    /**
     * Select [route]. SYSTEM routes hand off to [MediaRouter]; UPNP
     * routes mint a signed stream token + drive AVTransport on the
     * discovered renderer + pause local playback. Other protocols
     * (CAST / SONOS) are reserved for follow-up slices and are
     * silently ignored — the picker shouldn't show them yet.
     */
    fun select(route: OutputRoute) {
        when (route.protocol) {
            OutputRoute.Protocol.SYSTEM -> selectSystem(route)
            OutputRoute.Protocol.UPNP -> scope.launch { selectUpnp(route) }
            OutputRoute.Protocol.CAST, OutputRoute.Protocol.SONOS -> Unit
        }
    }

    private fun selectSystem(route: OutputRoute) {
        val wasUpnp = selectedUpnpRouteIdInternal.value
        if (wasUpnp != null) {
            val active = activeUpnpHolder.active.value
            val capturedPositionMs = remoteState.positionMs
            val wasPlayingRemote = remoteState.isPlaying
            scope.launch {
                runCatching { active?.avTransport?.stop() }
                    .onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
                activeUpnpHolder.set(null)
                activeUpnpHolder.setTarget(null)
                selectedUpnpRouteIdInternal.value = null
                playerController.seekTo(capturedPositionMs)
                if (wasPlayingRemote) playerController.play()
            }
        }
        val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
        mediaRouter.selectRoute(target)
    }

    /**
     * Drive the UPnP renderer using Sonos native queue mode:
     * clear the device's queue, load every track from our local queue
     * via AddURIToQueue, point the transport at the queue URI, seek to
     * the current index, and play. Wrapped in `runCatching` — SOAP
     * failure abandons the selection cleanly.
     *
     * Order of operations is deliberate:
     *  1. Pause local so the user doesn't keep hearing local audio.
     *  2. Set target early so ForwardingPlayer drops transport taps
     *     while the 17-second queue load is in progress.
     *  3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
     *     are never routed to a half-loaded Sonos queue.
     */
    private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
        val uiState = playerController.uiState.value
        val currentTrack = uiState.currentTrack
        if (currentTrack == null) {
            Timber.w("UPnP select skipped: no currentTrack (start playback first)")
            return@withLock
        }
        // Honor Sonos topology: pick the coordinator's route when the user
        // tapped a group row. Suppression in routesState already keeps the
        // visible row at the coordinator's id, so this is identity in the
        // common case -- defensive for follow-up flows.
        val effectiveRoute = upnpDiscovery.coordinatorRouteFor(route.id)
            ?.let { OutputRoute.fromUpnpRoute(it) } ?: route
        val transport = upnpDiscovery.transportFor(effectiveRoute.id)
        if (transport == null) {
            Timber.w(
                "UPnP select skipped: no transport for route id=${effectiveRoute.id} " +
                    "(route disappeared or id mismatch with discovery list)",
            )
            return@withLock
        }
        val rendering = renderingClientFor(effectiveRoute.id)
        // Pause local before flipping UI state -- user shouldn't keep hearing
        // local audio while we queue up Sonos.
        playerController.pause()
        selectedUpnpRouteIdInternal.value = effectiveRoute.id
        // If the renderer is already playing this exact queue (a "stray"
        // session we got disconnected from), attach to it in place instead of
        // wiping + reloading -- no jarring restart, and skip/seek land on its
        // live queue. Falls through to the clear+reload path when it isn't ours.
        if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
            return@withLock
        }
        // Mark UPnP loading. ForwardingPlayer overrides drop transport commands
        // silently while target is set but active is null -- the user's premature
        // taps don't hit Sonos's stale state from a prior session.
        activeUpnpHolder.setTarget(effectiveRoute.id)
        runCatching {
            loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
            // Wire active LAST -- SOAP path is now safe to use.
            activeUpnpHolder.set(
                ActiveUpnp(
                    routeId = effectiveRoute.id,
                    routeName = effectiveRoute.name,
                    avTransport = transport,
                    rendering = rendering,
                ),
            )
            // Selecting a route loads the queue and Plays on the renderer, so
            // the standing intent is "play" -- a later drop should resume local.
            remoteState.setPlayIntent(true)
        }.onFailure { e ->
            Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
            activeUpnpHolder.set(null)
            activeUpnpHolder.setTarget(null)
            selectedUpnpRouteIdInternal.value = null
        }
    }

    /**
     * The renderer's current position + transport state, validated as an
     * adoptable continuation of our local queue: same track id at the same
     * 1-based index. Null means "not ours / not playing" -> caller reloads.
     */
    private data class AdoptPlan(
        val localIndex: Int,
        val positionMs: Long,
        val durationMs: Long,
        val trackUri: String,
        val trackNumber: Int,
        val playing: Boolean,
    )

    /**
     * Attach to a renderer that is *already* playing our queue (a session the
     * phone got disconnected from but the speaker kept streaming) instead of
     * clearing + reloading it. Probes the renderer's live state, and if it's
     * mid-playback on the same track id at the same queue index, syncs the
     * local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
     * skips a beat, and skip/seek now drive its live queue. Returns false (and
     * the caller does the full clear+reload) when the renderer isn't playing
     * our queue. The local player is already paused by the caller.
     */
    private suspend fun tryAdoptRunningSession(
        transport: AVTransportClient,
        rendering: RenderingControlClient?,
        route: OutputRoute,
        localQueue: List<TrackRef>,
    ): Boolean {
        val plan = probeAdoptable(transport, route, localQueue) ?: return false
        Timber.w(
            "UPnP adopt: attaching to live session on %s at track %d (%dms)",
            route.name, plan.trackNumber, plan.positionMs,
        )
        // active is still null here, so this aligns the *local* cursor without
        // emitting SOAP; the holder.set below then starts the poll loop, which
        // reconciles to the renderer's advancing position from here.
        playerController.moveCursorTo(plan.localIndex, plan.positionMs)
        remoteState.applyPositionInfo(
            positionMs = plan.positionMs,
            durationMs = plan.durationMs,
            trackUri = plan.trackUri,
            trackNumber = plan.trackNumber,
        )
        remoteState.setPlayIntent(plan.playing)
        activeUpnpHolder.set(
            ActiveUpnp(
                routeId = route.id,
                routeName = route.name,
                avTransport = transport,
                rendering = rendering,
            ),
        )
        return true
    }

    private suspend fun probeAdoptable(
        transport: AVTransportClient,
        route: OutputRoute,
        localQueue: List<TrackRef>,
    ): AdoptPlan? {
        val probe = runCatching {
            transport.getTransportInfo() to transport.getPositionInfo()
        }.getOrElse {
            Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
            return null
        }
        val (info, pos) = probe
        val idx0 = pos.track - 1
        val playingId = trackIdFromStreamUrl(pos.trackUri)
        val midPlayback =
            info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
        val aligned = midPlayback && playingId != null &&
            idx0 in localQueue.indices && localQueue[idx0].id == playingId
        return if (aligned) {
            AdoptPlan(
                localIndex = idx0,
                positionMs = pos.relTimeMs,
                durationMs = pos.trackDurationMs,
                trackUri = pos.trackUri,
                trackNumber = pos.track,
                playing = info.state == TransportState.PLAYING,
            )
        } else {
            null
        }
    }

    /** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
    private fun trackIdFromStreamUrl(url: String): String? {
        val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
        val i = segments.indexOf("tracks")
        return if (i >= 0) segments.getOrNull(i + 1) else null
    }

    private suspend fun loadQueueOnSonos(
        transport: AVTransportClient,
        route: OutputRoute,
        queue: List<TrackRef>,
        currentIndex: Int,
    ) {
        Timber.w("UPnP select: clear queue on %s", route.name)
        transport.removeAllTracksFromQueue()
        val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
        val initialBatch = queue.subList(0, initialEnd)
        Timber.w(
            "UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
            initialBatch.size, currentIndex, queue.size,
        )
        initialBatch.forEachIndexed { idx, ref ->
            val token = streamTokens.mint(ref.id)
            transport.addURIToQueue(
                uri = token.url,
                mime = token.mime,
                title = token.title,
                enqueuedURIPosition = idx + 1,
            )
        }
        val coordinatorUdn = route.id.bareUdn()
        val queueUri = "x-rincon-queue:$coordinatorUdn#0"
        Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
        transport.setAVTransportURI(queueUri, "")
        Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
        transport.seekToTrack(currentIndex + 1)
        Timber.w("UPnP select: Play")
        transport.play()
        Timber.w("UPnP select: initial done; backgrounding remainder")
        val remaining = queue.drop(initialEnd)
        if (remaining.isNotEmpty()) {
            scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
        }
    }

    /**
     * Background-append tracks after activation. Runs concurrently with
     * Sonos playback. Cancels if the user disconnects from this route
     * (active.routeId changes or becomes null). Tolerates individual
     * AddURIToQueue failures — log and continue so some tracks loaded
     * is better than zero tracks loaded.
     */
    private suspend fun extendQueueOnSonos(
        transport: AVTransportClient,
        route: OutputRoute,
        tracks: List<TrackRef>,
        startPosition: Int,
    ) {
        Timber.w(
            "UPnP extend: appending %d tracks starting at position %d",
            tracks.size, startPosition + 1,
        )
        var consecutiveFailures = 0
        var succeeded = 0
        var aborted = false
        for ((i, ref) in tracks.withIndex()) {
            if (aborted) break
            if (activeUpnpHolder.active.value?.routeId != route.id) {
                Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
                aborted = true
            } else {
                val outcome = runCatching {
                    val token = streamTokens.mint(ref.id)
                    transport.addURIToQueue(
                        uri = token.url,
                        mime = token.mime,
                        title = token.title,
                        enqueuedURIPosition = startPosition + i + 1,
                    )
                }
                if (outcome.isSuccess) {
                    consecutiveFailures = 0
                    succeeded += 1
                    // Throttle the burst so we don't tickle Sonos's burst-add
                    // rejection -- logcat 2026-06-04 showed 33 consecutive
                    // failures clustered at ~10ms intervals once offset 39 was
                    // reached, which looks like a rate-limit kicking in. The
                    // delay is small enough that extending 100 tracks adds
                    // only ~5s to background work that's already async.
                    delay(EXTEND_THROTTLE_MS)
                } else {
                    consecutiveFailures += 1
                    val e = outcome.exceptionOrNull()
                    val detail = (e as? SoapFaultException)?.let {
                        "code=${it.code} desc=${it.description}"
                    } ?: e?.message
                    Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
                    if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
                        Timber.w(
                            "UPnP extend: aborting after %d consecutive failures",
                            consecutiveFailures,
                        )
                        aborted = true
                    }
                }
            }
        }
        Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
    }

    private fun renderingClientFor(routeId: String): RenderingControlClient? {
        val rcUrl = upnpDiscovery.routes.value
            .firstOrNull { it.id == routeId }
            ?.renderingControlUrl ?: return null
        return RenderingControlClient(SoapClient(okHttp), rcUrl)
    }

    private fun refresh() {
        systemRoutesInternal.value = snapshotFromRouter()
    }

    private fun snapshotFromRouter(): RouteSnapshot {
        val all = mediaRouter.routes
            .filter { it.matchesSelector(selector) }
            .map { OutputRoute.fromRouteInfo(it) }
        val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
        return RouteSnapshot(current = current, available = sortRoutes(all))
    }

    /**
     * BuiltIn "Phone speaker" pinned first; everything else
     * alphabetical. Selection state is conveyed by the radio button
     * indicator in the picker row, not by sort order.
     */
    private fun sortRoutes(all: List<OutputRoute>): List<OutputRoute> {
        val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn }
        return builtIn + rest.sortedBy { it.name.lowercase() }
    }

    private companion object {
        const val EXTEND_ABORT_AFTER_FAILURES = 3
        const val EXTEND_THROTTLE_MS = 50L

        // 5 minutes of continuous non-playing on a UPnP route before we
        // revert to the phone speaker, so a stale Sonos selection can't make
        // a later "tap play" do nothing.
        const val IDLE_REVERT_MS = 5 * 60 * 1000L
    }
}
