/*
 * Copyright (C) 2017 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.playbacktests.gts;

import static com.google.android.exoplayer2.C.WIDEVINE_UUID;

import android.media.MediaDrm;
import android.media.UnsupportedSchemeException;
import android.view.Surface;
import android.widget.FrameLayout;
import androidx.annotation.RequiresApi;
import androidx.annotation.Size;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.platform.app.InstrumentationRegistry;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.RendererCapabilities;
import com.google.android.exoplayer2.decoder.DecoderCounters;
import com.google.android.exoplayer2.drm.DefaultDrmSessionManager;
import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
import com.google.android.exoplayer2.drm.MediaDrmCallback;
import com.google.android.exoplayer2.drm.UnsupportedDrmException;
import com.google.android.exoplayer2.mediacodec.MediaCodecUtil;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.TrackGroup;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.source.dash.DashMediaSource;
import com.google.android.exoplayer2.testutil.ActionSchedule;
import com.google.android.exoplayer2.testutil.DecoderCountersUtil;
import com.google.android.exoplayer2.testutil.ExoHostedTest;
import com.google.android.exoplayer2.testutil.HostActivity;
import com.google.android.exoplayer2.testutil.HostActivity.HostedTest;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
import com.google.android.exoplayer2.trackselection.MappingTrackSelector;
import com.google.android.exoplayer2.trackselection.RandomTrackSelection;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSource;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Util;
import com.google.common.primitives.Ints;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/** {@link DashHostedTest} builder. */
/* package */ final class DashTestRunner {

  static final int VIDEO_RENDERER_INDEX = 0;
  static final int AUDIO_RENDERER_INDEX = 1;

  private static final long TEST_TIMEOUT_MS = 5 * 60 * 1000;

  // Whether adaptive tests should enable video formats beyond those mandated by the Android CDD
  // if the device advertises support for them.
  private static final boolean ALLOW_ADDITIONAL_VIDEO_FORMATS = Util.SDK_INT >= 24;

  private static final String AUDIO_TAG_SUFFIX = ":Audio";
  private static final String VIDEO_TAG_SUFFIX = ":Video";

  private static final int MIN_LOADABLE_RETRY_COUNT = 10;
  private static final int MAX_CONSECUTIVE_DROPPED_VIDEO_FRAMES = 10;
  private static final float MAX_DROPPED_VIDEO_FRAME_FRACTION = 0.01f;

  private static final String WIDEVINE_SECURITY_LEVEL_1 = "L1";
  private static final String WIDEVINE_SECURITY_LEVEL_3 = "L3";
  private static final String SECURITY_LEVEL_PROPERTY = "securityLevel";

  @Size(max = 23)
  private final String tag;

  private final HostActivity activity;

  private String streamName;
  private boolean fullPlaybackNoSeeking;
  private String audioFormat;
  private boolean canIncludeAdditionalVideoFormats;
  private ActionSchedule actionSchedule;
  private byte[] offlineLicenseKeySetId;
  private String[] videoFormats;
  private String manifestUrl;
  private boolean useL1Widevine;
  private String widevineLicenseUrl;
  private DataSource.Factory dataSourceFactory;

  @SuppressWarnings("ResourceType")
  public static boolean isL1WidevineAvailable(String mimeType) {
    if (Util.SDK_INT >= 18) {
      try {
        // Force L3 if secure decoder is not available.
        if (MediaCodecUtil.getDecoderInfo(mimeType, /* secure= */ true, /* tunneling= */ false)
            == null) {
          return false;
        }
        MediaDrm mediaDrm = MediaDrmBuilder.build();
        String securityProperty = mediaDrm.getPropertyString(SECURITY_LEVEL_PROPERTY);
        mediaDrm.release();
        return WIDEVINE_SECURITY_LEVEL_1.equals(securityProperty);
      } catch (MediaCodecUtil.DecoderQueryException e) {
        throw new IllegalStateException(e);
      }
    }
    return false;
  }

  public DashTestRunner(@Size(max = 23) String tag, HostActivity activity) {
    this.tag = tag;
    this.activity = activity;
  }

  @CanIgnoreReturnValue
  public DashTestRunner setStreamName(String streamName) {
    this.streamName = streamName;
    return this;
  }

  @CanIgnoreReturnValue
  public DashTestRunner setFullPlaybackNoSeeking(boolean fullPlaybackNoSeeking) {
    this.fullPlaybackNoSeeking = fullPlaybackNoSeeking;
    return this;
  }

  @CanIgnoreReturnValue
  public DashTestRunner setCanIncludeAdditionalVideoFormats(
      boolean canIncludeAdditionalVideoFormats) {
    this.canIncludeAdditionalVideoFormats =
        canIncludeAdditionalVideoFormats && ALLOW_ADDITIONAL_VIDEO_FORMATS;
    return this;
  }

  @CanIgnoreReturnValue
  public DashTestRunner setActionSchedule(ActionSchedule actionSchedule) {
    this.actionSchedule = actionSchedule;
    return this;
  }

  @CanIgnoreReturnValue
  public DashTestRunner setOfflineLicenseKeySetId(byte[] offlineLicenseKeySetId) {
    this.offlineLicenseKeySetId = offlineLicenseKeySetId;
    return this;
  }

  @CanIgnoreReturnValue
  public DashTestRunner setAudioVideoFormats(String audioFormat, String... videoFormats) {
    this.audioFormat = audioFormat;
    this.videoFormats = videoFormats;
    return this;
  }

  @CanIgnoreReturnValue
  public DashTestRunner setManifestUrl(String manifestUrl) {
    this.manifestUrl = manifestUrl;
    return this;
  }

  @CanIgnoreReturnValue
  public DashTestRunner setWidevineInfo(String mimeType, boolean videoIdRequiredInLicenseUrl) {
    this.useL1Widevine = isL1WidevineAvailable(mimeType);
    this.widevineLicenseUrl =
        DashTestData.getWidevineLicenseUrl(videoIdRequiredInLicenseUrl, useL1Widevine);
    return this;
  }

  @CanIgnoreReturnValue
  public DashTestRunner setDataSourceFactory(DataSource.Factory dataSourceFactory) {
    this.dataSourceFactory = dataSourceFactory;
    return this;
  }

  public void run() {
    DashHostedTest test = createDashHostedTest(canIncludeAdditionalVideoFormats, false);
    activity.runTest(test, TEST_TIMEOUT_MS);
    // Retry test exactly once if adaptive test fails due to excessive dropped buffers when
    // playing non-CDD required formats (b/28220076).
    if (test.needsCddLimitedRetry) {
      activity.runTest(createDashHostedTest(false, true), TEST_TIMEOUT_MS);
    }
  }

  private DashHostedTest createDashHostedTest(
      boolean canIncludeAdditionalVideoFormats, boolean isCddLimitedRetry) {
    MetricsLogger metricsLogger =
        MetricsLogger.DEFAULT_FACTORY.create(
            InstrumentationRegistry.getInstrumentation(), tag, streamName);
    return new DashHostedTest(
        tag,
        streamName,
        manifestUrl,
        metricsLogger,
        fullPlaybackNoSeeking,
        audioFormat,
        canIncludeAdditionalVideoFormats,
        isCddLimitedRetry,
        actionSchedule,
        offlineLicenseKeySetId,
        widevineLicenseUrl,
        useL1Widevine,
        dataSourceFactory,
        videoFormats);
  }

  /** A {@link HostedTest} for DASH playback tests. */
  private static final class DashHostedTest extends ExoHostedTest {

    private final String streamName;
    private final String manifestUrl;
    private final MetricsLogger metricsLogger;
    private final boolean fullPlaybackNoSeeking;
    private final boolean isCddLimitedRetry;
    private final DashTestTrackSelector trackSelector;
    private final byte[] offlineLicenseKeySetId;
    private final String widevineLicenseUrl;
    private final boolean useL1Widevine;
    private final DataSource.Factory dataSourceFactory;

    private boolean needsCddLimitedRetry;

    /**
     * @param tag A tag to use for logging.
     * @param streamName The name of the test stream for metric logging.
     * @param manifestUrl The manifest url.
     * @param metricsLogger Logger to log metrics from the test.
     * @param fullPlaybackNoSeeking Whether the test will play the entire source with no seeking.
     * @param audioFormat The audio format.
     * @param canIncludeAdditionalVideoFormats Whether to use video formats in addition to those
     *     listed in the videoFormats argument, if the device is capable of playing them.
     * @param isCddLimitedRetry Whether this is a CDD limited retry following a previous failure.
     * @param actionSchedule The action schedule for the test.
     * @param offlineLicenseKeySetId The key set id of the license to be used.
     * @param widevineLicenseUrl If the video is Widevine encrypted, this is the license url
     *     otherwise null.
     * @param useL1Widevine Whether to use L1 Widevine.
     * @param dataSourceFactory If not null, used to load manifest and media.
     * @param videoFormats The video formats.
     */
    private DashHostedTest(
        String tag,
        String streamName,
        String manifestUrl,
        MetricsLogger metricsLogger,
        boolean fullPlaybackNoSeeking,
        String audioFormat,
        boolean canIncludeAdditionalVideoFormats,
        boolean isCddLimitedRetry,
        ActionSchedule actionSchedule,
        byte[] offlineLicenseKeySetId,
        String widevineLicenseUrl,
        boolean useL1Widevine,
        DataSource.Factory dataSourceFactory,
        String... videoFormats) {
      super(tag, fullPlaybackNoSeeking);
      Assertions.checkArgument(!(isCddLimitedRetry && canIncludeAdditionalVideoFormats));
      this.streamName = streamName;
      this.manifestUrl = manifestUrl;
      this.metricsLogger = metricsLogger;
      this.fullPlaybackNoSeeking = fullPlaybackNoSeeking;
      this.isCddLimitedRetry = isCddLimitedRetry;
      this.offlineLicenseKeySetId = offlineLicenseKeySetId;
      this.widevineLicenseUrl = widevineLicenseUrl;
      this.useL1Widevine = useL1Widevine;
      this.dataSourceFactory = dataSourceFactory;
      trackSelector =
          new DashTestTrackSelector(
              tag, audioFormat, videoFormats, canIncludeAdditionalVideoFormats);
      if (actionSchedule != null) {
        setSchedule(actionSchedule);
      }
    }

    @Override
    protected DefaultTrackSelector buildTrackSelector(HostActivity host) {
      return trackSelector;
    }

    @Override
    protected DrmSessionManager buildDrmSessionManager() {
      if (widevineLicenseUrl == null) {
        return DrmSessionManager.DRM_UNSUPPORTED;
      }
      MediaDrmCallback drmCallback =
          new HttpMediaDrmCallback(widevineLicenseUrl, new DefaultHttpDataSource.Factory());
      DefaultDrmSessionManager drmSessionManager =
          new DefaultDrmSessionManager.Builder()
              .setUuidAndExoMediaDrmProvider(
                  C.WIDEVINE_UUID,
                  uuid -> {
                    try {
                      FrameworkMediaDrm drm = FrameworkMediaDrm.newInstance(WIDEVINE_UUID);
                      if (!useL1Widevine) {
                        drm.setPropertyString(SECURITY_LEVEL_PROPERTY, WIDEVINE_SECURITY_LEVEL_3);
                      }
                      return drm;
                    } catch (UnsupportedDrmException e) {
                      throw new IllegalStateException(e);
                    }
                  })
              .build(drmCallback);

      if (offlineLicenseKeySetId != null) {
        drmSessionManager.setMode(DefaultDrmSessionManager.MODE_PLAYBACK, offlineLicenseKeySetId);
      }
      return drmSessionManager;
    }

    @Override
    protected ExoPlayer buildExoPlayer(
        HostActivity host, Surface surface, MappingTrackSelector trackSelector) {
      ExoPlayer player =
          new ExoPlayer.Builder(host, new DebugRenderersFactory(host))
              .setTrackSelector(trackSelector)
              .build();
      player.setVideoSurface(surface);
      return player;
    }

    @Override
    protected MediaSource buildSource(
        HostActivity host, DrmSessionManager drmSessionManager, FrameLayout overlayFrameLayout) {
      DataSource.Factory dataSourceFactory =
          this.dataSourceFactory != null
              ? this.dataSourceFactory
              : new DefaultDataSource.Factory(host);
      return new DashMediaSource.Factory(dataSourceFactory)
          .setDrmSessionManagerProvider(unusedMediaItem -> drmSessionManager)
          .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(MIN_LOADABLE_RETRY_COUNT))
          .createMediaSource(MediaItem.fromUri(manifestUrl));
    }

    @Override
    protected void logMetrics(DecoderCounters audioCounters, DecoderCounters videoCounters) {
      metricsLogger.logMetric(MetricsLogger.KEY_TEST_NAME, streamName);
      metricsLogger.logMetric(MetricsLogger.KEY_IS_CDD_LIMITED_RETRY, isCddLimitedRetry);
      metricsLogger.logMetric(
          MetricsLogger.KEY_FRAMES_DROPPED_COUNT, videoCounters.droppedBufferCount);
      metricsLogger.logMetric(
          MetricsLogger.KEY_MAX_CONSECUTIVE_FRAMES_DROPPED_COUNT,
          videoCounters.maxConsecutiveDroppedBufferCount);
      metricsLogger.logMetric(
          MetricsLogger.KEY_FRAMES_SKIPPED_COUNT, videoCounters.skippedOutputBufferCount);
      metricsLogger.logMetric(
          MetricsLogger.KEY_FRAMES_RENDERED_COUNT, videoCounters.renderedOutputBufferCount);
      metricsLogger.close();
    }

    @Override
    protected void assertPassed(DecoderCounters audioCounters, DecoderCounters videoCounters) {
      if (fullPlaybackNoSeeking) {
        // We shouldn't have skipped any output buffers.
        DecoderCountersUtil.assertSkippedOutputBufferCount(
            tag + AUDIO_TAG_SUFFIX, audioCounters, 0);
        DecoderCountersUtil.assertSkippedOutputBufferCount(
            tag + VIDEO_TAG_SUFFIX, videoCounters, 0);
        DecoderCountersUtil.assertTotalBufferCount(tag + AUDIO_TAG_SUFFIX, audioCounters);
        DecoderCountersUtil.assertTotalBufferCount(tag + VIDEO_TAG_SUFFIX, videoCounters);
      }
      try {
        if (!shouldSkipDroppedOutputBufferPerformanceAssertions()) {
          int droppedFrameLimit =
              (int)
                  Math.ceil(
                      MAX_DROPPED_VIDEO_FRAME_FRACTION
                          * DecoderCountersUtil.getTotalBufferCount(videoCounters));
          // Assert that performance is acceptable.
          // Assert that total dropped frames were within limit.
          DecoderCountersUtil.assertDroppedBufferLimit(
              tag + VIDEO_TAG_SUFFIX, videoCounters, droppedFrameLimit);
          // Assert that consecutive dropped frames were within limit.
          DecoderCountersUtil.assertConsecutiveDroppedBufferLimit(
              tag + VIDEO_TAG_SUFFIX, videoCounters, MAX_CONSECUTIVE_DROPPED_VIDEO_FRAMES);
        }
      } catch (AssertionError e) {
        if (trackSelector.includedAdditionalVideoFormats) {
          // Retry limiting to CDD mandated formats (b/28220076).
          Log.e(tag, "Too many dropped or consecutive dropped frames.", e);
          needsCddLimitedRetry = true;
        } else {
          throw e;
        }
      }
    }
  }

  /** Provides a hook to skip dropped output buffer assertions in specific circumstances. */
  private static boolean shouldSkipDroppedOutputBufferPerformanceAssertions() {
    return false;
  }

  private static final class DashTestTrackSelector extends DefaultTrackSelector {

    @Size(max = 23)
    private final String tag;

    private final String audioFormatId;
    private final String[] videoFormatIds;
    private final boolean canIncludeAdditionalVideoFormats;

    public boolean includedAdditionalVideoFormats;

    private DashTestTrackSelector(
        String tag,
        String audioFormatId,
        String[] videoFormatIds,
        boolean canIncludeAdditionalVideoFormats) {
      super(
          ApplicationProvider.getApplicationContext(),
          new RandomTrackSelection.Factory(/* seed= */ 0));
      this.tag = tag;
      this.audioFormatId = audioFormatId;
      this.videoFormatIds = videoFormatIds;
      this.canIncludeAdditionalVideoFormats = canIncludeAdditionalVideoFormats;
    }

    @Override
    protected ExoTrackSelection.Definition[] selectAllTracks(
        MappedTrackInfo mappedTrackInfo,
        int[][][] rendererFormatSupports,
        int[] rendererMixedMimeTypeAdaptationSupports,
        Parameters parameters) {
      Assertions.checkState(
          mappedTrackInfo.getRendererType(VIDEO_RENDERER_INDEX) == C.TRACK_TYPE_VIDEO);
      Assertions.checkState(
          mappedTrackInfo.getRendererType(AUDIO_RENDERER_INDEX) == C.TRACK_TYPE_AUDIO);
      TrackGroupArray videoTrackGroups = mappedTrackInfo.getTrackGroups(VIDEO_RENDERER_INDEX);
      TrackGroupArray audioTrackGroups = mappedTrackInfo.getTrackGroups(AUDIO_RENDERER_INDEX);
      Assertions.checkState(videoTrackGroups.length == 1);
      Assertions.checkState(audioTrackGroups.length == 1);
      ExoTrackSelection.Definition[] definitions =
          new ExoTrackSelection.Definition[mappedTrackInfo.getRendererCount()];
      definitions[VIDEO_RENDERER_INDEX] =
          new ExoTrackSelection.Definition(
              videoTrackGroups.get(0),
              getVideoTrackIndices(
                  videoTrackGroups.get(0),
                  rendererFormatSupports[VIDEO_RENDERER_INDEX][0],
                  videoFormatIds,
                  canIncludeAdditionalVideoFormats));
      definitions[AUDIO_RENDERER_INDEX] =
          new ExoTrackSelection.Definition(
              audioTrackGroups.get(0), getTrackIndex(audioTrackGroups.get(0), audioFormatId));
      includedAdditionalVideoFormats =
          definitions[VIDEO_RENDERER_INDEX].tracks.length > videoFormatIds.length;
      return definitions;
    }

    private int[] getVideoTrackIndices(
        TrackGroup trackGroup,
        int[] formatSupports,
        String[] formatIds,
        boolean canIncludeAdditionalFormats) {
      List<Integer> trackIndices = new ArrayList<>();

      // Always select explicitly listed representations.
      for (String formatId : formatIds) {
        int trackIndex = getTrackIndex(trackGroup, formatId);
        Log.d(
            tag,
            "Adding base video format: " + Format.toLogString(trackGroup.getFormat(trackIndex)));
        trackIndices.add(trackIndex);
      }

      // Select additional video representations, if supported by the device.
      if (canIncludeAdditionalFormats) {
        for (int i = 0; i < trackGroup.length; i++) {
          if (!trackIndices.contains(i) && isFormatHandled(formatSupports[i])) {
            Log.d(tag, "Adding extra video format: " + Format.toLogString(trackGroup.getFormat(i)));
            trackIndices.add(i);
          }
        }
      }

      int[] trackIndicesArray = Ints.toArray(trackIndices);
      Arrays.sort(trackIndicesArray);
      return trackIndicesArray;
    }

    private static int getTrackIndex(TrackGroup trackGroup, String formatId) {
      for (int i = 0; i < trackGroup.length; i++) {
        if (trackGroup.getFormat(i).id.equals(formatId)) {
          return i;
        }
      }
      throw new IllegalStateException("Format " + formatId + " not found.");
    }

    private static boolean isFormatHandled(int formatSupport) {
      return RendererCapabilities.getFormatSupport(formatSupport) == C.FORMAT_HANDLED;
    }
  }

  /**
   * Creates a new {@code MediaDrm} object. The encapsulation ensures that the tests can be executed
   * for API level < 18.
   */
  @RequiresApi(18)
  private static final class MediaDrmBuilder {

    public static MediaDrm build() {
      try {
        return new MediaDrm(WIDEVINE_UUID);
      } catch (UnsupportedSchemeException e) {
        throw new IllegalStateException(e);
      }
    }
  }
}
