/*
 *  Copyright 2018 The WebRTC project authors. All Rights Reserved.
 *
 *  Use of this source code is governed by a BSD-style license
 *  that can be found in the LICENSE file in the root of the source
 *  tree. An additional intellectual property rights grant can be found
 *  in the file PATENTS.  All contributing project authors may
 *  be found in the AUTHORS file in the root of the source tree.
 */

package org.webrtc;

import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.graphics.Matrix;
import android.graphics.SurfaceTexture;
import android.media.MediaCodecInfo.CodecCapabilities;
import android.media.MediaFormat;
import android.os.Handler;
import androidx.test.runner.AndroidJUnit4;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.annotation.Config;
import org.webrtc.EncodedImage.FrameType;
import org.webrtc.FakeMediaCodecWrapper.State;
import org.webrtc.VideoDecoder.DecodeInfo;
import org.webrtc.VideoFrame.I420Buffer;
import org.webrtc.VideoFrame.TextureBuffer.Type;

@RunWith(AndroidJUnit4.class)
@Config(manifest = Config.NONE)
public class AndroidVideoDecoderTest {
  private static final VideoDecoder.Settings TEST_DECODER_SETTINGS =
      new VideoDecoder.Settings(/* numberOfCores= */ 1, /* width= */ 640, /* height= */ 480);
  private static final int COLOR_FORMAT = CodecCapabilities.COLOR_FormatYUV420Planar;
  private static final long POLL_DELAY_MS = 10;
  private static final long DELIVER_DECODED_IMAGE_DELAY_MS = 10;

  private static final byte[] ENCODED_TEST_DATA = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

  private class TestDecoder extends AndroidVideoDecoder {
    private final Object deliverDecodedFrameLock = new Object();
    private boolean deliverDecodedFrameDone = true;

    public TestDecoder(MediaCodecWrapperFactory mediaCodecFactory, String codecName,
        VideoCodecMimeType codecType, int colorFormat, EglBase.Context sharedContext) {
      super(mediaCodecFactory, codecName, codecType, colorFormat, sharedContext);
    }

    public void waitDeliverDecodedFrame() throws InterruptedException {
      synchronized (deliverDecodedFrameLock) {
        deliverDecodedFrameDone = false;
        deliverDecodedFrameLock.notifyAll();
        while (!deliverDecodedFrameDone) {
          deliverDecodedFrameLock.wait();
        }
      }
    }

    @SuppressWarnings("WaitNotInLoop") // This method is called inside a loop.
    @Override
    protected void deliverDecodedFrame() {
      synchronized (deliverDecodedFrameLock) {
        if (deliverDecodedFrameDone) {
          try {
            deliverDecodedFrameLock.wait(DELIVER_DECODED_IMAGE_DELAY_MS);
          } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
          }
        }
        if (deliverDecodedFrameDone) {
          return;
        }
        super.deliverDecodedFrame();
        deliverDecodedFrameDone = true;
        deliverDecodedFrameLock.notifyAll();
      }
    }

    @Override
    protected SurfaceTextureHelper createSurfaceTextureHelper() {
      return mockSurfaceTextureHelper;
    }

    @Override
    protected void releaseSurface() {}

    @Override
    protected VideoFrame.I420Buffer allocateI420Buffer(int width, int height) {
      int chromaHeight = (height + 1) / 2;
      int strideUV = (width + 1) / 2;
      int yPos = 0;
      int uPos = yPos + width * height;
      int vPos = uPos + strideUV * chromaHeight;

      ByteBuffer buffer = ByteBuffer.allocateDirect(width * height + 2 * strideUV * chromaHeight);

      buffer.position(yPos);
      buffer.limit(uPos);
      ByteBuffer dataY = buffer.slice();

      buffer.position(uPos);
      buffer.limit(vPos);
      ByteBuffer dataU = buffer.slice();

      buffer.position(vPos);
      buffer.limit(vPos + strideUV * chromaHeight);
      ByteBuffer dataV = buffer.slice();

      return JavaI420Buffer.wrap(width, height, dataY, width, dataU, strideUV, dataV, strideUV,
          /* releaseCallback= */ null);
    }

    @Override
    protected void copyPlane(
        ByteBuffer src, int srcStride, ByteBuffer dst, int dstStride, int width, int height) {
      for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
          dst.put(y * dstStride + x, src.get(y * srcStride + x));
        }
      }
    }
  }

  private class TestDecoderBuilder {
    private VideoCodecMimeType codecType = VideoCodecMimeType.VP8;
    private boolean useSurface = true;

    public TestDecoderBuilder setCodecType(VideoCodecMimeType codecType) {
      this.codecType = codecType;
      return this;
    }

    public TestDecoderBuilder setUseSurface(boolean useSurface) {
      this.useSurface = useSurface;
      return this;
    }

    public TestDecoder build() {
      return new TestDecoder((String name)
                                 -> fakeMediaCodecWrapper,
          /* codecName= */ "org.webrtc.testdecoder", codecType, COLOR_FORMAT,
          useSurface ? mockEglBaseContext : null);
    }
  }

  private static class FakeDecoderCallback implements VideoDecoder.Callback {
    public final List<VideoFrame> decodedFrames;

    public FakeDecoderCallback() {
      decodedFrames = new ArrayList<>();
    }

    @Override
    public void onDecodedFrame(VideoFrame frame, Integer decodeTimeMs, Integer qp) {
      frame.retain();
      decodedFrames.add(frame);
    }

    public void release() {
      for (VideoFrame frame : decodedFrames) frame.release();
      decodedFrames.clear();
    }
  }

  private EncodedImage createTestEncodedImage() {
    return EncodedImage.builder()
        .setBuffer(ByteBuffer.wrap(ENCODED_TEST_DATA), null)
        .setFrameType(FrameType.VideoFrameKey)
        .createEncodedImage();
  }

  @Mock private EglBase.Context mockEglBaseContext;
  @Mock private SurfaceTextureHelper mockSurfaceTextureHelper;
  @Mock private VideoDecoder.Callback mockDecoderCallback;
  private FakeMediaCodecWrapper fakeMediaCodecWrapper;
  private FakeDecoderCallback fakeDecoderCallback;

  @Before
  public void setUp() {
    MockitoAnnotations.initMocks(this);
    when(mockSurfaceTextureHelper.getSurfaceTexture())
        .thenReturn(new SurfaceTexture(/*texName=*/0));
    MediaFormat inputFormat = new MediaFormat();
    MediaFormat outputFormat = new MediaFormat();
    // TODO(sakal): Add more details to output format as needed.
    fakeMediaCodecWrapper = spy(new FakeMediaCodecWrapper(inputFormat, outputFormat));
    fakeDecoderCallback = new FakeDecoderCallback();
  }

  @After
  public void cleanUp() {
    fakeDecoderCallback.release();
  }

  @Test
  public void testInit() {
    // Set-up.
    AndroidVideoDecoder decoder =
        new TestDecoderBuilder().setCodecType(VideoCodecMimeType.VP8).build();

    // Test.
    assertThat(decoder.initDecode(TEST_DECODER_SETTINGS, mockDecoderCallback))
        .isEqualTo(VideoCodecStatus.OK);

    // Verify.
    assertThat(fakeMediaCodecWrapper.getState()).isEqualTo(State.EXECUTING_RUNNING);

    MediaFormat mediaFormat = fakeMediaCodecWrapper.getConfiguredFormat();
    assertThat(mediaFormat).isNotNull();
    assertThat(mediaFormat.getInteger(MediaFormat.KEY_WIDTH))
        .isEqualTo(TEST_DECODER_SETTINGS.width);
    assertThat(mediaFormat.getInteger(MediaFormat.KEY_HEIGHT))
        .isEqualTo(TEST_DECODER_SETTINGS.height);
    assertThat(mediaFormat.getString(MediaFormat.KEY_MIME))
        .isEqualTo(VideoCodecMimeType.VP8.mimeType());
  }

  @Test
  public void testRelease() {
    // Set-up.
    AndroidVideoDecoder decoder = new TestDecoderBuilder().build();
    decoder.initDecode(TEST_DECODER_SETTINGS, mockDecoderCallback);

    // Test.
    assertThat(decoder.release()).isEqualTo(VideoCodecStatus.OK);

    // Verify.
    assertThat(fakeMediaCodecWrapper.getState()).isEqualTo(State.RELEASED);
  }

  @Test
  public void testReleaseMultipleTimes() {
    // Set-up.
    AndroidVideoDecoder decoder = new TestDecoderBuilder().build();
    decoder.initDecode(TEST_DECODER_SETTINGS, mockDecoderCallback);

    // Test.
    assertThat(decoder.release()).isEqualTo(VideoCodecStatus.OK);
    assertThat(decoder.release()).isEqualTo(VideoCodecStatus.OK);

    // Verify.
    assertThat(fakeMediaCodecWrapper.getState()).isEqualTo(State.RELEASED);
  }

  @Test
  public void testDecodeQueuesData() {
    // Set-up.
    AndroidVideoDecoder decoder = new TestDecoderBuilder().build();
    decoder.initDecode(TEST_DECODER_SETTINGS, mockDecoderCallback);

    // Test.
    assertThat(decoder.decode(createTestEncodedImage(),
                   new DecodeInfo(/* isMissingFrames= */ false, /* renderTimeMs= */ 0)))
        .isEqualTo(VideoCodecStatus.OK);

    // Verify.
    ArgumentCaptor<Integer> indexCaptor = ArgumentCaptor.forClass(Integer.class);
    ArgumentCaptor<Integer> offsetCaptor = ArgumentCaptor.forClass(Integer.class);
    ArgumentCaptor<Integer> sizeCaptor = ArgumentCaptor.forClass(Integer.class);
    verify(fakeMediaCodecWrapper)
        .queueInputBuffer(indexCaptor.capture(), offsetCaptor.capture(), sizeCaptor.capture(),
            /* presentationTimeUs= */ anyLong(),
            /* flags= */ eq(0));

    ByteBuffer inputBuffer = fakeMediaCodecWrapper.getInputBuffer(indexCaptor.getValue());
    CodecTestHelper.assertEqualContents(
        ENCODED_TEST_DATA, inputBuffer, offsetCaptor.getValue(), sizeCaptor.getValue());
  }

  @Test
  public void testDeliversOutputByteBuffers() throws InterruptedException {
    final byte[] testOutputData = CodecTestHelper.generateRandomData(
        TEST_DECODER_SETTINGS.width * TEST_DECODER_SETTINGS.height * 3 / 2);
    final I420Buffer expectedDeliveredBuffer = CodecTestHelper.wrapI420(
        TEST_DECODER_SETTINGS.width, TEST_DECODER_SETTINGS.height, testOutputData);

    // Set-up.
    TestDecoder decoder = new TestDecoderBuilder().setUseSurface(/* useSurface = */ false).build();
    decoder.initDecode(TEST_DECODER_SETTINGS, fakeDecoderCallback);
    decoder.decode(createTestEncodedImage(),
        new DecodeInfo(/* isMissingFrames= */ false, /* renderTimeMs= */ 0));
    fakeMediaCodecWrapper.addOutputData(
        testOutputData, /* presentationTimestampUs= */ 0, /* flags= */ 0);

    // Test.
    decoder.waitDeliverDecodedFrame();

    // Verify.
    assertThat(fakeDecoderCallback.decodedFrames).hasSize(1);
    VideoFrame videoFrame = fakeDecoderCallback.decodedFrames.get(0);
    assertThat(videoFrame).isNotNull();
    assertThat(videoFrame.getRotatedWidth()).isEqualTo(TEST_DECODER_SETTINGS.width);
    assertThat(videoFrame.getRotatedHeight()).isEqualTo(TEST_DECODER_SETTINGS.height);
    assertThat(videoFrame.getRotation()).isEqualTo(0);
    I420Buffer deliveredBuffer = videoFrame.getBuffer().toI420();
    assertThat(deliveredBuffer.getDataY()).isEqualTo(expectedDeliveredBuffer.getDataY());
    assertThat(deliveredBuffer.getDataU()).isEqualTo(expectedDeliveredBuffer.getDataU());
    assertThat(deliveredBuffer.getDataV()).isEqualTo(expectedDeliveredBuffer.getDataV());
  }

  @Test
  public void testRendersOutputTexture() throws InterruptedException {
    // Set-up.
    TestDecoder decoder = new TestDecoderBuilder().build();
    decoder.initDecode(TEST_DECODER_SETTINGS, mockDecoderCallback);
    decoder.decode(createTestEncodedImage(),
        new DecodeInfo(/* isMissingFrames= */ false, /* renderTimeMs= */ 0));
    int bufferIndex =
        fakeMediaCodecWrapper.addOutputTexture(/* presentationTimestampUs= */ 0, /* flags= */ 0);

    // Test.
    decoder.waitDeliverDecodedFrame();

    // Verify.
    verify(fakeMediaCodecWrapper).releaseOutputBuffer(bufferIndex, /* render= */ true);
  }

  @Test
  public void testSurfaceTextureStall_FramesDropped() throws InterruptedException {
    final int numFrames = 10;
    // Maximum number of frame the decoder can keep queued on the output side.
    final int maxQueuedBuffers = 3;

    // Set-up.
    TestDecoder decoder = new TestDecoderBuilder().build();
    decoder.initDecode(TEST_DECODER_SETTINGS, mockDecoderCallback);

    // Test.
    int[] bufferIndices = new int[numFrames];
    for (int i = 0; i < 10; i++) {
      decoder.decode(createTestEncodedImage(),
          new DecodeInfo(/* isMissingFrames= */ false, /* renderTimeMs= */ 0));
      bufferIndices[i] =
          fakeMediaCodecWrapper.addOutputTexture(/* presentationTimestampUs= */ 0, /* flags= */ 0);
      decoder.waitDeliverDecodedFrame();
    }

    // Verify.
    InOrder releaseOrder = inOrder(fakeMediaCodecWrapper);
    releaseOrder.verify(fakeMediaCodecWrapper)
        .releaseOutputBuffer(bufferIndices[0], /* render= */ true);
    for (int i = 1; i < numFrames - maxQueuedBuffers; i++) {
      releaseOrder.verify(fakeMediaCodecWrapper)
          .releaseOutputBuffer(bufferIndices[i], /* render= */ false);
    }
  }

  @Test
  public void testDeliversRenderedBuffers() throws InterruptedException {
    // Set-up.
    TestDecoder decoder = new TestDecoderBuilder().build();
    decoder.initDecode(TEST_DECODER_SETTINGS, fakeDecoderCallback);
    decoder.decode(createTestEncodedImage(),
        new DecodeInfo(/* isMissingFrames= */ false, /* renderTimeMs= */ 0));
    fakeMediaCodecWrapper.addOutputTexture(/* presentationTimestampUs= */ 0, /* flags= */ 0);

    // Render the output buffer.
    decoder.waitDeliverDecodedFrame();

    ArgumentCaptor<VideoSink> videoSinkCaptor = ArgumentCaptor.forClass(VideoSink.class);
    verify(mockSurfaceTextureHelper).startListening(videoSinkCaptor.capture());

    // Test.
    Runnable releaseCallback = mock(Runnable.class);
    VideoFrame.TextureBuffer outputTextureBuffer =
        new TextureBufferImpl(TEST_DECODER_SETTINGS.width, TEST_DECODER_SETTINGS.height, Type.OES,
            /* id= */ 0,
            /* transformMatrix= */ new Matrix(),
            /* toI420Handler= */ new Handler(), new YuvConverter(), releaseCallback);
    VideoFrame outputVideoFrame =
        new VideoFrame(outputTextureBuffer, /* rotation= */ 0, /* timestampNs= */ 0);
    videoSinkCaptor.getValue().onFrame(outputVideoFrame);
    outputVideoFrame.release();

    // Verify.
    assertThat(fakeDecoderCallback.decodedFrames).hasSize(1);
    VideoFrame videoFrame = fakeDecoderCallback.decodedFrames.get(0);
    assertThat(videoFrame).isNotNull();
    assertThat(videoFrame.getBuffer()).isEqualTo(outputTextureBuffer);

    fakeDecoderCallback.release();

    verify(releaseCallback).run();
  }

  @Test
  public void testConfigureExceptionTriggerSWFallback() {
    // Set-up.
    doThrow(new IllegalStateException("Fake error"))
        .when(fakeMediaCodecWrapper)
        .configure(any(), any(), any(), anyInt());

    AndroidVideoDecoder decoder = new TestDecoderBuilder().build();

    // Test.
    assertThat(decoder.initDecode(TEST_DECODER_SETTINGS, mockDecoderCallback))
        .isEqualTo(VideoCodecStatus.FALLBACK_SOFTWARE);
  }

  @Test
  public void testStartExceptionTriggerSWFallback() {
    // Set-up.
    doThrow(new IllegalStateException("Fake error")).when(fakeMediaCodecWrapper).start();

    AndroidVideoDecoder decoder = new TestDecoderBuilder().build();

    // Test.
    assertThat(decoder.initDecode(TEST_DECODER_SETTINGS, mockDecoderCallback))
        .isEqualTo(VideoCodecStatus.FALLBACK_SOFTWARE);
  }
}
