/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * vim: ts=4 sw=4 expandtab:
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.geckoview;

import android.util.Log;
import androidx.annotation.AnyThread;
import androidx.annotation.LongDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import org.mozilla.gecko.util.EventCallback;
import org.mozilla.gecko.util.GeckoBundle;
import org.mozilla.gecko.util.ImageResource;

/**
 * The MediaSession API provides media controls and events for a GeckoSession. This includes support
 * for the DOM Media Session API and regular HTML media content.
 *
 * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaSession">Media Session
 *     API</a>
 */
@UiThread
public class MediaSession {
  private static final String LOGTAG = "MediaSession";
  private static final boolean DEBUG = false;

  private final GeckoSession mSession;
  private boolean mIsActive;

  /**
   * Create a new MediaSession for the given GeckoSession.
   *
   * @param session The GeckoSession this media session is associated with.
   */
  protected MediaSession(final GeckoSession session) {
    mSession = session;
  }

  /**
   * Get whether the media session is active. Only active media sessions can be controlled.
   *
   * <p>Changes in the active state are notified via {@link Delegate#onActivated} and {@link
   * Delegate#onDeactivated} respectively.
   *
   * @see MediaSession.Delegate#onActivated
   * @see MediaSession.Delegate#onDeactivated
   * @return True if this media session is active, false otherwise.
   */
  public boolean isActive() {
    return mIsActive;
  }

  /* package */ void setActive(final boolean active) {
    mIsActive = active;
  }

  /** Pause playback for the media session. */
  public void pause() {
    if (DEBUG) {
      Log.d(LOGTAG, "pause");
    }
    mSession.getEventDispatcher().dispatch(PAUSE_EVENT, null);
  }

  /** Stop playback for the media session. */
  public void stop() {
    if (DEBUG) {
      Log.d(LOGTAG, "stop");
    }
    mSession.getEventDispatcher().dispatch(STOP_EVENT, null);
  }

  /** Start playback for the media session. */
  public void play() {
    if (DEBUG) {
      Log.d(LOGTAG, "play");
    }
    mSession.getEventDispatcher().dispatch(PLAY_EVENT, null);
  }

  /**
   * Seek to a specific time. Prefer using fast seeking when calling this in a sequence. Don't use
   * fast seeking for the last or only call in a sequence.
   *
   * @param time The time in seconds to move the playback time to.
   * @param fast Whether fast seeking should be used.
   */
  public void seekTo(final double time, final boolean fast) {
    if (DEBUG) {
      Log.d(LOGTAG, "seekTo: time=" + time + ", fast=" + fast);
    }
    final GeckoBundle bundle = new GeckoBundle(2);
    bundle.putDouble("time", time);
    bundle.putBoolean("fast", fast);
    mSession.getEventDispatcher().dispatch(SEEK_TO_EVENT, bundle);
  }

  /** Seek forward by a sensible number of seconds. */
  public void seekForward() {
    if (DEBUG) {
      Log.d(LOGTAG, "seekForward");
    }
    final GeckoBundle bundle = new GeckoBundle(1);
    bundle.putDouble("offset", 0.0);
    mSession.getEventDispatcher().dispatch(SEEK_FORWARD_EVENT, bundle);
  }

  /** Seek backward by a sensible number of seconds. */
  public void seekBackward() {
    if (DEBUG) {
      Log.d(LOGTAG, "seekBackward");
    }
    final GeckoBundle bundle = new GeckoBundle(1);
    bundle.putDouble("offset", 0.0);
    mSession.getEventDispatcher().dispatch(SEEK_BACKWARD_EVENT, bundle);
  }

  /**
   * Select and play the next track. Move playback to the next item in the playlist when supported.
   */
  public void nextTrack() {
    if (DEBUG) {
      Log.d(LOGTAG, "nextTrack");
    }
    mSession.getEventDispatcher().dispatch(NEXT_TRACK_EVENT, null);
  }

  /**
   * Select and play the previous track. Move playback to the previous item in the playlist when
   * supported.
   */
  public void previousTrack() {
    if (DEBUG) {
      Log.d(LOGTAG, "previousTrack");
    }
    mSession.getEventDispatcher().dispatch(PREV_TRACK_EVENT, null);
  }

  /** Skip the advertisement that is currently playing. */
  public void skipAd() {
    if (DEBUG) {
      Log.d(LOGTAG, "skipAd");
    }
    mSession.getEventDispatcher().dispatch(SKIP_AD_EVENT, null);
  }

  /**
   * Set whether audio should be muted. Muting audio is supported by default and does not require
   * the media session to be active.
   *
   * @param mute True if audio for this media session should be muted.
   */
  public void muteAudio(final boolean mute) {
    if (DEBUG) {
      Log.d(LOGTAG, "muteAudio=" + mute);
    }
    final GeckoBundle bundle = new GeckoBundle(1);
    bundle.putBoolean("mute", mute);
    mSession.getEventDispatcher().dispatch(MUTE_AUDIO_EVENT, bundle);
  }

  /** Implement this delegate to receive media session events. */
  @UiThread
  public interface Delegate {
    /**
     * Notify that the given media session has become active. It is always the first event
     * dispatched for a new or previously deactivated media session.
     *
     * @param session The associated GeckoSession.
     * @param mediaSession The media session for the given GeckoSession.
     */
    default void onActivated(
        @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}

    /**
     * Notify that the given media session has become inactive. Inactive media sessions can not be
     * controlled.
     *
     * <p>TODO: Add settings links to control behavior.
     *
     * @param session The associated GeckoSession.
     * @param mediaSession The media session for the given GeckoSession.
     */
    default void onDeactivated(
        @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}

    /**
     * Notify on updated metadata. Metadata may be provided by content via the DOM API or by
     * GeckoView when not availble.
     *
     * @param session The associated GeckoSession.
     * @param mediaSession The media session for the given GeckoSession.
     * @param meta The updated metadata.
     */
    default void onMetadata(
        @NonNull final GeckoSession session,
        @NonNull final MediaSession mediaSession,
        @NonNull final Metadata meta) {}

    /**
     * Notify on updated supported features. Unsupported actions will have no effect.
     *
     * @param session The associated GeckoSession.
     * @param mediaSession The media session for the given GeckoSession.
     * @param features A combination of {@link Feature}.
     */
    default void onFeatures(
        @NonNull final GeckoSession session,
        @NonNull final MediaSession mediaSession,
        @MSFeature final long features) {}

    /**
     * Notify that playback has started for the given media session.
     *
     * @param session The associated GeckoSession.
     * @param mediaSession The media session for the given GeckoSession.
     */
    default void onPlay(
        @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}

    /**
     * Notify that playback has paused for the given media session.
     *
     * @param session The associated GeckoSession.
     * @param mediaSession The media session for the given GeckoSession.
     */
    default void onPause(
        @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}

    /**
     * Notify that playback has stopped for the given media session.
     *
     * @param session The associated GeckoSession.
     * @param mediaSession The media session for the given GeckoSession.
     */
    default void onStop(
        @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}

    /**
     * Notify on updated position state.
     *
     * @param session The associated GeckoSession.
     * @param mediaSession The media session for the given GeckoSession.
     * @param state An instance of {@link PositionState}.
     */
    default void onPositionState(
        @NonNull final GeckoSession session,
        @NonNull final MediaSession mediaSession,
        @NonNull final PositionState state) {}

    /**
     * Notify on changed fullscreen state.
     *
     * @param session The associated GeckoSession.
     * @param mediaSession The media session for the given GeckoSession.
     * @param enabled True when this media session in in fullscreen mode.
     * @param meta An instance of {@link ElementMetadata}, if enabled.
     */
    default void onFullscreen(
        @NonNull final GeckoSession session,
        @NonNull final MediaSession mediaSession,
        final boolean enabled,
        @Nullable final ElementMetadata meta) {}
  }

  /** The representation of a media element's metadata. */
  public static class ElementMetadata {
    /** The media source URI. */
    public final @Nullable String source;

    /** The duration of the media in seconds. 0.0 if unknown. */
    public final double duration;

    /** The width of the video in device pixels. 0 if unknown. */
    public final long width;

    /** The height of the video in device pixels. 0 if unknown. */
    public final long height;

    /** The number of audio tracks contained in this element. */
    public final int audioTrackCount;

    /** The number of video tracks contained in this element. */
    public final int videoTrackCount;

    /**
     * ElementMetadata constructor.
     *
     * @param source The media URI.
     * @param duration The media duration in seconds.
     * @param width The video width in device pixels.
     * @param height The video height in device pixels.
     * @param audioTrackCount The audio track count.
     * @param videoTrackCount The video track count.
     */
    public ElementMetadata(
        @Nullable final String source,
        final double duration,
        final long width,
        final long height,
        final int audioTrackCount,
        final int videoTrackCount) {
      this.source = source;
      this.duration = duration;
      this.width = width;
      this.height = height;
      this.audioTrackCount = audioTrackCount;
      this.videoTrackCount = videoTrackCount;
    }

    /* package */ static @NonNull ElementMetadata fromBundle(final GeckoBundle bundle) {
      // Sync with MediaUtils.sys.mjs.
      return new ElementMetadata(
          bundle.getString("src"),
          bundle.getDouble("duration", 0.0),
          bundle.getLong("width", 0),
          bundle.getLong("height", 0),
          bundle.getInt("audioTrackCount", 0),
          bundle.getInt("videoTrackCount", 0));
    }
  }

  /** The representation of a media session's metadata. */
  public static class Metadata {
    /** The media title. May be backfilled based on the document's title. May be null or empty. */
    public final @Nullable String title;

    /** The media artist name. May be null or empty. */
    public final @Nullable String artist;

    /** The media album title. May be null or empty. */
    public final @Nullable String album;

    /** The media artwork image. May be null. */
    public final @Nullable Image artwork;

    /**
     * Metadata constructor.
     *
     * @param title The media title string.
     * @param artist The media artist string.
     * @param album The media album string.
     * @param artwork The media artwork {@link Image}.
     */
    protected Metadata(
        final @Nullable String title,
        final @Nullable String artist,
        final @Nullable String album,
        final @Nullable Image artwork) {
      this.title = title;
      this.artist = artist;
      this.album = album;
      this.artwork = artwork;
    }

    @AnyThread
    /* package */ static final class Builder {
      private final GeckoBundle mBundle;

      public Builder(final GeckoBundle bundle) {
        mBundle = new GeckoBundle(bundle);
      }

      public Builder(final Metadata meta) {
        mBundle = meta.toBundle();
      }

      @NonNull
      Builder title(final @Nullable String title) {
        mBundle.putString("title", title);
        return this;
      }

      @NonNull
      Builder artist(final @Nullable String artist) {
        mBundle.putString("artist", artist);
        return this;
      }

      @NonNull
      Builder album(final @Nullable String album) {
        mBundle.putString("album", album);
        return this;
      }
    }

    /* package */ static @NonNull Metadata fromBundle(final GeckoBundle bundle) {
      final GeckoBundle[] artworkBundles = bundle.getBundleArray("artwork");

      final ImageResource.Collection.Builder artworkBuilder =
          new ImageResource.Collection.Builder();

      for (final GeckoBundle artworkBundle : artworkBundles) {
        artworkBuilder.add(ImageResource.fromBundle(artworkBundle));
      }

      return new Metadata(
          bundle.getString("title"),
          bundle.getString("artist"),
          bundle.getString("album"),
          new Image(artworkBuilder.build()));
    }

    /* package */ @NonNull
    GeckoBundle toBundle() {
      final GeckoBundle bundle = new GeckoBundle(3);
      bundle.putString("title", title);
      bundle.putString("artist", artist);
      bundle.putString("album", album);
      return bundle;
    }

    @Override
    public String toString() {
      final StringBuilder builder = new StringBuilder("Metadata {");
      builder
          .append("title=")
          .append(title)
          .append(", artist=")
          .append(artist)
          .append(", album=")
          .append(album)
          .append(", artwork=")
          .append(artwork)
          .append("}");
      return builder.toString();
    }
  }

  /** Holds the details of the media session's playback state. */
  public static class PositionState {
    /** The duration of the media in seconds. */
    public final double duration;

    /** The last reported media playback position in seconds. */
    public final double position;

    /**
     * The media playback rate coefficient. The rate is positive for forward and negative for
     * backward playback.
     */
    public final double playbackRate;

    /**
     * PositionState constructor.
     *
     * @param duration The media duration in seconds.
     * @param position The current media playback position in seconds.
     * @param playbackRate The playback rate coefficient.
     */
    protected PositionState(
        final double duration, final double position, final double playbackRate) {
      this.duration = duration;
      this.position = position;
      this.playbackRate = playbackRate;
    }

    /* package */ static @NonNull PositionState fromBundle(final GeckoBundle bundle) {
      return new PositionState(
          bundle.getDouble("duration"),
          bundle.getDouble("position"),
          bundle.getDouble("playbackRate"));
    }

    @Override
    public String toString() {
      final StringBuilder builder = new StringBuilder("PositionState {");
      builder
          .append("duration=")
          .append(duration)
          .append(", position=")
          .append(position)
          .append(", playbackRate=")
          .append(playbackRate)
          .append("}");
      return builder.toString();
    }
  }

  /** Media session feature flag definitions for supported media operations. */
  @Retention(RetentionPolicy.SOURCE)
  @LongDef(
      flag = true,
      value = {
        Feature.NONE,
        Feature.PLAY,
        Feature.PAUSE,
        Feature.STOP,
        Feature.SEEK_TO,
        Feature.SEEK_FORWARD,
        Feature.SEEK_BACKWARD,
        Feature.SKIP_AD,
        Feature.NEXT_TRACK,
        Feature.PREVIOUS_TRACK,
        // Feature.SET_VIDEO_SURFACE
      })
  public @interface MSFeature {}

  /** Flags for supported media session features. */
  public static class Feature {
    /** No media session features supported. */
    public static final long NONE = 0;

    /** Playback supported. */
    public static final long PLAY = 1 << 0;

    /** Pausing supported. */
    public static final long PAUSE = 1 << 1;

    /** Stopping supported. */
    public static final long STOP = 1 << 2;

    /** Absolute seeking supported. */
    public static final long SEEK_TO = 1 << 3;

    /** Relative seeking supported (forward). */
    public static final long SEEK_FORWARD = 1 << 4;

    /** Relative seeking supported (backward). */
    public static final long SEEK_BACKWARD = 1 << 5;

    /** Skipping advertisements supported. */
    public static final long SKIP_AD = 1 << 6;

    /** Next track selection supported. */
    public static final long NEXT_TRACK = 1 << 7;

    /** Previous track selection supported. */
    public static final long PREVIOUS_TRACK = 1 << 8;

    /** Focusing supported. */
    public static final long FOCUS = 1 << 9;

    // /**
    //  * Custom video surface supported.
    //  */
    // public static final long SET_VIDEO_SURFACE = 1 << 10;

    /* package */ static long fromBundle(final GeckoBundle bundle) {
      // Sync with MediaController.webidl.
      return NONE
          | (bundle.getBoolean("play") ? PLAY : NONE)
          | (bundle.getBoolean("pause") ? PAUSE : NONE)
          | (bundle.getBoolean("stop") ? STOP : NONE)
          | (bundle.getBoolean("seekto") ? SEEK_TO : NONE)
          | (bundle.getBoolean("seekforward") ? SEEK_FORWARD : NONE)
          | (bundle.getBoolean("seekbackward") ? SEEK_BACKWARD : NONE)
          | (bundle.getBoolean("nexttrack") ? NEXT_TRACK : NONE)
          | (bundle.getBoolean("previoustrack") ? PREVIOUS_TRACK : NONE)
          | (bundle.getBoolean("skipad") ? SKIP_AD : NONE)
          | (bundle.getBoolean("focus") ? FOCUS : NONE);
    }
  }

  private static final String ACTIVATED_EVENT = "GeckoView:MediaSession:Activated";
  private static final String DEACTIVATED_EVENT = "GeckoView:MediaSession:Deactivated";
  private static final String METADATA_EVENT = "GeckoView:MediaSession:Metadata";
  private static final String POSITION_STATE_EVENT = "GeckoView:MediaSession:PositionState";
  private static final String FEATURES_EVENT = "GeckoView:MediaSession:Features";
  private static final String FULLSCREEN_EVENT = "GeckoView:MediaSession:Fullscreen";
  private static final String PLAYBACK_NONE_EVENT = "GeckoView:MediaSession:Playback:None";
  private static final String PLAYBACK_PAUSED_EVENT = "GeckoView:MediaSession:Playback:Paused";
  private static final String PLAYBACK_PLAYING_EVENT = "GeckoView:MediaSession:Playback:Playing";

  private static final String PLAY_EVENT = "GeckoView:MediaSession:Play";
  private static final String PAUSE_EVENT = "GeckoView:MediaSession:Pause";
  private static final String STOP_EVENT = "GeckoView:MediaSession:Stop";
  private static final String NEXT_TRACK_EVENT = "GeckoView:MediaSession:NextTrack";
  private static final String PREV_TRACK_EVENT = "GeckoView:MediaSession:PrevTrack";
  private static final String SEEK_FORWARD_EVENT = "GeckoView:MediaSession:SeekForward";
  private static final String SEEK_BACKWARD_EVENT = "GeckoView:MediaSession:SeekBackward";
  private static final String SKIP_AD_EVENT = "GeckoView:MediaSession:SkipAd";
  private static final String SEEK_TO_EVENT = "GeckoView:MediaSession:SeekTo";
  private static final String MUTE_AUDIO_EVENT = "GeckoView:MediaSession:MuteAudio";

  /* package */ static class Handler extends GeckoSessionHandler<MediaSession.Delegate> {

    private final GeckoSession mSession;
    private final MediaSession mMediaSession;

    public Handler(final GeckoSession session) {
      super(
          "GeckoViewMediaControl",
          session,
          new String[] {
            ACTIVATED_EVENT,
            DEACTIVATED_EVENT,
            METADATA_EVENT,
            FULLSCREEN_EVENT,
            POSITION_STATE_EVENT,
            PLAYBACK_NONE_EVENT,
            PLAYBACK_PAUSED_EVENT,
            PLAYBACK_PLAYING_EVENT,
            FEATURES_EVENT,
          });
      mSession = session;
      mMediaSession = new MediaSession(session);
    }

    @Override
    public void handleMessage(
        final Delegate delegate,
        final String event,
        final GeckoBundle message,
        final EventCallback callback) {
      if (DEBUG) {
        Log.d(LOGTAG, "handleMessage " + event);
      }

      if (ACTIVATED_EVENT.equals(event)) {
        mMediaSession.setActive(true);
        delegate.onActivated(mSession, mMediaSession);
      } else if (DEACTIVATED_EVENT.equals(event)) {
        mMediaSession.setActive(false);
        delegate.onDeactivated(mSession, mMediaSession);
      } else if (METADATA_EVENT.equals(event)) {
        final Metadata meta = Metadata.fromBundle(message.getBundle("metadata"));
        delegate.onMetadata(mSession, mMediaSession, meta);
      } else if (POSITION_STATE_EVENT.equals(event)) {
        final PositionState state = PositionState.fromBundle(message.getBundle("state"));
        delegate.onPositionState(mSession, mMediaSession, state);
      } else if (PLAYBACK_NONE_EVENT.equals(event)) {
        delegate.onStop(mSession, mMediaSession);
      } else if (PLAYBACK_PAUSED_EVENT.equals(event)) {
        delegate.onPause(mSession, mMediaSession);
      } else if (PLAYBACK_PLAYING_EVENT.equals(event)) {
        delegate.onPlay(mSession, mMediaSession);
      } else if (FEATURES_EVENT.equals(event)) {
        final long features = Feature.fromBundle(message.getBundle("features"));
        delegate.onFeatures(mSession, mMediaSession, features);
      } else if (FULLSCREEN_EVENT.equals(event)) {
        final boolean enabled = message.getBoolean("enabled");
        final ElementMetadata meta = ElementMetadata.fromBundle(message.getBundle("metadata"));
        if (!mMediaSession.isActive()) {
          if (DEBUG) {
            Log.d(LOGTAG, "Media session is not active yet");
          }
          callback.sendSuccess(false);
          return;
        }
        delegate.onFullscreen(mSession, mMediaSession, enabled, meta);
        callback.sendSuccess(true);
      }
    }
  }
}
