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

import static java.lang.Math.max;
import static java.lang.Math.min;

import android.net.Uri;
import android.os.Handler;
import android.os.Message;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.TransferListener;
import androidx.media3.exoplayer.AbstractConcatenatedTimeline;
import androidx.media3.exoplayer.source.ConcatenatingMediaSource.MediaSourceHolder;
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder;
import androidx.media3.exoplayer.upstream.Allocator;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Concatenates multiple {@link MediaSource}s. The list of {@link MediaSource}s can be modified
 * during playback. It is valid for the same {@link MediaSource} instance to be present more than
 * once in the concatenation. Access to this class is thread-safe.
 */
@UnstableApi
public final class ConcatenatingMediaSource extends CompositeMediaSource<MediaSourceHolder> {

  private static final int MSG_ADD = 0;
  private static final int MSG_REMOVE = 1;
  private static final int MSG_MOVE = 2;
  private static final int MSG_SET_SHUFFLE_ORDER = 3;
  private static final int MSG_UPDATE_TIMELINE = 4;
  private static final int MSG_ON_COMPLETION = 5;

  private static final MediaItem EMPTY_MEDIA_ITEM =
      new MediaItem.Builder().setUri(Uri.EMPTY).build();

  // Accessed on any thread.
  @GuardedBy("this")
  private final List<MediaSourceHolder> mediaSourcesPublic;

  @GuardedBy("this")
  private final Set<HandlerAndRunnable> pendingOnCompletionActions;

  @GuardedBy("this")
  @Nullable
  private Handler playbackThreadHandler;

  // Accessed on the playback thread only.
  private final List<MediaSourceHolder> mediaSourceHolders;
  private final IdentityHashMap<MediaPeriod, MediaSourceHolder> mediaSourceByMediaPeriod;
  private final Map<Object, MediaSourceHolder> mediaSourceByUid;
  private final Set<MediaSourceHolder> enabledMediaSourceHolders;
  private final boolean isAtomic;
  private final boolean useLazyPreparation;

  private boolean timelineUpdateScheduled;
  private Set<HandlerAndRunnable> nextTimelineUpdateOnCompletionActions;
  private ShuffleOrder shuffleOrder;

  /**
   * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link
   *     MediaSource} instance to be present more than once in the array.
   */
  public ConcatenatingMediaSource(MediaSource... mediaSources) {
    this(/* isAtomic= */ false, mediaSources);
  }

  /**
   * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated
   *     as a single item for repeating and shuffling.
   * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link
   *     MediaSource} instance to be present more than once in the array.
   */
  public ConcatenatingMediaSource(boolean isAtomic, MediaSource... mediaSources) {
    this(isAtomic, new DefaultShuffleOrder(0), mediaSources);
  }

  /**
   * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated
   *     as a single item for repeating and shuffling.
   * @param shuffleOrder The {@link ShuffleOrder} to use when shuffling the child media sources.
   * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link
   *     MediaSource} instance to be present more than once in the array.
   */
  public ConcatenatingMediaSource(
      boolean isAtomic, ShuffleOrder shuffleOrder, MediaSource... mediaSources) {
    this(isAtomic, /* useLazyPreparation= */ false, shuffleOrder, mediaSources);
  }

  /**
   * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated
   *     as a single item for repeating and shuffling.
   * @param useLazyPreparation Whether playlist items are prepared lazily. If false, all manifest
   *     loads and other initial preparation steps happen immediately. If true, these initial
   *     preparations are triggered only when the player starts buffering the media.
   * @param shuffleOrder The {@link ShuffleOrder} to use when shuffling the child media sources.
   * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link
   *     MediaSource} instance to be present more than once in the array.
   */
  @SuppressWarnings("initialization")
  public ConcatenatingMediaSource(
      boolean isAtomic,
      boolean useLazyPreparation,
      ShuffleOrder shuffleOrder,
      MediaSource... mediaSources) {
    for (MediaSource mediaSource : mediaSources) {
      Assertions.checkNotNull(mediaSource);
    }
    this.shuffleOrder = shuffleOrder.getLength() > 0 ? shuffleOrder.cloneAndClear() : shuffleOrder;
    this.mediaSourceByMediaPeriod = new IdentityHashMap<>();
    this.mediaSourceByUid = new HashMap<>();
    this.mediaSourcesPublic = new ArrayList<>();
    this.mediaSourceHolders = new ArrayList<>();
    this.nextTimelineUpdateOnCompletionActions = new HashSet<>();
    this.pendingOnCompletionActions = new HashSet<>();
    this.enabledMediaSourceHolders = new HashSet<>();
    this.isAtomic = isAtomic;
    this.useLazyPreparation = useLazyPreparation;
    addMediaSources(Arrays.asList(mediaSources));
  }

  @Override
  public synchronized Timeline getInitialTimeline() {
    ShuffleOrder shuffleOrder =
        this.shuffleOrder.getLength() != mediaSourcesPublic.size()
            ? this.shuffleOrder
                .cloneAndClear()
                .cloneAndInsert(
                    /* insertionIndex= */ 0, /* insertionCount= */ mediaSourcesPublic.size())
            : this.shuffleOrder;
    return new ConcatenatedTimeline(mediaSourcesPublic, shuffleOrder, isAtomic);
  }

  @Override
  public boolean isSingleWindow() {
    return false;
  }

  /**
   * Appends a {@link MediaSource} to the playlist.
   *
   * @param mediaSource The {@link MediaSource} to be added to the list.
   */
  public synchronized void addMediaSource(MediaSource mediaSource) {
    addMediaSource(mediaSourcesPublic.size(), mediaSource);
  }

  /**
   * Appends a {@link MediaSource} to the playlist and executes a custom action on completion.
   *
   * @param mediaSource The {@link MediaSource} to be added to the list.
   * @param handler The {@link Handler} to run {@code onCompletionAction}.
   * @param onCompletionAction A {@link Runnable} which is executed immediately after the media
   *     source has been added to the playlist.
   */
  public synchronized void addMediaSource(
      MediaSource mediaSource, Handler handler, Runnable onCompletionAction) {
    addMediaSource(mediaSourcesPublic.size(), mediaSource, handler, onCompletionAction);
  }

  /**
   * Adds a {@link MediaSource} to the playlist.
   *
   * @param index The index at which the new {@link MediaSource} will be inserted. This index must
   *     be in the range of 0 &lt;= index &lt;= {@link #getSize()}.
   * @param mediaSource The {@link MediaSource} to be added to the list.
   */
  public synchronized void addMediaSource(int index, MediaSource mediaSource) {
    addPublicMediaSources(
        index,
        Collections.singletonList(mediaSource),
        /* handler= */ null,
        /* onCompletionAction= */ null);
  }

  /**
   * Adds a {@link MediaSource} to the playlist and executes a custom action on completion.
   *
   * @param index The index at which the new {@link MediaSource} will be inserted. This index must
   *     be in the range of 0 &lt;= index &lt;= {@link #getSize()}.
   * @param mediaSource The {@link MediaSource} to be added to the list.
   * @param handler The {@link Handler} to run {@code onCompletionAction}.
   * @param onCompletionAction A {@link Runnable} which is executed immediately after the media
   *     source has been added to the playlist.
   */
  public synchronized void addMediaSource(
      int index, MediaSource mediaSource, Handler handler, Runnable onCompletionAction) {
    addPublicMediaSources(
        index, Collections.singletonList(mediaSource), handler, onCompletionAction);
  }

  /**
   * Appends multiple {@link MediaSource}s to the playlist.
   *
   * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media
   *     sources are added in the order in which they appear in this collection.
   */
  public synchronized void addMediaSources(Collection<MediaSource> mediaSources) {
    addPublicMediaSources(
        mediaSourcesPublic.size(),
        mediaSources,
        /* handler= */ null,
        /* onCompletionAction= */ null);
  }

  /**
   * Appends multiple {@link MediaSource}s to the playlist and executes a custom action on
   * completion.
   *
   * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media
   *     sources are added in the order in which they appear in this collection.
   * @param handler The {@link Handler} to run {@code onCompletionAction}.
   * @param onCompletionAction A {@link Runnable} which is executed immediately after the media
   *     sources have been added to the playlist.
   */
  public synchronized void addMediaSources(
      Collection<MediaSource> mediaSources, Handler handler, Runnable onCompletionAction) {
    addPublicMediaSources(mediaSourcesPublic.size(), mediaSources, handler, onCompletionAction);
  }

  /**
   * Adds multiple {@link MediaSource}s to the playlist.
   *
   * @param index The index at which the new {@link MediaSource}s will be inserted. This index must
   *     be in the range of 0 &lt;= index &lt;= {@link #getSize()}.
   * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media
   *     sources are added in the order in which they appear in this collection.
   */
  public synchronized void addMediaSources(int index, Collection<MediaSource> mediaSources) {
    addPublicMediaSources(index, mediaSources, /* handler= */ null, /* onCompletionAction= */ null);
  }

  /**
   * Adds multiple {@link MediaSource}s to the playlist and executes a custom action on completion.
   *
   * @param index The index at which the new {@link MediaSource}s will be inserted. This index must
   *     be in the range of 0 &lt;= index &lt;= {@link #getSize()}.
   * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media
   *     sources are added in the order in which they appear in this collection.
   * @param handler The {@link Handler} to run {@code onCompletionAction}.
   * @param onCompletionAction A {@link Runnable} which is executed immediately after the media
   *     sources have been added to the playlist.
   */
  public synchronized void addMediaSources(
      int index,
      Collection<MediaSource> mediaSources,
      Handler handler,
      Runnable onCompletionAction) {
    addPublicMediaSources(index, mediaSources, handler, onCompletionAction);
  }

  /**
   * Removes a {@link MediaSource} from the playlist.
   *
   * <p>Note: If you want to move the instance, it's preferable to use {@link #moveMediaSource(int,
   * int)} instead.
   *
   * <p>Note: If you want to remove a set of contiguous sources, it's preferable to use {@link
   * #removeMediaSourceRange(int, int)} instead.
   *
   * @param index The index at which the media source will be removed. This index must be in the
   *     range of 0 &lt;= index &lt; {@link #getSize()}.
   * @return The removed {@link MediaSource}.
   */
  public synchronized MediaSource removeMediaSource(int index) {
    MediaSource removedMediaSource = getMediaSource(index);
    removePublicMediaSources(index, index + 1, /* handler= */ null, /* onCompletionAction= */ null);
    return removedMediaSource;
  }

  /**
   * Removes a {@link MediaSource} from the playlist and executes a custom action on completion.
   *
   * <p>Note: If you want to move the instance, it's preferable to use {@link #moveMediaSource(int,
   * int, Handler, Runnable)} instead.
   *
   * <p>Note: If you want to remove a set of contiguous sources, it's preferable to use {@link
   * #removeMediaSourceRange(int, int, Handler, Runnable)} instead.
   *
   * @param index The index at which the media source will be removed. This index must be in the
   *     range of 0 &lt;= index &lt; {@link #getSize()}.
   * @param handler The {@link Handler} to run {@code onCompletionAction}.
   * @param onCompletionAction A {@link Runnable} which is executed immediately after the media
   *     source has been removed from the playlist.
   * @return The removed {@link MediaSource}.
   */
  public synchronized MediaSource removeMediaSource(
      int index, Handler handler, Runnable onCompletionAction) {
    MediaSource removedMediaSource = getMediaSource(index);
    removePublicMediaSources(index, index + 1, handler, onCompletionAction);
    return removedMediaSource;
  }

  /**
   * Removes a range of {@link MediaSource}s from the playlist, by specifying an initial index
   * (included) and a final index (excluded).
   *
   * <p>Note: when specified range is empty, no actual media source is removed and no exception is
   * thrown.
   *
   * @param fromIndex The initial range index, pointing to the first media source that will be
   *     removed. This index must be in the range of 0 &lt;= index &lt;= {@link #getSize()}.
   * @param toIndex The final range index, pointing to the first media source that will be left
   *     untouched. This index must be in the range of 0 &lt;= index &lt;= {@link #getSize()}.
   * @throws IndexOutOfBoundsException When the range is malformed, i.e. {@code fromIndex} &lt; 0,
   *     {@code toIndex} &gt; {@link #getSize()}, {@code fromIndex} &gt; {@code toIndex}
   */
  public synchronized void removeMediaSourceRange(int fromIndex, int toIndex) {
    removePublicMediaSources(
        fromIndex, toIndex, /* handler= */ null, /* onCompletionAction= */ null);
  }

  /**
   * Removes a range of {@link MediaSource}s from the playlist, by specifying an initial index
   * (included) and a final index (excluded), and executes a custom action on completion.
   *
   * <p>Note: when specified range is empty, no actual media source is removed and no exception is
   * thrown.
   *
   * @param fromIndex The initial range index, pointing to the first media source that will be
   *     removed. This index must be in the range of 0 &lt;= index &lt;= {@link #getSize()}.
   * @param toIndex The final range index, pointing to the first media source that will be left
   *     untouched. This index must be in the range of 0 &lt;= index &lt;= {@link #getSize()}.
   * @param handler The {@link Handler} to run {@code onCompletionAction}.
   * @param onCompletionAction A {@link Runnable} which is executed immediately after the media
   *     source range has been removed from the playlist.
   * @throws IllegalArgumentException When the range is malformed, i.e. {@code fromIndex} &lt; 0,
   *     {@code toIndex} &gt; {@link #getSize()}, {@code fromIndex} &gt; {@code toIndex}
   */
  public synchronized void removeMediaSourceRange(
      int fromIndex, int toIndex, Handler handler, Runnable onCompletionAction) {
    removePublicMediaSources(fromIndex, toIndex, handler, onCompletionAction);
  }

  /**
   * Moves an existing {@link MediaSource} within the playlist.
   *
   * @param currentIndex The current index of the media source in the playlist. This index must be
   *     in the range of 0 &lt;= index &lt; {@link #getSize()}.
   * @param newIndex The target index of the media source in the playlist. This index must be in the
   *     range of 0 &lt;= index &lt; {@link #getSize()}.
   */
  public synchronized void moveMediaSource(int currentIndex, int newIndex) {
    movePublicMediaSource(
        currentIndex, newIndex, /* handler= */ null, /* onCompletionAction= */ null);
  }

  /**
   * Moves an existing {@link MediaSource} within the playlist and executes a custom action on
   * completion.
   *
   * @param currentIndex The current index of the media source in the playlist. This index must be
   *     in the range of 0 &lt;= index &lt; {@link #getSize()}.
   * @param newIndex The target index of the media source in the playlist. This index must be in the
   *     range of 0 &lt;= index &lt; {@link #getSize()}.
   * @param handler The {@link Handler} to run {@code onCompletionAction}.
   * @param onCompletionAction A {@link Runnable} which is executed immediately after the media
   *     source has been moved.
   */
  public synchronized void moveMediaSource(
      int currentIndex, int newIndex, Handler handler, Runnable onCompletionAction) {
    movePublicMediaSource(currentIndex, newIndex, handler, onCompletionAction);
  }

  /** Clears the playlist. */
  public synchronized void clear() {
    removeMediaSourceRange(0, getSize());
  }

  /**
   * Clears the playlist and executes a custom action on completion.
   *
   * @param handler The {@link Handler} to run {@code onCompletionAction}.
   * @param onCompletionAction A {@link Runnable} which is executed immediately after the playlist
   *     has been cleared.
   */
  public synchronized void clear(Handler handler, Runnable onCompletionAction) {
    removeMediaSourceRange(0, getSize(), handler, onCompletionAction);
  }

  /** Returns the number of media sources in the playlist. */
  public synchronized int getSize() {
    return mediaSourcesPublic.size();
  }

  /**
   * Returns the {@link MediaSource} at a specified index.
   *
   * @param index An index in the range of 0 &lt;= index &lt;= {@link #getSize()}.
   * @return The {@link MediaSource} at this index.
   */
  public synchronized MediaSource getMediaSource(int index) {
    return mediaSourcesPublic.get(index).mediaSource;
  }

  /**
   * Sets a new shuffle order to use when shuffling the child media sources.
   *
   * @param shuffleOrder A {@link ShuffleOrder}.
   */
  public synchronized void setShuffleOrder(ShuffleOrder shuffleOrder) {
    setPublicShuffleOrder(shuffleOrder, /* handler= */ null, /* onCompletionAction= */ null);
  }

  /**
   * Sets a new shuffle order to use when shuffling the child media sources.
   *
   * @param shuffleOrder A {@link ShuffleOrder}.
   * @param handler The {@link Handler} to run {@code onCompletionAction}.
   * @param onCompletionAction A {@link Runnable} which is executed immediately after the shuffle
   *     order has been changed.
   */
  public synchronized void setShuffleOrder(
      ShuffleOrder shuffleOrder, Handler handler, Runnable onCompletionAction) {
    setPublicShuffleOrder(shuffleOrder, handler, onCompletionAction);
  }

  // CompositeMediaSource implementation.

  @Override
  public MediaItem getMediaItem() {
    // This method is actually never called because getInitialTimeline is implemented and hence the
    // MaskingMediaSource does not need to create a placeholder timeline for this media source.
    return EMPTY_MEDIA_ITEM;
  }

  @Override
  protected synchronized void prepareSourceInternal(
      @Nullable TransferListener mediaTransferListener) {
    super.prepareSourceInternal(mediaTransferListener);
    playbackThreadHandler = new Handler(/* callback= */ this::handleMessage);
    if (mediaSourcesPublic.isEmpty()) {
      updateTimelineAndScheduleOnCompletionActions();
    } else {
      shuffleOrder = shuffleOrder.cloneAndInsert(0, mediaSourcesPublic.size());
      addMediaSourcesInternal(0, mediaSourcesPublic);
      scheduleTimelineUpdate();
    }
  }

  @SuppressWarnings("MissingSuperCall")
  @Override
  protected void enableInternal() {
    // Suppress enabling all child sources here as they can be lazily enabled when creating periods.
  }

  @Override
  public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) {
    Object mediaSourceHolderUid = getMediaSourceHolderUid(id.periodUid);
    MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(getChildPeriodUid(id.periodUid));
    @Nullable MediaSourceHolder holder = mediaSourceByUid.get(mediaSourceHolderUid);
    if (holder == null) {
      // Stale event. The media source has already been removed.
      holder = new MediaSourceHolder(new FakeMediaSource(), useLazyPreparation);
      holder.isRemoved = true;
      prepareChildSource(holder, holder.mediaSource);
    }
    enableMediaSource(holder);
    holder.activeMediaPeriodIds.add(childMediaPeriodId);
    MediaPeriod mediaPeriod =
        holder.mediaSource.createPeriod(childMediaPeriodId, allocator, startPositionUs);
    mediaSourceByMediaPeriod.put(mediaPeriod, holder);
    disableUnusedMediaSources();
    return mediaPeriod;
  }

  @Override
  public void releasePeriod(MediaPeriod mediaPeriod) {
    MediaSourceHolder holder =
        Assertions.checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod));
    holder.mediaSource.releasePeriod(mediaPeriod);
    holder.activeMediaPeriodIds.remove(((MaskingMediaPeriod) mediaPeriod).id);
    if (!mediaSourceByMediaPeriod.isEmpty()) {
      disableUnusedMediaSources();
    }
    maybeReleaseChildSource(holder);
  }

  @Override
  protected void disableInternal() {
    super.disableInternal();
    enabledMediaSourceHolders.clear();
  }

  @Override
  protected synchronized void releaseSourceInternal() {
    super.releaseSourceInternal();
    mediaSourceHolders.clear();
    enabledMediaSourceHolders.clear();
    mediaSourceByUid.clear();
    shuffleOrder = shuffleOrder.cloneAndClear();
    if (playbackThreadHandler != null) {
      playbackThreadHandler.removeCallbacksAndMessages(null);
      playbackThreadHandler = null;
    }
    timelineUpdateScheduled = false;
    nextTimelineUpdateOnCompletionActions.clear();
    dispatchOnCompletionActions(pendingOnCompletionActions);
  }

  @Override
  protected void onChildSourceInfoRefreshed(
      MediaSourceHolder mediaSourceHolder, MediaSource mediaSource, Timeline timeline) {
    updateMediaSourceInternal(mediaSourceHolder, timeline);
  }

  @Override
  @Nullable
  protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId(
      MediaSourceHolder mediaSourceHolder, MediaPeriodId mediaPeriodId) {
    for (int i = 0; i < mediaSourceHolder.activeMediaPeriodIds.size(); i++) {
      // Ensure the reported media period id has the same window sequence number as the one created
      // by this media source. Otherwise it does not belong to this child source.
      if (mediaSourceHolder.activeMediaPeriodIds.get(i).windowSequenceNumber
          == mediaPeriodId.windowSequenceNumber) {
        Object periodUid = getPeriodUid(mediaSourceHolder, mediaPeriodId.periodUid);
        return mediaPeriodId.copyWithPeriodUid(periodUid);
      }
    }
    return null;
  }

  @Override
  protected int getWindowIndexForChildWindowIndex(
      MediaSourceHolder mediaSourceHolder, int windowIndex) {
    return windowIndex + mediaSourceHolder.firstWindowIndexInChild;
  }

  // Internal methods. Called from any thread.

  @GuardedBy("this")
  private void addPublicMediaSources(
      int index,
      Collection<MediaSource> mediaSources,
      @Nullable Handler handler,
      @Nullable Runnable onCompletionAction) {
    Assertions.checkArgument((handler == null) == (onCompletionAction == null));
    @Nullable Handler playbackThreadHandler = this.playbackThreadHandler;
    for (MediaSource mediaSource : mediaSources) {
      Assertions.checkNotNull(mediaSource);
    }
    List<MediaSourceHolder> mediaSourceHolders = new ArrayList<>(mediaSources.size());
    for (MediaSource mediaSource : mediaSources) {
      mediaSourceHolders.add(new MediaSourceHolder(mediaSource, useLazyPreparation));
    }
    mediaSourcesPublic.addAll(index, mediaSourceHolders);
    if (playbackThreadHandler != null && !mediaSources.isEmpty()) {
      @Nullable
      HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction);
      playbackThreadHandler
          .obtainMessage(MSG_ADD, new MessageData<>(index, mediaSourceHolders, callbackAction))
          .sendToTarget();
    } else if (onCompletionAction != null && handler != null) {
      handler.post(onCompletionAction);
    }
  }

  @GuardedBy("this")
  private void removePublicMediaSources(
      int fromIndex,
      int toIndex,
      @Nullable Handler handler,
      @Nullable Runnable onCompletionAction) {
    Assertions.checkArgument((handler == null) == (onCompletionAction == null));
    @Nullable Handler playbackThreadHandler = this.playbackThreadHandler;
    Util.removeRange(mediaSourcesPublic, fromIndex, toIndex);
    if (playbackThreadHandler != null) {
      @Nullable
      HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction);
      playbackThreadHandler
          .obtainMessage(MSG_REMOVE, new MessageData<>(fromIndex, toIndex, callbackAction))
          .sendToTarget();
    } else if (onCompletionAction != null && handler != null) {
      handler.post(onCompletionAction);
    }
  }

  @GuardedBy("this")
  private void movePublicMediaSource(
      int currentIndex,
      int newIndex,
      @Nullable Handler handler,
      @Nullable Runnable onCompletionAction) {
    Assertions.checkArgument((handler == null) == (onCompletionAction == null));
    @Nullable Handler playbackThreadHandler = this.playbackThreadHandler;
    mediaSourcesPublic.add(newIndex, mediaSourcesPublic.remove(currentIndex));
    if (playbackThreadHandler != null) {
      @Nullable
      HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction);
      playbackThreadHandler
          .obtainMessage(MSG_MOVE, new MessageData<>(currentIndex, newIndex, callbackAction))
          .sendToTarget();
    } else if (onCompletionAction != null && handler != null) {
      handler.post(onCompletionAction);
    }
  }

  @GuardedBy("this")
  private void setPublicShuffleOrder(
      ShuffleOrder shuffleOrder, @Nullable Handler handler, @Nullable Runnable onCompletionAction) {
    Assertions.checkArgument((handler == null) == (onCompletionAction == null));
    @Nullable Handler playbackThreadHandler = this.playbackThreadHandler;
    if (playbackThreadHandler != null) {
      int size = getSize();
      if (shuffleOrder.getLength() != size) {
        shuffleOrder =
            shuffleOrder
                .cloneAndClear()
                .cloneAndInsert(/* insertionIndex= */ 0, /* insertionCount= */ size);
      }
      @Nullable
      HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction);
      playbackThreadHandler
          .obtainMessage(
              MSG_SET_SHUFFLE_ORDER,
              new MessageData<>(/* index= */ 0, shuffleOrder, callbackAction))
          .sendToTarget();
    } else {
      this.shuffleOrder =
          shuffleOrder.getLength() > 0 ? shuffleOrder.cloneAndClear() : shuffleOrder;
      if (onCompletionAction != null && handler != null) {
        handler.post(onCompletionAction);
      }
    }
  }

  @GuardedBy("this")
  @Nullable
  private HandlerAndRunnable createOnCompletionAction(
      @Nullable Handler handler, @Nullable Runnable runnable) {
    if (handler == null || runnable == null) {
      return null;
    }
    HandlerAndRunnable handlerAndRunnable = new HandlerAndRunnable(handler, runnable);
    pendingOnCompletionActions.add(handlerAndRunnable);
    return handlerAndRunnable;
  }

  // Internal methods. Called on the playback thread.

  @SuppressWarnings("unchecked")
  private boolean handleMessage(Message msg) {
    switch (msg.what) {
      case MSG_ADD:
        MessageData<Collection<MediaSourceHolder>> addMessage =
            (MessageData<Collection<MediaSourceHolder>>) Util.castNonNull(msg.obj);
        shuffleOrder = shuffleOrder.cloneAndInsert(addMessage.index, addMessage.customData.size());
        addMediaSourcesInternal(addMessage.index, addMessage.customData);
        scheduleTimelineUpdate(addMessage.onCompletionAction);
        break;
      case MSG_REMOVE:
        MessageData<Integer> removeMessage = (MessageData<Integer>) Util.castNonNull(msg.obj);
        int fromIndex = removeMessage.index;
        int toIndex = removeMessage.customData;
        if (fromIndex == 0 && toIndex == shuffleOrder.getLength()) {
          shuffleOrder = shuffleOrder.cloneAndClear();
        } else {
          shuffleOrder = shuffleOrder.cloneAndRemove(fromIndex, toIndex);
        }
        for (int index = toIndex - 1; index >= fromIndex; index--) {
          removeMediaSourceInternal(index);
        }
        scheduleTimelineUpdate(removeMessage.onCompletionAction);
        break;
      case MSG_MOVE:
        MessageData<Integer> moveMessage = (MessageData<Integer>) Util.castNonNull(msg.obj);
        shuffleOrder = shuffleOrder.cloneAndRemove(moveMessage.index, moveMessage.index + 1);
        shuffleOrder = shuffleOrder.cloneAndInsert(moveMessage.customData, 1);
        moveMediaSourceInternal(moveMessage.index, moveMessage.customData);
        scheduleTimelineUpdate(moveMessage.onCompletionAction);
        break;
      case MSG_SET_SHUFFLE_ORDER:
        MessageData<ShuffleOrder> shuffleOrderMessage =
            (MessageData<ShuffleOrder>) Util.castNonNull(msg.obj);
        shuffleOrder = shuffleOrderMessage.customData;
        scheduleTimelineUpdate(shuffleOrderMessage.onCompletionAction);
        break;
      case MSG_UPDATE_TIMELINE:
        updateTimelineAndScheduleOnCompletionActions();
        break;
      case MSG_ON_COMPLETION:
        Set<HandlerAndRunnable> actions = (Set<HandlerAndRunnable>) Util.castNonNull(msg.obj);
        dispatchOnCompletionActions(actions);
        break;
      default:
        throw new IllegalStateException();
    }
    return true;
  }

  private void scheduleTimelineUpdate() {
    scheduleTimelineUpdate(/* onCompletionAction= */ null);
  }

  private void scheduleTimelineUpdate(@Nullable HandlerAndRunnable onCompletionAction) {
    if (!timelineUpdateScheduled) {
      getPlaybackThreadHandlerOnPlaybackThread().obtainMessage(MSG_UPDATE_TIMELINE).sendToTarget();
      timelineUpdateScheduled = true;
    }
    if (onCompletionAction != null) {
      nextTimelineUpdateOnCompletionActions.add(onCompletionAction);
    }
  }

  private void updateTimelineAndScheduleOnCompletionActions() {
    timelineUpdateScheduled = false;
    Set<HandlerAndRunnable> onCompletionActions = nextTimelineUpdateOnCompletionActions;
    nextTimelineUpdateOnCompletionActions = new HashSet<>();
    refreshSourceInfo(new ConcatenatedTimeline(mediaSourceHolders, shuffleOrder, isAtomic));
    getPlaybackThreadHandlerOnPlaybackThread()
        .obtainMessage(MSG_ON_COMPLETION, onCompletionActions)
        .sendToTarget();
  }

  @SuppressWarnings("GuardedBy")
  private Handler getPlaybackThreadHandlerOnPlaybackThread() {
    // Write access to this value happens on the playback thread only, so playback thread reads
    // don't need to be synchronized.
    return Assertions.checkNotNull(playbackThreadHandler);
  }

  private synchronized void dispatchOnCompletionActions(
      Set<HandlerAndRunnable> onCompletionActions) {
    for (HandlerAndRunnable pendingAction : onCompletionActions) {
      pendingAction.dispatch();
    }
    pendingOnCompletionActions.removeAll(onCompletionActions);
  }

  private void addMediaSourcesInternal(
      int index, Collection<MediaSourceHolder> mediaSourceHolders) {
    for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) {
      addMediaSourceInternal(index++, mediaSourceHolder);
    }
  }

  private void addMediaSourceInternal(int newIndex, MediaSourceHolder newMediaSourceHolder) {
    if (newIndex > 0) {
      MediaSourceHolder previousHolder = mediaSourceHolders.get(newIndex - 1);
      Timeline previousTimeline = previousHolder.mediaSource.getTimeline();
      newMediaSourceHolder.reset(
          newIndex, previousHolder.firstWindowIndexInChild + previousTimeline.getWindowCount());
    } else {
      newMediaSourceHolder.reset(newIndex, /* firstWindowIndexInChild= */ 0);
    }
    Timeline newTimeline = newMediaSourceHolder.mediaSource.getTimeline();
    correctOffsets(newIndex, /* childIndexUpdate= */ 1, newTimeline.getWindowCount());
    mediaSourceHolders.add(newIndex, newMediaSourceHolder);
    mediaSourceByUid.put(newMediaSourceHolder.uid, newMediaSourceHolder);
    prepareChildSource(newMediaSourceHolder, newMediaSourceHolder.mediaSource);
    if (isEnabled() && mediaSourceByMediaPeriod.isEmpty()) {
      enabledMediaSourceHolders.add(newMediaSourceHolder);
    } else {
      disableChildSource(newMediaSourceHolder);
    }
  }

  private void updateMediaSourceInternal(MediaSourceHolder mediaSourceHolder, Timeline timeline) {
    if (mediaSourceHolder.childIndex + 1 < mediaSourceHolders.size()) {
      MediaSourceHolder nextHolder = mediaSourceHolders.get(mediaSourceHolder.childIndex + 1);
      int windowOffsetUpdate =
          timeline.getWindowCount()
              - (nextHolder.firstWindowIndexInChild - mediaSourceHolder.firstWindowIndexInChild);
      if (windowOffsetUpdate != 0) {
        correctOffsets(
            mediaSourceHolder.childIndex + 1, /* childIndexUpdate= */ 0, windowOffsetUpdate);
      }
    }
    scheduleTimelineUpdate();
  }

  private void removeMediaSourceInternal(int index) {
    MediaSourceHolder holder = mediaSourceHolders.remove(index);
    mediaSourceByUid.remove(holder.uid);
    Timeline oldTimeline = holder.mediaSource.getTimeline();
    correctOffsets(index, /* childIndexUpdate= */ -1, -oldTimeline.getWindowCount());
    holder.isRemoved = true;
    maybeReleaseChildSource(holder);
  }

  private void moveMediaSourceInternal(int currentIndex, int newIndex) {
    int startIndex = min(currentIndex, newIndex);
    int endIndex = max(currentIndex, newIndex);
    int windowOffset = mediaSourceHolders.get(startIndex).firstWindowIndexInChild;
    mediaSourceHolders.add(newIndex, mediaSourceHolders.remove(currentIndex));
    for (int i = startIndex; i <= endIndex; i++) {
      MediaSourceHolder holder = mediaSourceHolders.get(i);
      holder.childIndex = i;
      holder.firstWindowIndexInChild = windowOffset;
      windowOffset += holder.mediaSource.getTimeline().getWindowCount();
    }
  }

  private void correctOffsets(int startIndex, int childIndexUpdate, int windowOffsetUpdate) {
    // TODO: Replace window index with uid in reporting to get rid of this inefficient method and
    // the childIndex and firstWindowIndexInChild variables.
    for (int i = startIndex; i < mediaSourceHolders.size(); i++) {
      MediaSourceHolder holder = mediaSourceHolders.get(i);
      holder.childIndex += childIndexUpdate;
      holder.firstWindowIndexInChild += windowOffsetUpdate;
    }
  }

  private void maybeReleaseChildSource(MediaSourceHolder mediaSourceHolder) {
    // Release if the source has been removed from the playlist and no periods are still active.
    if (mediaSourceHolder.isRemoved && mediaSourceHolder.activeMediaPeriodIds.isEmpty()) {
      enabledMediaSourceHolders.remove(mediaSourceHolder);
      releaseChildSource(mediaSourceHolder);
    }
  }

  private void enableMediaSource(MediaSourceHolder mediaSourceHolder) {
    enabledMediaSourceHolders.add(mediaSourceHolder);
    enableChildSource(mediaSourceHolder);
  }

  private void disableUnusedMediaSources() {
    Iterator<MediaSourceHolder> iterator = enabledMediaSourceHolders.iterator();
    while (iterator.hasNext()) {
      MediaSourceHolder holder = iterator.next();
      if (holder.activeMediaPeriodIds.isEmpty()) {
        disableChildSource(holder);
        iterator.remove();
      }
    }
  }

  /** Return uid of media source holder from period uid of concatenated source. */
  private static Object getMediaSourceHolderUid(Object periodUid) {
    return ConcatenatedTimeline.getChildTimelineUidFromConcatenatedUid(periodUid);
  }

  /** Return uid of child period from period uid of concatenated source. */
  private static Object getChildPeriodUid(Object periodUid) {
    return ConcatenatedTimeline.getChildPeriodUidFromConcatenatedUid(periodUid);
  }

  private static Object getPeriodUid(MediaSourceHolder holder, Object childPeriodUid) {
    return ConcatenatedTimeline.getConcatenatedUid(holder.uid, childPeriodUid);
  }

  /** Data class to hold playlist media sources together with meta data needed to process them. */
  /* package */ static final class MediaSourceHolder {

    public final MaskingMediaSource mediaSource;
    public final Object uid;
    public final List<MediaPeriodId> activeMediaPeriodIds;

    public int childIndex;
    public int firstWindowIndexInChild;
    public boolean isRemoved;

    public MediaSourceHolder(MediaSource mediaSource, boolean useLazyPreparation) {
      this.mediaSource = new MaskingMediaSource(mediaSource, useLazyPreparation);
      this.activeMediaPeriodIds = new ArrayList<>();
      this.uid = new Object();
    }

    public void reset(int childIndex, int firstWindowIndexInChild) {
      this.childIndex = childIndex;
      this.firstWindowIndexInChild = firstWindowIndexInChild;
      this.isRemoved = false;
      this.activeMediaPeriodIds.clear();
    }
  }

  /** Message used to post actions from app thread to playback thread. */
  private static final class MessageData<T> {

    public final int index;
    public final T customData;
    @Nullable public final HandlerAndRunnable onCompletionAction;

    public MessageData(int index, T customData, @Nullable HandlerAndRunnable onCompletionAction) {
      this.index = index;
      this.customData = customData;
      this.onCompletionAction = onCompletionAction;
    }
  }

  /** Timeline exposing concatenated timelines of playlist media sources. */
  private static final class ConcatenatedTimeline extends AbstractConcatenatedTimeline {

    private final int windowCount;
    private final int periodCount;
    private final int[] firstPeriodInChildIndices;
    private final int[] firstWindowInChildIndices;
    private final Timeline[] timelines;
    private final Object[] uids;
    private final HashMap<Object, Integer> childIndexByUid;

    public ConcatenatedTimeline(
        Collection<MediaSourceHolder> mediaSourceHolders,
        ShuffleOrder shuffleOrder,
        boolean isAtomic) {
      super(isAtomic, shuffleOrder);
      int childCount = mediaSourceHolders.size();
      firstPeriodInChildIndices = new int[childCount];
      firstWindowInChildIndices = new int[childCount];
      timelines = new Timeline[childCount];
      uids = new Object[childCount];
      childIndexByUid = new HashMap<>();
      int index = 0;
      int windowCount = 0;
      int periodCount = 0;
      for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) {
        timelines[index] = mediaSourceHolder.mediaSource.getTimeline();
        firstWindowInChildIndices[index] = windowCount;
        firstPeriodInChildIndices[index] = periodCount;
        windowCount += timelines[index].getWindowCount();
        periodCount += timelines[index].getPeriodCount();
        uids[index] = mediaSourceHolder.uid;
        childIndexByUid.put(uids[index], index++);
      }
      this.windowCount = windowCount;
      this.periodCount = periodCount;
    }

    @Override
    protected int getChildIndexByPeriodIndex(int periodIndex) {
      return Util.binarySearchFloor(firstPeriodInChildIndices, periodIndex + 1, false, false);
    }

    @Override
    protected int getChildIndexByWindowIndex(int windowIndex) {
      return Util.binarySearchFloor(firstWindowInChildIndices, windowIndex + 1, false, false);
    }

    @Override
    protected int getChildIndexByChildUid(Object childUid) {
      @Nullable Integer index = childIndexByUid.get(childUid);
      return index == null ? C.INDEX_UNSET : index;
    }

    @Override
    protected Timeline getTimelineByChildIndex(int childIndex) {
      return timelines[childIndex];
    }

    @Override
    protected int getFirstPeriodIndexByChildIndex(int childIndex) {
      return firstPeriodInChildIndices[childIndex];
    }

    @Override
    protected int getFirstWindowIndexByChildIndex(int childIndex) {
      return firstWindowInChildIndices[childIndex];
    }

    @Override
    protected Object getChildUidByChildIndex(int childIndex) {
      return uids[childIndex];
    }

    @Override
    public int getWindowCount() {
      return windowCount;
    }

    @Override
    public int getPeriodCount() {
      return periodCount;
    }
  }

  /** A media source which does nothing and does not support creating periods. */
  private static final class FakeMediaSource extends BaseMediaSource {

    @Override
    protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
      // Do nothing.
    }

    @Override
    public MediaItem getMediaItem() {
      return EMPTY_MEDIA_ITEM;
    }

    @Override
    protected void releaseSourceInternal() {
      // Do nothing.
    }

    @Override
    public void maybeThrowSourceInfoRefreshError() {
      // Do nothing.
    }

    @Override
    public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) {
      throw new UnsupportedOperationException();
    }

    @Override
    public void releasePeriod(MediaPeriod mediaPeriod) {
      // Do nothing.
    }
  }

  private static final class HandlerAndRunnable {

    private final Handler handler;
    private final Runnable runnable;

    public HandlerAndRunnable(Handler handler, Runnable runnable) {
      this.handler = handler;
      this.runnable = runnable;
    }

    public void dispatch() {
      handler.post(runnable);
    }
  }
}
