// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.media_router;

import android.annotation.SuppressLint;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.mediarouter.media.MediaRouter;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.StrictModeContext;
import org.chromium.base.SysUtils;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.annotations.NativeMethods;
import org.chromium.components.media_router.caf.CafMediaRouteProvider;
import org.chromium.components.media_router.caf.remoting.CafRemotingMediaRouteProvider;
import org.chromium.content_public.browser.WebContents;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Implements the JNI interface called from the C++ Media Router implementation on Android.
 * Owns a list of {@link MediaRouteProvider} implementations and dispatches native calls to them.
 */
@JNINamespace("media_router")
public class BrowserMediaRouter implements MediaRouteManager {
    private static final String TAG = "MediaRouter";
    private static final int MIN_GOOGLE_PLAY_SERVICES_APK_VERSION = 12600000;

    private static MediaRouteProvider.Factory sRouteProviderFactory =
            new MediaRouteProvider.Factory() {
                @Override
                public void addProviders(MediaRouteManager manager) {
                    int googleApiAvailabilityResult =
                            GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(
                                    ContextUtils.getApplicationContext(),
                                    MIN_GOOGLE_PLAY_SERVICES_APK_VERSION);
                    if (googleApiAvailabilityResult != ConnectionResult.SUCCESS) {
                        GoogleApiAvailability.getInstance().showErrorNotification(
                                ContextUtils.getApplicationContext(), googleApiAvailabilityResult);
                        return;
                    }
                    MediaRouteProvider cafProvider = CafMediaRouteProvider.create(manager);
                    manager.addMediaRouteProvider(cafProvider);
                    MediaRouteProvider remotingProvider =
                            CafRemotingMediaRouteProvider.create(manager);
                    manager.addMediaRouteProvider(remotingProvider);
                }
            };

    // The pointer to the native object. Can be null during tests, or when the
    // native object has been destroyed.
    private long mNativeMediaRouterAndroidBridge;
    private final List<MediaRouteProvider> mRouteProviders = new ArrayList<MediaRouteProvider>();
    private final Map<String, MediaRouteProvider> mRouteIdsToProviders =
            new HashMap<String, MediaRouteProvider>();
    private final Map<String, Map<MediaRouteProvider, List<MediaSink>>> mSinksPerSourcePerProvider =
            new HashMap<String, Map<MediaRouteProvider, List<MediaSink>>>();
    private final Map<String, List<MediaSink>> mSinksPerSource =
            new HashMap<String, List<MediaSink>>();
    private static boolean sAndroidMediaRouterSetForTest;
    @SuppressLint("StaticFieldLeak") // This is for test only.
    private static MediaRouter sAndroidMediaRouterForTest;

    public static void setAndroidMediaRouterForTest(MediaRouter router) {
        sAndroidMediaRouterSetForTest = true;
        sAndroidMediaRouterForTest = router;
        ResettersForTesting.register(() -> {
            sAndroidMediaRouterSetForTest = false;
            sAndroidMediaRouterForTest = null;
        });
    }

    public static void setRouteProviderFactoryForTest(MediaRouteProvider.Factory factory) {
        var oldValue = sRouteProviderFactory;
        sRouteProviderFactory = factory;
        ResettersForTesting.register(() -> sRouteProviderFactory = oldValue);
    }

    protected List<MediaRouteProvider> getRouteProvidersForTest() {
        return mRouteProviders;
    }

    protected Map<String, MediaRouteProvider> getRouteIdsToProvidersForTest() {
        return mRouteIdsToProviders;
    }

    protected Map<String, Map<MediaRouteProvider, List<MediaSink>>>
    getSinksPerSourcePerProviderForTest() {
        return mSinksPerSourcePerProvider;
    }

    protected Map<String, List<MediaSink>> getSinksPerSourceForTest() {
        return mSinksPerSource;
    }

    /**
     * Obtains the {@link MediaRouter} instance given the application context.
     * @return Null if the media router API is not supported, the service instance otherwise.
     */
    @Nullable
    public static MediaRouter getAndroidMediaRouter() {
        if (sAndroidMediaRouterSetForTest) return sAndroidMediaRouterForTest;

        // Some manufacturers have an implementation that causes StrictMode
        // violations. See https://crbug.com/818325.
        try (StrictModeContext ignored = StrictModeContext.allowDiskReads()) {
            // Pre-MR1 versions of JB do not have the complete MediaRouter APIs,
            // so getting the MediaRouter instance will throw an exception.
            return MediaRouter.getInstance(ContextUtils.getApplicationContext());
        } catch (NoSuchMethodError e) {
            return null;
        } catch (NoClassDefFoundError e) {
            // TODO(mlamouri): happens with Robolectric.
            return null;
        }
    }

    @Override
    public void addMediaRouteProvider(MediaRouteProvider provider) {
        mRouteProviders.add(provider);
    }

    @Override
    public void onSinksReceived(
            String sourceId, MediaRouteProvider provider, List<MediaSink> sinks) {
        if (!mSinksPerSourcePerProvider.containsKey(sourceId)) {
            mSinksPerSourcePerProvider.put(
                    sourceId, new HashMap<MediaRouteProvider, List<MediaSink>>());
        }

        // Replace the sinks found by this provider with the new list.
        Map<MediaRouteProvider, List<MediaSink>> sinksPerProvider =
                mSinksPerSourcePerProvider.get(sourceId);
        sinksPerProvider.put(provider, sinks);

        List<MediaSink> allSinksPerSource = new ArrayList<MediaSink>();
        for (List<MediaSink> s : sinksPerProvider.values()) allSinksPerSource.addAll(s);

        mSinksPerSource.put(sourceId, allSinksPerSource);
        if (mNativeMediaRouterAndroidBridge != 0) {
            BrowserMediaRouterJni.get().onSinksReceived(mNativeMediaRouterAndroidBridge,
                    BrowserMediaRouter.this, sourceId, allSinksPerSource.size());
        }
    }

    @Override
    public void onRouteCreated(String mediaRouteId, String mediaSinkId, int requestId,
            MediaRouteProvider provider, boolean wasLaunched) {
        mRouteIdsToProviders.put(mediaRouteId, provider);
        if (mNativeMediaRouterAndroidBridge != 0) {
            BrowserMediaRouterJni.get().onRouteCreated(mNativeMediaRouterAndroidBridge,
                    BrowserMediaRouter.this, mediaRouteId, mediaSinkId, requestId, wasLaunched);
        }
    }

    @Override
    public void onCreateRouteRequestError(String errorText, int requestId) {
        if (mNativeMediaRouterAndroidBridge != 0) {
            BrowserMediaRouterJni.get().onCreateRouteRequestError(
                    mNativeMediaRouterAndroidBridge, BrowserMediaRouter.this, errorText, requestId);
        }
    }
    @Override
    public void onJoinRouteRequestError(String errorText, int requestId) {
        if (mNativeMediaRouterAndroidBridge != 0) {
            BrowserMediaRouterJni.get().onJoinRouteRequestError(
                    mNativeMediaRouterAndroidBridge, BrowserMediaRouter.this, errorText, requestId);
        }
    }

    @Override
    public void onRouteTerminated(String mediaRouteId) {
        if (mNativeMediaRouterAndroidBridge != 0) {
            BrowserMediaRouterJni.get().onRouteTerminated(
                    mNativeMediaRouterAndroidBridge, BrowserMediaRouter.this, mediaRouteId);
        }
        mRouteIdsToProviders.remove(mediaRouteId);
    }

    @Override
    public void onRouteClosed(String mediaRouteId, String error) {
        if (mNativeMediaRouterAndroidBridge != 0) {
            BrowserMediaRouterJni.get().onRouteClosed(
                    mNativeMediaRouterAndroidBridge, BrowserMediaRouter.this, mediaRouteId, error);
        }
        mRouteIdsToProviders.remove(mediaRouteId);
    }

    @Override
    public void onMessage(String mediaRouteId, String message) {
        if (mNativeMediaRouterAndroidBridge != 0) {
            BrowserMediaRouterJni.get().onMessage(mNativeMediaRouterAndroidBridge,
                    BrowserMediaRouter.this, mediaRouteId, message);
        }
    }

    @Override
    public void onRouteMediaSourceUpdated(String mediaRouteId, String mediaSourceId) {
        if (mNativeMediaRouterAndroidBridge != 0) {
            BrowserMediaRouterJni.get().onRouteMediaSourceUpdated(mNativeMediaRouterAndroidBridge,
                    BrowserMediaRouter.this, mediaRouteId, mediaSourceId);
        }
    }

    /**
     * Initializes the media router and its providers.
     * @param nativeMediaRouterAndroidBridge the handler for the native counterpart of this instance
     * @return an initialized {@link BrowserMediaRouter} instance
     */
    @CalledByNative
    public static BrowserMediaRouter create(long nativeMediaRouterAndroidBridge) {
        BrowserMediaRouter router = new BrowserMediaRouter(nativeMediaRouterAndroidBridge);
        sRouteProviderFactory.addProviders(router);
        return router;
    }

    /**
     * Starts background monitoring for available media sinks compatible with the given
     * |sourceUrn| if the device is in a state that allows it.
     * @param sourceId a URL to use for filtering of the available media sinks
     * @return whether the monitoring started (ie. was allowed).
     */
    @CalledByNative
    public boolean startObservingMediaSinks(String sourceId) {
        Log.d(TAG, "startObservingMediaSinks: " + sourceId);
        if (SysUtils.isLowEndDevice()) return false;

        for (MediaRouteProvider provider : mRouteProviders) {
            provider.startObservingMediaSinks(sourceId);
        }

        return true;
    }

    /**
     * Stops background monitoring for available media sinks compatible with the given
     * |sourceUrn|
     * @param sourceId a URL passed to {@link #startObservingMediaSinks(String)} before.
     */
    @CalledByNative
    public void stopObservingMediaSinks(String sourceId) {
        Log.d(TAG, "stopObservingMediaSinks: " + sourceId);
        for (MediaRouteProvider provider : mRouteProviders) {
            provider.stopObservingMediaSinks(sourceId);
        }
        mSinksPerSource.remove(sourceId);
        mSinksPerSourcePerProvider.remove(sourceId);
    }

    /**
     * Returns the URN of the media sink corresponding to the given source URN
     * and an index. Essentially a way to access the corresponding {@link MediaSink}'s
     * list via JNI.
     * @param sourceUrn The URN to get the sink for.
     * @param index The index of the sink in the current sink array.
     * @return the corresponding sink URN if found or null.
     */
    @CalledByNative
    public String getSinkUrn(String sourceUrn, int index) {
        return getSink(sourceUrn, index).getUrn();
    }

    /**
     * Returns the name of the media sink corresponding to the given source URN
     * and an index. Essentially a way to access the corresponding {@link MediaSink}'s
     * list via JNI.
     * @param sourceUrn The URN to get the sink for.
     * @param index The index of the sink in the current sink array.
     * @return the corresponding sink name if found or null.
     */
    @CalledByNative
    public String getSinkName(String sourceUrn, int index) {
        return getSink(sourceUrn, index).getName();
    }

    /**
     * Initiates route creation with the given parameters. Notifies the native client of success
     * and failure.
     * @param sourceId the id of the {@link MediaSource} to route to the sink.
     * @param sinkId the id of the {@link MediaSink} to route the source to.
     * @param presentationId the id of the presentation to be used by the page.
     * @param origin the origin of the frame requesting a new route.
     * @param webContents the {@link WebContents} that owns the frame that requested the route.
     * @param requestId the id of the route creation request tracked by the native side.
     */
    @CalledByNative
    public void createRoute(String sourceId, String sinkId, String presentationId, String origin,
            WebContents webContents, int requestId) {
        MediaRouteProvider provider = getProviderForSource(sourceId);
        if (provider == null) {
            onCreateRouteRequestError("No provider supports createRoute with source: " + sourceId
                            + " and sink: " + sinkId,
                    requestId);
            return;
        }

        provider.createRoute(sourceId, sinkId, presentationId, origin,
                MediaRouterClient.getInstance().getTabId(webContents), webContents.isIncognito(),
                requestId);
    }

    /**
     * Initiates route joining with the given parameters. Notifies the native client of success
     * or failure.
     * @param sourceId the id of the {@link MediaSource} to route to the sink.
     * @param sinkId the id of the {@link MediaSink} to route the source to.
     * @param presentationId the id of the presentation to be used by the page.
     * @param origin the origin of the frame requesting a new route.
     * @param webContents the {@link WebContents} that owns the frame that requested the route.
     * @param requestId the id of the route creation request tracked by the native side.
     */
    @CalledByNative
    public void joinRoute(String sourceId, String presentationId, String origin,
            WebContents webContents, int requestId) {
        MediaRouteProvider provider = getProviderForSource(sourceId);
        if (provider == null) {
            onJoinRouteRequestError("Route not found.", requestId);
            return;
        }

        provider.joinRoute(sourceId, presentationId, origin,
                MediaRouterClient.getInstance().getTabId(webContents), requestId);
    }

    /**
     * Closes the route specified by the id.
     * @param routeId the id of the route to close.
     */
    @CalledByNative
    public void closeRoute(String routeId) {
        MediaRouteProvider provider = mRouteIdsToProviders.get(routeId);
        if (provider == null) return;

        provider.closeRoute(routeId);
    }

    /**
     * Notifies the specified route that it's not attached to the web page anymore.
     * @param routeId the id of the route that was detached.
     */
    @CalledByNative
    public void detachRoute(String routeId) {
        MediaRouteProvider provider = mRouteIdsToProviders.get(routeId);
        if (provider == null) return;

        provider.detachRoute(routeId);
        mRouteIdsToProviders.remove(routeId);
    }

    /**
     * Sends a string message to the specified route.
     * @param routeId The id of the route to send the message to.
     * @param message The message to send.
     */
    @CalledByNative
    public void sendStringMessage(String routeId, String message) {
        MediaRouteProvider provider = mRouteIdsToProviders.get(routeId);
        if (provider == null) {
            return;
        }

        provider.sendStringMessage(routeId, message);
    }

    /**
     * Gets a media controller to be used by native.
     * @param routeId The route ID tied to the CastSession for which we want a media controller.
     * @return A MediaControllerBridge if it can be obtained from |routeId|, null otherwise.
     */
    @Nullable
    @CalledByNative
    public FlingingControllerBridge getFlingingControllerBridge(String routeId) {
        MediaRouteProvider provider = mRouteIdsToProviders.get(routeId);
        if (provider == null) return null;

        FlingingController controller = provider.getFlingingController(routeId);
        if (controller == null) return null;

        return new FlingingControllerBridge(controller);
    }

    @VisibleForTesting
    protected BrowserMediaRouter(long nativeMediaRouterAndroidBridge) {
        mNativeMediaRouterAndroidBridge = nativeMediaRouterAndroidBridge;
    }

    /**
     * Called when the native object is being destroyed.
     */
    @CalledByNative
    public void teardown() {
        // The native object has been destroyed.
        mNativeMediaRouterAndroidBridge = 0;
    }

    private MediaSink getSink(String sourceId, int index) {
        assert mSinksPerSource.containsKey(sourceId);
        return mSinksPerSource.get(sourceId).get(index);
    }

    private MediaRouteProvider getProviderForSource(String sourceId) {
        for (MediaRouteProvider provider : mRouteProviders) {
            if (provider.supportsSource(sourceId)) return provider;
        }
        return null;
    }

    @NativeMethods
    interface Natives {
        void onSinksReceived(long nativeMediaRouterAndroidBridge, BrowserMediaRouter caller,
                String sourceUrn, int count);
        void onRouteCreated(long nativeMediaRouterAndroidBridge, BrowserMediaRouter caller,
                String mediaRouteId, String mediaSinkId, int createRouteRequestId,
                boolean wasLaunched);
        void onCreateRouteRequestError(long nativeMediaRouterAndroidBridge,
                BrowserMediaRouter caller, String errorText, int requestId);
        void onJoinRouteRequestError(long nativeMediaRouterAndroidBridge, BrowserMediaRouter caller,
                String errorText, int requestId);
        void onRouteTerminated(long nativeMediaRouterAndroidBridge, BrowserMediaRouter caller,
                String mediaRouteId);
        void onRouteClosed(long nativeMediaRouterAndroidBridge, BrowserMediaRouter caller,
                String mediaRouteId, String message);
        void onMessage(long nativeMediaRouterAndroidBridge, BrowserMediaRouter caller,
                String mediaRouteId, String message);
        void onRouteMediaSourceUpdated(long nativeMediaRouterAndroidBridge,
                BrowserMediaRouter caller, String mediaRouteId, String mediaSourceId);
    }
}
