package com.github.libretube.services

import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import com.github.libretube.R
import com.github.libretube.api.JsonHelper
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.setMetadata
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.extensions.toID
import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.getSubtitleRoleFlags
import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.parcelable.PlayerData
import com.github.libretube.util.DeArrowUtil
import com.github.libretube.util.PlaybackCache
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.YoutubeHlsPlaylistParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import java.util.concurrent.ConcurrentHashMap

/**
 * Loads the selected videos audio in background mode with a notification area.
 */
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
open class OnlinePlayerService : AbstractPlayerService() {
    override val isOfflinePlayer: Boolean = false

    // PlaylistId/ChannelId for autoplay
    private var playlistId: String? = null
    private var channelId: String? = null
    private var startTimestampSeconds: Long? = null
    private var shufflePlaylist: Boolean = false
    private var strictPlaylistMode: Boolean = false

    /**
     * The response that gets when called the Api.
     */
    private var streams: Streams? = null

    private val scope = CoroutineScope(Dispatchers.IO)

    private var youtubeApiCircuitBreakerOpen = false
    private var youtubeApiCircuitBreakerOpenedAt = 0L
    private val youtubeApiCircuitBreakerTimeoutMs = 15_000L

    /*
    Current job that's loading a new video (the value is null if no video is loading at the moment).
     */
    private var fetchVideoInfoJob: Job? = null
    private var hasTriedHlsFallback = false

    // Keep the stream metadata for the already-added next item. Media3 can then preload
    // the actual media while this cache lets us update queue/history/SponsorBlock instantly
    // when the automatic transition happens.
    private val prefetchedStreamsById = ConcurrentHashMap<String, Streams>()
    private val prefetchingVideoIds = ConcurrentHashMap.newKeySet<String>()
    private var prefetchRetryJob: Job? = null

    // Radio-style autoplay profile. For a single manually selected song, keep the future queue
    // anchored to that musical style instead of eventually drifting into unrelated content.
    // Explicit playlists/channels keep their own order and do not use this filter.
    private var preserveExplicitQueue = false
    private var autoplaySeedGenres: Set<String> = emptySet()
    private var autoplaySeedKeywords: Set<String> = emptySet()
    private var autoplaySeedUploader: String? = null
    private var autoplaySeedIsMusic = false
    private var isAutomaticQueueNavigation = false

    private val playerListener = object : Player.Listener {
        override fun onPlaybackStateChanged(playbackState: Int) {
            when (playbackState) {
                Player.STATE_ENDED -> {
                    if (exoPlayer?.hasNextMediaItem() == true) return
                    if (!isTransitioning) playNextVideo()
                }

                Player.STATE_IDLE -> {
                    // STATE_IDLE is also used briefly while changing/recovering media sources.
                    // Do not tear down the whole background service here.
                }

                Player.STATE_BUFFERING -> {}
                Player.STATE_READY -> {
                    // reset fallback state when playback is recovered
                    hasTriedHlsFallback = false

                    if (PlayerHelper.watchHistoryEnabled) {
                        scope.launch(Dispatchers.IO) {
                            streams?.let { streams ->
                                val watchHistoryItem =
                                    streams.toStreamItem(videoId).toWatchHistoryItem(videoId)
                                DatabaseHelper.addToWatchHistory(watchHistoryItem)
                            }
                        }
                    }
                }
            }
        }

        override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
            super.onMediaItemTransition(mediaItem, reason)

            val newVideoId = mediaItem?.mediaMetadata?.extras?.getString(IntentData.videoId)
                ?: return
            if (newVideoId == videoId) return

            // Promote the prefetched stream to current instead of starting a second network
            // navigation. ExoPlayer is already playing this item seamlessly at this point.
            val promotedStreams = prefetchedStreamsById.remove(newVideoId)
            streams = promotedStreams ?: mediaItem.mediaMetadata.extras
                ?.getString(IntentData.streams)
                ?.let { encoded ->
                    runCatching { JsonHelper.json.decodeFromString<Streams>(encoded) }.getOrNull()
                }

            videoId = newVideoId
            updatePlaylistMetadata {
                setExtras(bundleOf(IntentData.videoId to newVideoId))
            }
            val queueItem = promotedStreams?.toStreamItem(newVideoId)
                ?: PlayingQueue.getStreams().firstOrNull { it.url?.toID() == newVideoId }
                ?: StreamItem(url = "https://www.youtube.com/watch?v=$newVideoId")
            PlayingQueue.updateCurrent(queueItem)
            streams?.let { refreshGenreRadioQueue(it) }

            // Warm the next queue item immediately. SponsorBlock is independent and must never
            // delay continuous-playback preloading on a slow network.
            prefetchNextTrack()

            resetSponsorBlockSegments()
            val segmentVideoId = newVideoId
            scope.launch {
                val segments = getSponsorBlockSegments(segmentVideoId)
                withContext(Dispatchers.Main) {
                    if (videoId == segmentVideoId) setSponsorBlockSegments(segments)
                }
            }
        }

        override fun onPlayerError(error: PlaybackException) {
            if (!hasTriedHlsFallback && streams?.hls != null) {
                hasTriedHlsFallback = true
                val shouldPlay = exoPlayer?.playWhenReady == true
                runCatching {
                    exoPlayer?.stop()
                    exoPlayer?.clearMediaItems()
                    setStreamSource(forceHls = true)
                    exoPlayer?.prepare()
                    exoPlayer?.playWhenReady = shouldPlay
                    prefetchNextTrack()
                }
                return
            }

            toastFromMainThread(error.localizedMessage.orEmpty())
        }
    }

    override suspend fun onServiceCreated(args: Bundle) {
        val playerData = args.parcelable<PlayerData>(IntentData.playerData)
        if (playerData == null) {
            stopSelf()
            return
        }
        isAudioOnlyPlayer = args.getBoolean(IntentData.audioOnly)

        // get the intent arguments
        videoId = playerData.videoId!!
        playlistId = playerData.playlistId
        shufflePlaylist = playerData.shuffle
        strictPlaylistMode = playerData.strictPlaylistMode
        channelId = playerData.channelId
        startTimestampSeconds = playerData.timestamp
        preserveExplicitQueue = playerData.keepQueue && PlayingQueue.isNotEmpty()

        if (!playerData.keepQueue) PlayingQueue.clear()

        exoPlayer?.addListener(playerListener)
        trackSelector?.updateParameters {
            setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, isAudioOnlyPlayer)
        }
    }

    override suspend fun startPlayback() {
        super.startPlayback()

        val timestampMs = startTimestampSeconds?.times(1000) ?: 0L
        startTimestampSeconds = null

        // If UI provided prefetched streams, prefer them and skip the network fetch.
        fetchVideoInfoJob?.cancelAndJoin()

        val pre = prefetchedStreams
        if (pre != null) {
            streams = pre
            prefetchedStreams = null

            streams?.toStreamItem(videoId)?.let {
                updatePlaybackQueue(it, streams!!)
                SubscriptionHelper.submitFeedItemChange(it.toFeedItem())
            }

            // sponsor block segments fetch should still run async
            val segmentVideoId = videoId
            scope.launch {
                val segments = getSponsorBlockSegments(segmentVideoId)
                withContext(Dispatchers.Main) {
                    if (videoId == segmentVideoId) setSponsorBlockSegments(segments)
                }
            }

            val lastWatchPositionMs = null

            if (strictPlaylistMode && playlistId != null && streams != null) {
                setStrictPlaylistMediaItems(streams!!)
            }

            withContext(Dispatchers.Main) {
                // apply shuffle if playlist playback requested
                exoPlayer?.shuffleModeEnabled = shufflePlaylist
                if (!strictPlaylistMode) {
                    setStreamSource()
                }
                configurePlayer(timestampMs, lastWatchPositionMs)
                if (!strictPlaylistMode) prefetchNextTrack()
            }

            return
        }

        fetchVideoInfoJob = scope.launch {
            val fetched = fetchStreamsWithTimeout(videoId)
            if (fetched == null) {
                // Never jump to an unrelated history item when a stream request is slow/fails.
                // Keep queue state intact so the user can retry or skip normally.
                return@launch
            }
            streams = fetched

            streams?.toStreamItem(videoId)?.let {
                // save the current stream to the queue
                updatePlaybackQueue(it, streams!!)

                // update feed item with newer information, e.g. more up-to-date views
                SubscriptionHelper.submitFeedItemChange(it.toFeedItem())
            }

            val segmentVideoId = videoId
            launch {
                val segments = getSponsorBlockSegments(segmentVideoId)
                withContext(Dispatchers.Main) {
                    if (videoId == segmentVideoId) setSponsorBlockSegments(segments)
                }
            }

            val lastWatchPositionMs = null

            // Prepare strict playlist media items if needed (must be done in IO context)
            if (strictPlaylistMode && playlistId != null && streams != null) {
                setStrictPlaylistMediaItems(streams!!)
            }

            withContext(Dispatchers.Main) {
                 // apply shuffle if playlist playback requested
                 exoPlayer?.shuffleModeEnabled = shufflePlaylist
                if (!strictPlaylistMode) {
                    setStreamSource()
                }
                configurePlayer(timestampMs, lastWatchPositionMs)
                if (!strictPlaylistMode) prefetchNextTrack()
            }
        }

        fetchVideoInfoJob?.join()
        fetchVideoInfoJob = null
    }

    private fun updatePlaybackQueue(streamItem: StreamItem, currentStreams: Streams) {
        PlayingQueue.updateCurrent(streamItem)

        if (playlistId != null || channelId != null || preserveExplicitQueue) {
            if (!PlayingQueue.hasNext()) {
                PlayingQueue.updateQueue(
                    streamItem,
                    playlistId,
                    channelId,
                    currentStreams.relatedStreams
                )
            }
            return
        }

        initializeAutoplaySeed(currentStreams)
        refreshGenreRadioQueue(currentStreams)
    }

    private fun refreshGenreRadioQueue(currentStreams: Streams) {
        if (playlistId != null || channelId != null || preserveExplicitQueue) return

        initializeAutoplaySeed(currentStreams)
        val rankedRelated = rankRelatedForAutoplay(currentStreams.relatedStreams)

        // Keep history/current item for Previous, but replace every future item with fresh
        // recommendations from the song that is actually playing. This prevents long sessions
        // from walking through a stale mixed queue built from the very first response.
        PlayingQueue.clearAfterCurrent()
        PlayingQueue.insertRelatedStreams(rankedRelated)
    }

    private fun initializeAutoplaySeed(currentStreams: Streams) {
        if (autoplaySeedGenres.isNotEmpty() || autoplaySeedKeywords.isNotEmpty() || autoplaySeedUploader != null) {
            return
        }

        val sourceText = buildString {
            append(currentStreams.title).append(' ')
            append(currentStreams.description).append(' ')
            append(currentStreams.category).append(' ')
            append(currentStreams.tags.joinToString(" "))
        }.normalizeAutoplayText()

        autoplaySeedGenres = KNOWN_MUSIC_GENRES.filterTo(linkedSetOf()) { genre ->
            sourceText.containsAutoplayPhrase(genre)
        }

        autoplaySeedKeywords = currentStreams.tags
            .asSequence()
            .flatMap { it.normalizeAutoplayText().split(' ').asSequence() }
            .filter { it.length >= 4 }
            .filterNot { it in GENERIC_AUTOPLAY_WORDS }
            .take(12)
            .toSet()

        autoplaySeedUploader = currentStreams.uploader
            .normalizeAutoplayText()
            .takeIf { it.isNotBlank() }

        autoplaySeedIsMusic = currentStreams.category.equals(Streams.CATEGORY_MUSIC, ignoreCase = true) ||
            autoplaySeedGenres.isNotEmpty() ||
            sourceText.contains("music") ||
            sourceText.contains("musica")
    }

    private fun rankRelatedForAutoplay(relatedStreams: List<StreamItem>): List<StreamItem> {
        val candidates = relatedStreams
            .asSequence()
            .filter { !it.url.isNullOrBlank() && !it.title.isNullOrBlank() }
            .filter { !it.isLive && !it.isShort }
            .filter { item ->
                if (!autoplaySeedIsMusic) return@filter true
                val text = item.autoplaySearchText()
                if (TALK_CONTENT_MARKERS.any { marker -> text.contains(marker) }) return@filter false

                // If both the seed and the recommendation explicitly advertise a genre, reject
                // a conflicting genre. Items with no explicit genre marker remain eligible so
                // ordinary song titles are not accidentally discarded.
                val candidateGenres = KNOWN_MUSIC_GENRES.filterTo(linkedSetOf()) { genre ->
                    text.containsAutoplayPhrase(genre)
                }
                autoplaySeedGenres.isEmpty() || candidateGenres.isEmpty() ||
                    candidateGenres.any { it in autoplaySeedGenres }
            }
            .distinctBy { it.url?.toID() }
            .toList()

        if (candidates.isEmpty()) return emptyList()

        return candidates
            .mapIndexed { index, item ->
                val text = item.autoplaySearchText()
                var score = 0

                // A genre match is the strongest signal (vallenato stays vallenato, salsa stays
                // salsa, etc.). Related-order remains the tie-breaker because the backend itself
                // already provides a useful similarity ranking.
                if (autoplaySeedGenres.any { text.containsAutoplayPhrase(it) }) score += 1_000

                autoplaySeedUploader?.let { uploader ->
                    if (uploader.isNotBlank() && item.uploaderName?.normalizeAutoplayText() == uploader) {
                        score += 250
                    }
                }

                score += autoplaySeedKeywords.count { keyword -> text.contains(keyword) } * 40

                RankedAutoplayItem(item, score, index)
            }
            .sortedWith(
                compareByDescending<RankedAutoplayItem> { it.score }
                    .thenBy { it.originalIndex }
            )
            .map { it.item }
    }

    private fun String.normalizeAutoplayText(): String = lowercase()
        .replace('á', 'a')
        .replace('é', 'e')
        .replace('í', 'i')
        .replace('ó', 'o')
        .replace('ú', 'u')
        .replace('ü', 'u')
        .replace('ñ', 'n')
        .replace(Regex("[^a-z0-9]+"), " ")
        .trim()

    private fun String.containsAutoplayPhrase(phrase: String): Boolean =
        " $this ".contains(" ${phrase.normalizeAutoplayText()} ")

    private fun StreamItem.autoplaySearchText(): String = buildString {
        append(title.orEmpty()).append(' ')
        append(shortDescription.orEmpty()).append(' ')
        append(uploaderName.orEmpty())
    }.normalizeAutoplayText()

    private data class RankedAutoplayItem(
        val item: StreamItem,
        val score: Int,
        val originalIndex: Int
    )

    private companion object {
        val KNOWN_MUSIC_GENRES = setOf(
            "vallenato", "salsa", "bachata", "merengue", "cumbia", "champeta",
            "reggaeton", "regueton", "urbano", "trap", "rap", "hip hop",
            "bolero", "balada", "ranchera", "corrido", "regional mexicano",
            "musica popular", "despecho", "tropical", "reggae", "ska",
            "rock", "metal", "punk", "pop", "jazz", "blues", "soul", "rnb",
            "electronica", "electronic", "house", "techno", "dance", "edm",
            "gospel", "cristiana", "flamenco", "tango", "folk", "country"
        )

        val GENERIC_AUTOPLAY_WORDS = setOf(
            "music", "musica", "official", "oficial", "video", "audio", "lyrics",
            "letra", "song", "cancion", "youtube", "topic", "records", "record"
        )

        val TALK_CONTENT_MARKERS = setOf(
            "podcast", "entrevista", "interview", "noticias", "news", "debate",
            "conferencia", "conference", "documental", "documentary", "audiolibro",
            "audiobook", "reaccion", "reaction", "review", "tutorial"
        )
    }

    private fun prefetchNextTrack(retryIfQueuePending: Boolean = true) {
        val nextVideoId = PlayingQueue.getNext()
        if (nextVideoId == null) {
            // Playlist/channel queue population can finish a fraction later than the current
            // stream request. Retry briefly so the immediate next stream metadata is ready well
            // before STATE_ENDED.
            if (retryIfQueuePending && prefetchRetryJob?.isActive != true) {
                prefetchRetryJob = scope.launch {
                    repeat(10) {
                        delay(400L)
                        if (PlayingQueue.getNext() != null) {
                            prefetchNextTrack(retryIfQueuePending = false)
                            return@launch
                        }
                    }
                }
            }
            return
        }

        prefetchRetryJob?.cancel()
        prefetchRetryJob = null
        if (nextVideoId == videoId || prefetchedStreamsById.containsKey(nextVideoId)) return
        if (!prefetchingVideoIds.add(nextVideoId)) return

        scope.launch {
            try {
                val nextStreams = withContext(Dispatchers.IO) {
                    runCatching {
                        withTimeoutOrNull(15_000L) {
                            MediaServiceRepository.instance.getStreams(nextVideoId)
                        }?.let {
                            DeArrowUtil.deArrowStreams(it, nextVideoId)
                        }
                    }.getOrNull()
                } ?: return@launch

                if (PlayingQueue.getNext() == nextVideoId) {
                    prefetchedStreamsById[nextVideoId] = nextStreams

                    // Put the *real* next MediaSource in ExoPlayer while the current track is
                    // still playing. This gives both the large-player Next button and autoplay
                    // one single, gap-free path. HLS uses the same custom playlist parser and
                    // DASH uses the same disk-backed data source as normal playback.
                    val nextMediaSource = buildMediaSourceForStreams(nextStreams, nextVideoId)
                    if (nextMediaSource != null) {
                        withContext(Dispatchers.Main) {
                            val player = exoPlayer
                            if (player != null &&
                                PlayingQueue.getNext() == nextVideoId &&
                                findMediaItemIndex(nextVideoId) < 0
                            ) {
                                player.addMediaSource(nextMediaSource)
                            }
                        }
                    }
                }
            } finally {
                prefetchingVideoIds.remove(nextVideoId)
            }
        }
    }

    private fun findMediaItemIndex(videoId: String): Int {
        val player = exoPlayer ?: return -1
        for (index in 0 until player.mediaItemCount) {
            val itemVideoId = player.getMediaItemAt(index).mediaMetadata.extras
                ?.getString(IntentData.videoId)
            if (itemVideoId == videoId) return index
        }
        return -1
    }

    private fun buildMediaItemForStreams(streams: Streams, videoId: String): MediaItem? {
        return when {
            streams.videoStreams.isNotEmpty() -> {
                val dashUri = if (streams.isLive && streams.dash != null) {
                    ProxyHelper.rewriteUrlUsingProxyPreference(streams.dash).toUri()
                } else {
                    PlayerHelper.createDashSource(streams, this)
                }
                createMediaItem(dashUri, MimeTypes.APPLICATION_MPD, streams, videoId)
            }

            streams.hls != null -> {
                val hlsUri = ProxyHelper.rewriteUrlUsingProxyPreference(streams.hls).toUri()
                createMediaItem(hlsUri, MimeTypes.APPLICATION_M3U8, streams, videoId)
            }

            else -> null
        }
    }

    private fun buildMediaSourceForStreams(
        streams: Streams,
        itemVideoId: String,
        forceHls: Boolean = false
    ): MediaSource? {
        if (forceHls || streams.videoStreams.isEmpty()) {
            streams.hls?.let { hlsUrl ->
                val item = createMediaItem(
                    ProxyHelper.rewriteUrlUsingProxyPreference(hlsUrl).toUri(),
                    MimeTypes.APPLICATION_M3U8,
                    streams,
                    itemVideoId
                )
                return HlsMediaSource.Factory(PlaybackCache.dataSourceFactory(this))
                    .setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory())
                    .createMediaSource(item)
            }
        }

        if (streams.videoStreams.isNotEmpty()) {
            val dashUri = if (streams.isLive && streams.dash != null) {
                ProxyHelper.rewriteUrlUsingProxyPreference(streams.dash).toUri()
            } else {
                PlayerHelper.createDashSource(streams, this)
            }
            val item = createMediaItem(
                dashUri,
                MimeTypes.APPLICATION_MPD,
                streams,
                itemVideoId
            )
            return DefaultMediaSourceFactory(PlaybackCache.dataSourceFactory(this))
                .createMediaSource(item)
        }

        streams.hls?.let { hlsUrl ->
            val item = createMediaItem(
                ProxyHelper.rewriteUrlUsingProxyPreference(hlsUrl).toUri(),
                MimeTypes.APPLICATION_M3U8,
                streams,
                itemVideoId
            )
            return HlsMediaSource.Factory(PlaybackCache.dataSourceFactory(this))
                .setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory())
                .createMediaSource(item)
        }

        return null
    }

    private fun configurePlayer(seekToPositionMs: Long, lastWatchPositionMs: Long?) {
        // seek to the previous position if available
        if (seekToPositionMs != 0L) {
            exoPlayer?.seekTo(seekToPositionMs)
        } else if (lastWatchPositionMs != null && !DatabaseHelper.isVideoWatched(lastWatchPositionMs, streams?.duration)) {
            exoPlayer?.seekTo(lastWatchPositionMs)
        }

        if ((exoPlayer?.mediaItemCount ?: 0) == 0) {
            Log.w(TAG(), "No media item available for playback")
            toastFromMainThread(R.string.unknown_error)
            return
        }

        try {
            exoPlayer?.apply {
                // automatically start playback when using the audio player
                playWhenReady = PlayerHelper.playAutomatically || isAudioOnlyPlayer
                prepare()
            }
        } catch (e: Exception) {
            Log.e(TAG(), "Error preparing ExoPlayer: ${e.message}", e)
        }
    }

    private suspend fun fetchStreamsWithTimeout(videoId: String): Streams? {
        if (youtubeApiCircuitBreakerOpen && System.currentTimeMillis() - youtubeApiCircuitBreakerOpenedAt < youtubeApiCircuitBreakerTimeoutMs) {
            Log.w(TAG(), "YouTube API circuit breaker open, skipping remote fetch for $videoId")
            return null
        }

        return withContext(Dispatchers.IO) {
            val result = runCatching {
                withTimeoutOrNull(10_000L) {
                    MediaServiceRepository.instance.getStreams(videoId)
                }
            }

            result.fold(
                onSuccess = { streamsResponse ->
                    if (streamsResponse == null) {
                        // A temporary slow response should not disable all following YouTube
                        // requests. Reserve the breaker for explicit rate limiting (HTTP 429).
                        Log.w(TAG(), "Stream fetch timed out for $videoId")
                        toastFromMainDispatcher(getString(R.string.unknown_error))
                        null
                    } else {
                        closeYoutubeApiCircuitBreaker()
                        DeArrowUtil.deArrowStreams(streamsResponse, videoId)
                    }
                },
                onFailure = { throwable ->
                    if (throwable is HttpException && throwable.code() == 429) {
                        openYoutubeApiCircuitBreaker()
                    }
                    Log.e(TAG(), throwable.stackTraceToString())
                    toastFromMainDispatcher(throwable.localizedMessage.orEmpty())
                    null
                }
            )
        }
    }

    private fun openYoutubeApiCircuitBreaker() {
        youtubeApiCircuitBreakerOpen = true
        youtubeApiCircuitBreakerOpenedAt = System.currentTimeMillis()
    }

    private fun closeYoutubeApiCircuitBreaker() {
        youtubeApiCircuitBreakerOpen = false
    }

    /**
     * Plays the next video from the queue
     */
    private fun playNextVideo(nextId: String? = null) {
        if (nextId == null) {
            if (!PlayerHelper.isAutoPlayEnabled(playlistId != null) || !shouldHandleAutoplay) return
        }

        val nextVideo = nextId ?: PlayingQueue.getNext() ?: return

        // play new video on background. Mark this navigation as automatic so it keeps the
        // current radio/genre seed; a manual selection starts a new seed instead.
        isAutomaticQueueNavigation = true
        try {
            navigateVideo(nextVideo)
        } finally {
            isAutomaticQueueNavigation = false
        }
    }

    private suspend fun getSponsorBlockSegments(targetVideoId: String = videoId): List<Segment> {
        return runCatching {
            MediaServiceRepository.instance.getSegments(
                targetVideoId,
                sponsorBlockConfig.keys.toList(),
                listOf("skip", "mute", "full", "poi", "chapter")
            ).segments
        }.getOrElse { emptyList() }
    }

    override fun navigateVideo(videoId: String) {
        val targetAlreadyInQueue = PlayingQueue.getStreams().any { it.url?.toID() == videoId }
        val manualGenreChange = !isAutomaticQueueNavigation &&
            ((playlistId == null && channelId == null && !preserveExplicitQueue) || !targetAlreadyInQueue)

        if (manualGenreChange) {
            // The user explicitly chose another video: it becomes the new radio seed. This is
            // what makes switching from vallenato to salsa (or any other genre) intentional.
            autoplaySeedGenres = emptySet()
            autoplaySeedKeywords = emptySet()
            autoplaySeedUploader = null
            autoplaySeedIsMusic = false
            preserveExplicitQueue = false
            playlistId = null
            channelId = null
            strictPlaylistMode = false
            shufflePlaylist = false
            PlayingQueue.clear()
        }

        // Strict playlists may already contain a fully prepared item. Reuse it when available.
        val queuedIndex = findMediaItemIndex(videoId)
        if (queuedIndex >= 0 && exoPlayer?.currentMediaItemIndex != queuedIndex) {
            exoPlayer?.seekToDefaultPosition(queuedIndex)
            exoPlayer?.play()
            return
        }

        // Normal continuous playback prefetches the Streams response instead of injecting a raw
        // MediaItem into ExoPlayer. Promote it here so startPlayback() skips the API call and uses
        // the same proven source-building path as a normal/manual playback.
        val preparedStreams = prefetchedStreamsById.remove(videoId)
        if (preparedStreams != null) {
            prefetchedStreams = preparedStreams
        }

        this.streams = null
        prefetchedStreamsById.clear()

        // Immediately update PlayingQueue with provisional StreamItem to prevent race conditions
        // where rapid next/prev clicks could return the same video ID.
        val provisionalUrl = "https://www.youtube.com/watch?v=$videoId"
        PlayingQueue.updateCurrent(StreamItem(url = provisionalUrl))

        super.navigateVideo(videoId)
    }

    /**
     * Sets the [MediaItem] with the [streams] into the [exoPlayer]
     */
    private fun setStreamSource(forceHls: Boolean = false) {
        val currentStreams = streams ?: return
        val mediaSource = buildMediaSourceForStreams(currentStreams, videoId, forceHls)
        if (mediaSource == null) {
            toastFromMainThread(R.string.unknown_error)
            return
        }

        // Current item first; prefetchNextTrack() appends the next fully-built MediaSource.
        exoPlayer?.setMediaSource(mediaSource)
        exoPlayer?.seekTo(0L)
    }

    private suspend fun setStrictPlaylistMediaItems(currentStreams: Streams) {
        val currentItem = buildMediaItemForStreams(currentStreams, videoId) ?: return
        val mediaItems = mutableListOf(currentItem)

        // Loading stream manifests for an entire long playlist before playback can make the app
        // feel frozen. Prepare only the immediate next item; subsequent items are appended as
        // playback advances, preserving order while keeping startup fast.
        val nextVideoId = PlayingQueue.getNext()
        if (!nextVideoId.isNullOrBlank() && nextVideoId != videoId) {
            val nextStreams = runCatching {
                withTimeoutOrNull(10_000L) {
                    MediaServiceRepository.instance.getStreams(nextVideoId)
                }?.let {
                    DeArrowUtil.deArrowStreams(it, nextVideoId)
                }
            }.getOrNull()

            if (nextStreams != null) {
                prefetchedStreamsById[nextVideoId] = nextStreams
                buildMediaItemForStreams(nextStreams, nextVideoId)?.let(mediaItems::add)
            }
        }

        withContext(Dispatchers.Main) {
            exoPlayer?.setMediaItems(mediaItems, 0, C.TIME_UNSET)
        }
    }

    private fun getSubtitleConfigs(streams: Streams): List<SubtitleConfiguration> =
        streams.subtitles.mapNotNull { subtitle ->
            val subtitleUrl = subtitle.url ?: return@mapNotNull null
            val roleFlags = getSubtitleRoleFlags(subtitle)
            SubtitleConfiguration.Builder(subtitleUrl.toUri())
                .setRoleFlags(roleFlags)
                .setLanguage(subtitle.code)
                .setMimeType(subtitle.mimeType)
                .build()
        }

    private fun createMediaItem(
        uri: Uri,
        mimeType: String,
        streams: Streams,
        itemVideoId: String = videoId
    ) = MediaItem.Builder()
        .setUri(uri)
        .setMimeType(mimeType)
        .setSubtitleConfigurations(getSubtitleConfigs(streams))
        .setMetadata(streams, itemVideoId)
        .build()
}
