/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.media

import android.media.session.MediaController
import android.media.session.PlaybackState
import android.os.SystemProperties
import android.util.Log
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState
import com.android.systemui.util.concurrency.DelayableExecutor
import java.util.concurrent.TimeUnit
import javax.inject.Inject

private const val DEBUG = true
private const val TAG = "MediaTimeout"
private val PAUSED_MEDIA_TIMEOUT = SystemProperties
        .getLong("debug.sysui.media_timeout", TimeUnit.MINUTES.toMillis(10))

/**
 * Controller responsible for keeping track of playback states and expiring inactive streams.
 */
@SysUISingleton
class MediaTimeoutListener @Inject constructor(
    private val mediaControllerFactory: MediaControllerFactory,
    @Main private val mainExecutor: DelayableExecutor
) : MediaDataManager.Listener {

    private val mediaListeners: MutableMap<String, PlaybackStateListener> = mutableMapOf()

    /**
     * Callback representing that a media object is now expired:
     * @param token Media session unique identifier
     * @param pauseTimeout True when expired for {@code PAUSED_MEDIA_TIMEOUT}
     */
    lateinit var timeoutCallback: (String, Boolean) -> Unit

    override fun onMediaDataLoaded(key: String, oldKey: String?, data: MediaData) {
        if (mediaListeners.containsKey(key)) {
            return
        }
        // Having an old key means that we're migrating from/to resumption. We should update
        // the old listener to make sure that events will be dispatched to the new location.
        val migrating = oldKey != null && key != oldKey
        if (migrating) {
            val reusedListener = mediaListeners.remove(oldKey)
            if (reusedListener != null) {
                val wasPlaying = reusedListener.playing ?: false
                if (DEBUG) Log.d(TAG, "migrating key $oldKey to $key, for resumption")
                reusedListener.mediaData = data
                reusedListener.key = key
                mediaListeners[key] = reusedListener
                if (wasPlaying != reusedListener.playing) {
                    // If a player becomes active because of a migration, we'll need to broadcast
                    // its state. Doing it now would lead to reentrant callbacks, so let's wait
                    // until we're done.
                    mainExecutor.execute {
                        if (mediaListeners[key]?.playing == true) {
                            if (DEBUG) Log.d(TAG, "deliver delayed playback state for $key")
                            timeoutCallback.invoke(key, false /* timedOut */)
                        }
                    }
                }
                return
            } else {
                Log.w(TAG, "Old key $oldKey for player $key doesn't exist. Continuing...")
            }
        }
        mediaListeners[key] = PlaybackStateListener(key, data)
    }

    override fun onMediaDataRemoved(key: String) {
        mediaListeners.remove(key)?.destroy()
    }

    fun isTimedOut(key: String): Boolean {
        return mediaListeners[key]?.timedOut ?: false
    }

    private inner class PlaybackStateListener(
        var key: String,
        data: MediaData
    ) : MediaController.Callback() {

        var timedOut = false
        var playing: Boolean? = null

        var mediaData: MediaData = data
            set(value) {
                mediaController?.unregisterCallback(this)
                field = value
                mediaController = if (field.token != null) {
                    mediaControllerFactory.create(field.token)
                } else {
                    null
                }
                mediaController?.registerCallback(this)
                // Let's register the cancellations, but not dispatch events now.
                // Timeouts didn't happen yet and reentrant events are troublesome.
                processState(mediaController?.playbackState, dispatchEvents = false)
            }

        // Resume controls may have null token
        private var mediaController: MediaController? = null
        private var cancellation: Runnable? = null

        init {
            mediaData = data
        }

        fun destroy() {
            mediaController?.unregisterCallback(this)
            cancellation?.run()
        }

        override fun onPlaybackStateChanged(state: PlaybackState?) {
            processState(state, dispatchEvents = true)
        }

        private fun processState(state: PlaybackState?, dispatchEvents: Boolean) {
            if (DEBUG) {
                Log.v(TAG, "processState: $state")
            }

            val isPlaying = state != null && isPlayingState(state.state)
            if (playing == isPlaying && playing != null) {
                return
            }
            playing = isPlaying

            if (!isPlaying) {
                if (DEBUG) {
                    Log.v(TAG, "schedule timeout for $key")
                }
                if (cancellation != null) {
                    if (DEBUG) Log.d(TAG, "cancellation already exists, continuing.")
                    return
                }
                expireMediaTimeout(key, "PLAYBACK STATE CHANGED - $state")
                cancellation = mainExecutor.executeDelayed({
                    cancellation = null
                    if (DEBUG) {
                        Log.v(TAG, "Execute timeout for $key")
                    }
                    timedOut = true
                    // this event is async, so it's safe even when `dispatchEvents` is false
                    timeoutCallback(key, timedOut)
                }, PAUSED_MEDIA_TIMEOUT)
            } else {
                expireMediaTimeout(key, "playback started - $state, $key")
                timedOut = false
                if (dispatchEvents) {
                    timeoutCallback(key, timedOut)
                }
            }
        }

        private fun expireMediaTimeout(mediaKey: String, reason: String) {
            cancellation?.apply {
                if (DEBUG) {
                    Log.v(TAG,
                            "media timeout cancelled for  $mediaKey, reason: $reason")
                }
                run()
            }
            cancellation = null
        }
    }
}
