/*
 * Copyright 2019 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.google.android.exoplayer2.ext.media2;

import android.Manifest;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.provider.Settings;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.media.MediaSessionManager;
import androidx.media.MediaSessionManager.RemoteUserInfo;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.Rating;
import androidx.media2.common.SessionPlayer;
import androidx.media2.session.MediaController;
import androidx.media2.session.MediaSession;
import androidx.media2.session.MediaSession.ControllerInfo;
import androidx.media2.session.SessionCommand;
import androidx.media2.session.SessionCommandGroup;
import androidx.media2.session.SessionResult;
import com.google.android.exoplayer2.util.Assertions;
import java.util.ArrayList;
import java.util.List;

/**
 * Builds a {@link MediaSession.SessionCallback} with various collaborators.
 *
 * @see MediaSession.SessionCallback
 */
public final class SessionCallbackBuilder {
  /** Default timeout value for {@link #setSeekTimeoutMs}. */
  public static final int DEFAULT_SEEK_TIMEOUT_MS = 1_000;

  private final Context context;
  private final SessionPlayerConnector sessionPlayerConnector;
  private int fastForwardMs;
  private int rewindMs;
  private int seekTimeoutMs;
  @Nullable private RatingCallback ratingCallback;
  @Nullable private CustomCommandProvider customCommandProvider;
  @Nullable private MediaItemProvider mediaItemProvider;
  @Nullable private AllowedCommandProvider allowedCommandProvider;
  @Nullable private SkipCallback skipCallback;
  @Nullable private PostConnectCallback postConnectCallback;
  @Nullable private DisconnectedCallback disconnectedCallback;

  /** Provides allowed commands for {@link MediaController}. */
  public interface AllowedCommandProvider {
    /**
     * Called to query whether to allow connection from the controller.
     *
     * <p>If it returns {@code true} to accept connection, then {@link #getAllowedCommands} will be
     * immediately followed to return initial allowed command.
     *
     * <p>Prefer use {@link PostConnectCallback} for any extra initialization about controller,
     * where controller is connected and session can send commands to the controller.
     *
     * @param session The media session.
     * @param controllerInfo The {@link ControllerInfo} for the controller that is requesting
     *     connect.
     * @return {@code true} to accept connection. {@code false} otherwise.
     */
    boolean acceptConnection(MediaSession session, ControllerInfo controllerInfo);

    /**
     * Called to query allowed commands in following cases:
     *
     * <ul>
     *   <li>A {@link MediaController} requests to connect, and allowed commands is required to tell
     *       initial allowed commands.
     *   <li>Underlying {@link SessionPlayer} state changes, and allowed commands may be updated via
     *       {@link MediaSession#setAllowedCommands}.
     * </ul>
     *
     * <p>The provided {@code baseAllowedSessionCommand} is built automatically based on the state
     * of the {@link SessionPlayer}, {@link RatingCallback}, {@link MediaItemProvider}, {@link
     * CustomCommandProvider}, and {@link SkipCallback} so may be a useful starting point for any
     * required customizations.
     *
     * @param session The media session.
     * @param controllerInfo The {@link ControllerInfo} for the controller for which allowed
     *     commands are being queried.
     * @param baseAllowedSessionCommand Base allowed session commands for customization.
     * @return The allowed commands for the controller.
     * @see MediaSession.SessionCallback#onConnect(MediaSession, ControllerInfo)
     */
    SessionCommandGroup getAllowedCommands(
        MediaSession session,
        ControllerInfo controllerInfo,
        SessionCommandGroup baseAllowedSessionCommand);

    /**
     * Called when a {@link MediaController} has called an API that controls {@link SessionPlayer}
     * set to the {@link MediaSession}.
     *
     * @param session The media session.
     * @param controllerInfo A {@link ControllerInfo} that needs allowed command update.
     * @param command A {@link SessionCommand} from the controller.
     * @return A session result code defined in {@link SessionResult}.
     * @see MediaSession.SessionCallback#onCommandRequest
     */
    int onCommandRequest(
        MediaSession session, ControllerInfo controllerInfo, SessionCommand command);
  }

  /** Callback receiving a user rating for a specified media id. */
  public interface RatingCallback {
    /**
     * Called when the specified controller has set a rating for the specified media id.
     *
     * @see MediaSession.SessionCallback#onSetRating(MediaSession, MediaSession.ControllerInfo,
     *     String, Rating)
     * @see androidx.media2.session.MediaController#setRating(String, Rating)
     * @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or
     *     failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the
     *     operation succeeded.
     */
    int onSetRating(MediaSession session, ControllerInfo controller, String mediaId, Rating rating);
  }

  /**
   * Callbacks for querying what custom commands are supported, and for handling a custom command
   * when a controller sends it.
   */
  public interface CustomCommandProvider {
    /**
     * Called when a controller has sent a custom command.
     *
     * @param session The media session.
     * @param controllerInfo The {@link ControllerInfo} for the controller that sent the custom
     *     command.
     * @param customCommand A {@link SessionCommand} from the controller.
     * @param args A {@link Bundle} with the extra argument.
     * @see MediaSession.SessionCallback#onCustomCommand(MediaSession, MediaSession.ControllerInfo,
     *     SessionCommand, Bundle)
     * @see androidx.media2.session.MediaController#sendCustomCommand(SessionCommand, Bundle)
     */
    SessionResult onCustomCommand(
        MediaSession session,
        ControllerInfo controllerInfo,
        SessionCommand customCommand,
        @Nullable Bundle args);

    /**
     * Returns a {@link SessionCommandGroup} with custom commands to publish to the controller, or
     * {@code null} if no custom commands should be published.
     *
     * @param session The media session.
     * @param controllerInfo The {@link ControllerInfo} for the controller that is requesting custom
     *     commands.
     * @return The custom commands to publish, or {@code null} if no custom commands should be
     *     published.
     */
    @Nullable
    SessionCommandGroup getCustomCommands(MediaSession session, ControllerInfo controllerInfo);
  }

  /** Provides the {@link MediaItem}. */
  public interface MediaItemProvider {
    /**
     * Called when {@link MediaSession.SessionCallback#onCreateMediaItem(MediaSession,
     * ControllerInfo, String)} is called.
     *
     * @param session The media session.
     * @param controllerInfo The {@link ControllerInfo} for the controller that has requested to
     *     create the item.
     * @return A new {@link MediaItem} that {@link SessionPlayerConnector} can play.
     * @see MediaSession.SessionCallback#onCreateMediaItem(MediaSession, ControllerInfo, String)
     * @see androidx.media2.session.MediaController#addPlaylistItem(int, String)
     * @see androidx.media2.session.MediaController#replacePlaylistItem(int, String)
     * @see androidx.media2.session.MediaController#setMediaItem(String)
     * @see androidx.media2.session.MediaController#setPlaylist(List, MediaMetadata)
     */
    @Nullable
    MediaItem onCreateMediaItem(
        MediaSession session, ControllerInfo controllerInfo, String mediaId);
  }

  /** Callback receiving skip backward and skip forward. */
  public interface SkipCallback {
    /**
     * Called when the specified controller has sent skip backward.
     *
     * @param session The media session.
     * @param controllerInfo The {@link ControllerInfo} for the controller that has requested to
     *     skip backward.
     * @see MediaSession.SessionCallback#onSkipBackward(MediaSession, MediaSession.ControllerInfo)
     * @see MediaController#skipBackward()
     * @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or
     *     failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the
     *     operation succeeded.
     */
    int onSkipBackward(MediaSession session, ControllerInfo controllerInfo);

    /**
     * Called when the specified controller has sent skip forward.
     *
     * @param session The media session.
     * @param controllerInfo The {@link ControllerInfo} for the controller that has requested to
     *     skip forward.
     * @see MediaSession.SessionCallback#onSkipForward(MediaSession, MediaSession.ControllerInfo)
     * @see MediaController#skipForward()
     * @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or
     *     failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the
     *     operation succeeded.
     */
    int onSkipForward(MediaSession session, ControllerInfo controllerInfo);
  }

  /** Callback for handling extra initialization after the connection. */
  public interface PostConnectCallback {
    /**
     * Called after the specified controller is connected, and you need extra initialization.
     *
     * @param session The media session.
     * @param controllerInfo The {@link ControllerInfo} for the controller that just connected.
     * @see MediaSession.SessionCallback#onPostConnect(MediaSession, ControllerInfo)
     */
    void onPostConnect(MediaSession session, MediaSession.ControllerInfo controllerInfo);
  }

  /** Callback for handling controller disconnection. */
  public interface DisconnectedCallback {
    /**
     * Called when the specified controller is disconnected.
     *
     * @param session The media session.
     * @param controllerInfo The {@link ControllerInfo} for the disconnected controller.
     * @see MediaSession.SessionCallback#onDisconnected(MediaSession, ControllerInfo)
     */
    void onDisconnected(MediaSession session, MediaSession.ControllerInfo controllerInfo);
  }

  /**
   * Default implementation of {@link AllowedCommandProvider} that behaves as follows:
   *
   * <ul>
   *   <li>Accepts connection requests from controller if any of the following conditions are met:
   *       <ul>
   *         <li>Controller is in the same package as the session.
   *         <li>Controller is allowed via {@link #setTrustedPackageNames(List)}.
   *         <li>Controller has package name {@link RemoteUserInfo#LEGACY_CONTROLLER}. See {@link
   *             ControllerInfo#getPackageName() package name limitation} for details.
   *         <li>Controller is trusted (i.e. has MEDIA_CONTENT_CONTROL permission or has enabled
   *             notification manager).
   *       </ul>
   *   <li>Allows all commands that the current player can handle.
   *   <li>Accepts all command requests for allowed commands.
   * </ul>
   *
   * <p>Note: this implementation matches the behavior of the ExoPlayer MediaSession extension and
   * {@link android.support.v4.media.session.MediaSessionCompat}.
   */
  public static final class DefaultAllowedCommandProvider implements AllowedCommandProvider {
    private final Context context;
    private final List<String> trustedPackageNames;

    public DefaultAllowedCommandProvider(Context context) {
      this.context = context;
      trustedPackageNames = new ArrayList<>();
    }

    @Override
    public boolean acceptConnection(MediaSession session, ControllerInfo controllerInfo) {
      return TextUtils.equals(controllerInfo.getPackageName(), context.getPackageName())
          || TextUtils.equals(controllerInfo.getPackageName(), RemoteUserInfo.LEGACY_CONTROLLER)
          || trustedPackageNames.contains(controllerInfo.getPackageName())
          || isTrusted(controllerInfo);
    }

    @Override
    public SessionCommandGroup getAllowedCommands(
        MediaSession session,
        ControllerInfo controllerInfo,
        SessionCommandGroup baseAllowedSessionCommands) {
      return baseAllowedSessionCommands;
    }

    @Override
    public int onCommandRequest(
        MediaSession session, ControllerInfo controllerInfo, SessionCommand command) {
      return SessionResult.RESULT_SUCCESS;
    }

    /**
     * Sets the package names from which the session will accept incoming connections.
     *
     * <p>Apps that have {@code android.Manifest.permission.MEDIA_CONTENT_CONTROL}, packages listed
     * in enabled_notification_listeners and the current package are always trusted, even if they
     * are not specified here.
     *
     * @param packageNames Package names from which the session will accept incoming connections.
     * @see MediaSession.SessionCallback#onConnect(MediaSession, MediaSession.ControllerInfo)
     * @see MediaSessionManager#isTrustedForMediaControl(RemoteUserInfo)
     */
    public void setTrustedPackageNames(@Nullable List<String> packageNames) {
      trustedPackageNames.clear();
      if (packageNames != null && !packageNames.isEmpty()) {
        trustedPackageNames.addAll(packageNames);
      }
    }

    // TODO: Replace with ControllerInfo#isTrusted() when it's unhidden [Internal: b/142835448].
    private boolean isTrusted(MediaSession.ControllerInfo controllerInfo) {
      // Check whether the controller has granted MEDIA_CONTENT_CONTROL.
      if (context
              .getPackageManager()
              .checkPermission(
                  Manifest.permission.MEDIA_CONTENT_CONTROL, controllerInfo.getPackageName())
          == PackageManager.PERMISSION_GRANTED) {
        return true;
      }

      // Check whether the app has an enabled notification listener.
      String enabledNotificationListeners =
          Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners");
      if (!TextUtils.isEmpty(enabledNotificationListeners)) {
        String[] components = enabledNotificationListeners.split(":");
        for (String componentString : components) {
          @Nullable ComponentName component = ComponentName.unflattenFromString(componentString);
          if (component != null) {
            if (component.getPackageName().equals(controllerInfo.getPackageName())) {
              return true;
            }
          }
        }
      }
      return false;
    }
  }

  /** A {@link MediaItemProvider} that creates media items containing only a media ID. */
  public static final class MediaIdMediaItemProvider implements MediaItemProvider {
    @Override
    @Nullable
    public MediaItem onCreateMediaItem(
        MediaSession session, ControllerInfo controllerInfo, String mediaId) {
      if (TextUtils.isEmpty(mediaId)) {
        return null;
      }
      MediaMetadata metadata =
          new MediaMetadata.Builder()
              .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, mediaId)
              .build();
      return new MediaItem.Builder().setMetadata(metadata).build();
    }
  }

  /**
   * Creates a new builder.
   *
   * <p>The builder uses the following default values:
   *
   * <ul>
   *   <li>{@link AllowedCommandProvider}: {@link DefaultAllowedCommandProvider}
   *   <li>Seek timeout: {@link #DEFAULT_SEEK_TIMEOUT_MS}
   *   <li>
   * </ul>
   *
   * Unless stated above, {@code null} or {@code 0} would be used to disallow relevant features.
   *
   * @param context A context.
   * @param sessionPlayerConnector A session player connector to handle incoming calls from the
   *     controller.
   */
  public SessionCallbackBuilder(Context context, SessionPlayerConnector sessionPlayerConnector) {
    this.context = Assertions.checkNotNull(context);
    this.sessionPlayerConnector = Assertions.checkNotNull(sessionPlayerConnector);
    this.seekTimeoutMs = DEFAULT_SEEK_TIMEOUT_MS;
  }

  /**
   * Sets the {@link RatingCallback} to handle user ratings.
   *
   * @param ratingCallback A rating callback.
   * @return This builder.
   * @see MediaSession.SessionCallback#onSetRating(MediaSession, ControllerInfo, String, Rating)
   * @see androidx.media2.session.MediaController#setRating(String, Rating)
   */
  public SessionCallbackBuilder setRatingCallback(@Nullable RatingCallback ratingCallback) {
    this.ratingCallback = ratingCallback;
    return this;
  }

  /**
   * Sets the {@link CustomCommandProvider} to handle incoming custom commands.
   *
   * @param customCommandProvider A custom command provider.
   * @return This builder.
   * @see MediaSession.SessionCallback#onCustomCommand(MediaSession, ControllerInfo, SessionCommand,
   *     Bundle)
   * @see androidx.media2.session.MediaController#sendCustomCommand(SessionCommand, Bundle)
   */
  public SessionCallbackBuilder setCustomCommandProvider(
      @Nullable CustomCommandProvider customCommandProvider) {
    this.customCommandProvider = customCommandProvider;
    return this;
  }

  /**
   * Sets the {@link MediaItemProvider} that will convert media ids to {@link MediaItem MediaItems}.
   *
   * @param mediaItemProvider The media item provider.
   * @return This builder.
   * @see MediaSession.SessionCallback#onCreateMediaItem(MediaSession, ControllerInfo, String)
   * @see androidx.media2.session.MediaController#addPlaylistItem(int, String)
   * @see androidx.media2.session.MediaController#replacePlaylistItem(int, String)
   * @see androidx.media2.session.MediaController#setMediaItem(String)
   * @see androidx.media2.session.MediaController#setPlaylist(List, MediaMetadata)
   */
  public SessionCallbackBuilder setMediaItemProvider(
      @Nullable MediaItemProvider mediaItemProvider) {
    this.mediaItemProvider = mediaItemProvider;
    return this;
  }

  /**
   * Sets the {@link AllowedCommandProvider} to provide allowed commands for controllers.
   *
   * @param allowedCommandProvider A allowed command provider.
   * @return This builder.
   */
  public SessionCallbackBuilder setAllowedCommandProvider(
      @Nullable AllowedCommandProvider allowedCommandProvider) {
    this.allowedCommandProvider = allowedCommandProvider;
    return this;
  }

  /**
   * Sets the {@link SkipCallback} to handle skip backward and skip forward.
   *
   * @param skipCallback The skip callback.
   * @return This builder.
   * @see MediaSession.SessionCallback#onSkipBackward(MediaSession, ControllerInfo)
   * @see MediaSession.SessionCallback#onSkipForward(MediaSession, ControllerInfo)
   * @see MediaController#skipBackward()
   * @see MediaController#skipForward()
   */
  public SessionCallbackBuilder setSkipCallback(@Nullable SkipCallback skipCallback) {
    this.skipCallback = skipCallback;
    return this;
  }

  /**
   * Sets the {@link PostConnectCallback} to handle extra initialization after the connection.
   *
   * @param postConnectCallback The post connect callback.
   * @return This builder.
   * @see MediaSession.SessionCallback#onPostConnect(MediaSession, ControllerInfo)
   */
  public SessionCallbackBuilder setPostConnectCallback(
      @Nullable PostConnectCallback postConnectCallback) {
    this.postConnectCallback = postConnectCallback;
    return this;
  }

  /**
   * Sets the {@link DisconnectedCallback} to handle cleaning up controller.
   *
   * @param disconnectedCallback The disconnected callback.
   * @return This builder.
   * @see MediaSession.SessionCallback#onDisconnected(MediaSession, ControllerInfo)
   */
  public SessionCallbackBuilder setDisconnectedCallback(
      @Nullable DisconnectedCallback disconnectedCallback) {
    this.disconnectedCallback = disconnectedCallback;
    return this;
  }

  /**
   * Sets the rewind increment in milliseconds.
   *
   * @param rewindMs The rewind increment in milliseconds. A non-positive value will cause the
   *     rewind to be disabled.
   * @return This builder.
   * @see MediaSession.SessionCallback#onRewind(MediaSession, MediaSession.ControllerInfo)
   * @see #setSeekTimeoutMs(int)
   */
  public SessionCallbackBuilder setRewindIncrementMs(int rewindMs) {
    this.rewindMs = rewindMs;
    return this;
  }

  /**
   * Sets the fast forward increment in milliseconds.
   *
   * @param fastForwardMs The fast forward increment in milliseconds. A non-positive value will
   *     cause the fast forward to be disabled.
   * @return This builder.
   * @see MediaSession.SessionCallback#onFastForward(MediaSession, MediaSession.ControllerInfo)
   * @see #setSeekTimeoutMs(int)
   */
  public SessionCallbackBuilder setFastForwardIncrementMs(int fastForwardMs) {
    this.fastForwardMs = fastForwardMs;
    return this;
  }

  /**
   * Sets the timeout in milliseconds for fast forward and rewind operations, or {@code 0} for no
   * timeout. If a timeout is set, controllers will receive an error if the session's call to {@link
   * SessionPlayer#seekTo} takes longer than this amount of time.
   *
   * @param seekTimeoutMs A timeout for {@link SessionPlayer#seekTo}. A non-positive value will wait
   *     forever.
   * @return This builder.
   */
  public SessionCallbackBuilder setSeekTimeoutMs(int seekTimeoutMs) {
    this.seekTimeoutMs = seekTimeoutMs;
    return this;
  }

  /**
   * Builds {@link MediaSession.SessionCallback}.
   *
   * @return A new callback for a media session.
   */
  public MediaSession.SessionCallback build() {
    return new SessionCallback(
        sessionPlayerConnector,
        fastForwardMs,
        rewindMs,
        seekTimeoutMs,
        allowedCommandProvider == null
            ? new DefaultAllowedCommandProvider(context)
            : allowedCommandProvider,
        ratingCallback,
        customCommandProvider,
        mediaItemProvider,
        skipCallback,
        postConnectCallback,
        disconnectedCallback);
  }
}
