/**
 * Teapod
 *
 * Copyright 2020-2022  <seil0@mosad.xyz>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
 * MA 02110-1301, USA.
 *
 */

package org.mosad.teapod.ui.activity.player

import android.app.Application
import android.net.Uri
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import kotlinx.coroutines.*
import org.mosad.teapod.R
import org.mosad.teapod.parser.crunchyroll.*
import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.util.metadb.EpisodeMeta
import org.mosad.teapod.util.metadb.Meta
import org.mosad.teapod.util.metadb.MetaDBController
import org.mosad.teapod.util.metadb.TVShowMeta
import org.mosad.teapod.util.toPlayheadsMap
import java.util.*
import kotlin.concurrent.scheduleAtFixedRate

/**
 * PlayerViewModel handles all stuff related to media/episodes.
 * When currentEpisode is changed the player will start playing it (not initial media),
 * the next episode will be update and the callback is handled.
 */
class PlayerViewModel(application: Application) : AndroidViewModel(application) {
    private val classTag = javaClass.name

    val player = ExoPlayer.Builder(application).build()
    private val mediaSession = MediaSessionCompat(application, "TEAPOD_PLAYER_SESSION")
    private val playheadAutoUpdate: TimerTask

    val currentEpisodeChangedListener = ArrayList<() -> Unit>()
    private var currentPlayhead: Long = 0

    // tmdb/meta data
    var mediaMeta: Meta? = null
        internal set
    var currentEpisodeMeta: EpisodeMeta? = null
        internal set
    var currentPlayheads = mapOf<String, PlayheadObject>()
        internal set
    var currentIntroMetadata: DatalabIntro = NoneDatalabIntro
        internal set
//    var tmdbTVSeason: TMDBTVSeason? =null
//        internal set

    // crunchyroll episodes/playback
    var episodes = NoneEpisodes
        internal set
    var currentEpisode = NoneEpisode
        internal set
    var currentVersion = NoneVersion
        internal set
    var currentStreams = NoneStreams
        internal set

    // current playback settings
    var currentAudioLocale: Locale = Preferences.preferredAudioLocale
        internal set
    var currentSubtitleLocale: Locale = Preferences.preferredSubtitleLocale
        internal set

    init {
        // disable platform diagnostics since they might be shared with google
        ExoPlayer.Builder(application).setUsePlatformDiagnostics(false)

        initMediaSession()

        player.addListener(object : Player.Listener {
            override fun onPlaybackStateChanged(state: Int) {
                super.onPlaybackStateChanged(state)

                if (state == ExoPlayer.STATE_ENDED) updatePlayhead()
            }

            override fun onIsPlayingChanged(isPlaying: Boolean) {
                super.onIsPlayingChanged(isPlaying)

                if (!isPlaying) updatePlayhead()
            }
        })

        playheadAutoUpdate = Timer().scheduleAtFixedRate(0, 30000) {
            viewModelScope.launch {
                if (player.isPlaying){
                    updatePlayhead()
                }
            }
        }
    }

    override fun onCleared() {
        super.onCleared()

        mediaSession.release()
        player.release()

        Log.d(classTag, "Released player")
    }

    /**
     * set the media session to active
     * create a media session connector to set title and description
     */
    private fun initMediaSession() {
        val mediaSessionConnector = MediaSessionConnector(mediaSession)
        mediaSessionConnector.setPlayer(player)

        mediaSession.isActive = true
    }

    fun loadMediaAsync(seasonId: String, episodeId: String) = viewModelScope.launch {
        episodes = Crunchyroll.episodes(seasonId)

        listOf(
            viewModelScope.launch { mediaMeta = loadMediaMeta(episodes.data.first().seriesId) },
            viewModelScope.launch {
                val episodeIDs = episodes.data.map { it.id }
                currentPlayheads = Crunchyroll.playheads(episodeIDs).toPlayheadsMap()
            }
        ).joinAll()
        Log.d(classTag, "meta: $mediaMeta")

        setCurrentEpisode(episodeId)
        playCurrentMedia(currentPlayhead)
    }

    fun setLanguage(newAudioLocale: Locale, newSubtitleLocale: Locale) {
        // TODO if the audio locale has changes update the streams, if only the subtitle locale has changed load the new stream
        if (newAudioLocale != currentAudioLocale) {
            currentAudioLocale = newAudioLocale

            currentVersion = currentEpisode.versions?.firstOrNull {
                it.audioLocale == currentAudioLocale.toLanguageTag()
            } ?: currentEpisode.versions?.first() ?: NoneVersion

            viewModelScope.launch {
                currentStreams = Crunchyroll.streamsFromMediaGUID(currentVersion.mediaGUID)
                Log.d(classTag, currentVersion.toString())

                playCurrentMedia(player.currentPosition)
            }
        } else if (newSubtitleLocale != currentSubtitleLocale) {
            currentSubtitleLocale = newSubtitleLocale
            playCurrentMedia(player.currentPosition)
        }

        // else nothing has changed so no need do do anything
    }

    // player actions

    /**
     * Seeks to a offset position specified in milliseconds in the current MediaItem.
     * @param offset The offset position in the current MediaItem.
     */
    fun seekToOffset(offset: Long) {
        player.seekTo(player.currentPosition + offset)
    }

    fun togglePausePlay() {
        if (player.isPlaying) player.pause() else player.play()
    }

    /**
     * play the next episode, if nextEpisodeId is not null
     */
    fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId ->
        updatePlayhead() // update playhead before switching to new episode
        viewModelScope.launch { setCurrentEpisode(nextEpisodeId, startPlayback = true) }
    }

    /**
     * Set currentEpisodeCr to the episode of the given ID
     * @param episodeId The ID of the episode you want to set currentEpisodeCr to
     */
    suspend fun setCurrentEpisode(episodeId: String, startPlayback: Boolean = false) {
        currentEpisode = episodes.data.find { episode ->
            episode.id == episodeId
        } ?: NoneEpisode

        // TODO improve handling of none present seasons/episodes
        // update current episode meta
        currentEpisodeMeta = if (mediaMeta is TVShowMeta && currentEpisode.episodeNumber != null) {
            (mediaMeta as TVShowMeta)
                .seasons.getOrNull(currentEpisode.seasonNumber - 1)
                ?.episodes?.getOrNull(currentEpisode.episodeNumber!! - 1)
        } else {
            null
        }

        // update player gui (title, next ep button) after currentEpisode has changed
        currentEpisodeChangedListener.forEach { it() }

        // needs to be blocking, currentPlayback must be present when calling playCurrentMedia()
        joinAll(
            viewModelScope.launch(Dispatchers.IO) {
                currentVersion = currentEpisode.versions?.firstOrNull {
                    it.audioLocale == currentAudioLocale.toLanguageTag()
                } ?: currentEpisode.versions?.first() ?: NoneVersion

                // get the current streams object, if no version is set, use streamsLink
                currentStreams = if (currentVersion != NoneVersion) {
                    Crunchyroll.streamsFromMediaGUID(currentVersion.mediaGUID)
                } else {
                    Crunchyroll.streams(currentEpisode.streamsLink)
                }
                Log.d(classTag, currentVersion.toString())
            },
            viewModelScope.launch(Dispatchers.IO) {
                Crunchyroll.playheads(listOf(currentEpisode.id)).data.firstOrNull {
                    it.contentId == currentEpisode.id
                }?.let {
                    // if the episode was fully watched, start at the beginning
                    currentPlayhead = if (it.fullyWatched) {
                        0
                    } else {
                        (it.playhead.times(1000)).toLong()
                    }
                }
            },
            viewModelScope.launch(Dispatchers.IO) {
                currentIntroMetadata = Crunchyroll.datalabIntro(currentEpisode.id)
            }
        )
        Log.d(classTag, "streams: ${currentEpisode.streamsLink}")

        if (startPlayback) {
            playCurrentMedia()
        }
    }

    /**
     * Play the current media from currentStreams.
     *
     * @param seekPosition The seek position for the media (default = 0).
     */
    fun playCurrentMedia(seekPosition: Long = 0) {
        // get preferred stream url, set current language if it differs from the preferred one
        val preferredLocale = currentSubtitleLocale
        val fallbackLocal = Locale.US
        val url = when {
            currentStreams.data[0].adaptive_hls.containsKey(preferredLocale.toLanguageTag()) -> {
                currentStreams.data[0].adaptive_hls[preferredLocale.toLanguageTag()]?.url
            }
            currentStreams.data[0].adaptive_hls.containsKey(fallbackLocal.toLanguageTag()) -> {
                currentSubtitleLocale = fallbackLocal
                currentStreams.data[0].adaptive_hls[fallbackLocal.toLanguageTag()]?.url
            }
            else -> {
                // if no language tag is present use the first entry
                currentSubtitleLocale = Locale.ROOT
                currentStreams.data[0].adaptive_hls.entries.first().value.url
            }
        }
        Log.i(classTag, "stream url: $url")

        // create the media item
        val mediaItem = MediaItem.fromUri(Uri.parse(url))
        player.setMediaItem(mediaItem)
        player.prepare()

        if (seekPosition > 0) player.seekTo(seekPosition)
        player.playWhenReady = true
    }

    /**
     * Returns the current episode title (with episode number, if it's a tv show)
     */
    fun getMediaTitle(): String {
        // currentEpisode.episodeNumber defines the media type (tv show = none null, movie = null)
        return if (currentEpisode.episodeNumber != null) {
            getApplication<Application>().getString(
                R.string.component_episode_title,
                currentEpisode.episode,
                currentEpisode.title
            )
        } else {
            currentEpisode.title
        }
    }

    /**
     * Check if the current episode is the last in the episodes list.
     *
     * @return Boolean: true if it is the last, else false.
     */
    fun currentEpisodeIsLastEpisode(): Boolean {
        return episodes.data.lastOrNull()?.id == currentEpisode.id
    }

    private suspend fun loadMediaMeta(crSeriesId: String): Meta? {
        return MetaDBController.getTVShowMetadata(crSeriesId)
    }

    /**
     * Update the playhead of the current episode, if currentPosition > 1000ms.
     */
    private fun updatePlayhead() {
        val playhead = (player.currentPosition / 1000)

        if (playhead > 0 && Preferences.updatePlayhead) {
            // don't use viewModelScope here. This task may needs to finish, when ViewModel will be cleared
            CoroutineScope(Dispatchers.IO).launch { Crunchyroll.postPlayheads(currentEpisode.id, playhead.toInt()) }
            Log.i(javaClass.name, "Set playhead for episode ${currentEpisode.id} to $playhead sec.")
        }

        viewModelScope.launch {
            val episodeIDs = episodes.data.map { it.id }
            currentPlayheads = Crunchyroll.playheads(episodeIDs).toPlayheadsMap()
        }
    }

}
