package com.keylightpiano.pianotimeline.player

import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

/**
 * Owns a single ExoPlayer and exposes its state as a Flow.
 *
 * Caching: media is read through a CacheDataSource backed by the app-wide
 * MediaCache singleton. First play of a track streams from the network and
 * writes to disk simultaneously; subsequent plays read straight from disk.
 *
 * Threading: ExoPlayer must be created and called on a single thread (the main
 * thread here, which is where Compose callbacks land). The position-polling loop
 * runs on a coroutine but only reads player state, which is main-safe because we
 * launch it on the main dispatcher.
 */
@OptIn(UnstableApi::class)
class PlayerManager(
    private val context: Context,
    private val scope: CoroutineScope,
    private val onTrackEnded: () -> Unit = {},
    // Fired when a track finishes playing all the way through, which means it has
    // now been streamed-and-cached end to end. The ViewModel uses this to refresh
    // the "downloaded" glyphs without waiting for an app restart. Best-effort: it
    // signals "the cache probably changed, re-check", not a guarantee.
    private val onCacheMayHaveChanged: () -> Unit = {}
) {
    private val _state = MutableStateFlow(PlaybackState())
    val state: StateFlow<PlaybackState> = _state.asStateFlow()

    // The recording id currently loaded into the player.
    private var currentId: String? = null

    private var positionJob: Job? = null

    private val cacheDataSourceFactory: CacheDataSource.Factory by lazy {
        val upstream = DefaultHttpDataSource.Factory()
        CacheDataSource.Factory()
            .setCache(MediaCache.get(context))
            .setUpstreamDataSourceFactory(upstream)
            .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
    }

    private val player: ExoPlayer by lazy {
        ExoPlayer.Builder(context)
            .setMediaSourceFactory(ProgressiveMediaSource.Factory(cacheDataSourceFactory))
            .build()
            .also { it.addListener(playerListener) }
    }

    private val playerListener = object : Player.Listener {
        override fun onPlaybackStateChanged(playbackState: Int) {
            updateState()
            if (playbackState == Player.STATE_READY) startPositionLoop()
            if (playbackState == Player.STATE_ENDED) {
                stopPositionLoop()
                // The track played to the end, so its bytes are now fully in the
                // disk cache. Let the ViewModel know before advancing, so the
                // just-finished recording can show its "downloaded" glyph.
                onCacheMayHaveChanged()
                onTrackEnded()
            }
        }

        override fun onIsPlayingChanged(isPlaying: Boolean) {
            updateState()
            if (isPlaying) startPositionLoop() else stopPositionLoop()
        }
    }

    /**
     * Play a recording from its full (already percent-encoded) URL.
     * If the same recording is tapped while playing, this toggles pause/resume.
     */
    fun playOrToggle(recordingId: String, url: String) {
        if (recordingId == currentId) {
            // Same track -- toggle play/pause.
            if (player.isPlaying) player.pause() else player.play()
            return
        }
        // New track.
        currentId = recordingId
        player.setMediaItem(MediaItem.fromUri(url))
        player.prepare()
        player.play()
        updateState()
    }

    fun pause() {
        player.pause()
    }

    /**
     * Load a track and seek to [positionMs] but do NOT start playing -- used to
     * restore the last session on launch, Spotify-style (comes back paused).
     * ExoPlayer defers the seek until the item is prepared enough to honor it,
     * so calling prepare() then seekTo() before playback is safe.
     */
    fun preparePaused(recordingId: String, url: String, positionMs: Long) {
        currentId = recordingId
        player.setMediaItem(MediaItem.fromUri(url))
        player.prepare()
        player.seekTo(positionMs.coerceAtLeast(0L))
        // Deliberately no play(). Reflect the restored position right away so the
        // mini-player renders at the correct spot before ExoPlayer reports ready.
        _state.value = PlaybackState(
            nowPlayingId = recordingId,
            isPlaying = false,
            isBuffering = false,
            positionMs = positionMs.coerceAtLeast(0L),
            durationMs = 0L
        )
    }

    fun resume() {
        player.play()
    }

    /** Restart the current track from the beginning (used by Repeat One). */
    fun replayCurrent() {
        player.seekTo(0)
        player.play()
        updateState()
    }

    fun seekTo(positionMs: Long) {
        player.seekTo(positionMs)
        updateState()
    }

    /** Stop and clear the current track entirely (mini-player dismissed). */
    fun stop() {
        player.stop()
        player.clearMediaItems()
        currentId = null
        stopPositionLoop()
        _state.value = PlaybackState()
    }

    private fun startPositionLoop() {
        if (positionJob?.isActive == true) return
        positionJob = scope.launch {
            while (true) {
                updateState()
                delay(200)  // 5x/sec -- smooth enough for a progress bar
            }
        }
    }

    private fun stopPositionLoop() {
        positionJob?.cancel()
        positionJob = null
    }

    private fun updateState() {
        _state.value = PlaybackState(
            nowPlayingId = currentId,
            isPlaying = player.isPlaying,
            isBuffering = player.playbackState == Player.STATE_BUFFERING,
            positionMs = player.currentPosition.coerceAtLeast(0L),
            durationMs = player.duration.coerceAtLeast(0L)
        )
    }

    /** Release the player. Call when the owning ViewModel is cleared. */
    fun release() {
        stopPositionLoop()
        player.removeListener(playerListener)
        player.release()
    }
}
