/*
 * 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 androidx.media3.exoplayer.dash;

import static androidx.media3.common.util.Util.parseXsDateTime;

import android.os.Handler;
import android.os.Message;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.DataReader;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.FormatHolder;
import androidx.media3.exoplayer.dash.manifest.DashManifest;
import androidx.media3.exoplayer.source.SampleQueue;
import androidx.media3.exoplayer.source.chunk.Chunk;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.extractor.TrackOutput;
import androidx.media3.extractor.metadata.MetadataInputBuffer;
import androidx.media3.extractor.metadata.emsg.EventMessage;
import androidx.media3.extractor.metadata.emsg.EventMessageDecoder;
import java.io.IOException;
import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;

/**
 * Handles all emsg messages from all media tracks for the player.
 *
 * <p>This class will only respond to emsg messages which have schemeIdUri
 * "urn:mpeg:dash:event:2012", and value "1"/"2"/"3". When it encounters one of these messages, it
 * will handle the message according to Section 4.5.2.1 DASH -IF IOP Version 4.1:
 *
 * <ul>
 *   <li>If both presentation time delta and event duration are zero, it means the media
 *       presentation has ended.
 *   <li>Else, it will parse the message data from the emsg message to find the publishTime of the
 *       expired manifest, and mark manifest with publishTime smaller than that values to be
 *       expired.
 * </ul>
 *
 * In both cases, the DASH media source will be notified, and a manifest reload should be triggered.
 */
@UnstableApi
public final class PlayerEmsgHandler implements Handler.Callback {

  private static final int EMSG_MANIFEST_EXPIRED = 1;

  /** Callbacks for player emsg events encountered during DASH live stream. */
  public interface PlayerEmsgCallback {

    /** Called when the current manifest should be refreshed. */
    void onDashManifestRefreshRequested();

    /**
     * Called when the manifest with the publish time has been expired.
     *
     * @param expiredManifestPublishTimeUs The manifest publish time that has been expired.
     */
    void onDashManifestPublishTimeExpired(long expiredManifestPublishTimeUs);
  }

  private final Allocator allocator;
  private final PlayerEmsgCallback playerEmsgCallback;
  private final EventMessageDecoder decoder;
  private final Handler handler;
  private final TreeMap<Long, Long> manifestPublishTimeToExpiryTimeUs;

  private DashManifest manifest;

  private long expiredManifestPublishTimeUs;
  private boolean chunkLoadedCompletedSinceLastManifestRefreshRequest;
  private boolean isWaitingForManifestRefresh;
  private boolean released;

  /**
   * @param manifest The initial manifest.
   * @param playerEmsgCallback The callback that this event handler can invoke when handling emsg
   *     messages that generate DASH media source events.
   * @param allocator An {@link Allocator} from which allocations can be obtained.
   */
  public PlayerEmsgHandler(
      DashManifest manifest, PlayerEmsgCallback playerEmsgCallback, Allocator allocator) {
    this.manifest = manifest;
    this.playerEmsgCallback = playerEmsgCallback;
    this.allocator = allocator;

    manifestPublishTimeToExpiryTimeUs = new TreeMap<>();
    handler = Util.createHandlerForCurrentLooper(/* callback= */ this);
    decoder = new EventMessageDecoder();
  }

  /**
   * Updates the {@link DashManifest} that this handler works on.
   *
   * @param newManifest The updated manifest.
   */
  public void updateManifest(DashManifest newManifest) {
    isWaitingForManifestRefresh = false;
    expiredManifestPublishTimeUs = C.TIME_UNSET;
    this.manifest = newManifest;
    removePreviouslyExpiredManifestPublishTimeValues();
  }

  /** Returns a {@link TrackOutput} that emsg messages could be written to. */
  public PlayerTrackEmsgHandler newPlayerTrackEmsgHandler() {
    return new PlayerTrackEmsgHandler(allocator);
  }

  /** Release this emsg handler. It should not be reused after this call. */
  public void release() {
    released = true;
    handler.removeCallbacksAndMessages(null);
  }

  @Override
  public boolean handleMessage(Message message) {
    if (released) {
      return true;
    }
    switch (message.what) {
      case EMSG_MANIFEST_EXPIRED:
        ManifestExpiryEventInfo messageObj = (ManifestExpiryEventInfo) message.obj;
        handleManifestExpiredMessage(
            messageObj.eventTimeUs, messageObj.manifestPublishTimeMsInEmsg);
        return true;
      default:
        // Do nothing.
    }
    return false;
  }

  // Internal methods.

  /* package */ boolean maybeRefreshManifestBeforeLoadingNextChunk(long presentationPositionUs) {
    if (!manifest.dynamic) {
      return false;
    }
    if (isWaitingForManifestRefresh) {
      return true;
    }
    boolean manifestRefreshNeeded = false;
    // Find the smallest publishTime (greater than or equal to the current manifest's publish time)
    // that has a corresponding expiry time.
    Map.Entry<Long, Long> expiredEntry = ceilingExpiryEntryForPublishTime(manifest.publishTimeMs);
    if (expiredEntry != null) {
      long expiredPointUs = expiredEntry.getValue();
      if (expiredPointUs < presentationPositionUs) {
        expiredManifestPublishTimeUs = expiredEntry.getKey();
        notifyManifestPublishTimeExpired();
        manifestRefreshNeeded = true;
      }
    }
    if (manifestRefreshNeeded) {
      maybeNotifyDashManifestRefreshNeeded();
    }
    return manifestRefreshNeeded;
  }

  /* package */ void onChunkLoadCompleted(Chunk chunk) {
    chunkLoadedCompletedSinceLastManifestRefreshRequest = true;
  }

  /* package */ boolean onChunkLoadError(boolean isForwardSeek) {
    if (!manifest.dynamic) {
      return false;
    }
    if (isWaitingForManifestRefresh) {
      return true;
    }
    if (isForwardSeek) {
      // If a forward seek has occurred, there's a chance that the seek has skipped EMSGs signalling
      // end-of-stream or manifest expiration. We must assume that the manifest might need to be
      // refreshed.
      maybeNotifyDashManifestRefreshNeeded();
      return true;
    }
    return false;
  }

  private void handleManifestExpiredMessage(long eventTimeUs, long manifestPublishTimeMsInEmsg) {
    Long previousExpiryTimeUs = manifestPublishTimeToExpiryTimeUs.get(manifestPublishTimeMsInEmsg);
    if (previousExpiryTimeUs == null) {
      manifestPublishTimeToExpiryTimeUs.put(manifestPublishTimeMsInEmsg, eventTimeUs);
    } else {
      if (previousExpiryTimeUs > eventTimeUs) {
        manifestPublishTimeToExpiryTimeUs.put(manifestPublishTimeMsInEmsg, eventTimeUs);
      }
    }
  }

  @Nullable
  private Map.Entry<Long, Long> ceilingExpiryEntryForPublishTime(long publishTimeMs) {
    return manifestPublishTimeToExpiryTimeUs.ceilingEntry(publishTimeMs);
  }

  private void removePreviouslyExpiredManifestPublishTimeValues() {
    for (Iterator<Map.Entry<Long, Long>> it =
            manifestPublishTimeToExpiryTimeUs.entrySet().iterator();
        it.hasNext(); ) {
      Map.Entry<Long, Long> entry = it.next();
      long expiredManifestPublishTime = entry.getKey();
      if (expiredManifestPublishTime < manifest.publishTimeMs) {
        it.remove();
      }
    }
  }

  private void notifyManifestPublishTimeExpired() {
    playerEmsgCallback.onDashManifestPublishTimeExpired(expiredManifestPublishTimeUs);
  }

  /** Requests DASH media manifest to be refreshed if necessary. */
  private void maybeNotifyDashManifestRefreshNeeded() {
    if (!chunkLoadedCompletedSinceLastManifestRefreshRequest) {
      // Don't request a refresh unless some progress has been made.
      return;
    }
    isWaitingForManifestRefresh = true;
    chunkLoadedCompletedSinceLastManifestRefreshRequest = false;
    playerEmsgCallback.onDashManifestRefreshRequested();
  }

  private static long getManifestPublishTimeMsInEmsg(EventMessage eventMessage) {
    try {
      return parseXsDateTime(Util.fromUtf8Bytes(eventMessage.messageData));
    } catch (ParserException ignored) {
      // if we can't parse this event, ignore
      return C.TIME_UNSET;
    }
  }

  /**
   * Returns whether an event with given schemeIdUri and value is a DASH emsg event targeting the
   * player.
   */
  private static boolean isPlayerEmsgEvent(String schemeIdUri, String value) {
    return "urn:mpeg:dash:event:2012".equals(schemeIdUri)
        && ("1".equals(value) || "2".equals(value) || "3".equals(value));
  }

  /** Handles emsg messages for a specific track for the player. */
  public final class PlayerTrackEmsgHandler implements TrackOutput {

    private final SampleQueue sampleQueue;
    private final FormatHolder formatHolder;
    private final MetadataInputBuffer buffer;

    private long maxLoadedChunkEndTimeUs;

    /* package */ PlayerTrackEmsgHandler(Allocator allocator) {
      this.sampleQueue = SampleQueue.createWithoutDrm(allocator);
      formatHolder = new FormatHolder();
      buffer = new MetadataInputBuffer();
      maxLoadedChunkEndTimeUs = C.TIME_UNSET;
    }

    @Override
    public void format(Format format) {
      sampleQueue.format(format);
    }

    @Override
    public int sampleData(
        DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
        throws IOException {
      return sampleQueue.sampleData(input, length, allowEndOfInput);
    }

    @Override
    public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) {
      sampleQueue.sampleData(data, length);
    }

    @Override
    public void sampleMetadata(
        long timeUs, int flags, int size, int offset, @Nullable CryptoData cryptoData) {
      sampleQueue.sampleMetadata(timeUs, flags, size, offset, cryptoData);
      parseAndDiscardSamples();
    }

    /**
     * For live streaming, check if the DASH manifest is expired before the next segment start time.
     * If it is, the DASH media source will be notified to refresh the manifest.
     *
     * @param presentationPositionUs The next load position in presentation time.
     * @return True if manifest refresh has been requested, false otherwise.
     */
    public boolean maybeRefreshManifestBeforeLoadingNextChunk(long presentationPositionUs) {
      return PlayerEmsgHandler.this.maybeRefreshManifestBeforeLoadingNextChunk(
          presentationPositionUs);
    }

    /**
     * Called when a chunk load has been completed.
     *
     * @param chunk The chunk whose load has been completed.
     */
    public void onChunkLoadCompleted(Chunk chunk) {
      if (maxLoadedChunkEndTimeUs == C.TIME_UNSET || chunk.endTimeUs > maxLoadedChunkEndTimeUs) {
        maxLoadedChunkEndTimeUs = chunk.endTimeUs;
      }
      PlayerEmsgHandler.this.onChunkLoadCompleted(chunk);
    }

    /**
     * Called when a chunk load has encountered an error.
     *
     * @param chunk The chunk whose load encountered an error.
     * @return Whether a manifest refresh has been requested.
     */
    public boolean onChunkLoadError(Chunk chunk) {
      boolean isAfterForwardSeek =
          maxLoadedChunkEndTimeUs != C.TIME_UNSET && maxLoadedChunkEndTimeUs < chunk.startTimeUs;
      return PlayerEmsgHandler.this.onChunkLoadError(isAfterForwardSeek);
    }

    /** Release this track emsg handler. It should not be reused after this call. */
    public void release() {
      sampleQueue.release();
    }

    // Internal methods.

    private void parseAndDiscardSamples() {
      while (sampleQueue.isReady(/* loadingFinished= */ false)) {
        @Nullable MetadataInputBuffer inputBuffer = dequeueSample();
        if (inputBuffer == null) {
          continue;
        }
        long eventTimeUs = inputBuffer.timeUs;
        @Nullable Metadata metadata = decoder.decode(inputBuffer);
        if (metadata == null) {
          continue;
        }
        EventMessage eventMessage = (EventMessage) metadata.get(0);
        if (isPlayerEmsgEvent(eventMessage.schemeIdUri, eventMessage.value)) {
          parsePlayerEmsgEvent(eventTimeUs, eventMessage);
        }
      }
      sampleQueue.discardToRead();
    }

    @Nullable
    private MetadataInputBuffer dequeueSample() {
      buffer.clear();
      int result =
          sampleQueue.read(formatHolder, buffer, /* readFlags= */ 0, /* loadingFinished= */ false);
      if (result == C.RESULT_BUFFER_READ) {
        buffer.flip();
        return buffer;
      }
      return null;
    }

    private void parsePlayerEmsgEvent(long eventTimeUs, EventMessage eventMessage) {
      long manifestPublishTimeMsInEmsg = getManifestPublishTimeMsInEmsg(eventMessage);
      if (manifestPublishTimeMsInEmsg == C.TIME_UNSET) {
        return;
      }
      onManifestExpiredMessageEncountered(eventTimeUs, manifestPublishTimeMsInEmsg);
    }

    private void onManifestExpiredMessageEncountered(
        long eventTimeUs, long manifestPublishTimeMsInEmsg) {
      ManifestExpiryEventInfo manifestExpiryEventInfo =
          new ManifestExpiryEventInfo(eventTimeUs, manifestPublishTimeMsInEmsg);
      handler.sendMessage(handler.obtainMessage(EMSG_MANIFEST_EXPIRED, manifestExpiryEventInfo));
    }
  }

  /** Holds information related to a manifest expiry event. */
  private static final class ManifestExpiryEventInfo {

    public final long eventTimeUs;
    public final long manifestPublishTimeMsInEmsg;

    public ManifestExpiryEventInfo(long eventTimeUs, long manifestPublishTimeMsInEmsg) {
      this.eventTimeUs = eventTimeUs;
      this.manifestPublishTimeMsInEmsg = manifestPublishTimeMsInEmsg;
    }
  }
}
