package dev.castarr.tv.player

import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import java.util.Locale

/**
 * Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
 * keys and Google Assistant work alongside the phone remote (TV-PP/TV-VC of
 * the TV app quality guidelines).
 */
class PlayerController(
    context: Context,
    private val onChanged: () -> Unit,
) {

    val player: ExoPlayer = ExoPlayer.Builder(context).build()
    // Explicit id: Media3 keeps a process-wide registry and throws when a
    // session with the same (default, empty) id is still registered.
    private val mediaSession: MediaSession =
        MediaSession.Builder(context, player).setId("castarr").build()

    var channelName: String = ""
        private set
    var channelGroup: String = ""
        private set
    var errorMessage: String? = null
        private set
    private var retryCount = 0
    private var lastUrl: String = ""
    private val retryHandler = android.os.Handler(android.os.Looper.getMainLooper())
    private var pendingRetry: Runnable? = null

    /** True while a reconnect attempt is scheduled or running. */
    var reconnecting: Boolean = false
        private set

    /** When the current channel was opened — used to pace re-entry. */
    var startedAt: Long = 0L
        private set

    /** One selectable audio track of the current stream. */
    data class AudioTrack(val label: String, val selected: Boolean)

    init {
        player.addListener(object : Player.Listener {
            override fun onPlaybackStateChanged(playbackState: Int) = onChanged()

            override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()

            override fun onTracksChanged(tracks: Tracks) = onChanged()

            override fun onPlayerError(error: PlaybackException) {
                // Self-heal with spacing: providers rate-limit reconnects, so
                // three back-to-back attempts all land in the same blocked
                // window and burn the budget in under a second.
                if (retryCount < RETRY_DELAYS_MS.size && lastUrl.isNotEmpty()) {
                    val delay = RETRY_DELAYS_MS[retryCount]
                    retryCount++
                    reconnecting = true
                    onChanged()
                    scheduleRetry(delay)
                    return
                }
                reconnecting = false
                errorMessage = "unreachable"
                onChanged()
            }
        })
    }

    val state: String
        get() = when {
            errorMessage != null -> "error"
            reconnecting -> "reconnecting"
            player.playbackState == Player.STATE_BUFFERING -> "buffering"
            player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
            player.playbackState == Player.STATE_READY -> "paused"
            else -> "idle"
        }

    val hasMedia: Boolean
        get() = player.mediaItemCount > 0 && player.playbackState != Player.STATE_IDLE

    fun play(url: String, name: String, group: String) {
        if (url.isEmpty()) return
        cancelRetry()
        errorMessage = null
        reconnecting = false
        retryCount = 0
        lastUrl = url
        startedAt = System.currentTimeMillis()
        channelName = name.ifEmpty { url }
        channelGroup = group
        val item = MediaItem.Builder()
            .setUri(url)
            .setMediaMetadata(MediaMetadata.Builder().setTitle(channelName).build())
            .build()
        player.setMediaItem(item)
        player.prepare()
        player.play()
    }

    private fun scheduleRetry(delayMs: Long) {
        cancelRetry()
        val runnable = Runnable {
            pendingRetry = null
            player.setMediaItem(MediaItem.fromUri(lastUrl))
            player.prepare()
            player.play()
        }
        pendingRetry = runnable
        retryHandler.postDelayed(runnable, delayMs)
    }

    private fun cancelRetry() {
        pendingRetry?.let { retryHandler.removeCallbacks(it) }
        pendingRetry = null
    }

    /** Retries the current channel immediately, resetting the backoff. */
    fun retryNow() {
        if (lastUrl.isEmpty()) return
        cancelRetry()
        retryCount = 0
        errorMessage = null
        reconnecting = true
        onChanged()
        player.setMediaItem(MediaItem.fromUri(lastUrl))
        player.prepare()
        player.play()
    }

    fun toggle() {
        if (!hasMedia) return
        // playWhenReady, not isPlaying: the latter is false while buffering,
        // which made pausing during a stall silently do nothing.
        if (player.playWhenReady) pause() else resume()
    }

    fun pause() {
        player.playWhenReady = false
    }

    fun resume() {
        if (!hasMedia) return
        if (player.playbackState == Player.STATE_ENDED) player.seekToDefaultPosition()
        player.play()
    }

    fun stop() {
        cancelRetry()
        reconnecting = false
        player.stop()
        player.clearMediaItems()
        channelName = ""
        channelGroup = ""
        errorMessage = null
        retryCount = 0
        lastUrl = ""
        onChanged()
    }

    /** Audio tracks of the current stream, in group order. */
    fun audioTracks(): List<AudioTrack> {
        val result = mutableListOf<AudioTrack>()
        val seen = mutableSetOf<String>()
        player.currentTracks.groups
            .filter { it.type == C.TRACK_TYPE_AUDIO }
            .forEach { group ->
                for (i in 0 until group.length) {
                    if (!group.isTrackSupported(i)) continue
                    val format = group.getTrackFormat(i)
                    var label = format.label
                        ?: format.language?.let { Locale(it).getDisplayLanguage(Locale.GERMAN) }
                            ?.replaceFirstChar { c -> c.uppercase() }
                        ?: "Ton ${result.size + 1}"
                    if (!seen.add(label)) label = "$label ${result.size + 1}"
                    result.add(AudioTrack(label, group.isTrackSelected(i)))
                }
            }
        return result
    }

    /** Selects the audio track at [index] of [audioTracks]'s order. */
    fun selectAudio(index: Int) {
        var flat = 0
        player.currentTracks.groups
            .filter { it.type == C.TRACK_TYPE_AUDIO }
            .forEach { group ->
                for (i in 0 until group.length) {
                    if (!group.isTrackSupported(i)) continue
                    if (flat == index) {
                        player.trackSelectionParameters = player.trackSelectionParameters
                            .buildUpon()
                            .setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, i))
                            .build()
                        onChanged()
                        return
                    }
                    flat++
                }
            }
    }

    fun seekBy(deltaSeconds: Long) {
        if (!hasMedia || !player.isCurrentMediaItemSeekable) return
        // An unknown duration is C.TIME_UNSET (large negative). Clamping the
        // upper bound to it collapsed every seek to position 0.
        val duration = player.duration
        var target = (player.currentPosition + deltaSeconds * 1000).coerceAtLeast(0)
        if (duration > 0) target = target.coerceAtMost(duration)
        player.seekTo(target)
    }

    fun release() {
        cancelRetry()
        mediaSession.release()
        player.release()
    }

    private companion object {
        /** Spacing between reconnect attempts after a stream drops. */
        val RETRY_DELAYS_MS = longArrayOf(1_000L, 3_000L, 8_000L)
    }
}
