/*
 * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg;

import androidx.annotation.VisibleForTesting;
import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException;
import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput;
import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap;
import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint;
import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
import java.io.EOFException;
import java.io.IOException;

/** Seeks in an Ogg stream. */
/* package */ final class DefaultOggSeeker implements OggSeeker {

  private static final int MATCH_RANGE = 72000;
  private static final int MATCH_BYTE_RANGE = 100000;
  private static final int DEFAULT_OFFSET = 30000;

  private static final int STATE_SEEK_TO_END = 0;
  private static final int STATE_READ_LAST_PAGE = 1;
  private static final int STATE_SEEK = 2;
  private static final int STATE_SKIP = 3;
  private static final int STATE_IDLE = 4;

  private final OggPageHeader pageHeader = new OggPageHeader();
  private final long payloadStartPosition;
  private final long payloadEndPosition;
  private final StreamReader streamReader;

  private int state;
  private long totalGranules;
  private long positionBeforeSeekToEnd;
  private long targetGranule;

  private long start;
  private long end;
  private long startGranule;
  private long endGranule;

  /**
   * Constructs an OggSeeker.
   *
   * @param streamReader The {@link StreamReader} that owns this seeker.
   * @param payloadStartPosition Start position of the payload (inclusive).
   * @param payloadEndPosition End position of the payload (exclusive).
   * @param firstPayloadPageSize The total size of the first payload page, in bytes.
   * @param firstPayloadPageGranulePosition The granule position of the first payload page.
   * @param firstPayloadPageIsLastPage Whether the first payload page is also the last page.
   */
  public DefaultOggSeeker(
      StreamReader streamReader,
      long payloadStartPosition,
      long payloadEndPosition,
      long firstPayloadPageSize,
      long firstPayloadPageGranulePosition,
      boolean firstPayloadPageIsLastPage) {
    Assertions.checkArgument(
        payloadStartPosition >= 0 && payloadEndPosition > payloadStartPosition);
    this.streamReader = streamReader;
    this.payloadStartPosition = payloadStartPosition;
    this.payloadEndPosition = payloadEndPosition;
    if (firstPayloadPageSize == payloadEndPosition - payloadStartPosition
        || firstPayloadPageIsLastPage) {
      totalGranules = firstPayloadPageGranulePosition;
      state = STATE_IDLE;
    } else {
      state = STATE_SEEK_TO_END;
    }
  }

  @Override
  @SuppressWarnings("fallthrough")
  public long read(ExtractorInput input) throws IOException, InterruptedException {
    switch (state) {
      case STATE_IDLE:
        return -1;
      case STATE_SEEK_TO_END:
        positionBeforeSeekToEnd = input.getPosition();
        state = STATE_READ_LAST_PAGE;
        // Seek to the end just before the last page of stream to get the duration.
        long lastPageSearchPosition = payloadEndPosition - OggPageHeader.MAX_PAGE_SIZE;
        if (lastPageSearchPosition > positionBeforeSeekToEnd) {
          return lastPageSearchPosition;
        }
        // Fall through.
      case STATE_READ_LAST_PAGE:
        totalGranules = readGranuleOfLastPage(input);
        state = STATE_IDLE;
        return positionBeforeSeekToEnd;
      case STATE_SEEK:
        long position = getNextSeekPosition(input);
        if (position != C.POSITION_UNSET) {
          return position;
        }
        state = STATE_SKIP;
        // Fall through.
      case STATE_SKIP:
        skipToPageOfTargetGranule(input);
        state = STATE_IDLE;
        return -(startGranule + 2);
      default:
        // Never happens.
        throw new IllegalStateException();
    }
  }

  @Override
  public OggSeekMap createSeekMap() {
    return totalGranules != 0 ? new OggSeekMap() : null;
  }

  @Override
  public void startSeek(long targetGranule) {
    this.targetGranule = Util.constrainValue(targetGranule, 0, totalGranules - 1);
    state = STATE_SEEK;
    start = payloadStartPosition;
    end = payloadEndPosition;
    startGranule = 0;
    endGranule = totalGranules;
  }

  /**
   * Performs a single step of a seeking binary search, returning the byte position from which data
   * should be provided for the next step, or {@link C#POSITION_UNSET} if the search has converged.
   * If the search has converged then {@link #skipToPageOfTargetGranule(ExtractorInput)} should be
   * called to skip to the target page.
   *
   * @param input The {@link ExtractorInput} to read from.
   * @return The byte position from which data should be provided for the next step, or {@link
   *     C#POSITION_UNSET} if the search has converged.
   * @throws IOException If reading from the input fails.
   * @throws InterruptedException If interrupted while reading from the input.
   */
  private long getNextSeekPosition(ExtractorInput input) throws IOException, InterruptedException {
    if (start == end) {
      return C.POSITION_UNSET;
    }

    long currentPosition = input.getPosition();
    if (!skipToNextPage(input, end)) {
      if (start == currentPosition) {
        throw new IOException("No ogg page can be found.");
      }
      return start;
    }

    pageHeader.populate(input, /* quiet= */ false);
    input.resetPeekPosition();

    long granuleDistance = targetGranule - pageHeader.granulePosition;
    int pageSize = pageHeader.headerSize + pageHeader.bodySize;
    if (0 <= granuleDistance && granuleDistance < MATCH_RANGE) {
      return C.POSITION_UNSET;
    }

    if (granuleDistance < 0) {
      end = currentPosition;
      endGranule = pageHeader.granulePosition;
    } else {
      start = input.getPosition() + pageSize;
      startGranule = pageHeader.granulePosition;
    }

    if (end - start < MATCH_BYTE_RANGE) {
      end = start;
      return start;
    }

    long offset = pageSize * (granuleDistance <= 0 ? 2L : 1L);
    long nextPosition =
        input.getPosition()
            - offset
            + (granuleDistance * (end - start) / (endGranule - startGranule));
    return Util.constrainValue(nextPosition, start, end - 1);
  }

  /**
   * Skips forward to the start of the page containing the {@code targetGranule}.
   *
   * @param input The {@link ExtractorInput} to read from.
   * @throws ParserException If populating the page header fails.
   * @throws IOException If reading from the input fails.
   * @throws InterruptedException If interrupted while reading from the input.
   */
  private void skipToPageOfTargetGranule(ExtractorInput input)
      throws IOException, InterruptedException {
    pageHeader.populate(input, /* quiet= */ false);
    while (pageHeader.granulePosition <= targetGranule) {
      input.skipFully(pageHeader.headerSize + pageHeader.bodySize);
      start = input.getPosition();
      startGranule = pageHeader.granulePosition;
      pageHeader.populate(input, /* quiet= */ false);
    }
    input.resetPeekPosition();
  }

  /**
   * Skips to the next page.
   *
   * @param input The {@code ExtractorInput} to skip to the next page.
   * @throws IOException If peeking/reading from the input fails.
   * @throws InterruptedException If the thread is interrupted.
   * @throws EOFException If the next page can't be found before the end of the input.
   */
  @VisibleForTesting
  void skipToNextPage(ExtractorInput input) throws IOException, InterruptedException {
    if (!skipToNextPage(input, payloadEndPosition)) {
      // Not found until eof.
      throw new EOFException();
    }
  }

  /**
   * Skips to the next page. Searches for the next page header.
   *
   * @param input The {@code ExtractorInput} to skip to the next page.
   * @param limit The limit up to which the search should take place.
   * @return Whether the next page was found.
   * @throws IOException If peeking/reading from the input fails.
   * @throws InterruptedException If interrupted while peeking/reading from the input.
   */
  private boolean skipToNextPage(ExtractorInput input, long limit)
      throws IOException, InterruptedException {
    limit = Math.min(limit + 3, payloadEndPosition);
    byte[] buffer = new byte[2048];
    int peekLength = buffer.length;
    while (true) {
      if (input.getPosition() + peekLength > limit) {
        // Make sure to not peek beyond the end of the input.
        peekLength = (int) (limit - input.getPosition());
        if (peekLength < 4) {
          // Not found until end.
          return false;
        }
      }
      input.peekFully(buffer, 0, peekLength, false);
      for (int i = 0; i < peekLength - 3; i++) {
        if (buffer[i] == 'O'
            && buffer[i + 1] == 'g'
            && buffer[i + 2] == 'g'
            && buffer[i + 3] == 'S') {
          // Match! Skip to the start of the pattern.
          input.skipFully(i);
          return true;
        }
      }
      // Overlap by not skipping the entire peekLength.
      input.skipFully(peekLength - 3);
    }
  }

  /**
   * Skips to the last Ogg page in the stream and reads the header's granule field which is the
   * total number of samples per channel.
   *
   * @param input The {@link ExtractorInput} to read from.
   * @return The total number of samples of this input.
   * @throws IOException If reading from the input fails.
   * @throws InterruptedException If the thread is interrupted.
   */
  @VisibleForTesting
  long readGranuleOfLastPage(ExtractorInput input) throws IOException, InterruptedException {
    skipToNextPage(input);
    pageHeader.reset();
    while ((pageHeader.type & 0x04) != 0x04 && input.getPosition() < payloadEndPosition) {
      pageHeader.populate(input, /* quiet= */ false);
      input.skipFully(pageHeader.headerSize + pageHeader.bodySize);
    }
    return pageHeader.granulePosition;
  }

  private final class OggSeekMap implements SeekMap {

    @Override
    public boolean isSeekable() {
      return true;
    }

    @Override
    public SeekPoints getSeekPoints(long timeUs) {
      long targetGranule = streamReader.convertTimeToGranule(timeUs);
      long estimatedPosition =
          payloadStartPosition
              + (targetGranule * (payloadEndPosition - payloadStartPosition) / totalGranules)
              - DEFAULT_OFFSET;
      estimatedPosition =
          Util.constrainValue(estimatedPosition, payloadStartPosition, payloadEndPosition - 1);
      return new SeekPoints(new SeekPoint(timeUs, estimatedPosition));
    }

    @Override
    public long getDurationUs() {
      return streamReader.convertGranuleToTime(totalGranules);
    }
  }
}
