/*
 * 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.telegram.messenger.exoplayer2;

import android.annotation.SuppressLint;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.Nullable;
import android.util.Log;
import android.util.Pair;
import org.telegram.messenger.exoplayer2.PlayerMessage.Target;
import org.telegram.messenger.exoplayer2.source.MediaSource;
import org.telegram.messenger.exoplayer2.source.MediaSource.MediaPeriodId;
import org.telegram.messenger.exoplayer2.source.TrackGroupArray;
import org.telegram.messenger.exoplayer2.trackselection.TrackSelection;
import org.telegram.messenger.exoplayer2.trackselection.TrackSelectionArray;
import org.telegram.messenger.exoplayer2.trackselection.TrackSelector;
import org.telegram.messenger.exoplayer2.trackselection.TrackSelectorResult;
import org.telegram.messenger.exoplayer2.util.Assertions;
import org.telegram.messenger.exoplayer2.util.Clock;
import org.telegram.messenger.exoplayer2.util.Util;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArraySet;

/**
 * An {@link ExoPlayer} implementation. Instances can be obtained from {@link ExoPlayerFactory}.
 */
/* package */ final class ExoPlayerImpl implements ExoPlayer {

  private static final String TAG = "ExoPlayerImpl";

  private final Renderer[] renderers;
  private final TrackSelector trackSelector;
  private final TrackSelectorResult emptyTrackSelectorResult;
  private final Handler eventHandler;
  private final ExoPlayerImplInternal internalPlayer;
  private final Handler internalPlayerHandler;
  private final CopyOnWriteArraySet<Player.EventListener> listeners;
  private final Timeline.Window window;
  private final Timeline.Period period;

  private boolean playWhenReady;
  private @RepeatMode int repeatMode;
  private boolean shuffleModeEnabled;
  private int pendingOperationAcks;
  private boolean hasPendingPrepare;
  private boolean hasPendingSeek;
  private PlaybackParameters playbackParameters;
  private @Nullable ExoPlaybackException playbackError;

  // Playback information when there is no pending seek/set source operation.
  private PlaybackInfo playbackInfo;

  // Playback information when there is a pending seek/set source operation.
  private int maskingWindowIndex;
  private int maskingPeriodIndex;
  private long maskingWindowPositionMs;

  /**
   * Constructs an instance. Must be called from a thread that has an associated {@link Looper}.
   *
   * @param renderers The {@link Renderer}s that will be used by the instance.
   * @param trackSelector The {@link TrackSelector} that will be used by the instance.
   * @param loadControl The {@link LoadControl} that will be used by the instance.
   * @param clock The {@link Clock} that will be used by the instance.
   */
  @SuppressLint("HandlerLeak")
  public ExoPlayerImpl(
      Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl, Clock clock) {
    Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " ["
        + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "]");
    Assertions.checkState(renderers.length > 0);
    this.renderers = Assertions.checkNotNull(renderers);
    this.trackSelector = Assertions.checkNotNull(trackSelector);
    this.playWhenReady = false;
    this.repeatMode = Player.REPEAT_MODE_OFF;
    this.shuffleModeEnabled = false;
    this.listeners = new CopyOnWriteArraySet<>();
    emptyTrackSelectorResult =
        new TrackSelectorResult(
            new RendererConfiguration[renderers.length],
            new TrackSelection[renderers.length],
            null);
    window = new Timeline.Window();
    period = new Timeline.Period();
    playbackParameters = PlaybackParameters.DEFAULT;
    Looper eventLooper = Looper.myLooper() != null ? Looper.myLooper() : Looper.getMainLooper();
    eventHandler = new Handler(eventLooper) {
      @Override
      public void handleMessage(Message msg) {
        ExoPlayerImpl.this.handleEvent(msg);
      }
    };
    playbackInfo =
        new PlaybackInfo(
            Timeline.EMPTY,
            /* startPositionUs= */ 0,
            TrackGroupArray.EMPTY,
            emptyTrackSelectorResult);
    internalPlayer =
        new ExoPlayerImplInternal(
            renderers,
            trackSelector,
            emptyTrackSelectorResult,
            loadControl,
            playWhenReady,
            repeatMode,
            shuffleModeEnabled,
            eventHandler,
            this,
            clock);
    internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper());
  }

  @Override
  public VideoComponent getVideoComponent() {
    return null;
  }

  @Override
  public TextComponent getTextComponent() {
    return null;
  }

  @Override
  public Looper getPlaybackLooper() {
    return internalPlayer.getPlaybackLooper();
  }

  @Override
  public void addListener(Player.EventListener listener) {
    listeners.add(listener);
  }

  @Override
  public void removeListener(Player.EventListener listener) {
    listeners.remove(listener);
  }

  @Override
  public int getPlaybackState() {
    return playbackInfo.playbackState;
  }

  @Override
  public @Nullable ExoPlaybackException getPlaybackError() {
    return playbackError;
  }

  @Override
  public void prepare(MediaSource mediaSource) {
    prepare(mediaSource, true, true);
  }

  @Override
  public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) {
    playbackError = null;
    PlaybackInfo playbackInfo =
        getResetPlaybackInfo(
            resetPosition, resetState, /* playbackState= */ Player.STATE_BUFFERING);
    // Trigger internal prepare first before updating the playback info and notifying external
    // listeners to ensure that new operations issued in the listener notifications reach the
    // player after this prepare. The internal player can't change the playback info immediately
    // because it uses a callback.
    hasPendingPrepare = true;
    pendingOperationAcks++;
    internalPlayer.prepare(mediaSource, resetPosition, resetState);
    updatePlaybackInfo(
        playbackInfo,
        /* positionDiscontinuity= */ false,
        /* ignored */ DISCONTINUITY_REASON_INTERNAL,
        TIMELINE_CHANGE_REASON_RESET,
        /* seekProcessed= */ false);
  }

  @Override
  public void setPlayWhenReady(boolean playWhenReady) {
    if (this.playWhenReady != playWhenReady) {
      this.playWhenReady = playWhenReady;
      internalPlayer.setPlayWhenReady(playWhenReady);
      PlaybackInfo playbackInfo = this.playbackInfo;
      for (Player.EventListener listener : listeners) {
        listener.onPlayerStateChanged(playWhenReady, playbackInfo.playbackState);
      }
    }
  }

  @Override
  public boolean getPlayWhenReady() {
    return playWhenReady;
  }

  @Override
  public void setRepeatMode(@RepeatMode int repeatMode) {
    if (this.repeatMode != repeatMode) {
      this.repeatMode = repeatMode;
      internalPlayer.setRepeatMode(repeatMode);
      for (Player.EventListener listener : listeners) {
        listener.onRepeatModeChanged(repeatMode);
      }
    }
  }

  @Override
  public @RepeatMode int getRepeatMode() {
    return repeatMode;
  }

  @Override
  public void setShuffleModeEnabled(boolean shuffleModeEnabled) {
    if (this.shuffleModeEnabled != shuffleModeEnabled) {
      this.shuffleModeEnabled = shuffleModeEnabled;
      internalPlayer.setShuffleModeEnabled(shuffleModeEnabled);
      for (Player.EventListener listener : listeners) {
        listener.onShuffleModeEnabledChanged(shuffleModeEnabled);
      }
    }
  }

  @Override
  public boolean getShuffleModeEnabled() {
    return shuffleModeEnabled;
  }

  @Override
  public boolean isLoading() {
    return playbackInfo.isLoading;
  }

  @Override
  public void seekToDefaultPosition() {
    seekToDefaultPosition(getCurrentWindowIndex());
  }

  @Override
  public void seekToDefaultPosition(int windowIndex) {
    seekTo(windowIndex, C.TIME_UNSET);
  }

  @Override
  public void seekTo(long positionMs) {
    seekTo(getCurrentWindowIndex(), positionMs);
  }

  @Override
  public void seekTo(int windowIndex, long positionMs) {
    Timeline timeline = playbackInfo.timeline;
    if (windowIndex < 0 || (!timeline.isEmpty() && windowIndex >= timeline.getWindowCount())) {
      throw new IllegalSeekPositionException(timeline, windowIndex, positionMs);
    }
    hasPendingSeek = true;
    pendingOperationAcks++;
    if (isPlayingAd()) {
      // TODO: Investigate adding support for seeking during ads. This is complicated to do in
      // general because the midroll ad preceding the seek destination must be played before the
      // content position can be played, if a different ad is playing at the moment.
      Log.w(TAG, "seekTo ignored because an ad is playing");
      eventHandler
          .obtainMessage(
              ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED,
              /* operationAcks */ 1,
              /* positionDiscontinuityReason */ C.INDEX_UNSET,
              playbackInfo)
          .sendToTarget();
      return;
    }
    maskingWindowIndex = windowIndex;
    if (timeline.isEmpty()) {
      maskingWindowPositionMs = positionMs == C.TIME_UNSET ? 0 : positionMs;
      maskingPeriodIndex = 0;
    } else {
      long windowPositionUs = positionMs == C.TIME_UNSET
          ? timeline.getWindow(windowIndex, window).getDefaultPositionUs() : C.msToUs(positionMs);
      Pair<Integer, Long> periodIndexAndPositon =
          timeline.getPeriodPosition(window, period, windowIndex, windowPositionUs);
      maskingWindowPositionMs = C.usToMs(windowPositionUs);
      maskingPeriodIndex = periodIndexAndPositon.first;
    }
    internalPlayer.seekTo(timeline, windowIndex, C.msToUs(positionMs));
    for (Player.EventListener listener : listeners) {
      listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK);
    }
  }

  @Override
  public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) {
    if (playbackParameters == null) {
      playbackParameters = PlaybackParameters.DEFAULT;
    }
    internalPlayer.setPlaybackParameters(playbackParameters);
  }

  @Override
  public PlaybackParameters getPlaybackParameters() {
    return playbackParameters;
  }

  @Override
  public void setSeekParameters(@Nullable SeekParameters seekParameters) {
    if (seekParameters == null) {
      seekParameters = SeekParameters.DEFAULT;
    }
    internalPlayer.setSeekParameters(seekParameters);
  }

  @Override
  public @Nullable Object getCurrentTag() {
    int windowIndex = getCurrentWindowIndex();
    return windowIndex > playbackInfo.timeline.getWindowCount()
        ? null
        : playbackInfo.timeline.getWindow(windowIndex, window, /* setTag= */ true).tag;
  }

  @Override
  public void stop() {
    stop(/* reset= */ false);
  }

  @Override
  public void stop(boolean reset) {
    if (reset) {
      playbackError = null;
    }
    PlaybackInfo playbackInfo =
        getResetPlaybackInfo(
            /* resetPosition= */ reset,
            /* resetState= */ reset,
            /* playbackState= */ Player.STATE_IDLE);
    // Trigger internal stop first before updating the playback info and notifying external
    // listeners to ensure that new operations issued in the listener notifications reach the
    // player after this stop. The internal player can't change the playback info immediately
    // because it uses a callback.
    pendingOperationAcks++;
    internalPlayer.stop(reset);
    updatePlaybackInfo(
        playbackInfo,
        /* positionDiscontinuity= */ false,
        /* ignored */ DISCONTINUITY_REASON_INTERNAL,
        TIMELINE_CHANGE_REASON_RESET,
        /* seekProcessed= */ false);
  }

  @Override
  public void release() {
    Log.i(TAG, "Release " + Integer.toHexString(System.identityHashCode(this)) + " ["
        + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "] ["
        + ExoPlayerLibraryInfo.registeredModules() + "]");
    internalPlayer.release();
    eventHandler.removeCallbacksAndMessages(null);
  }

  @Override
  public void sendMessages(ExoPlayerMessage... messages) {
    for (ExoPlayerMessage message : messages) {
      createMessage(message.target).setType(message.messageType).setPayload(message.message).send();
    }
  }

  @Override
  public PlayerMessage createMessage(Target target) {
    return new PlayerMessage(
        internalPlayer,
        target,
        playbackInfo.timeline,
        getCurrentWindowIndex(),
        internalPlayerHandler);
  }

  @Override
  public void blockingSendMessages(ExoPlayerMessage... messages) {
    List<PlayerMessage> playerMessages = new ArrayList<>();
    for (ExoPlayerMessage message : messages) {
      playerMessages.add(
          createMessage(message.target)
              .setType(message.messageType)
              .setPayload(message.message)
              .send());
    }
    boolean wasInterrupted = false;
    for (PlayerMessage message : playerMessages) {
      boolean blockMessage = true;
      while (blockMessage) {
        try {
          message.blockUntilDelivered();
          blockMessage = false;
        } catch (InterruptedException e) {
          wasInterrupted = true;
        }
      }
    }
    if (wasInterrupted) {
      // Restore the interrupted status.
      Thread.currentThread().interrupt();
    }
  }

  @Override
  public int getCurrentPeriodIndex() {
    if (shouldMaskPosition()) {
      return maskingPeriodIndex;
    } else {
      return playbackInfo.periodId.periodIndex;
    }
  }

  @Override
  public int getCurrentWindowIndex() {
    if (shouldMaskPosition()) {
      return maskingWindowIndex;
    } else {
      return playbackInfo.timeline.getPeriod(playbackInfo.periodId.periodIndex, period).windowIndex;
    }
  }

  @Override
  public int getNextWindowIndex() {
    Timeline timeline = playbackInfo.timeline;
    return timeline.isEmpty() ? C.INDEX_UNSET
        : timeline.getNextWindowIndex(getCurrentWindowIndex(), repeatMode, shuffleModeEnabled);
  }

  @Override
  public int getPreviousWindowIndex() {
    Timeline timeline = playbackInfo.timeline;
    return timeline.isEmpty() ? C.INDEX_UNSET
        : timeline.getPreviousWindowIndex(getCurrentWindowIndex(), repeatMode, shuffleModeEnabled);
  }

  @Override
  public long getDuration() {
    Timeline timeline = playbackInfo.timeline;
    if (timeline.isEmpty()) {
      return C.TIME_UNSET;
    }
    if (isPlayingAd()) {
      MediaPeriodId periodId = playbackInfo.periodId;
      timeline.getPeriod(periodId.periodIndex, period);
      long adDurationUs = period.getAdDurationUs(periodId.adGroupIndex, periodId.adIndexInAdGroup);
      return C.usToMs(adDurationUs);
    } else {
      return timeline.getWindow(getCurrentWindowIndex(), window).getDurationMs();
    }
  }

  @Override
  public long getCurrentPosition() {
    if (shouldMaskPosition()) {
      return maskingWindowPositionMs;
    } else {
      return playbackInfoPositionUsToWindowPositionMs(playbackInfo.positionUs);
    }
  }

  @Override
  public long getBufferedPosition() {
    // TODO - Implement this properly.
    if (shouldMaskPosition()) {
      return maskingWindowPositionMs;
    } else {
      return playbackInfoPositionUsToWindowPositionMs(playbackInfo.bufferedPositionUs);
    }
  }

  @Override
  public int getBufferedPercentage() {
    long position = getBufferedPosition();
    long duration = getDuration();
    return position == C.TIME_UNSET || duration == C.TIME_UNSET ? 0
        : (duration == 0 ? 100 : Util.constrainValue((int) ((position * 100) / duration), 0, 100));
  }

  @Override
  public boolean isCurrentWindowDynamic() {
    Timeline timeline = playbackInfo.timeline;
    return !timeline.isEmpty() && timeline.getWindow(getCurrentWindowIndex(), window).isDynamic;
  }

  @Override
  public boolean isCurrentWindowSeekable() {
    Timeline timeline = playbackInfo.timeline;
    return !timeline.isEmpty() && timeline.getWindow(getCurrentWindowIndex(), window).isSeekable;
  }

  @Override
  public boolean isPlayingAd() {
    return !shouldMaskPosition() && playbackInfo.periodId.isAd();
  }

  @Override
  public int getCurrentAdGroupIndex() {
    return isPlayingAd() ? playbackInfo.periodId.adGroupIndex : C.INDEX_UNSET;
  }

  @Override
  public int getCurrentAdIndexInAdGroup() {
    return isPlayingAd() ? playbackInfo.periodId.adIndexInAdGroup : C.INDEX_UNSET;
  }

  @Override
  public long getContentPosition() {
    if (isPlayingAd()) {
      playbackInfo.timeline.getPeriod(playbackInfo.periodId.periodIndex, period);
      return period.getPositionInWindowMs() + C.usToMs(playbackInfo.contentPositionUs);
    } else {
      return getCurrentPosition();
    }
  }

  @Override
  public int getRendererCount() {
    return renderers.length;
  }

  @Override
  public int getRendererType(int index) {
    return renderers[index].getTrackType();
  }

  @Override
  public TrackGroupArray getCurrentTrackGroups() {
    return playbackInfo.trackGroups;
  }

  @Override
  public TrackSelectionArray getCurrentTrackSelections() {
    return playbackInfo.trackSelectorResult.selections;
  }

  @Override
  public Timeline getCurrentTimeline() {
    return playbackInfo.timeline;
  }

  @Override
  public Object getCurrentManifest() {
    return playbackInfo.manifest;
  }

  // Not private so it can be called from an inner class without going through a thunk method.
  /* package */ void handleEvent(Message msg) {
    switch (msg.what) {
      case ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED:
        handlePlaybackInfo(
            (PlaybackInfo) msg.obj,
            /* operationAcks= */ msg.arg1,
            /* positionDiscontinuity= */ msg.arg2 != C.INDEX_UNSET,
            /* positionDiscontinuityReason= */ msg.arg2);
        break;
      case ExoPlayerImplInternal.MSG_PLAYBACK_PARAMETERS_CHANGED:
        PlaybackParameters playbackParameters = (PlaybackParameters) msg.obj;
        if (!this.playbackParameters.equals(playbackParameters)) {
          this.playbackParameters = playbackParameters;
          for (Player.EventListener listener : listeners) {
            listener.onPlaybackParametersChanged(playbackParameters);
          }
        }
        break;
      case ExoPlayerImplInternal.MSG_ERROR:
        ExoPlaybackException playbackError = (ExoPlaybackException) msg.obj;
        this.playbackError = playbackError;
        for (Player.EventListener listener : listeners) {
          listener.onPlayerError(playbackError);
        }
        break;
      default:
        throw new IllegalStateException();
    }
  }

  private void handlePlaybackInfo(
      PlaybackInfo playbackInfo,
      int operationAcks,
      boolean positionDiscontinuity,
      @DiscontinuityReason int positionDiscontinuityReason) {
    pendingOperationAcks -= operationAcks;
    if (pendingOperationAcks == 0) {
      if (playbackInfo.startPositionUs == C.TIME_UNSET) {
        // Replace internal unset start position with externally visible start position of zero.
        playbackInfo =
            playbackInfo.fromNewPosition(
                playbackInfo.periodId, /* startPositionUs= */ 0, playbackInfo.contentPositionUs);
      }
      if ((!this.playbackInfo.timeline.isEmpty() || hasPendingPrepare)
          && playbackInfo.timeline.isEmpty()) {
        // Update the masking variables, which are used when the timeline becomes empty.
        maskingPeriodIndex = 0;
        maskingWindowIndex = 0;
        maskingWindowPositionMs = 0;
      }
      @Player.TimelineChangeReason
      int timelineChangeReason =
          hasPendingPrepare
              ? Player.TIMELINE_CHANGE_REASON_PREPARED
              : Player.TIMELINE_CHANGE_REASON_DYNAMIC;
      boolean seekProcessed = hasPendingSeek;
      hasPendingPrepare = false;
      hasPendingSeek = false;
      updatePlaybackInfo(
          playbackInfo,
          positionDiscontinuity,
          positionDiscontinuityReason,
          timelineChangeReason,
          seekProcessed);
    }
  }

  private PlaybackInfo getResetPlaybackInfo(
      boolean resetPosition, boolean resetState, int playbackState) {
    if (resetPosition) {
      maskingWindowIndex = 0;
      maskingPeriodIndex = 0;
      maskingWindowPositionMs = 0;
    } else {
      maskingWindowIndex = getCurrentWindowIndex();
      maskingPeriodIndex = getCurrentPeriodIndex();
      maskingWindowPositionMs = getCurrentPosition();
    }
    return new PlaybackInfo(
        resetState ? Timeline.EMPTY : playbackInfo.timeline,
        resetState ? null : playbackInfo.manifest,
        playbackInfo.periodId,
        playbackInfo.startPositionUs,
        playbackInfo.contentPositionUs,
        playbackState,
        /* isLoading= */ false,
        resetState ? TrackGroupArray.EMPTY : playbackInfo.trackGroups,
        resetState ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult);
  }

  private void updatePlaybackInfo(
      PlaybackInfo newPlaybackInfo,
      boolean positionDiscontinuity,
      @Player.DiscontinuityReason int positionDiscontinuityReason,
      @Player.TimelineChangeReason int timelineChangeReason,
      boolean seekProcessed) {
    boolean timelineOrManifestChanged =
        playbackInfo.timeline != newPlaybackInfo.timeline
            || playbackInfo.manifest != newPlaybackInfo.manifest;
    boolean playbackStateChanged = playbackInfo.playbackState != newPlaybackInfo.playbackState;
    boolean isLoadingChanged = playbackInfo.isLoading != newPlaybackInfo.isLoading;
    boolean trackSelectorResultChanged =
        playbackInfo.trackSelectorResult != newPlaybackInfo.trackSelectorResult;
    playbackInfo = newPlaybackInfo;
    if (timelineOrManifestChanged || timelineChangeReason == TIMELINE_CHANGE_REASON_PREPARED) {
      for (Player.EventListener listener : listeners) {
        listener.onTimelineChanged(
            playbackInfo.timeline, playbackInfo.manifest, timelineChangeReason);
      }
    }
    if (positionDiscontinuity) {
      for (Player.EventListener listener : listeners) {
        listener.onPositionDiscontinuity(positionDiscontinuityReason);
      }
    }
    if (trackSelectorResultChanged) {
      trackSelector.onSelectionActivated(playbackInfo.trackSelectorResult.info);
      for (Player.EventListener listener : listeners) {
        listener.onTracksChanged(
            playbackInfo.trackGroups, playbackInfo.trackSelectorResult.selections);
      }
    }
    if (isLoadingChanged) {
      for (Player.EventListener listener : listeners) {
        listener.onLoadingChanged(playbackInfo.isLoading);
      }
    }
    if (playbackStateChanged) {
      for (Player.EventListener listener : listeners) {
        listener.onPlayerStateChanged(playWhenReady, playbackInfo.playbackState);
      }
    }
    if (seekProcessed) {
      for (Player.EventListener listener : listeners) {
        listener.onSeekProcessed();
      }
    }
  }

  private long playbackInfoPositionUsToWindowPositionMs(long positionUs) {
    long positionMs = C.usToMs(positionUs);
    if (!playbackInfo.periodId.isAd()) {
      playbackInfo.timeline.getPeriod(playbackInfo.periodId.periodIndex, period);
      positionMs += period.getPositionInWindowMs();
    }
    return positionMs;
  }

  private boolean shouldMaskPosition() {
    return playbackInfo.timeline.isEmpty() || pendingOperationAcks > 0;
  }
}
