/*
 * Copyright 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 android.media;

import static android.Manifest.permission.MEDIA_CONTENT_CONTROL;
import static android.annotation.SystemApi.Client.MODULE_LIBRARIES;

import android.annotation.CallbackExecutor;
import android.annotation.FlaggedApi;
import android.annotation.IntRange;
import android.annotation.NonNull;
import android.annotation.RequiresPermission;
import android.annotation.SystemApi;
import android.annotation.SystemService;
import android.content.Context;
import android.media.session.MediaSession;
import android.media.session.MediaSessionManager;
import android.os.Build;
import android.os.RemoteException;
import android.os.UserHandle;
import android.service.media.MediaBrowserService;
import android.util.Log;
import android.view.KeyEvent;

import androidx.annotation.RequiresApi;

import com.android.internal.annotations.GuardedBy;
import com.android.media.mainline.flags.Flags;
import com.android.modules.annotation.MinSdk;
import com.android.modules.utils.build.SdkLevel;

import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;

/**
 * Provides support for interacting with {@link android.media.MediaSession2 MediaSession2s}
 * that applications have published to express their ongoing media playback state.
 */
@MinSdk(Build.VERSION_CODES.S)
@RequiresApi(Build.VERSION_CODES.S)
@SystemService(Context.MEDIA_COMMUNICATION_SERVICE)
public class MediaCommunicationManager {
    private static final String TAG = "MediaCommunicationManager";

    /**
     * The manager version used from beginning.
     */
    private static final int VERSION_1 = 1;

    /**
     * Current manager version.
     */
    private static final int CURRENT_VERSION = VERSION_1;

    private final Context mContext;
    // Do not access directly use getService().
    private IMediaCommunicationService mService;

    private final Object mLock = new Object();
    private final CopyOnWriteArrayList<SessionCallbackRecord> mTokenCallbackRecords =
            new CopyOnWriteArrayList<>();

    @GuardedBy("mLock")
    private MediaCommunicationServiceCallbackStub mCallbackStub;

    // TODO: remove this when MCS implements dispatchMediaKeyEvent.
    private MediaSessionManager mMediaSessionManager;

    /**
     * @hide
     */
    public MediaCommunicationManager(@NonNull Context context) {
        if (!SdkLevel.isAtLeastS()) {
            throw new UnsupportedOperationException("Android version must be S or greater.");
        }
        mContext = context;
    }

    /**
     * Gets the version of this {@link MediaCommunicationManager}.
     */
    public @IntRange(from = 1) int getVersion() {
        return CURRENT_VERSION;
    }

    /**
     * Notifies that a new {@link MediaSession2} with type {@link Session2Token#TYPE_SESSION} is
     * created.
     * @param token newly created session2 token
     * @hide
     */
    public void notifySession2Created(@NonNull Session2Token token) {
        Objects.requireNonNull(token, "token shouldn't be null");
        if (token.getType() != Session2Token.TYPE_SESSION) {
            throw new IllegalArgumentException("token's type should be TYPE_SESSION");
        }
        try {
            getService().notifySession2Created(token);
        } catch (RemoteException e) {
            e.rethrowFromSystemServer();
        }
    }

    /**
     * Checks whether the remote user is a trusted app.
     * <p>
     * An app is trusted if the app holds the
     * {@link android.Manifest.permission#MEDIA_CONTENT_CONTROL} permission or has an enabled
     * notification listener.
     *
     * @param userInfo The remote user info from either
     *            {@link MediaSession#getCurrentControllerInfo()} or
     *            {@link MediaBrowserService#getCurrentBrowserInfo()}.
     * @return {@code true} if the remote user is trusted or {@code false} otherwise.
     * @hide
     */
    public boolean isTrustedForMediaControl(@NonNull MediaSessionManager.RemoteUserInfo userInfo) {
        Objects.requireNonNull(userInfo, "userInfo shouldn't be null");
        if (userInfo.getPackageName() == null) {
            return false;
        }
        try {
            return getService().isTrusted(
                    userInfo.getPackageName(), userInfo.getPid(), userInfo.getUid());
        } catch (RemoteException e) {
            Log.w(TAG, "Cannot communicate with the service.", e);
        }
        return false;
    }

    /**
     * This API is not generally intended for third party application developers.
     * Use the <a href="{@docRoot}jetpack/androidx.html">AndroidX</a>
     * <a href="{@docRoot}media/media3/session/control-playback">Media3 session
     * Library</a> for consistent behavior across all devices.
     * <p>
     * Gets a list of {@link Session2Token} with type {@link Session2Token#TYPE_SESSION} for the
     * current user.
     * <p>
     * Although this API can be used without any restriction, each session owners can accept or
     * reject your uses of {@link MediaSession2}.
     *
     * @return A list of {@link Session2Token}.
     */
    @NonNull
    public List<Session2Token> getSession2Tokens() {
        return getSession2Tokens(UserHandle.myUserId());
    }

    /**
     * Adds a callback to be notified when the list of active sessions changes.
     * <p>
     * This requires the {@link android.Manifest.permission#MEDIA_CONTENT_CONTROL} permission be
     * held by the calling app.
     * </p>
     * @hide
     */
    @SystemApi(client = MODULE_LIBRARIES)
    @RequiresPermission(MEDIA_CONTENT_CONTROL)
    public void registerSessionCallback(@CallbackExecutor @NonNull Executor executor,
            @NonNull SessionCallback callback) {
        Objects.requireNonNull(executor, "executor must not be null");
        Objects.requireNonNull(callback, "callback must not be null");

        if (!mTokenCallbackRecords.addIfAbsent(
                new SessionCallbackRecord(executor, callback))) {
            Log.w(TAG, "registerSession2TokenCallback: Ignoring the same callback");
            return;
        }
        synchronized (mLock) {
            if (mCallbackStub == null) {
                MediaCommunicationServiceCallbackStub callbackStub =
                        new MediaCommunicationServiceCallbackStub();
                try {
                    getService().registerCallback(callbackStub, mContext.getPackageName());
                    mCallbackStub = callbackStub;
                } catch (RemoteException ex) {
                    ex.rethrowFromSystemServer();
                }
            }
        }
    }

    /**
     * Stops receiving active sessions updates on the specified callback.
     * @hide
     */
    @SystemApi(client = MODULE_LIBRARIES)
    public void unregisterSessionCallback(@NonNull SessionCallback callback) {
        if (!mTokenCallbackRecords.remove(
                new SessionCallbackRecord(null, callback))) {
            Log.w(TAG, "unregisterSession2TokenCallback: Ignoring an unknown callback.");
            return;
        }
        synchronized (mLock) {
            if (mCallbackStub != null && mTokenCallbackRecords.isEmpty()) {
                try {
                    getService().unregisterCallback(mCallbackStub);
                } catch (RemoteException ex) {
                    Log.e(TAG, "Failed to unregister callback.", ex);
                }
                mCallbackStub = null;
            }
        }
    }

    private IMediaCommunicationService getService() {
        if (mService == null) {
            mService = IMediaCommunicationService.Stub.asInterface(
                    MediaFrameworkInitializer.getMediaServiceManager()
                            .getMediaCommunicationServiceRegisterer()
                            .get());
        }
        return mService;
    }

    // TODO: remove this when MCS implements dispatchMediaKeyEvent.
    private MediaSessionManager getMediaSessionManager() {
        if (mMediaSessionManager == null) {
            mMediaSessionManager = mContext.getSystemService(MediaSessionManager.class);
        }
        return mMediaSessionManager;
    }

    private List<Session2Token> getSession2Tokens(int userId) {
        try {
            MediaParceledListSlice slice = getService().getSession2Tokens(userId);
            return slice == null ? Collections.emptyList() : slice.getList();
        } catch (RemoteException e) {
            Log.e(TAG, "Failed to get session tokens", e);
        }
        return Collections.emptyList();
    }

    /**
     * Sends a media key event. The receiver will be selected automatically.
     *
     * @param keyEvent the key event to send, non-media key events will be ignored.
     * @param asSystemService if {@code true}, the event is sent to the session as if it was come
     *                        from the system service instead of the app process. It only affects
     *                        {@link MediaSession.Callback#getCurrentControllerInfo()}.
     * @hide
     */
    @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
    public void dispatchMediaKeyEvent(@NonNull KeyEvent keyEvent, boolean asSystemService) {
        Objects.requireNonNull(keyEvent, "keyEvent shouldn't be null");

        // When MCS handles this, caller is changed.
        // TODO: remove this when MCS implementation is done.
        if (!asSystemService) {
            getMediaSessionManager().dispatchMediaKeyEvent(keyEvent, false);
            return;
        }

        try {
            getService().dispatchMediaKeyEvent(mContext.getPackageName(),
                    keyEvent, asSystemService);
        } catch (RemoteException e) {
            Log.e(TAG, "Failed to send key event.", e);
        }
    }

    /**
     * Callback for listening to changes to the sessions.
     * @see #registerSessionCallback(Executor, SessionCallback)
     * @hide
     */
    @SystemApi(client = MODULE_LIBRARIES)
    public interface SessionCallback {
        /**
         * Equivalent to {@link #onSession2TokenCreated(Session2Token, int)}, except
         * it does not take the pid of the session.
         *
         * <p>Not invoked if the implementation overrides {@link
         * #onSession2TokenCreated(Session2Token, int)}.
         *
         * @param token the newly created token
         */
        default void onSession2TokenCreated(@NonNull Session2Token token) {}

        /**
         * Called when a new {@link MediaSession2} is created.
         *
         * <p>The default implementation calls {@link #onSession2TokenCreated(Session2Token,
         * int)}.
         *
         * @param token the newly created token
         * @param pid the pid of the process hosting the media session
         */
        @FlaggedApi(Flags.FLAG_ENABLE_PID_TO_MEDIA_SESSION_2)
        default void onSession2TokenCreated(@NonNull Session2Token token, int pid) {
            onSession2TokenCreated(token);
        }

        /**
         * Called when {@link #getSession2Tokens() session tokens} are changed.
         */
        default void onSession2TokensChanged(@NonNull List<Session2Token> tokens) {}
    }

    private static final class SessionCallbackRecord {
        public final Executor executor;
        public final SessionCallback callback;

        SessionCallbackRecord(Executor executor, SessionCallback callback) {
            this.executor = executor;
            this.callback = callback;
        }

        @Override
        public int hashCode() {
            return Objects.hash(callback);
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (!(obj instanceof SessionCallbackRecord)) {
                return false;
            }
            return Objects.equals(this.callback, ((SessionCallbackRecord) obj).callback);
        }
    }

    class MediaCommunicationServiceCallbackStub extends IMediaCommunicationServiceCallback.Stub {
        @Override
        public void onSession2Created(Session2Token token, int pid) throws RemoteException {
            for (SessionCallbackRecord record : mTokenCallbackRecords) {
                record.executor.execute(() -> {
                    if (Flags.enablePidToMediaSession2()) {
                        record.callback.onSession2TokenCreated(token, pid);
                    } else {
                        record.callback.onSession2TokenCreated(token);
                    }
                });
            }
        }

        @Override
        public void onSession2Changed(MediaParceledListSlice tokens) throws RemoteException {
            List<Session2Token> tokenList = tokens.getList();
            for (SessionCallbackRecord record : mTokenCallbackRecords) {
                record.executor.execute(() -> record.callback.onSession2TokensChanged(tokenList));
            }
        }
    }
}
