/*
 * Copyright (C) 2019 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 com.google.android.exoplayer2.analytics;

import android.os.SystemClock;
import android.util.Pair;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.Timeline.Period;
import com.google.android.exoplayer2.analytics.PlaybackStats.PlaybackState;
import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId;
import com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo;
import com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.trackselection.TrackSelection;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.Util;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.checkerframework.checker.nullness.compatqual.NullableType;

/**
 * {@link AnalyticsListener} to gather {@link PlaybackStats} from the player.
 *
 * <p>For accurate measurements, the listener should be added to the player before loading media,
 * i.e., {@link Player#getPlaybackState()} should be {@link Player#STATE_IDLE}.
 *
 * <p>Playback stats are gathered separately for all playback session, i.e. each window in the
 * {@link Timeline} and each single ad.
 */
public final class PlaybackStatsListener
    implements AnalyticsListener, PlaybackSessionManager.Listener {

  /** A listener for {@link PlaybackStats} updates. */
  public interface Callback {

    /**
     * Called when a playback session ends and its {@link PlaybackStats} are ready.
     *
     * @param eventTime The {@link EventTime} at which the playback session started. Can be used to
     *     identify the playback session.
     * @param playbackStats The {@link PlaybackStats} for the ended playback session.
     */
    void onPlaybackStatsReady(EventTime eventTime, PlaybackStats playbackStats);
  }

  private final PlaybackSessionManager sessionManager;
  private final Map<String, PlaybackStatsTracker> playbackStatsTrackers;
  private final Map<String, EventTime> sessionStartEventTimes;
  @Nullable private final Callback callback;
  private final boolean keepHistory;
  private final Period period;

  private PlaybackStats finishedPlaybackStats;
  @Nullable private String activeContentPlayback;
  @Nullable private String activeAdPlayback;
  private boolean playWhenReady;
  @Player.State private int playbackState;
  private boolean isSuppressed;
  private float playbackSpeed;

  /**
   * Creates listener for playback stats.
   *
   * @param keepHistory Whether the reported {@link PlaybackStats} should keep the full history of
   *     events.
   * @param callback An optional callback for finished {@link PlaybackStats}.
   */
  public PlaybackStatsListener(boolean keepHistory, @Nullable Callback callback) {
    this.callback = callback;
    this.keepHistory = keepHistory;
    sessionManager = new DefaultPlaybackSessionManager();
    playbackStatsTrackers = new HashMap<>();
    sessionStartEventTimes = new HashMap<>();
    finishedPlaybackStats = PlaybackStats.EMPTY;
    playWhenReady = false;
    playbackState = Player.STATE_IDLE;
    playbackSpeed = 1f;
    period = new Period();
    sessionManager.setListener(this);
  }

  /**
   * Returns the combined {@link PlaybackStats} for all playback sessions this listener was and is
   * listening to.
   *
   * <p>Note that these {@link PlaybackStats} will not contain the full history of events.
   *
   * @return The combined {@link PlaybackStats} for all playback sessions.
   */
  public PlaybackStats getCombinedPlaybackStats() {
    PlaybackStats[] allPendingPlaybackStats = new PlaybackStats[playbackStatsTrackers.size() + 1];
    allPendingPlaybackStats[0] = finishedPlaybackStats;
    int index = 1;
    for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) {
      allPendingPlaybackStats[index++] = tracker.build(/* isFinal= */ false);
    }
    return PlaybackStats.merge(allPendingPlaybackStats);
  }

  /**
   * Returns the {@link PlaybackStats} for the currently playback session, or null if no session is
   * active.
   *
   * @return {@link PlaybackStats} for the current playback session.
   */
  @Nullable
  public PlaybackStats getPlaybackStats() {
    PlaybackStatsTracker activeStatsTracker =
        activeAdPlayback != null
            ? playbackStatsTrackers.get(activeAdPlayback)
            : activeContentPlayback != null
                ? playbackStatsTrackers.get(activeContentPlayback)
                : null;
    return activeStatsTracker == null ? null : activeStatsTracker.build(/* isFinal= */ false);
  }

  /**
   * Finishes all pending playback sessions. Should be called when the listener is removed from the
   * player or when the player is released.
   */
  public void finishAllSessions() {
    // TODO: Add AnalyticsListener.onAttachedToPlayer and onDetachedFromPlayer to auto-release with
    // an actual EventTime. Should also simplify other cases where the listener needs to be released
    // separately from the player.
    HashMap<String, PlaybackStatsTracker> trackerCopy = new HashMap<>(playbackStatsTrackers);
    EventTime dummyEventTime =
        new EventTime(
            SystemClock.elapsedRealtime(),
            Timeline.EMPTY,
            /* windowIndex= */ 0,
            /* mediaPeriodId= */ null,
            /* eventPlaybackPositionMs= */ 0,
            /* currentPlaybackPositionMs= */ 0,
            /* totalBufferedDurationMs= */ 0);
    for (String session : trackerCopy.keySet()) {
      onSessionFinished(dummyEventTime, session, /* automaticTransition= */ false);
    }
  }

  // PlaybackSessionManager.Listener implementation.

  @Override
  public void onSessionCreated(EventTime eventTime, String session) {
    PlaybackStatsTracker tracker = new PlaybackStatsTracker(keepHistory, eventTime);
    tracker.onPlayerStateChanged(
        eventTime, playWhenReady, playbackState, /* belongsToPlayback= */ true);
    tracker.onIsSuppressedChanged(eventTime, isSuppressed, /* belongsToPlayback= */ true);
    tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed);
    playbackStatsTrackers.put(session, tracker);
    sessionStartEventTimes.put(session, eventTime);
  }

  @Override
  public void onSessionActive(EventTime eventTime, String session) {
    Assertions.checkNotNull(playbackStatsTrackers.get(session)).onForeground(eventTime);
    if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) {
      activeAdPlayback = session;
    } else {
      activeContentPlayback = session;
    }
  }

  @Override
  public void onAdPlaybackStarted(EventTime eventTime, String contentSession, String adSession) {
    Assertions.checkState(Assertions.checkNotNull(eventTime.mediaPeriodId).isAd());
    long contentPositionUs =
        eventTime
            .timeline
            .getPeriodByUid(eventTime.mediaPeriodId.periodUid, period)
            .getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex);
    EventTime contentEventTime =
        new EventTime(
            eventTime.realtimeMs,
            eventTime.timeline,
            eventTime.windowIndex,
            new MediaPeriodId(
                eventTime.mediaPeriodId.periodUid,
                eventTime.mediaPeriodId.windowSequenceNumber,
                eventTime.mediaPeriodId.adGroupIndex),
            /* eventPlaybackPositionMs= */ C.usToMs(contentPositionUs),
            eventTime.currentPlaybackPositionMs,
            eventTime.totalBufferedDurationMs);
    Assertions.checkNotNull(playbackStatsTrackers.get(contentSession))
        .onInterruptedByAd(contentEventTime);
  }

  @Override
  public void onSessionFinished(EventTime eventTime, String session, boolean automaticTransition) {
    if (session.equals(activeAdPlayback)) {
      activeAdPlayback = null;
    } else if (session.equals(activeContentPlayback)) {
      activeContentPlayback = null;
    }
    PlaybackStatsTracker tracker = Assertions.checkNotNull(playbackStatsTrackers.remove(session));
    EventTime startEventTime = Assertions.checkNotNull(sessionStartEventTimes.remove(session));
    if (automaticTransition) {
      // Simulate ENDED state to record natural ending of playback.
      tracker.onPlayerStateChanged(
          eventTime, /* playWhenReady= */ true, Player.STATE_ENDED, /* belongsToPlayback= */ false);
    }
    tracker.onFinished(eventTime);
    PlaybackStats playbackStats = tracker.build(/* isFinal= */ true);
    finishedPlaybackStats = PlaybackStats.merge(finishedPlaybackStats, playbackStats);
    if (callback != null) {
      callback.onPlaybackStatsReady(startEventTime, playbackStats);
    }
  }

  // AnalyticsListener implementation.

  @Override
  public void onPlayerStateChanged(
      EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) {
    this.playWhenReady = playWhenReady;
    this.playbackState = playbackState;
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
      playbackStatsTrackers
          .get(session)
          .onPlayerStateChanged(eventTime, playWhenReady, playbackState, belongsToPlayback);
    }
  }

  @Override
  public void onPlaybackSuppressionReasonChanged(
      EventTime eventTime, int playbackSuppressionReason) {
    isSuppressed = playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE;
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
      playbackStatsTrackers
          .get(session)
          .onIsSuppressedChanged(eventTime, isSuppressed, belongsToPlayback);
    }
  }

  @Override
  public void onTimelineChanged(EventTime eventTime, int reason) {
    sessionManager.handleTimelineUpdate(eventTime);
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime);
      }
    }
  }

  @Override
  public void onPositionDiscontinuity(EventTime eventTime, int reason) {
    sessionManager.handlePositionDiscontinuity(eventTime, reason);
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime);
      }
    }
  }

  @Override
  public void onSeekStarted(EventTime eventTime) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onSeekStarted(eventTime);
      }
    }
  }

  @Override
  public void onSeekProcessed(EventTime eventTime) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onSeekProcessed(eventTime);
      }
    }
  }

  @Override
  public void onPlayerError(EventTime eventTime, ExoPlaybackException error) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onFatalError(eventTime, error);
      }
    }
  }

  @Override
  public void onPlaybackParametersChanged(
      EventTime eventTime, PlaybackParameters playbackParameters) {
    playbackSpeed = playbackParameters.speed;
    sessionManager.updateSessions(eventTime);
    for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) {
      tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed);
    }
  }

  @Override
  public void onTracksChanged(
      EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onTracksChanged(eventTime, trackSelections);
      }
    }
  }

  @Override
  public void onLoadStarted(
      EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onLoadStarted(eventTime);
      }
    }
  }

  @Override
  public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onDownstreamFormatChanged(eventTime, mediaLoadData);
      }
    }
  }

  @Override
  public void onVideoSizeChanged(
      EventTime eventTime,
      int width,
      int height,
      int unappliedRotationDegrees,
      float pixelWidthHeightRatio) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onVideoSizeChanged(eventTime, width, height);
      }
    }
  }

  @Override
  public void onBandwidthEstimate(
      EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onBandwidthData(totalLoadTimeMs, totalBytesLoaded);
      }
    }
  }

  @Override
  public void onAudioUnderrun(
      EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onAudioUnderrun();
      }
    }
  }

  @Override
  public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onDroppedVideoFrames(droppedFrames);
      }
    }
  }

  @Override
  public void onLoadError(
      EventTime eventTime,
      LoadEventInfo loadEventInfo,
      MediaLoadData mediaLoadData,
      IOException error,
      boolean wasCanceled) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onNonFatalError(eventTime, error);
      }
    }
  }

  @Override
  public void onDrmSessionManagerError(EventTime eventTime, Exception error) {
    sessionManager.updateSessions(eventTime);
    for (String session : playbackStatsTrackers.keySet()) {
      if (sessionManager.belongsToSession(eventTime, session)) {
        playbackStatsTrackers.get(session).onNonFatalError(eventTime, error);
      }
    }
  }

  /** Tracker for playback stats of a single playback. */
  private static final class PlaybackStatsTracker {

    // Final stats.
    private final boolean keepHistory;
    private final long[] playbackStateDurationsMs;
    private final List<Pair<EventTime, @PlaybackState Integer>> playbackStateHistory;
    private final List<long[]> mediaTimeHistory;
    private final List<Pair<EventTime, @NullableType Format>> videoFormatHistory;
    private final List<Pair<EventTime, @NullableType Format>> audioFormatHistory;
    private final List<Pair<EventTime, Exception>> fatalErrorHistory;
    private final List<Pair<EventTime, Exception>> nonFatalErrorHistory;
    private final boolean isAd;

    private long firstReportedTimeMs;
    private boolean hasBeenReady;
    private boolean hasEnded;
    private boolean isJoinTimeInvalid;
    private int pauseCount;
    private int pauseBufferCount;
    private int seekCount;
    private int rebufferCount;
    private long maxRebufferTimeMs;
    private int initialVideoFormatHeight;
    private long initialVideoFormatBitrate;
    private long initialAudioFormatBitrate;
    private long videoFormatHeightTimeMs;
    private long videoFormatHeightTimeProduct;
    private long videoFormatBitrateTimeMs;
    private long videoFormatBitrateTimeProduct;
    private long audioFormatTimeMs;
    private long audioFormatBitrateTimeProduct;
    private long bandwidthTimeMs;
    private long bandwidthBytes;
    private long droppedFrames;
    private long audioUnderruns;
    private int fatalErrorCount;
    private int nonFatalErrorCount;

    // Current player state tracking.
    private @PlaybackState int currentPlaybackState;
    private long currentPlaybackStateStartTimeMs;
    private boolean isSeeking;
    private boolean isForeground;
    private boolean isInterruptedByAd;
    private boolean isFinished;
    private boolean playWhenReady;
    @Player.State private int playerPlaybackState;
    private boolean isSuppressed;
    private boolean hasFatalError;
    private boolean startedLoading;
    private long lastRebufferStartTimeMs;
    @Nullable private Format currentVideoFormat;
    @Nullable private Format currentAudioFormat;
    private long lastVideoFormatStartTimeMs;
    private long lastAudioFormatStartTimeMs;
    private float currentPlaybackSpeed;

    /**
     * Creates a tracker for playback stats.
     *
     * @param keepHistory Whether to keep a full history of events.
     * @param startTime The {@link EventTime} at which the playback stats start.
     */
    public PlaybackStatsTracker(boolean keepHistory, EventTime startTime) {
      this.keepHistory = keepHistory;
      playbackStateDurationsMs = new long[PlaybackStats.PLAYBACK_STATE_COUNT];
      playbackStateHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      mediaTimeHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      videoFormatHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      audioFormatHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      fatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      nonFatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
      currentPlaybackState = PlaybackStats.PLAYBACK_STATE_NOT_STARTED;
      currentPlaybackStateStartTimeMs = startTime.realtimeMs;
      playerPlaybackState = Player.STATE_IDLE;
      firstReportedTimeMs = C.TIME_UNSET;
      maxRebufferTimeMs = C.TIME_UNSET;
      isAd = startTime.mediaPeriodId != null && startTime.mediaPeriodId.isAd();
      initialAudioFormatBitrate = C.LENGTH_UNSET;
      initialVideoFormatBitrate = C.LENGTH_UNSET;
      initialVideoFormatHeight = C.LENGTH_UNSET;
      currentPlaybackSpeed = 1f;
    }

    /**
     * Notifies the tracker of a player state change event, including all player state changes while
     * the playback is not in the foreground.
     *
     * @param eventTime The {@link EventTime}.
     * @param playWhenReady Whether the playback will proceed when ready.
     * @param playbackState The current {@link Player.State}.
     * @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback.
     */
    public void onPlayerStateChanged(
        EventTime eventTime,
        boolean playWhenReady,
        @Player.State int playbackState,
        boolean belongsToPlayback) {
      this.playWhenReady = playWhenReady;
      playerPlaybackState = playbackState;
      if (playbackState != Player.STATE_IDLE) {
        hasFatalError = false;
      }
      if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) {
        isInterruptedByAd = false;
      }
      maybeUpdatePlaybackState(eventTime, belongsToPlayback);
    }

    /**
     * Notifies the tracker of a change to the playback suppression (e.g. due to audio focus loss),
     * including all updates while the playback is not in the foreground.
     *
     * @param eventTime The {@link EventTime}.
     * @param isSuppressed Whether playback is suppressed.
     * @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback.
     */
    public void onIsSuppressedChanged(
        EventTime eventTime, boolean isSuppressed, boolean belongsToPlayback) {
      this.isSuppressed = isSuppressed;
      maybeUpdatePlaybackState(eventTime, belongsToPlayback);
    }

    /**
     * Notifies the tracker of a position discontinuity or timeline update for the current playback.
     *
     * @param eventTime The {@link EventTime}.
     */
    public void onPositionDiscontinuity(EventTime eventTime) {
      isInterruptedByAd = false;
      maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
    }

    /**
     * Notifies the tracker of the start of a seek in the current playback.
     *
     * @param eventTime The {@link EventTime}.
     */
    public void onSeekStarted(EventTime eventTime) {
      isSeeking = true;
      maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
    }

    /**
     * Notifies the tracker of a seek has been processed in the current playback.
     *
     * @param eventTime The {@link EventTime}.
     */
    public void onSeekProcessed(EventTime eventTime) {
      isSeeking = false;
      maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
    }

    /**
     * Notifies the tracker of fatal player error in the current playback.
     *
     * @param eventTime The {@link EventTime}.
     */
    public void onFatalError(EventTime eventTime, Exception error) {
      fatalErrorCount++;
      if (keepHistory) {
        fatalErrorHistory.add(Pair.create(eventTime, error));
      }
      hasFatalError = true;
      isInterruptedByAd = false;
      isSeeking = false;
      maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
    }

    /**
     * Notifies the tracker that a load for the current playback has started.
     *
     * @param eventTime The {@link EventTime}.
     */
    public void onLoadStarted(EventTime eventTime) {
      startedLoading = true;
      maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
    }

    /**
     * Notifies the tracker that the current playback became the active foreground playback.
     *
     * @param eventTime The {@link EventTime}.
     */
    public void onForeground(EventTime eventTime) {
      isForeground = true;
      maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
    }

    /**
     * Notifies the tracker that the current playback has been interrupted for ad playback.
     *
     * @param eventTime The {@link EventTime}.
     */
    public void onInterruptedByAd(EventTime eventTime) {
      isInterruptedByAd = true;
      isSeeking = false;
      maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
    }

    /**
     * Notifies the tracker that the current playback has finished.
     *
     * @param eventTime The {@link EventTime}. Not guaranteed to belong to the current playback.
     */
    public void onFinished(EventTime eventTime) {
      isFinished = true;
      maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ false);
    }

    /**
     * Notifies the tracker that the track selection for the current playback changed.
     *
     * @param eventTime The {@link EventTime}.
     * @param trackSelections The new {@link TrackSelectionArray}.
     */
    public void onTracksChanged(EventTime eventTime, TrackSelectionArray trackSelections) {
      boolean videoEnabled = false;
      boolean audioEnabled = false;
      for (TrackSelection trackSelection : trackSelections.getAll()) {
        if (trackSelection != null && trackSelection.length() > 0) {
          int trackType = MimeTypes.getTrackType(trackSelection.getFormat(0).sampleMimeType);
          if (trackType == C.TRACK_TYPE_VIDEO) {
            videoEnabled = true;
          } else if (trackType == C.TRACK_TYPE_AUDIO) {
            audioEnabled = true;
          }
        }
      }
      if (!videoEnabled) {
        maybeUpdateVideoFormat(eventTime, /* newFormat= */ null);
      }
      if (!audioEnabled) {
        maybeUpdateAudioFormat(eventTime, /* newFormat= */ null);
      }
    }

    /**
     * Notifies the tracker that a format being read by the renderers for the current playback
     * changed.
     *
     * @param eventTime The {@link EventTime}.
     * @param mediaLoadData The {@link MediaLoadData} describing the format change.
     */
    public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {
      if (mediaLoadData.trackType == C.TRACK_TYPE_VIDEO
          || mediaLoadData.trackType == C.TRACK_TYPE_DEFAULT) {
        maybeUpdateVideoFormat(eventTime, mediaLoadData.trackFormat);
      } else if (mediaLoadData.trackType == C.TRACK_TYPE_AUDIO) {
        maybeUpdateAudioFormat(eventTime, mediaLoadData.trackFormat);
      }
    }

    /**
     * Notifies the tracker that the video size for the current playback changed.
     *
     * @param eventTime The {@link EventTime}.
     * @param width The video width in pixels.
     * @param height The video height in pixels.
     */
    public void onVideoSizeChanged(EventTime eventTime, int width, int height) {
      if (currentVideoFormat != null && currentVideoFormat.height == Format.NO_VALUE) {
        Format formatWithHeight = currentVideoFormat.copyWithVideoSize(width, height);
        maybeUpdateVideoFormat(eventTime, formatWithHeight);
      }
    }

    /**
     * Notifies the tracker of a playback speed change, including all playback speed changes while
     * the playback is not in the foreground.
     *
     * @param eventTime The {@link EventTime}.
     * @param playbackSpeed The new playback speed.
     */
    public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) {
      maybeUpdateMediaTimeHistory(eventTime.realtimeMs, eventTime.eventPlaybackPositionMs);
      maybeRecordVideoFormatTime(eventTime.realtimeMs);
      maybeRecordAudioFormatTime(eventTime.realtimeMs);
      currentPlaybackSpeed = playbackSpeed;
    }

    /** Notifies the builder of an audio underrun for the current playback. */
    public void onAudioUnderrun() {
      audioUnderruns++;
    }

    /**
     * Notifies the tracker of dropped video frames for the current playback.
     *
     * @param droppedFrames The number of dropped video frames.
     */
    public void onDroppedVideoFrames(int droppedFrames) {
      this.droppedFrames += droppedFrames;
    }

    /**
     * Notifies the tracker of bandwidth measurement data for the current playback.
     *
     * @param timeMs The time for which bandwidth measurement data is available, in milliseconds.
     * @param bytes The bytes transferred during {@code timeMs}.
     */
    public void onBandwidthData(long timeMs, long bytes) {
      bandwidthTimeMs += timeMs;
      bandwidthBytes += bytes;
    }

    /**
     * Notifies the tracker of a non-fatal error in the current playback.
     *
     * @param eventTime The {@link EventTime}.
     * @param error The error.
     */
    public void onNonFatalError(EventTime eventTime, Exception error) {
      nonFatalErrorCount++;
      if (keepHistory) {
        nonFatalErrorHistory.add(Pair.create(eventTime, error));
      }
    }

    /**
     * Builds the playback stats.
     *
     * @param isFinal Whether this is the final build and no further events are expected.
     */
    public PlaybackStats build(boolean isFinal) {
      long[] playbackStateDurationsMs = this.playbackStateDurationsMs;
      List<long[]> mediaTimeHistory = this.mediaTimeHistory;
      if (!isFinal) {
        long buildTimeMs = SystemClock.elapsedRealtime();
        playbackStateDurationsMs =
            Arrays.copyOf(this.playbackStateDurationsMs, PlaybackStats.PLAYBACK_STATE_COUNT);
        long lastStateDurationMs = Math.max(0, buildTimeMs - currentPlaybackStateStartTimeMs);
        playbackStateDurationsMs[currentPlaybackState] += lastStateDurationMs;
        maybeUpdateMaxRebufferTimeMs(buildTimeMs);
        maybeRecordVideoFormatTime(buildTimeMs);
        maybeRecordAudioFormatTime(buildTimeMs);
        mediaTimeHistory = new ArrayList<>(this.mediaTimeHistory);
        if (keepHistory && currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING) {
          mediaTimeHistory.add(guessMediaTimeBasedOnElapsedRealtime(buildTimeMs));
        }
      }
      boolean isJoinTimeInvalid = this.isJoinTimeInvalid || !hasBeenReady;
      long validJoinTimeMs =
          isJoinTimeInvalid
              ? C.TIME_UNSET
              : playbackStateDurationsMs[PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND];
      boolean hasBackgroundJoin =
          playbackStateDurationsMs[PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND] > 0;
      List<Pair<EventTime, @NullableType Format>> videoHistory =
          isFinal ? videoFormatHistory : new ArrayList<>(videoFormatHistory);
      List<Pair<EventTime, @NullableType Format>> audioHistory =
          isFinal ? audioFormatHistory : new ArrayList<>(audioFormatHistory);
      return new PlaybackStats(
          /* playbackCount= */ 1,
          playbackStateDurationsMs,
          isFinal ? playbackStateHistory : new ArrayList<>(playbackStateHistory),
          mediaTimeHistory,
          firstReportedTimeMs,
          /* foregroundPlaybackCount= */ isForeground ? 1 : 0,
          /* abandonedBeforeReadyCount= */ hasBeenReady ? 0 : 1,
          /* endedCount= */ hasEnded ? 1 : 0,
          /* backgroundJoiningCount= */ hasBackgroundJoin ? 1 : 0,
          validJoinTimeMs,
          /* validJoinTimeCount= */ isJoinTimeInvalid ? 0 : 1,
          pauseCount,
          pauseBufferCount,
          seekCount,
          rebufferCount,
          maxRebufferTimeMs,
          /* adPlaybackCount= */ isAd ? 1 : 0,
          videoHistory,
          audioHistory,
          videoFormatHeightTimeMs,
          videoFormatHeightTimeProduct,
          videoFormatBitrateTimeMs,
          videoFormatBitrateTimeProduct,
          audioFormatTimeMs,
          audioFormatBitrateTimeProduct,
          /* initialVideoFormatHeightCount= */ initialVideoFormatHeight == C.LENGTH_UNSET ? 0 : 1,
          /* initialVideoFormatBitrateCount= */ initialVideoFormatBitrate == C.LENGTH_UNSET ? 0 : 1,
          initialVideoFormatHeight,
          initialVideoFormatBitrate,
          /* initialAudioFormatBitrateCount= */ initialAudioFormatBitrate == C.LENGTH_UNSET ? 0 : 1,
          initialAudioFormatBitrate,
          bandwidthTimeMs,
          bandwidthBytes,
          droppedFrames,
          audioUnderruns,
          /* fatalErrorPlaybackCount= */ fatalErrorCount > 0 ? 1 : 0,
          fatalErrorCount,
          nonFatalErrorCount,
          fatalErrorHistory,
          nonFatalErrorHistory);
    }

    private void maybeUpdatePlaybackState(EventTime eventTime, boolean belongsToPlayback) {
      @PlaybackState int newPlaybackState = resolveNewPlaybackState();
      if (newPlaybackState == currentPlaybackState) {
        return;
      }
      Assertions.checkArgument(eventTime.realtimeMs >= currentPlaybackStateStartTimeMs);

      long stateDurationMs = eventTime.realtimeMs - currentPlaybackStateStartTimeMs;
      playbackStateDurationsMs[currentPlaybackState] += stateDurationMs;
      if (firstReportedTimeMs == C.TIME_UNSET) {
        firstReportedTimeMs = eventTime.realtimeMs;
      }
      isJoinTimeInvalid |= isInvalidJoinTransition(currentPlaybackState, newPlaybackState);
      hasBeenReady |= isReadyState(newPlaybackState);
      hasEnded |= newPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED;
      if (!isPausedState(currentPlaybackState) && isPausedState(newPlaybackState)) {
        pauseCount++;
      }
      if (newPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEKING) {
        seekCount++;
      }
      if (!isRebufferingState(currentPlaybackState) && isRebufferingState(newPlaybackState)) {
        rebufferCount++;
        lastRebufferStartTimeMs = eventTime.realtimeMs;
      }
      if (isRebufferingState(currentPlaybackState)
          && currentPlaybackState != PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING
          && newPlaybackState == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING) {
        pauseBufferCount++;
      }

      maybeUpdateMediaTimeHistory(
          eventTime.realtimeMs,
          /* mediaTimeMs= */ belongsToPlayback ? eventTime.eventPlaybackPositionMs : C.TIME_UNSET);
      maybeUpdateMaxRebufferTimeMs(eventTime.realtimeMs);
      maybeRecordVideoFormatTime(eventTime.realtimeMs);
      maybeRecordAudioFormatTime(eventTime.realtimeMs);

      currentPlaybackState = newPlaybackState;
      currentPlaybackStateStartTimeMs = eventTime.realtimeMs;
      if (keepHistory) {
        playbackStateHistory.add(Pair.create(eventTime, currentPlaybackState));
      }
    }

    private @PlaybackState int resolveNewPlaybackState() {
      if (isFinished) {
        // Keep VIDEO_STATE_ENDED if playback naturally ended (or progressed to next item).
        return currentPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED
            ? PlaybackStats.PLAYBACK_STATE_ENDED
            : PlaybackStats.PLAYBACK_STATE_ABANDONED;
      } else if (isSeeking) {
        // Seeking takes precedence over errors such that we report a seek while in error state.
        return PlaybackStats.PLAYBACK_STATE_SEEKING;
      } else if (hasFatalError) {
        return PlaybackStats.PLAYBACK_STATE_FAILED;
      } else if (!isForeground) {
        // Before the playback becomes foreground, only report background joining and not started.
        return startedLoading
            ? PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND
            : PlaybackStats.PLAYBACK_STATE_NOT_STARTED;
      } else if (isInterruptedByAd) {
        return PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD;
      } else if (playerPlaybackState == Player.STATE_ENDED) {
        return PlaybackStats.PLAYBACK_STATE_ENDED;
      } else if (playerPlaybackState == Player.STATE_BUFFERING) {
        if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_NOT_STARTED
            || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND
            || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND
            || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) {
          return PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND;
        }
        if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEKING
            || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING) {
          return PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING;
        }
        if (!playWhenReady) {
          return PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING;
        }
        return isSuppressed
            ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING
            : PlaybackStats.PLAYBACK_STATE_BUFFERING;
      } else if (playerPlaybackState == Player.STATE_READY) {
        if (!playWhenReady) {
          return PlaybackStats.PLAYBACK_STATE_PAUSED;
        }
        return isSuppressed
            ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED
            : PlaybackStats.PLAYBACK_STATE_PLAYING;
      } else if (playerPlaybackState == Player.STATE_IDLE
          && currentPlaybackState != PlaybackStats.PLAYBACK_STATE_NOT_STARTED) {
        // This case only applies for calls to player.stop(). All other IDLE cases are handled by
        // !isForeground, hasFatalError or isSuspended. NOT_STARTED is deliberately ignored.
        return PlaybackStats.PLAYBACK_STATE_STOPPED;
      }
      return currentPlaybackState;
    }

    private void maybeUpdateMaxRebufferTimeMs(long nowMs) {
      if (isRebufferingState(currentPlaybackState)) {
        long rebufferDurationMs = nowMs - lastRebufferStartTimeMs;
        if (maxRebufferTimeMs == C.TIME_UNSET || rebufferDurationMs > maxRebufferTimeMs) {
          maxRebufferTimeMs = rebufferDurationMs;
        }
      }
    }

    private void maybeUpdateMediaTimeHistory(long realtimeMs, long mediaTimeMs) {
      if (currentPlaybackState != PlaybackStats.PLAYBACK_STATE_PLAYING) {
        if (mediaTimeMs == C.TIME_UNSET) {
          return;
        }
        if (!mediaTimeHistory.isEmpty()) {
          long previousMediaTimeMs = mediaTimeHistory.get(mediaTimeHistory.size() - 1)[1];
          if (previousMediaTimeMs != mediaTimeMs) {
            mediaTimeHistory.add(new long[] {realtimeMs, previousMediaTimeMs});
          }
        }
      }
      mediaTimeHistory.add(
          mediaTimeMs == C.TIME_UNSET
              ? guessMediaTimeBasedOnElapsedRealtime(realtimeMs)
              : new long[] {realtimeMs, mediaTimeMs});
    }

    private long[] guessMediaTimeBasedOnElapsedRealtime(long realtimeMs) {
      long[] previousKnownMediaTimeHistory = mediaTimeHistory.get(mediaTimeHistory.size() - 1);
      long previousRealtimeMs = previousKnownMediaTimeHistory[0];
      long previousMediaTimeMs = previousKnownMediaTimeHistory[1];
      long elapsedMediaTimeEstimateMs =
          (long) ((realtimeMs - previousRealtimeMs) * currentPlaybackSpeed);
      long mediaTimeEstimateMs = previousMediaTimeMs + elapsedMediaTimeEstimateMs;
      return new long[] {realtimeMs, mediaTimeEstimateMs};
    }

    private void maybeUpdateVideoFormat(EventTime eventTime, @Nullable Format newFormat) {
      if (Util.areEqual(currentVideoFormat, newFormat)) {
        return;
      }
      maybeRecordVideoFormatTime(eventTime.realtimeMs);
      if (newFormat != null) {
        if (initialVideoFormatHeight == C.LENGTH_UNSET && newFormat.height != Format.NO_VALUE) {
          initialVideoFormatHeight = newFormat.height;
        }
        if (initialVideoFormatBitrate == C.LENGTH_UNSET && newFormat.bitrate != Format.NO_VALUE) {
          initialVideoFormatBitrate = newFormat.bitrate;
        }
      }
      currentVideoFormat = newFormat;
      if (keepHistory) {
        videoFormatHistory.add(Pair.create(eventTime, currentVideoFormat));
      }
    }

    private void maybeUpdateAudioFormat(EventTime eventTime, @Nullable Format newFormat) {
      if (Util.areEqual(currentAudioFormat, newFormat)) {
        return;
      }
      maybeRecordAudioFormatTime(eventTime.realtimeMs);
      if (newFormat != null
          && initialAudioFormatBitrate == C.LENGTH_UNSET
          && newFormat.bitrate != Format.NO_VALUE) {
        initialAudioFormatBitrate = newFormat.bitrate;
      }
      currentAudioFormat = newFormat;
      if (keepHistory) {
        audioFormatHistory.add(Pair.create(eventTime, currentAudioFormat));
      }
    }

    private void maybeRecordVideoFormatTime(long nowMs) {
      if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING
          && currentVideoFormat != null) {
        long mediaDurationMs = (long) ((nowMs - lastVideoFormatStartTimeMs) * currentPlaybackSpeed);
        if (currentVideoFormat.height != Format.NO_VALUE) {
          videoFormatHeightTimeMs += mediaDurationMs;
          videoFormatHeightTimeProduct += mediaDurationMs * currentVideoFormat.height;
        }
        if (currentVideoFormat.bitrate != Format.NO_VALUE) {
          videoFormatBitrateTimeMs += mediaDurationMs;
          videoFormatBitrateTimeProduct += mediaDurationMs * currentVideoFormat.bitrate;
        }
      }
      lastVideoFormatStartTimeMs = nowMs;
    }

    private void maybeRecordAudioFormatTime(long nowMs) {
      if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING
          && currentAudioFormat != null
          && currentAudioFormat.bitrate != Format.NO_VALUE) {
        long mediaDurationMs = (long) ((nowMs - lastAudioFormatStartTimeMs) * currentPlaybackSpeed);
        audioFormatTimeMs += mediaDurationMs;
        audioFormatBitrateTimeProduct += mediaDurationMs * currentAudioFormat.bitrate;
      }
      lastAudioFormatStartTimeMs = nowMs;
    }

    private static boolean isReadyState(@PlaybackState int state) {
      return state == PlaybackStats.PLAYBACK_STATE_PLAYING
          || state == PlaybackStats.PLAYBACK_STATE_PAUSED
          || state == PlaybackStats.PLAYBACK_STATE_SUPPRESSED;
    }

    private static boolean isPausedState(@PlaybackState int state) {
      return state == PlaybackStats.PLAYBACK_STATE_PAUSED
          || state == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING;
    }

    private static boolean isRebufferingState(@PlaybackState int state) {
      return state == PlaybackStats.PLAYBACK_STATE_BUFFERING
          || state == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING
          || state == PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING;
    }

    private static boolean isInvalidJoinTransition(
        @PlaybackState int oldState, @PlaybackState int newState) {
      if (oldState != PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND
          && oldState != PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND
          && oldState != PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) {
        return false;
      }
      return newState != PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND
          && newState != PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND
          && newState != PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD
          && newState != PlaybackStats.PLAYBACK_STATE_PLAYING
          && newState != PlaybackStats.PLAYBACK_STATE_PAUSED
          && newState != PlaybackStats.PLAYBACK_STATE_SUPPRESSED
          && newState != PlaybackStats.PLAYBACK_STATE_ENDED;
    }
  }
}