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

import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.FutureTask;
import java.util.concurrent.atomic.AtomicInteger;
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.annotation.ReflectionTarget;
import org.mozilla.geckoview.BuildConfig;
import org.mozilla.geckoview.GeckoSession;
import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
import org.mozilla.thirdparty.com.google.android.exoplayer2.DefaultLoadControl;
import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException;
import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayer;
import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters;
import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline;
import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource;
import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener;
import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup;
import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray;
import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsMediaSource;
import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection;
import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultAllocator;
import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultBandwidthMeter;
import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource;
import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;

@ReflectionTarget
public class GeckoHlsPlayer implements BaseHlsPlayer, ExoPlayer.EventListener {
  private static final String LOGTAG = "GeckoHlsPlayer";
  private static final int MAX_TIMELINE_ITEM_LINES = 3;
  private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL;

  private static final AtomicInteger sPlayerId = new AtomicInteger(0);
  /*
   *  Because we treat GeckoHlsPlayer as a source data provider.
   *  It will be created and initialized with a URL by HLSResource in
   *  Gecko media pipleine (in cpp). Once HLSDemuxer is created later, we
   *  need to bridge this HLSResource to the created demuxer. And they share
   *  the same GeckoHlsPlayer.
   *  mPlayerId is a token used for Gecko media pipeline to obtain corresponding player.
   */
  private final int mPlayerId;
  // Accessed only in GeckoHlsPlayerThread.
  private boolean mExoplayerSuspended = false;

  private static final int DEFAULT_MIN_BUFFER_MS = 5 * 1000;
  private static final int DEFAULT_MAX_BUFFER_MS = 10 * 1000;

  private enum MediaDecoderPlayState {
    PLAY_STATE_PREPARING,
    PLAY_STATE_PAUSED,
    PLAY_STATE_PLAYING
  }

  // Default value is PLAY_STATE_PREPARING and it will be set to PLAY_STATE_PLAYING
  // once HTMLMediaElement calls PlayInternal().
  // Accessed only in GeckoHlsPlayerThread.
  private MediaDecoderPlayState mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PREPARING;

  private Handler mMainHandler;
  private HandlerThread mThread;
  private ExoPlayer mPlayer;
  private GeckoHlsRendererBase[] mRenderers;
  private DefaultTrackSelector mTrackSelector;
  private MediaSource mMediaSource;
  private SourceEventListener mSourceEventListener;
  private ComponentListener mComponentListener;
  private ComponentEventDispatcher mComponentEventDispatcher;

  private volatile boolean mIsTimelineStatic = false;
  private long mDurationUs;

  private GeckoHlsVideoRenderer mVRenderer = null;
  private GeckoHlsAudioRenderer mARenderer = null;

  // Able to control if we only want V/A/V+A tracks from bitstream.
  private class RendererController {
    private final boolean mEnableV;
    private final boolean mEnableA;

    RendererController(final boolean enableVideoRenderer, final boolean enableAudioRenderer) {
      this.mEnableV = enableVideoRenderer;
      this.mEnableA = enableAudioRenderer;
    }

    boolean isVideoRendererEnabled() {
      return mEnableV;
    }

    boolean isAudioRendererEnabled() {
      return mEnableA;
    }
  }

  private RendererController mRendererController = new RendererController(true, true);

  // Provide statistical information of tracks.
  private class HlsMediaTracksInfo {
    private int mNumVideoTracks = 0;
    private int mNumAudioTracks = 0;
    private boolean mVideoInfoUpdated = false;
    private boolean mAudioInfoUpdated = false;
    private boolean mVideoDataArrived = false;
    private boolean mAudioDataArrived = false;

    HlsMediaTracksInfo() {}

    public void reset() {
      mNumVideoTracks = 0;
      mNumAudioTracks = 0;
      mVideoInfoUpdated = false;
      mAudioInfoUpdated = false;
      mVideoDataArrived = false;
      mAudioDataArrived = false;
    }

    public void updateNumOfVideoTracks(final int numOfTracks) {
      mNumVideoTracks = numOfTracks;
    }

    public void updateNumOfAudioTracks(final int numOfTracks) {
      mNumAudioTracks = numOfTracks;
    }

    public boolean hasVideo() {
      return mNumVideoTracks > 0;
    }

    public boolean hasAudio() {
      return mNumAudioTracks > 0;
    }

    public int getNumOfVideoTracks() {
      return mNumVideoTracks;
    }

    public int getNumOfAudioTracks() {
      return mNumAudioTracks;
    }

    public void onVideoInfoUpdated() {
      mVideoInfoUpdated = true;
    }

    public void onAudioInfoUpdated() {
      mAudioInfoUpdated = true;
    }

    public void onDataArrived(final int trackType) {
      if (trackType == C.TRACK_TYPE_VIDEO) {
        mVideoDataArrived = true;
      } else if (trackType == C.TRACK_TYPE_AUDIO) {
        mAudioDataArrived = true;
      }
    }

    public boolean videoReady() {
      return !hasVideo() || (mVideoInfoUpdated && mVideoDataArrived);
    }

    public boolean audioReady() {
      return !hasAudio() || (mAudioInfoUpdated && mAudioDataArrived);
    }
  }

  private HlsMediaTracksInfo mTracksInfo = new HlsMediaTracksInfo();

  // Used only in GeckoHlsPlayerThread.
  private boolean mIsPlayerInitDone = false;
  private boolean mIsDemuxerInitDone = false;
  private BaseHlsPlayer.DemuxerCallbacks mDemuxerCallbacks;
  private BaseHlsPlayer.ResourceCallbacks mResourceCallbacks;

  private boolean mReleasing = false; // Used only in Gecko Main thread.

  private static void assertTrue(final boolean condition) {
    if (DEBUG && !condition) {
      throw new AssertionError("Expected condition to be true");
    }
  }

  protected void checkInitDone() {
    if (mIsDemuxerInitDone) {
      return;
    }
    assertTrue(mDemuxerCallbacks != null);

    if (DEBUG) {
      Log.d(
          LOGTAG,
          "[checkInitDone] VReady:"
              + mTracksInfo.videoReady()
              + ",AReady:"
              + mTracksInfo.audioReady()
              + ",hasV:"
              + mTracksInfo.hasVideo()
              + ",hasA:"
              + mTracksInfo.hasAudio());
    }
    if (mTracksInfo.videoReady() && mTracksInfo.audioReady()) {
      if (mDemuxerCallbacks != null) {
        mDemuxerCallbacks.onInitialized(mTracksInfo.hasAudio(), mTracksInfo.hasVideo());
      }
      mIsDemuxerInitDone = true;
    }
  }

  private final class SourceEventListener implements MediaSourceEventListener {
    public void onLoadStarted(
        final int windowIndex,
        final MediaSource.MediaPeriodId mediaPeriodId,
        final LoadEventInfo loadEventInfo,
        final MediaLoadData mediaLoadData) {
      assertTrue(isPlayerThread());

      synchronized (GeckoHlsPlayer.this) {
        if (mediaLoadData.dataType != C.DATA_TYPE_MEDIA) {
          // Don't report non-media URLs.
          return;
        }
        if (mResourceCallbacks == null || loadEventInfo.uri == null || mReleasing) {
          return;
        }

        if (DEBUG) {
          Log.d(LOGTAG, "on-load: url=" + loadEventInfo.uri);
        }
        mResourceCallbacks.onLoad(loadEventInfo.uri.toString());
      }
    }
  }

  public final class ComponentEventDispatcher {
    // Called from GeckoHls{Audio,Video}Renderer/ExoPlayer internal playback thread
    // or GeckoHlsPlayerThread.
    public void onDataArrived(final int trackType) {
      assertTrue(mComponentListener != null);

      if (mComponentListener != null) {
        runOnPlayerThread(() -> mComponentListener.onDataArrived(trackType));
      }
    }

    // Called from GeckoHls{Audio,Video}Renderer internal playback thread.
    public void onVideoInputFormatChanged(final Format format) {
      assertTrue(mComponentListener != null);

      if (mComponentListener != null) {
        runOnPlayerThread(() -> mComponentListener.onVideoInputFormatChanged(format));
      }
    }

    // Called from GeckoHls{Audio,Video}Renderer internal playback thread.
    public void onAudioInputFormatChanged(final Format format) {
      assertTrue(mComponentListener != null);

      if (mComponentListener != null) {
        runOnPlayerThread(() -> mComponentListener.onAudioInputFormatChanged(format));
      }
    }
  }

  public final class ComponentListener {

    // General purpose implementation
    // Called on GeckoHlsPlayerThread
    public void onDataArrived(final int trackType) {
      assertTrue(isPlayerThread());

      synchronized (GeckoHlsPlayer.this) {
        if (DEBUG) {
          Log.d(LOGTAG, "[CB][onDataArrived] id " + mPlayerId);
        }
        if (!mIsPlayerInitDone) {
          return;
        }

        mTracksInfo.onDataArrived(trackType);
        if (!mReleasing) {
          mResourceCallbacks.onDataArrived();
        }
        checkInitDone();
      }
    }

    // Called on GeckoHlsPlayerThread
    public void onVideoInputFormatChanged(final Format format) {
      assertTrue(isPlayerThread());

      synchronized (GeckoHlsPlayer.this) {
        if (DEBUG) {
          Log.d(LOGTAG, "[CB] onVideoInputFormatChanged [" + format + "]");
          Log.d(
              LOGTAG,
              "[CB] SampleMIMEType ["
                  + format.sampleMimeType
                  + "], ContainerMIMEType ["
                  + format.containerMimeType
                  + "], id : "
                  + mPlayerId);
        }
        if (!mIsPlayerInitDone) {
          return;
        }
        mTracksInfo.onVideoInfoUpdated();
        checkInitDone();
      }
    }

    // Called on GeckoHlsPlayerThread
    public void onAudioInputFormatChanged(final Format format) {
      assertTrue(isPlayerThread());

      synchronized (GeckoHlsPlayer.this) {
        if (DEBUG) {
          Log.d(LOGTAG, "[CB] onAudioInputFormatChanged [" + format + "], mPlayerId :" + mPlayerId);
        }
        if (!mIsPlayerInitDone) {
          return;
        }
        mTracksInfo.onAudioInfoUpdated();
        checkInitDone();
      }
    }
  }

  private HlsMediaSource.Factory buildDataSourceFactory(
      final Context ctx, final DefaultBandwidthMeter bandwidthMeter) {
    return new HlsMediaSource.Factory(
        new DefaultDataSourceFactory(
            ctx, bandwidthMeter, buildHttpDataSourceFactory(bandwidthMeter)));
  }

  private HttpDataSource.Factory buildHttpDataSourceFactory(
      final DefaultBandwidthMeter bandwidthMeter) {
    return new DefaultHttpDataSourceFactory(
        GeckoSession.getDefaultUserAgent(),
        bandwidthMeter /* listener */,
        DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS,
        DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS,
        true /* allowCrossProtocolRedirects */);
  }

  private long getDuration() {
    return awaitPlayerThread(
        () -> {
          long duration = 0L;
          // Value returned by getDuration() is in milliseconds.
          if (mPlayer != null && !isLiveStream()) {
            duration = Math.max(0L, mPlayer.getDuration() * 1000L);
          }
          if (DEBUG) {
            Log.d(LOGTAG, "getDuration : " + duration + "(Us)");
          }
          return duration;
        });
  }

  // To make sure that each player has a unique id, GeckoHlsPlayer should be
  // created only from synchronized APIs in GeckoPlayerFactory.
  public GeckoHlsPlayer() {
    mPlayerId = sPlayerId.incrementAndGet();
    if (DEBUG) {
      Log.d(LOGTAG, " construct player with id(" + mPlayerId + ")");
    }
  }

  // Should be only called by GeckoPlayerFactory and GeckoHLSResourceWrapper.
  // The mPlayerId is used to make sure that the same GeckoHlsPlayer is used by
  // corresponding HLSResource and HLSDemuxer for each media playback.
  // Called on Gecko's main thread
  @Override
  public int getId() {
    return mPlayerId;
  }

  // Called on Gecko's main thread
  @Override
  public synchronized void addDemuxerWrapperCallbackListener(
      final BaseHlsPlayer.DemuxerCallbacks callback) {
    if (DEBUG) {
      Log.d(LOGTAG, " addDemuxerWrapperCallbackListener ...");
    }
    mDemuxerCallbacks = callback;
  }

  // Called on GeckoHlsPlayerThread from ExoPlayer
  @Override
  public synchronized void onLoadingChanged(final boolean isLoading) {
    assertTrue(isPlayerThread());

    if (DEBUG) {
      Log.d(LOGTAG, "loading [" + isLoading + "]");
    }
    if (!isLoading) {
      if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) {
        suspendExoplayer();
      }
      // To update buffered position.
      mComponentEventDispatcher.onDataArrived(C.TRACK_TYPE_DEFAULT);
    }
  }

  // Called on GeckoHlsPlayerThread from ExoPlayer
  @Override
  public synchronized void onPlayerStateChanged(final boolean playWhenReady, final int state) {
    assertTrue(isPlayerThread());

    if (DEBUG) {
      Log.d(LOGTAG, "state [" + playWhenReady + ", " + getStateString(state) + "]");
    }
    if (state == ExoPlayer.STATE_READY
        && !mExoplayerSuspended
        && mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) {
      resumeExoplayer();
    }
  }

  // Called on GeckoHlsPlayerThread from ExoPlayer
  @Override
  public void onPositionDiscontinuity(final int reason) {
    assertTrue(isPlayerThread());

    if (DEBUG) {
      Log.d(LOGTAG, "positionDiscontinuity: reason=" + reason);
    }
  }

  // Called on GeckoHlsPlayerThread from ExoPlayer
  @Override
  public void onPlaybackParametersChanged(final PlaybackParameters playbackParameters) {
    assertTrue(isPlayerThread());

    if (DEBUG) {
      Log.d(
          LOGTAG,
          "playbackParameters "
              + String.format(
                  "[speed=%.2f, pitch=%.2f]", playbackParameters.speed, playbackParameters.pitch));
    }
  }

  // Called on GeckoHlsPlayerThread from ExoPlayer
  @Override
  public synchronized void onPlayerError(final ExoPlaybackException e) {
    assertTrue(isPlayerThread());

    if (DEBUG) {
      Log.e(LOGTAG, "playerFailed", e);
    }
    mIsPlayerInitDone = false;
    if (mReleasing) {
      return;
    }
    if (mResourceCallbacks != null) {
      mResourceCallbacks.onError(ResourceError.PLAYER.code());
    }
    if (mDemuxerCallbacks != null) {
      mDemuxerCallbacks.onError(DemuxerError.PLAYER.code());
    }
  }

  // Called on GeckoHlsPlayerThread from ExoPlayer
  @Override
  public synchronized void onTracksChanged(
      final TrackGroupArray ignored, final TrackSelectionArray trackSelections) {
    assertTrue(isPlayerThread());

    if (DEBUG) {
      Log.d(LOGTAG, "onTracksChanged : TGA[" + ignored + "], TSA[" + trackSelections + "]");

      final MappedTrackInfo mappedTrackInfo = mTrackSelector.getCurrentMappedTrackInfo();
      if (mappedTrackInfo == null) {
        Log.d(LOGTAG, "Tracks []");
        return;
      }
      Log.d(LOGTAG, "Tracks [");
      // Log tracks associated to renderers.
      for (int rendererIndex = 0; rendererIndex < mappedTrackInfo.length; rendererIndex++) {
        final TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex);
        final TrackSelection trackSelection = trackSelections.get(rendererIndex);
        if (rendererTrackGroups.length > 0) {
          Log.d(LOGTAG, "  Renderer:" + rendererIndex + " [");
          for (int groupIndex = 0; groupIndex < rendererTrackGroups.length; groupIndex++) {
            final TrackGroup trackGroup = rendererTrackGroups.get(groupIndex);
            final String adaptiveSupport =
                getAdaptiveSupportString(
                    trackGroup.length,
                    mappedTrackInfo.getAdaptiveSupport(rendererIndex, groupIndex, false));
            Log.d(
                LOGTAG,
                "    Group:" + groupIndex + ", adaptive_supported=" + adaptiveSupport + " [");
            for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) {
              final String status = getTrackStatusString(trackSelection, trackGroup, trackIndex);
              final String formatSupport =
                  getFormatSupportString(
                      mappedTrackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex));
              Log.d(
                  LOGTAG,
                  "      "
                      + status
                      + " Track:"
                      + trackIndex
                      + ", "
                      + Format.toLogString(trackGroup.getFormat(trackIndex))
                      + ", supported="
                      + formatSupport);
            }
            Log.d(LOGTAG, "    ]");
          }
          Log.d(LOGTAG, "  ]");
        }
      }
      // Log tracks not associated with a renderer.
      final TrackGroupArray unassociatedTrackGroups = mappedTrackInfo.getUnassociatedTrackGroups();
      if (unassociatedTrackGroups.length > 0) {
        Log.d(LOGTAG, "  Renderer:None [");
        for (int groupIndex = 0; groupIndex < unassociatedTrackGroups.length; groupIndex++) {
          Log.d(LOGTAG, "    Group:" + groupIndex + " [");
          final TrackGroup trackGroup = unassociatedTrackGroups.get(groupIndex);
          for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) {
            final String status = getTrackStatusString(false);
            final String formatSupport =
                getFormatSupportString(RendererCapabilities.FORMAT_UNSUPPORTED_TYPE);
            Log.d(
                LOGTAG,
                "      "
                    + status
                    + " Track:"
                    + trackIndex
                    + ", "
                    + Format.toLogString(trackGroup.getFormat(trackIndex))
                    + ", supported="
                    + formatSupport);
          }
          Log.d(LOGTAG, "    ]");
        }
        Log.d(LOGTAG, "  ]");
      }
      Log.d(LOGTAG, "]");
    }
    mTracksInfo.reset();
    int numVideoTracks = 0;
    int numAudioTracks = 0;
    for (int j = 0; j < ignored.length; j++) {
      final TrackGroup tg = ignored.get(j);
      for (int i = 0; i < tg.length; i++) {
        final Format fmt = tg.getFormat(i);
        if (fmt.sampleMimeType != null) {
          if (mRendererController.isVideoRendererEnabled()
              && fmt.sampleMimeType.startsWith(new String("video"))) {
            numVideoTracks++;
          } else if (mRendererController.isAudioRendererEnabled()
              && fmt.sampleMimeType.startsWith(new String("audio"))) {
            numAudioTracks++;
          }
        }
      }
    }
    mTracksInfo.updateNumOfVideoTracks(numVideoTracks);
    mTracksInfo.updateNumOfAudioTracks(numAudioTracks);
  }

  // Called on GeckoHlsPlayerThread from ExoPlayer
  @Override
  public synchronized void onTimelineChanged(final Timeline timeline, final int reason) {
    assertTrue(isPlayerThread());

    // For now, we use the interface ExoPlayer.getDuration() for gecko,
    // so here we create local variable 'window' & 'peroid' to obtain
    // the dynamic duration.
    // See.
    // http://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/Timeline.html
    // for further information.
    final Timeline.Window window = new Timeline.Window();
    mIsTimelineStatic =
        !timeline.isEmpty() && !timeline.getWindow(timeline.getWindowCount() - 1, window).isDynamic;

    final int periodCount = timeline.getPeriodCount();
    final int windowCount = timeline.getWindowCount();
    if (DEBUG) {
      Log.d(LOGTAG, "sourceInfo [periodCount=" + periodCount + ", windowCount=" + windowCount);
    }
    final Timeline.Period period = new Timeline.Period();
    for (int i = 0; i < Math.min(periodCount, MAX_TIMELINE_ITEM_LINES); i++) {
      timeline.getPeriod(i, period);
      if (mDurationUs < period.getDurationUs()) {
        mDurationUs = period.getDurationUs();
      }
    }
    for (int i = 0; i < Math.min(windowCount, MAX_TIMELINE_ITEM_LINES); i++) {
      timeline.getWindow(i, window);
      if (mDurationUs < window.getDurationUs()) {
        mDurationUs = window.getDurationUs();
      }
    }
    // TODO : Need to check if the duration from play.getDuration is different
    // with the one calculated from multi-timelines/windows.
    if (DEBUG) {
      Log.d(
          LOGTAG,
          "Media duration (from Timeline) = "
              + mDurationUs
              + "(us)"
              + " player.getDuration() = "
              + mPlayer.getDuration()
              + "(ms)");
    }
  }

  private static String getStateString(final int state) {
    switch (state) {
      case ExoPlayer.STATE_BUFFERING:
        return "B";
      case ExoPlayer.STATE_ENDED:
        return "E";
      case ExoPlayer.STATE_IDLE:
        return "I";
      case ExoPlayer.STATE_READY:
        return "R";
      default:
        return "?";
    }
  }

  private static String getFormatSupportString(final int formatSupport) {
    switch (formatSupport) {
      case RendererCapabilities.FORMAT_HANDLED:
        return "YES";
      case RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES:
        return "NO_EXCEEDS_CAPABILITIES";
      case RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE:
        return "NO_UNSUPPORTED_TYPE";
      case RendererCapabilities.FORMAT_UNSUPPORTED_TYPE:
        return "NO";
      default:
        return "?";
    }
  }

  private static String getAdaptiveSupportString(final int trackCount, final int adaptiveSupport) {
    if (trackCount < 2) {
      return "N/A";
    }
    switch (adaptiveSupport) {
      case RendererCapabilities.ADAPTIVE_SEAMLESS:
        return "YES";
      case RendererCapabilities.ADAPTIVE_NOT_SEAMLESS:
        return "YES_NOT_SEAMLESS";
      case RendererCapabilities.ADAPTIVE_NOT_SUPPORTED:
        return "NO";
      default:
        return "?";
    }
  }

  private static String getTrackStatusString(
      final TrackSelection selection, final TrackGroup group, final int trackIndex) {
    return getTrackStatusString(
        selection != null
            && selection.getTrackGroup() == group
            && selection.indexOf(trackIndex) != C.INDEX_UNSET);
  }

  private static String getTrackStatusString(final boolean enabled) {
    return enabled ? "[X]" : "[ ]";
  }

  private final DefaultBandwidthMeter mBandwidthMeter =
      new DefaultBandwidthMeter.Builder(null).build();

  // Called on GeckoHlsPlayerThread
  private void createExoPlayer(final String url) {
    assertTrue(isPlayerThread());

    final Context ctx = GeckoAppShell.getApplicationContext();
    mComponentListener = new ComponentListener();
    mComponentEventDispatcher = new ComponentEventDispatcher();
    mDurationUs = 0;

    // Prepare trackSelector
    final TrackSelection.Factory videoTrackSelectionFactory =
        new AdaptiveTrackSelection.Factory(mBandwidthMeter);
    mTrackSelector = new DefaultTrackSelector(videoTrackSelectionFactory);

    // Prepare customized renderer
    mRenderers = new GeckoHlsRendererBase[2];
    mVRenderer = new GeckoHlsVideoRenderer(mComponentEventDispatcher);
    mARenderer = new GeckoHlsAudioRenderer(mComponentEventDispatcher);
    mRenderers[0] = mVRenderer;
    mRenderers[1] = mARenderer;

    final DefaultLoadControl dlc =
        new DefaultLoadControl.Builder()
            .setAllocator(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE))
            .setBufferDurationsMs(
                DEFAULT_MIN_BUFFER_MS,
                DEFAULT_MAX_BUFFER_MS,
                DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
                DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
            .createDefaultLoadControl();
    // Create ExoPlayer instance with specific components.
    mPlayer =
        new ExoPlayer.Builder(ctx, mRenderers)
            .setTrackSelector(mTrackSelector)
            .setLoadControl(dlc)
            .build();
    mPlayer.addListener(this);

    final Uri uri = Uri.parse(url);
    mMediaSource = buildDataSourceFactory(ctx, mBandwidthMeter).createMediaSource(uri);
    mSourceEventListener = new SourceEventListener();
    mMediaSource.addEventListener(mMainHandler, mSourceEventListener);
    if (DEBUG) {
      Log.d(
          LOGTAG,
          "Uri is " + uri + ", ContentType is " + Util.inferContentType(uri.getLastPathSegment()));
    }
    mPlayer.setPlayWhenReady(false);
    mPlayer.prepare(mMediaSource);
    mIsPlayerInitDone = true;
  }

  // =======================================================================
  // API for GeckoHLSResourceWrapper
  // =======================================================================
  // Called on Gecko Main Thread
  @Override
  public synchronized void init(final String url, final BaseHlsPlayer.ResourceCallbacks callback) {
    if (DEBUG) {
      Log.d(LOGTAG, " init");
    }
    assertTrue(callback != null);
    assertTrue(!mIsPlayerInitDone);

    mThread = new HandlerThread("GeckoHlsPlayerThread");
    mThread.start();
    mMainHandler = new Handler(mThread.getLooper());

    mMainHandler.post(
        () -> {
          mResourceCallbacks = callback;
          createExoPlayer(url);
        });
  }

  // Called on MDSM's TaskQueue
  @Override
  public boolean isLiveStream() {
    return !mIsTimelineStatic;
  }

  // =======================================================================
  // API for GeckoHLSDemuxerWrapper
  // =======================================================================
  // Called on HLSDemuxer's TaskQueue
  @Override
  public synchronized ConcurrentLinkedQueue<GeckoHLSSample> getSamples(
      final TrackType trackType, final int number) {
    if (trackType == TrackType.VIDEO) {
      return mVRenderer != null
          ? mVRenderer.getQueuedSamples(number)
          : new ConcurrentLinkedQueue<GeckoHLSSample>();
    } else if (trackType == TrackType.AUDIO) {
      return mARenderer != null
          ? mARenderer.getQueuedSamples(number)
          : new ConcurrentLinkedQueue<GeckoHLSSample>();
    } else {
      return new ConcurrentLinkedQueue<GeckoHLSSample>();
    }
  }

  // Called on MFR's TaskQueue
  @Override
  public long getBufferedPosition() {
    return awaitPlayerThread(
        () -> {
          // Value returned by getBufferedPosition() is in milliseconds.
          final long bufferedPos =
              mPlayer == null ? 0L : Math.max(0L, mPlayer.getBufferedPosition() * 1000L);
          if (DEBUG) {
            Log.d(LOGTAG, "getBufferedPosition : " + bufferedPos + "(Us)");
          }
          return bufferedPos;
        });
  }

  // Called on MFR's TaskQueue
  @Override
  public synchronized int getNumberOfTracks(final TrackType trackType) {
    if (DEBUG) {
      Log.d(LOGTAG, "getNumberOfTracks : type " + trackType);
    }
    if (trackType == TrackType.VIDEO) {
      return mTracksInfo.getNumOfVideoTracks();
    } else if (trackType == TrackType.AUDIO) {
      return mTracksInfo.getNumOfAudioTracks();
    }
    return 0;
  }

  // Called on MFR's TaskQueue
  @Override
  public GeckoVideoInfo getVideoInfo(final int index) {
    final Format fmt;
    synchronized (this) {
      if (DEBUG) {
        Log.d(LOGTAG, "getVideoInfo");
      }
      if (mVRenderer == null) {
        Log.e(LOGTAG, "no render to get video info from. Index : " + index);
        return null;
      }
      if (!mTracksInfo.hasVideo()) {
        return null;
      }
      fmt = mVRenderer.getFormat(index);
      if (fmt == null) {
        return null;
      }
    }
    return new GeckoVideoInfo(
        fmt.width,
        fmt.height,
        fmt.width,
        fmt.height,
        fmt.rotationDegrees,
        fmt.stereoMode,
        getDuration(),
        fmt.sampleMimeType,
        null,
        null);
  }

  // Called on MFR's TaskQueue
  @Override
  public GeckoAudioInfo getAudioInfo(final int index) {
    final Format fmt;
    synchronized (this) {
      if (DEBUG) {
        Log.d(LOGTAG, "getAudioInfo");
      }
      if (mARenderer == null) {
        Log.e(LOGTAG, "no render to get audio info from. Index : " + index);
        return null;
      }
      if (!mTracksInfo.hasAudio()) {
        return null;
      }
      fmt = mARenderer.getFormat(index);
      if (fmt == null) {
        return null;
      }
    }
    /* According to https://github.com/google/ExoPlayer/blob
     * /d979469659861f7fe1d39d153b90bdff1ab479cc/library/core/src/main
     * /java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java#L221-L224,
     * if the input audio format is not raw, exoplayer would assure that
     * the sample's pcm encoding bitdepth is 16.
     * For HLS content, it should always be 16.
     */
    assertTrue(!MimeTypes.AUDIO_RAW.equals(fmt.sampleMimeType));
    // For HLS content, csd-0 is enough.
    final byte[] csd = fmt.initializationData.isEmpty() ? null : fmt.initializationData.get(0);
    return new GeckoAudioInfo(
        fmt.sampleRate, fmt.channelCount, 16, 0, getDuration(), fmt.sampleMimeType, csd);
  }

  // Called on HLSDemuxer's TaskQueue
  @Override
  public boolean seek(final long positionUs) {
    synchronized (this) {
      if (mPlayer == null) {
        Log.d(LOGTAG, "Seek operation won't be performed as no player exists!");
        return false;
      }
    }
    return awaitPlayerThread(
        () -> {
          // Need to temporarily resume Exoplayer to download the chunks for getting the demuxed
          // keyframe sample when HTMLMediaElement is paused. Suspend Exoplayer when collecting
          // enough
          // samples in onLoadingChanged.
          if (mExoplayerSuspended) {
            resumeExoplayer();
          }
          // positionUs : microseconds.
          // NOTE : 1) It's not possible to seek media by tracktype via ExoPlayer Interface.
          //        2) positionUs is samples PTS from MFR, we need to re-adjust it
          //           for ExoPlayer by subtracting sample start time.
          //        3) Time unit for ExoPlayer.seek() is milliseconds.
          try {
            // TODO : Gather Timeline Period / Window information to develop
            //        complete timeline, and seekTime should be inside the duration.
            Long startTime = Long.MAX_VALUE;
            for (final GeckoHlsRendererBase r : mRenderers) {
              if (r == mVRenderer
                      && mRendererController.isVideoRendererEnabled()
                      && mTracksInfo.hasVideo()
                  || r == mARenderer
                      && mRendererController.isAudioRendererEnabled()
                      && mTracksInfo.hasAudio()) {
                // Find the min value of the start time
                startTime = Math.min(startTime, r.getFirstSamplePTS());
              }
            }
            if (DEBUG) {
              Log.d(
                  LOGTAG,
                  "seeking  : "
                      + positionUs / 1000
                      + " (ms); startTime : "
                      + startTime / 1000
                      + " (ms)");
            }
            assertTrue(startTime != Long.MAX_VALUE && startTime != Long.MIN_VALUE);
            mPlayer.seekTo(positionUs / 1000 - startTime / 1000);
          } catch (final Exception e) {
            if (mReleasing) {
              return false;
            }
            if (mDemuxerCallbacks != null) {
              mDemuxerCallbacks.onError(DemuxerError.UNKNOWN.code());
            }
            return false;
          }
          return true;
        });
  }

  // Called on HLSDemuxer's TaskQueue
  @Override
  public synchronized long getNextKeyFrameTime() {
    return mVRenderer != null ? mVRenderer.getNextKeyFrameTime() : Long.MAX_VALUE;
  }

  // Called on Gecko's main thread.
  @Override
  public synchronized void suspend() {
    runOnPlayerThread(
        () -> {
          if (mExoplayerSuspended) {
            return;
          }
          if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) {
            if (DEBUG) {
              Log.d(LOGTAG, "suspend player id : " + mPlayerId);
            }
            suspendExoplayer();
          }
        });
  }

  // Called on Gecko's main thread.
  @Override
  public synchronized void resume() {
    runOnPlayerThread(
        () -> {
          if (!mExoplayerSuspended) {
            return;
          }
          if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) {
            if (DEBUG) {
              Log.d(LOGTAG, "resume player id : " + mPlayerId);
            }
            resumeExoplayer();
          }
        });
  }

  // Called on Gecko's main thread.
  @Override
  public synchronized void play() {
    runOnPlayerThread(
        () -> {
          if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) {
            return;
          }
          if (DEBUG) {
            Log.d(LOGTAG, "MediaDecoder played.");
          }
          mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PLAYING;
          resumeExoplayer();
        });
  }

  // Called on Gecko's main thread.
  @Override
  public synchronized void pause() {
    runOnPlayerThread(
        () -> {
          if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) {
            return;
          }
          if (DEBUG) {
            Log.d(LOGTAG, "MediaDecoder paused.");
          }
          mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PAUSED;
          suspendExoplayer();
        });
  }

  private void suspendExoplayer() {
    assertTrue(isPlayerThread());

    if (mPlayer == null) {
      return;
    }
    mExoplayerSuspended = true;
    if (DEBUG) {
      Log.d(LOGTAG, "suspend Exoplayer");
    }
    mPlayer.setPlayWhenReady(false);
  }

  private void resumeExoplayer() {
    assertTrue(isPlayerThread());

    if (mPlayer == null) {
      return;
    }
    mExoplayerSuspended = false;
    if (DEBUG) {
      Log.d(LOGTAG, "resume Exoplayer");
    }
    mPlayer.setPlayWhenReady(true);
  }

  // Called on Gecko's main thread, when HLSDemuxer or HLSResource destructs.
  @Override
  public void release() {
    if (DEBUG) {
      Log.d(LOGTAG, "releasing  ... id : " + mPlayerId);
    }

    synchronized (this) {
      if (mReleasing) {
        return;
      } else {
        mReleasing = true;
      }
    }

    runOnPlayerThread(
        () -> {
          if (mPlayer != null) {
            mPlayer.removeListener(this);
            mPlayer.stop();
            mPlayer.release();
            mVRenderer = null;
            mARenderer = null;
            mPlayer = null;
          }
          if (mThread != null) {
            mThread.quit();
            mThread = null;
          }
          mDemuxerCallbacks = null;
          mResourceCallbacks = null;
          mIsPlayerInitDone = false;
          mIsDemuxerInitDone = false;
        });
  }

  private void runOnPlayerThread(final Runnable task) {
    assertTrue(mMainHandler != null);
    if (isPlayerThread()) {
      task.run();
    } else {
      mMainHandler.post(task);
    }
  }

  private boolean isPlayerThread() {
    return Thread.currentThread() == mMainHandler.getLooper().getThread();
  }

  private <T> T awaitPlayerThread(final Callable<T> task) {
    assertTrue(!isPlayerThread());

    try {
      final FutureTask<T> wait = new FutureTask<T>(task);
      mMainHandler.post(wait);
      return wait.get();
    } catch (final Exception e) {
      throw new RuntimeException(e);
    }
  }
}
