/*
 * Copyright (C) 2016 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 androidx.media3.test.exoplayer.playback.gts;

import static java.lang.Math.max;

import android.content.Context;
import android.media.MediaCrypto;
import android.media.MediaFormat;
import android.os.Handler;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.exoplayer.DecoderReuseEvaluation;
import androidx.media3.exoplayer.DefaultRenderersFactory;
import androidx.media3.exoplayer.ExoPlaybackException;
import androidx.media3.exoplayer.FormatHolder;
import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter;
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo;
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector;
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer;
import androidx.media3.exoplayer.video.VideoRendererEventListener;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.ArrayList;

/**
 * A debug extension of {@link DefaultRenderersFactory}. Provides a video renderer that performs
 * video buffer timestamp assertions, and modifies the default value for {@link
 * #setAllowedVideoJoiningTimeMs(long)} to be {@code 0}.
 */
// TODO: Move this class to `testutils` and add basic tests.
/* package */ final class DebugRenderersFactory extends DefaultRenderersFactory {

  public DebugRenderersFactory(Context context) {
    super(context);
    setAllowedVideoJoiningTimeMs(0);
  }

  @Override
  protected void buildVideoRenderers(
      Context context,
      @ExtensionRendererMode int extensionRendererMode,
      MediaCodecSelector mediaCodecSelector,
      boolean enableDecoderFallback,
      Handler eventHandler,
      VideoRendererEventListener eventListener,
      long allowedVideoJoiningTimeMs,
      ArrayList<Renderer> out) {
    out.add(
        new DebugMediaCodecVideoRenderer(
            context,
            mediaCodecSelector,
            allowedVideoJoiningTimeMs,
            eventHandler,
            eventListener,
            MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY));
  }

  /**
   * Decodes and renders video using {@link MediaCodecVideoRenderer}. Provides buffer timestamp
   * assertions.
   */
  private static class DebugMediaCodecVideoRenderer extends MediaCodecVideoRenderer {

    private static final String TAG = "DMCodecVideoRenderer";
    private static final int ARRAY_SIZE = 1000;

    private final long[] timestampsList;
    private final ArrayDeque<Long> inputFormatChangeTimesUs;
    private final boolean shouldMediaFormatChangeTimesBeChecked;

    private boolean skipToPositionBeforeRenderingFirstFrame;

    private int startIndex;
    private int queueSize;
    private int bufferCount;
    private int minimumInsertIndex;
    private boolean inputFormatChanged;
    private boolean outputMediaFormatChanged;

    @Nullable private MediaFormat currentMediaFormat;

    public DebugMediaCodecVideoRenderer(
        Context context,
        MediaCodecSelector mediaCodecSelector,
        long allowedJoiningTimeMs,
        Handler eventHandler,
        VideoRendererEventListener eventListener,
        int maxDroppedFrameCountToNotify) {
      super(
          context,
          mediaCodecSelector,
          allowedJoiningTimeMs,
          eventHandler,
          eventListener,
          maxDroppedFrameCountToNotify);
      timestampsList = new long[ARRAY_SIZE];
      inputFormatChangeTimesUs = new ArrayDeque<>();

      /*
      // Output MediaFormat changes are known to occur too early until API 30 (see [internal:
      // b/149818050, b/149751672]).
      shouldMediaFormatChangeTimesBeChecked = Util.SDK_INT > 30;
      */

      // [Internal ref: b/149751672] Seeking currently causes an unexpected MediaFormat change, so
      // this check is disabled until that is deemed fixed.
      shouldMediaFormatChangeTimesBeChecked = false;
    }

    @Override
    public String getName() {
      return TAG;
    }

    @Override
    protected MediaCodecAdapter.Configuration getMediaCodecConfiguration(
        MediaCodecInfo codecInfo, Format format, MediaCrypto crypto, float operatingRate) {
      return super.getMediaCodecConfiguration(codecInfo, format, crypto, operatingRate);
    }

    @Override
    protected void resetCodecStateForFlush() {
      super.resetCodecStateForFlush();
      clearTimestamps();
      // Check if there is a format change on the input side still pending propagation to the
      // output.
      inputFormatChanged = !inputFormatChangeTimesUs.isEmpty();
      inputFormatChangeTimesUs.clear();
      outputMediaFormatChanged = false;
    }

    @Override
    protected void onCodecInitialized(
        String name,
        MediaCodecAdapter.Configuration configuration,
        long initializedTimestampMs,
        long initializationDurationMs) {
      // If the codec was initialized whilst the renderer is started, default behavior is to
      // render the first frame (i.e. the keyframe before the current position), then drop frames up
      // to the current playback position. For test runs that place a maximum limit on the number of
      // dropped frames allowed, this is not desired behavior. Hence we skip (rather than drop)
      // frames up to the current playback position [Internal: b/66494991].
      skipToPositionBeforeRenderingFirstFrame = getState() == Renderer.STATE_STARTED;
      super.onCodecInitialized(
          name, configuration, initializedTimestampMs, initializationDurationMs);
    }

    @Override
    protected void resetCodecStateForRelease() {
      super.resetCodecStateForRelease();
      skipToPositionBeforeRenderingFirstFrame = false;
    }

    @Override
    @Nullable
    protected DecoderReuseEvaluation onInputFormatChanged(FormatHolder formatHolder)
        throws ExoPlaybackException {
      @Nullable DecoderReuseEvaluation evaluation = super.onInputFormatChanged(formatHolder);
      // Ensure timestamps of buffers queued after this format change are never inserted into the
      // queue of expected output timestamps before those of buffers that have already been queued.
      minimumInsertIndex = startIndex + queueSize;
      inputFormatChanged = true;
      return evaluation;
    }

    @Override
    protected void onQueueInputBuffer(DecoderInputBuffer buffer) throws ExoPlaybackException {
      super.onQueueInputBuffer(buffer);
      insertTimestamp(buffer.timeUs);
      maybeShiftTimestampsList();
      if (inputFormatChanged) {
        inputFormatChangeTimesUs.add(buffer.timeUs);
        inputFormatChanged = false;
      }
    }

    @Override
    protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaFormat) {
      super.onOutputFormatChanged(format, mediaFormat);
      if (mediaFormat != null && !mediaFormat.equals(currentMediaFormat)) {
        outputMediaFormatChanged = true;
        currentMediaFormat = mediaFormat;
      } else {
        inputFormatChangeTimesUs.remove();
      }
    }

    @Override
    protected boolean processOutputBuffer(
        long positionUs,
        long elapsedRealtimeUs,
        @Nullable MediaCodecAdapter codec,
        ByteBuffer buffer,
        int bufferIndex,
        int bufferFlags,
        int sampleCount,
        long bufferPresentationTimeUs,
        boolean isDecodeOnlyBuffer,
        boolean isLastBuffer,
        Format format)
        throws ExoPlaybackException {
      if (skipToPositionBeforeRenderingFirstFrame && bufferPresentationTimeUs < positionUs) {
        // After the codec has been initialized, don't render the first frame until we've caught up
        // to the playback position. Else test runs on devices that do not support dummy surface
        // will drop frames between rendering the first one and catching up [Internal: b/66494991].
        isDecodeOnlyBuffer = true;
      }
      return super.processOutputBuffer(
          positionUs,
          elapsedRealtimeUs,
          codec,
          buffer,
          bufferIndex,
          bufferFlags,
          sampleCount,
          bufferPresentationTimeUs,
          isDecodeOnlyBuffer,
          isLastBuffer,
          format);
    }

    @Override
    protected void renderOutputBuffer(MediaCodecAdapter codec, int index, long presentationTimeUs) {
      skipToPositionBeforeRenderingFirstFrame = false;
      super.renderOutputBuffer(codec, index, presentationTimeUs);
    }

    @RequiresApi(21)
    @Override
    protected void renderOutputBufferV21(
        MediaCodecAdapter codec, int index, long presentationTimeUs, long releaseTimeNs) {
      skipToPositionBeforeRenderingFirstFrame = false;
      super.renderOutputBufferV21(codec, index, presentationTimeUs, releaseTimeNs);
    }

    @Override
    protected void onProcessedOutputBuffer(long presentationTimeUs) {
      super.onProcessedOutputBuffer(presentationTimeUs);
      bufferCount++;
      long expectedTimestampUs = dequeueTimestamp();
      if (expectedTimestampUs != presentationTimeUs) {
        throw new IllegalStateException(
            "Expected to dequeue video buffer with presentation "
                + "timestamp: "
                + expectedTimestampUs
                + ". Instead got: "
                + presentationTimeUs
                + " (Processed buffers since last flush: "
                + bufferCount
                + ").");
      }

      if (outputMediaFormatChanged) {
        long inputFormatChangeTimeUs =
            inputFormatChangeTimesUs.isEmpty() ? C.TIME_UNSET : inputFormatChangeTimesUs.remove();
        outputMediaFormatChanged = false;

        if (shouldMediaFormatChangeTimesBeChecked
            && presentationTimeUs != inputFormatChangeTimeUs) {
          throw new IllegalStateException(
              "Expected output MediaFormat change timestamp ("
                  + presentationTimeUs
                  + " us) to match input Format change timestamp ("
                  + inputFormatChangeTimeUs
                  + " us).");
        }
      }
    }

    @Override
    protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) {
      // Disable all workarounds for testing - devices that require the workaround should fail GTS.
      return false;
    }

    private void clearTimestamps() {
      startIndex = 0;
      queueSize = 0;
      bufferCount = 0;
      minimumInsertIndex = 0;
    }

    private void insertTimestamp(long presentationTimeUs) {
      for (int i = startIndex + queueSize - 1; i >= minimumInsertIndex; i--) {
        if (presentationTimeUs >= timestampsList[i]) {
          timestampsList[i + 1] = presentationTimeUs;
          queueSize++;
          return;
        }
        timestampsList[i + 1] = timestampsList[i];
      }
      timestampsList[minimumInsertIndex] = presentationTimeUs;
      queueSize++;
    }

    private void maybeShiftTimestampsList() {
      if (startIndex + queueSize == ARRAY_SIZE) {
        System.arraycopy(timestampsList, startIndex, timestampsList, 0, queueSize);
        minimumInsertIndex -= startIndex;
        startIndex = 0;
      }
    }

    private long dequeueTimestamp() {
      queueSize--;
      startIndex++;
      minimumInsertIndex = max(minimumInsertIndex, startIndex);
      return timestampsList[startIndex - 1];
    }
  }
}
