/*
 * 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.app.smartspace.SmartspaceTarget
import android.content.ComponentName
import android.content.Context
import android.media.session.MediaController
import android.media.session.MediaController.PlaybackInfo
import android.media.session.MediaSession
import android.media.session.MediaSessionManager
import android.util.Log
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.statusbar.phone.NotificationListenerWithPlugins
import java.util.concurrent.Executor
import javax.inject.Inject

private const val TAG = "MediaSessionBasedFilter"

/**
 * Filters media loaded events for local media sessions while an app is casting.
 *
 * When an app is casting there can be one remote media sessions and potentially more local media
 * sessions. In this situation, there should only be a media object for the remote session. To
 * achieve this, update events for the local session need to be filtered.
 */
class MediaSessionBasedFilter @Inject constructor(
    context: Context,
    private val sessionManager: MediaSessionManager,
    @Main private val foregroundExecutor: Executor,
    @Background private val backgroundExecutor: Executor
) : MediaDataManager.Listener {

    private val listeners: MutableSet<MediaDataManager.Listener> = mutableSetOf()

    // Keep track of MediaControllers for a given package to check if an app is casting and it
    // filter loaded events for local sessions.
    private val packageControllers: LinkedHashMap<String, MutableList<MediaController>> =
            LinkedHashMap()

    // Keep track of the key used for the session tokens. This information is used to know when to
    // dispatch a removed event so that a media object for a local session will be removed.
    private val keyedTokens: MutableMap<String, MutableSet<MediaSession.Token>> = mutableMapOf()

    // Keep track of which media session tokens have associated notifications.
    private val tokensWithNotifications: MutableSet<MediaSession.Token> = mutableSetOf()

    private val sessionListener = object : MediaSessionManager.OnActiveSessionsChangedListener {
        override fun onActiveSessionsChanged(controllers: List<MediaController>) {
            handleControllersChanged(controllers)
        }
    }

    init {
        backgroundExecutor.execute {
            val name = ComponentName(context, NotificationListenerWithPlugins::class.java)
            sessionManager.addOnActiveSessionsChangedListener(sessionListener, name)
            handleControllersChanged(sessionManager.getActiveSessions(name))
        }
    }

    /**
     * Add a listener for filtered [MediaData] changes
     */
    fun addListener(listener: MediaDataManager.Listener) = listeners.add(listener)

    /**
     * Remove a listener that was registered with addListener
     */
    fun removeListener(listener: MediaDataManager.Listener) = listeners.remove(listener)

    /**
     * May filter loaded events by not passing them along to listeners.
     *
     * If an app has only one session with playback type PLAYBACK_TYPE_REMOTE, then assuming that
     * the app is casting. Sometimes apps will send redundant updates to a local session with
     * playback type PLAYBACK_TYPE_LOCAL. These updates should be filtered to improve the usability
     * of the media controls.
     */
    override fun onMediaDataLoaded(key: String, oldKey: String?, info: MediaData) {
        backgroundExecutor.execute {
            info.token?.let {
                tokensWithNotifications.add(it)
            }
            val isMigration = oldKey != null && key != oldKey
            if (isMigration) {
                keyedTokens.remove(oldKey)?.let { removed -> keyedTokens.put(key, removed) }
            }
            if (info.token != null) {
                keyedTokens.get(key)?.let {
                    tokens ->
                    tokens.add(info.token)
                } ?: run {
                    val tokens = mutableSetOf(info.token)
                    keyedTokens.put(key, tokens)
                }
            }
            // Determine if an app is casting by checking if it has a session with playback type
            // PLAYBACK_TYPE_REMOTE.
            val remoteControllers = packageControllers.get(info.packageName)?.filter {
                it.playbackInfo?.playbackType == PlaybackInfo.PLAYBACK_TYPE_REMOTE
            }
            // Limiting search to only apps with a single remote session.
            val remote = if (remoteControllers?.size == 1) remoteControllers.firstOrNull() else null
            if (isMigration || remote == null || remote.sessionToken == info.token ||
                    !tokensWithNotifications.contains(remote.sessionToken)) {
                // Not filtering in this case. Passing the event along to listeners.
                dispatchMediaDataLoaded(key, oldKey, info)
            } else {
                // Filtering this event because the app is casting and the loaded events is for a
                // local session.
                Log.d(TAG, "filtering key=$key local=${info.token} remote=${remote?.sessionToken}")
                // If the local session uses a different notification key, then lets go a step
                // farther and dismiss the media data so that media controls for the local session
                // don't hang around while casting.
                if (!keyedTokens.get(key)!!.contains(remote.sessionToken)) {
                    dispatchMediaDataRemoved(key)
                }
            }
        }
    }

    override fun onSmartspaceMediaDataLoaded(key: String, data: SmartspaceTarget) {
        backgroundExecutor.execute {
            dispatchSmartspaceMediaDataLoaded(key, data)
        }
    }

    override fun onMediaDataRemoved(key: String) {
        // Queue on background thread to ensure ordering of loaded and removed events is maintained.
        backgroundExecutor.execute {
            keyedTokens.remove(key)
            dispatchMediaDataRemoved(key)
        }
    }

    override fun onSmartspaceMediaDataRemoved(key: String) {
        backgroundExecutor.execute {
            dispatchSmartspaceMediaDataRemoved(key)
        }
    }

    private fun dispatchMediaDataLoaded(key: String, oldKey: String?, info: MediaData) {
        foregroundExecutor.execute {
            listeners.toSet().forEach { it.onMediaDataLoaded(key, oldKey, info) }
        }
    }

    private fun dispatchMediaDataRemoved(key: String) {
        foregroundExecutor.execute {
            listeners.toSet().forEach { it.onMediaDataRemoved(key) }
        }
    }

    private fun dispatchSmartspaceMediaDataLoaded(key: String, info: SmartspaceTarget) {
        foregroundExecutor.execute {
            listeners.toSet().forEach { it.onSmartspaceMediaDataLoaded(key, info) }
        }
    }

    private fun dispatchSmartspaceMediaDataRemoved(key: String) {
        foregroundExecutor.execute {
            listeners.toSet().forEach { it.onSmartspaceMediaDataRemoved(key) }
        }
    }

    private fun handleControllersChanged(controllers: List<MediaController>) {
        packageControllers.clear()
        controllers.forEach {
            controller ->
            packageControllers.get(controller.packageName)?.let {
                tokens ->
                tokens.add(controller)
            } ?: run {
                val tokens = mutableListOf(controller)
                packageControllers.put(controller.packageName, tokens)
            }
        }
        tokensWithNotifications.retainAll(controllers.map { it.sessionToken })
    }
}
