/*
 * Copyright (C) 2018 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.offline;

import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Util.castNonNull;

import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.util.SparseIntArray;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.StreamKey;
import androidx.media3.common.Timeline;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.TrackSelectionOverride;
import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.TransferListener;
import androidx.media3.exoplayer.ExoPlaybackException;
import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RendererCapabilities;
import androidx.media3.exoplayer.RenderersFactory;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.audio.AudioRendererEventListener;
import androidx.media3.exoplayer.drm.DrmSessionManager;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.source.MediaPeriod;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
import androidx.media3.exoplayer.source.MediaSource.MediaSourceCaller;
import androidx.media3.exoplayer.source.TrackGroupArray;
import androidx.media3.exoplayer.source.chunk.MediaChunk;
import androidx.media3.exoplayer.source.chunk.MediaChunkIterator;
import androidx.media3.exoplayer.trackselection.BaseTrackSelection;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
import androidx.media3.exoplayer.trackselection.MappingTrackSelector.MappedTrackInfo;
import androidx.media3.exoplayer.trackselection.TrackSelectionUtil;
import androidx.media3.exoplayer.trackselection.TrackSelectorResult;
import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.exoplayer.upstream.BandwidthMeter;
import androidx.media3.exoplayer.upstream.DefaultAllocator;
import androidx.media3.exoplayer.video.VideoRendererEventListener;
import androidx.media3.extractor.ExtractorsFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.checkerframework.checker.nullness.compatqual.NullableType;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;

/**
 * A helper for initializing and removing downloads.
 *
 * <p>The helper extracts track information from the media, selects tracks for downloading, and
 * creates {@link DownloadRequest download requests} based on the selected tracks.
 *
 * <p>A typical usage of DownloadHelper follows these steps:
 *
 * <ol>
 *   <li>Build the helper using one of the {@code forMediaItem} methods.
 *   <li>Prepare the helper using {@link #prepare(Callback)} and wait for the callback.
 *   <li>Optional: Inspect the selected tracks using {@link #getMappedTrackInfo(int)} and {@link
 *       #getTrackSelections(int, int)}, and make adjustments using {@link
 *       #clearTrackSelections(int)}, {@link #replaceTrackSelections(int, TrackSelectionParameters)}
 *       and {@link #addTrackSelection(int, TrackSelectionParameters)}.
 *   <li>Create a download request for the selected track using {@link #getDownloadRequest(byte[])}.
 *   <li>Release the helper using {@link #release()}.
 * </ol>
 */
@UnstableApi
public final class DownloadHelper {

  /**
   * Default track selection parameters for downloading, but without any {@link Context}
   * constraints.
   *
   * <p>If possible, use {@link #getDefaultTrackSelectorParameters(Context)} instead.
   *
   * @see DefaultTrackSelector.Parameters#DEFAULT_WITHOUT_CONTEXT
   */
  public static final DefaultTrackSelector.Parameters
      DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT =
          DefaultTrackSelector.Parameters.DEFAULT_WITHOUT_CONTEXT
              .buildUpon()
              .setForceHighestSupportedBitrate(true)
              .setConstrainAudioChannelCountToDeviceCapabilities(false)
              .build();

  /** Returns the default parameters used for track selection for downloading. */
  public static DefaultTrackSelector.Parameters getDefaultTrackSelectorParameters(Context context) {
    return DefaultTrackSelector.Parameters.getDefaults(context)
        .buildUpon()
        .setForceHighestSupportedBitrate(true)
        .setConstrainAudioChannelCountToDeviceCapabilities(false)
        .build();
  }

  /** A callback to be notified when the {@link DownloadHelper} is prepared. */
  public interface Callback {

    /**
     * Called when preparation completes.
     *
     * @param helper The reporting {@link DownloadHelper}.
     */
    void onPrepared(DownloadHelper helper);

    /**
     * Called when preparation fails.
     *
     * @param helper The reporting {@link DownloadHelper}.
     * @param e The error.
     */
    void onPrepareError(DownloadHelper helper, IOException e);
  }

  /** Thrown at an attempt to download live content. */
  public static class LiveContentUnsupportedException extends IOException {}

  /**
   * Extracts renderer capabilities for the renderers created by the provided renderers factory.
   *
   * @param renderersFactory A {@link RenderersFactory}.
   * @return The {@link RendererCapabilities} for each renderer created by the {@code
   *     renderersFactory}.
   */
  public static RendererCapabilities[] getRendererCapabilities(RenderersFactory renderersFactory) {
    Renderer[] renderers =
        renderersFactory.createRenderers(
            Util.createHandlerForCurrentOrMainLooper(),
            new VideoRendererEventListener() {},
            new AudioRendererEventListener() {},
            (cues) -> {},
            (metadata) -> {});
    RendererCapabilities[] capabilities = new RendererCapabilities[renderers.length];
    for (int i = 0; i < renderers.length; i++) {
      capabilities[i] = renderers[i].getCapabilities();
    }
    return capabilities;
  }

  /**
   * @deprecated Use {@link #forMediaItem(Context, MediaItem)}
   */
  @Deprecated
  public static DownloadHelper forProgressive(Context context, Uri uri) {
    return forMediaItem(context, new MediaItem.Builder().setUri(uri).build());
  }

  /**
   * @deprecated Use {@link #forMediaItem(Context, MediaItem)}
   */
  @Deprecated
  public static DownloadHelper forProgressive(Context context, Uri uri, @Nullable String cacheKey) {
    return forMediaItem(
        context, new MediaItem.Builder().setUri(uri).setCustomCacheKey(cacheKey).build());
  }

  /**
   * @deprecated Use {@link #forMediaItem(MediaItem, TrackSelectionParameters, RenderersFactory,
   *     DataSource.Factory)} instead.
   */
  @SuppressWarnings("deprecation")
  @Deprecated
  public static DownloadHelper forDash(
      Context context,
      Uri uri,
      DataSource.Factory dataSourceFactory,
      RenderersFactory renderersFactory) {
    return forDash(
        uri,
        dataSourceFactory,
        renderersFactory,
        /* drmSessionManager= */ null,
        getDefaultTrackSelectorParameters(context));
  }

  /**
   * @deprecated Use {@link #forMediaItem(MediaItem, TrackSelectionParameters, RenderersFactory,
   *     DataSource.Factory, DrmSessionManager)} instead.
   */
  @Deprecated
  public static DownloadHelper forDash(
      Uri uri,
      DataSource.Factory dataSourceFactory,
      RenderersFactory renderersFactory,
      @Nullable DrmSessionManager drmSessionManager,
      TrackSelectionParameters trackSelectionParameters) {
    return forMediaItem(
        new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_MPD).build(),
        trackSelectionParameters,
        renderersFactory,
        dataSourceFactory,
        drmSessionManager);
  }

  /**
   * @deprecated Use {@link #forMediaItem(MediaItem, TrackSelectionParameters, RenderersFactory,
   *     DataSource.Factory)} instead.
   */
  @SuppressWarnings("deprecation")
  @Deprecated
  public static DownloadHelper forHls(
      Context context,
      Uri uri,
      DataSource.Factory dataSourceFactory,
      RenderersFactory renderersFactory) {
    return forHls(
        uri,
        dataSourceFactory,
        renderersFactory,
        /* drmSessionManager= */ null,
        getDefaultTrackSelectorParameters(context));
  }

  /**
   * @deprecated Use {@link #forMediaItem(MediaItem, TrackSelectionParameters, RenderersFactory,
   *     DataSource.Factory, DrmSessionManager)} instead.
   */
  @Deprecated
  public static DownloadHelper forHls(
      Uri uri,
      DataSource.Factory dataSourceFactory,
      RenderersFactory renderersFactory,
      @Nullable DrmSessionManager drmSessionManager,
      TrackSelectionParameters trackSelectionParameters) {
    return forMediaItem(
        new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_M3U8).build(),
        trackSelectionParameters,
        renderersFactory,
        dataSourceFactory,
        drmSessionManager);
  }

  /**
   * @deprecated Use {@link #forMediaItem(MediaItem, TrackSelectionParameters, RenderersFactory,
   *     DataSource.Factory)} instead.
   */
  @SuppressWarnings("deprecation")
  @Deprecated
  public static DownloadHelper forSmoothStreaming(
      Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) {
    return forSmoothStreaming(
        uri,
        dataSourceFactory,
        renderersFactory,
        /* drmSessionManager= */ null,
        DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT);
  }

  /**
   * @deprecated Use {@link #forMediaItem(MediaItem, TrackSelectionParameters, RenderersFactory,
   *     DataSource.Factory)} instead.
   */
  @SuppressWarnings("deprecation")
  @Deprecated
  public static DownloadHelper forSmoothStreaming(
      Context context,
      Uri uri,
      DataSource.Factory dataSourceFactory,
      RenderersFactory renderersFactory) {
    return forSmoothStreaming(
        uri,
        dataSourceFactory,
        renderersFactory,
        /* drmSessionManager= */ null,
        getDefaultTrackSelectorParameters(context));
  }

  /**
   * @deprecated Use {@link #forMediaItem(MediaItem, TrackSelectionParameters, RenderersFactory,
   *     DataSource.Factory, DrmSessionManager)} instead.
   */
  @Deprecated
  public static DownloadHelper forSmoothStreaming(
      Uri uri,
      DataSource.Factory dataSourceFactory,
      RenderersFactory renderersFactory,
      @Nullable DrmSessionManager drmSessionManager,
      TrackSelectionParameters trackSelectionParameters) {
    return forMediaItem(
        new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_SS).build(),
        trackSelectionParameters,
        renderersFactory,
        dataSourceFactory,
        drmSessionManager);
  }

  /**
   * Creates a {@link DownloadHelper} for the given progressive media item.
   *
   * @param context The context.
   * @param mediaItem A {@link MediaItem}.
   * @return A {@link DownloadHelper} for progressive streams.
   * @throws IllegalStateException If the media item is of type DASH, HLS or SmoothStreaming.
   */
  public static DownloadHelper forMediaItem(Context context, MediaItem mediaItem) {
    Assertions.checkArgument(isProgressive(checkNotNull(mediaItem.localConfiguration)));
    return forMediaItem(
        mediaItem,
        getDefaultTrackSelectorParameters(context),
        /* renderersFactory= */ null,
        /* dataSourceFactory= */ null,
        /* drmSessionManager= */ null);
  }

  /**
   * Creates a {@link DownloadHelper} for the given media item.
   *
   * @param context The context.
   * @param mediaItem A {@link MediaItem}.
   * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are
   *     selected.
   * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive
   *     streams. This argument is required for adaptive streams and ignored for progressive
   *     streams.
   * @return A {@link DownloadHelper}.
   * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or
   *     SmoothStreaming media items.
   * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams.
   */
  public static DownloadHelper forMediaItem(
      Context context,
      MediaItem mediaItem,
      @Nullable RenderersFactory renderersFactory,
      @Nullable DataSource.Factory dataSourceFactory) {
    return forMediaItem(
        mediaItem,
        getDefaultTrackSelectorParameters(context),
        renderersFactory,
        dataSourceFactory,
        /* drmSessionManager= */ null);
  }

  /**
   * Creates a {@link DownloadHelper} for the given media item.
   *
   * @param mediaItem A {@link MediaItem}.
   * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are
   *     selected.
   * @param trackSelectionParameters {@link TrackSelectionParameters} for selecting tracks for
   *     downloading.
   * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive
   *     streams. This argument is required for adaptive streams and ignored for progressive
   *     streams.
   * @return A {@link DownloadHelper}.
   * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or
   *     SmoothStreaming media items.
   * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams.
   */
  public static DownloadHelper forMediaItem(
      MediaItem mediaItem,
      TrackSelectionParameters trackSelectionParameters,
      @Nullable RenderersFactory renderersFactory,
      @Nullable DataSource.Factory dataSourceFactory) {
    return forMediaItem(
        mediaItem,
        trackSelectionParameters,
        renderersFactory,
        dataSourceFactory,
        /* drmSessionManager= */ null);
  }

  /**
   * Creates a {@link DownloadHelper} for the given media item.
   *
   * @param mediaItem A {@link MediaItem}.
   * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are
   *     selected.
   * @param trackSelectionParameters {@link TrackSelectionParameters} for selecting tracks for
   *     downloading.
   * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive
   *     streams. This argument is required for adaptive streams and ignored for progressive
   *     streams.
   * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which
   *     tracks can be selected.
   * @return A {@link DownloadHelper}.
   * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or
   *     SmoothStreaming media items.
   * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams.
   */
  public static DownloadHelper forMediaItem(
      MediaItem mediaItem,
      TrackSelectionParameters trackSelectionParameters,
      @Nullable RenderersFactory renderersFactory,
      @Nullable DataSource.Factory dataSourceFactory,
      @Nullable DrmSessionManager drmSessionManager) {
    boolean isProgressive = isProgressive(checkNotNull(mediaItem.localConfiguration));
    Assertions.checkArgument(isProgressive || dataSourceFactory != null);
    return new DownloadHelper(
        mediaItem,
        isProgressive
            ? null
            : createMediaSourceInternal(
                mediaItem, castNonNull(dataSourceFactory), drmSessionManager),
        trackSelectionParameters,
        renderersFactory != null
            ? getRendererCapabilities(renderersFactory)
            : new RendererCapabilities[0]);
  }

  /**
   * Equivalent to {@link #createMediaSource(DownloadRequest, DataSource.Factory, DrmSessionManager)
   * createMediaSource(downloadRequest, dataSourceFactory, null)}.
   */
  public static MediaSource createMediaSource(
      DownloadRequest downloadRequest, DataSource.Factory dataSourceFactory) {
    return createMediaSource(downloadRequest, dataSourceFactory, /* drmSessionManager= */ null);
  }

  /**
   * Utility method to create a {@link MediaSource} that only exposes the tracks defined in {@code
   * downloadRequest}.
   *
   * @param downloadRequest A {@link DownloadRequest}.
   * @param dataSourceFactory A factory for {@link DataSource}s to read the media.
   * @param drmSessionManager An optional {@link DrmSessionManager} to be passed to the {@link
   *     MediaSource}.
   * @return A {@link MediaSource} that only exposes the tracks defined in {@code downloadRequest}.
   */
  public static MediaSource createMediaSource(
      DownloadRequest downloadRequest,
      DataSource.Factory dataSourceFactory,
      @Nullable DrmSessionManager drmSessionManager) {
    return createMediaSourceInternal(
        downloadRequest.toMediaItem(), dataSourceFactory, drmSessionManager);
  }

  private final MediaItem.LocalConfiguration localConfiguration;
  @Nullable private final MediaSource mediaSource;
  private final DefaultTrackSelector trackSelector;
  private final RendererCapabilities[] rendererCapabilities;
  private final SparseIntArray scratchSet;
  private final Handler callbackHandler;
  private final Timeline.Window window;

  private boolean isPreparedWithMedia;
  private @MonotonicNonNull Callback callback;
  private @MonotonicNonNull MediaPreparer mediaPreparer;
  private TrackGroupArray @MonotonicNonNull [] trackGroupArrays;
  private MappedTrackInfo @MonotonicNonNull [] mappedTrackInfos;
  private List<ExoTrackSelection> @MonotonicNonNull [][] trackSelectionsByPeriodAndRenderer;
  private List<ExoTrackSelection> @MonotonicNonNull [][]
      immutableTrackSelectionsByPeriodAndRenderer;

  /**
   * Creates download helper.
   *
   * @param mediaItem The media item.
   * @param mediaSource A {@link MediaSource} for which tracks are selected, or null if no track
   *     selection needs to be made.
   * @param trackSelectionParameters {@link TrackSelectionParameters} for selecting tracks for
   *     downloading.
   * @param rendererCapabilities The {@link RendererCapabilities} of the renderers for which tracks
   *     are selected.
   */
  public DownloadHelper(
      MediaItem mediaItem,
      @Nullable MediaSource mediaSource,
      TrackSelectionParameters trackSelectionParameters,
      RendererCapabilities[] rendererCapabilities) {
    this.localConfiguration = checkNotNull(mediaItem.localConfiguration);
    this.mediaSource = mediaSource;
    this.trackSelector =
        new DefaultTrackSelector(trackSelectionParameters, new DownloadTrackSelection.Factory());
    this.rendererCapabilities = rendererCapabilities;
    this.scratchSet = new SparseIntArray();
    trackSelector.init(/* listener= */ () -> {}, new FakeBandwidthMeter());
    callbackHandler = Util.createHandlerForCurrentOrMainLooper();
    window = new Timeline.Window();
  }

  /**
   * Initializes the helper for starting a download.
   *
   * @param callback A callback to be notified when preparation completes or fails.
   * @throws IllegalStateException If the download helper has already been prepared.
   */
  public void prepare(Callback callback) {
    Assertions.checkState(this.callback == null);
    this.callback = callback;
    if (mediaSource != null) {
      mediaPreparer = new MediaPreparer(mediaSource, /* downloadHelper= */ this);
    } else {
      callbackHandler.post(() -> callback.onPrepared(this));
    }
  }

  /** Releases the helper and all resources it is holding. */
  public void release() {
    if (mediaPreparer != null) {
      mediaPreparer.release();
    }
    trackSelector.release();
  }

  /**
   * Returns the manifest, or null if no manifest is loaded. Must not be called until after
   * preparation completes.
   */
  @Nullable
  public Object getManifest() {
    if (mediaSource == null) {
      return null;
    }
    assertPreparedWithMedia();
    return mediaPreparer.timeline.getWindowCount() > 0
        ? mediaPreparer.timeline.getWindow(/* windowIndex= */ 0, window).manifest
        : null;
  }

  /**
   * Returns the number of periods for which media is available. Must not be called until after
   * preparation completes.
   */
  public int getPeriodCount() {
    if (mediaSource == null) {
      return 0;
    }
    assertPreparedWithMedia();
    return trackGroupArrays.length;
  }

  /**
   * Returns {@link Tracks} for the given period. Must not be called until after preparation
   * completes.
   *
   * @param periodIndex The period index.
   * @return The {@link Tracks} for the period. May be {@link Tracks#EMPTY} for single stream
   *     content.
   */
  public Tracks getTracks(int periodIndex) {
    assertPreparedWithMedia();
    return TrackSelectionUtil.buildTracks(
        mappedTrackInfos[periodIndex], immutableTrackSelectionsByPeriodAndRenderer[periodIndex]);
  }

  /**
   * Returns the track groups for the given period. Must not be called until after preparation
   * completes.
   *
   * <p>Use {@link #getMappedTrackInfo(int)} to get the track groups mapped to renderers.
   *
   * @param periodIndex The period index.
   * @return The track groups for the period. May be {@link TrackGroupArray#EMPTY} for single stream
   *     content.
   */
  public TrackGroupArray getTrackGroups(int periodIndex) {
    assertPreparedWithMedia();
    return trackGroupArrays[periodIndex];
  }

  /**
   * Returns the mapped track info for the given period. Must not be called until after preparation
   * completes.
   *
   * @param periodIndex The period index.
   * @return The {@link MappedTrackInfo} for the period.
   */
  public MappedTrackInfo getMappedTrackInfo(int periodIndex) {
    assertPreparedWithMedia();
    return mappedTrackInfos[periodIndex];
  }

  /**
   * Returns all {@link ExoTrackSelection track selections} for a period and renderer. Must not be
   * called until after preparation completes.
   *
   * @param periodIndex The period index.
   * @param rendererIndex The renderer index.
   * @return A list of selected {@link ExoTrackSelection track selections}.
   */
  public List<ExoTrackSelection> getTrackSelections(int periodIndex, int rendererIndex) {
    assertPreparedWithMedia();
    return immutableTrackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex];
  }

  /**
   * Clears the selection of tracks for a period. Must not be called until after preparation
   * completes.
   *
   * @param periodIndex The period index for which track selections are cleared.
   */
  public void clearTrackSelections(int periodIndex) {
    assertPreparedWithMedia();
    for (int i = 0; i < rendererCapabilities.length; i++) {
      trackSelectionsByPeriodAndRenderer[periodIndex][i].clear();
    }
  }

  /**
   * Replaces a selection of tracks to be downloaded. Must not be called until after preparation
   * completes.
   *
   * @param periodIndex The period index for which the track selection is replaced.
   * @param trackSelectionParameters The {@link TrackSelectionParameters} to obtain the new
   *     selection of tracks.
   */
  public void replaceTrackSelections(
      int periodIndex, TrackSelectionParameters trackSelectionParameters) {
    try {
      assertPreparedWithMedia();
      clearTrackSelections(periodIndex);
      addTrackSelectionInternal(periodIndex, trackSelectionParameters);
    } catch (ExoPlaybackException e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * Adds a selection of tracks to be downloaded. Must not be called until after preparation
   * completes.
   *
   * @param periodIndex The period index this track selection is added for.
   * @param trackSelectionParameters The {@link TrackSelectionParameters} to obtain the new
   *     selection of tracks.
   */
  public void addTrackSelection(
      int periodIndex, TrackSelectionParameters trackSelectionParameters) {
    try {
      assertPreparedWithMedia();
      addTrackSelectionInternal(periodIndex, trackSelectionParameters);
    } catch (ExoPlaybackException e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * Convenience method to add selections of tracks for all specified audio languages. If an audio
   * track in one of the specified languages is not available, the default fallback audio track is
   * used instead. Must not be called until after preparation completes.
   *
   * @param languages A list of audio languages for which tracks should be added to the download
   *     selection, as IETF BCP 47 conformant tags.
   */
  public void addAudioLanguagesToSelection(String... languages) {
    try {
      assertPreparedWithMedia();

      TrackSelectionParameters.Builder parametersBuilder =
          DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT.buildUpon();
      // Prefer highest supported bitrate for downloads.
      parametersBuilder.setForceHighestSupportedBitrate(true);
      // Disable all non-audio track types supported by the renderers.
      for (RendererCapabilities capabilities : rendererCapabilities) {
        @C.TrackType int trackType = capabilities.getTrackType();
        parametersBuilder.setTrackTypeDisabled(
            trackType, /* disabled= */ trackType != C.TRACK_TYPE_AUDIO);
      }

      // Add a track selection to each period for each of the languages.
      int periodCount = getPeriodCount();
      for (String language : languages) {
        TrackSelectionParameters parameters =
            parametersBuilder.setPreferredAudioLanguage(language).build();
        for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) {
          addTrackSelectionInternal(periodIndex, parameters);
        }
      }
    } catch (ExoPlaybackException e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * Convenience method to add selections of tracks for all specified text languages. Must not be
   * called until after preparation completes.
   *
   * @param selectUndeterminedTextLanguage Whether a text track with undetermined language should be
   *     selected for downloading if no track with one of the specified {@code languages} is
   *     available.
   * @param languages A list of text languages for which tracks should be added to the download
   *     selection, as IETF BCP 47 conformant tags.
   */
  public void addTextLanguagesToSelection(
      boolean selectUndeterminedTextLanguage, String... languages) {
    try {
      assertPreparedWithMedia();

      TrackSelectionParameters.Builder parametersBuilder =
          DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT.buildUpon();
      parametersBuilder.setSelectUndeterminedTextLanguage(selectUndeterminedTextLanguage);
      // Prefer highest supported bitrate for downloads.
      parametersBuilder.setForceHighestSupportedBitrate(true);
      // Disable all non-text track types supported by the renderers.
      for (RendererCapabilities capabilities : rendererCapabilities) {
        @C.TrackType int trackType = capabilities.getTrackType();
        parametersBuilder.setTrackTypeDisabled(
            trackType, /* disabled= */ trackType != C.TRACK_TYPE_TEXT);
      }

      // Add a track selection to each period for each of the languages.
      int periodCount = getPeriodCount();
      for (String language : languages) {
        TrackSelectionParameters parameters =
            parametersBuilder.setPreferredTextLanguage(language).build();
        for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) {
          addTrackSelectionInternal(periodIndex, parameters);
        }
      }
    } catch (ExoPlaybackException e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * Convenience method to add a selection of tracks to be downloaded for a single renderer. Must
   * not be called until after preparation completes.
   *
   * @param periodIndex The period index the track selection is added for.
   * @param rendererIndex The renderer index the track selection is added for.
   * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new
   *     selection of tracks.
   * @param overrides A list of {@link SelectionOverride SelectionOverrides} to apply to the {@code
   *     trackSelectorParameters}. If empty, {@code trackSelectorParameters} are used as they are.
   */
  public void addTrackSelectionForSingleRenderer(
      int periodIndex,
      int rendererIndex,
      DefaultTrackSelector.Parameters trackSelectorParameters,
      List<SelectionOverride> overrides) {
    try {
      assertPreparedWithMedia();
      DefaultTrackSelector.Parameters.Builder builder = trackSelectorParameters.buildUpon();
      for (int i = 0; i < mappedTrackInfos[periodIndex].getRendererCount(); i++) {
        builder.setRendererDisabled(/* rendererIndex= */ i, /* disabled= */ i != rendererIndex);
      }
      if (overrides.isEmpty()) {
        addTrackSelectionInternal(periodIndex, builder.build());
      } else {
        TrackGroupArray trackGroupArray =
            mappedTrackInfos[periodIndex].getTrackGroups(rendererIndex);
        for (int i = 0; i < overrides.size(); i++) {
          builder.setSelectionOverride(rendererIndex, trackGroupArray, overrides.get(i));
          addTrackSelectionInternal(periodIndex, builder.build());
        }
      }
    } catch (ExoPlaybackException e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * Builds a {@link DownloadRequest} for downloading the selected tracks. Must not be called until
   * after preparation completes. The uri of the {@link DownloadRequest} will be used as content id.
   *
   * @param data Application provided data to store in {@link DownloadRequest#data}.
   * @return The built {@link DownloadRequest}.
   */
  public DownloadRequest getDownloadRequest(@Nullable byte[] data) {
    return getDownloadRequest(localConfiguration.uri.toString(), data);
  }

  /**
   * Builds a {@link DownloadRequest} for downloading the selected tracks. Must not be called until
   * after preparation completes.
   *
   * @param id The unique content id.
   * @param data Application provided data to store in {@link DownloadRequest#data}.
   * @return The built {@link DownloadRequest}.
   */
  public DownloadRequest getDownloadRequest(String id, @Nullable byte[] data) {
    DownloadRequest.Builder requestBuilder =
        new DownloadRequest.Builder(id, localConfiguration.uri)
            .setMimeType(localConfiguration.mimeType)
            .setKeySetId(
                localConfiguration.drmConfiguration != null
                    ? localConfiguration.drmConfiguration.getKeySetId()
                    : null)
            .setCustomCacheKey(localConfiguration.customCacheKey)
            .setData(data);
    if (mediaSource == null) {
      return requestBuilder.build();
    }
    assertPreparedWithMedia();
    List<StreamKey> streamKeys = new ArrayList<>();
    List<ExoTrackSelection> allSelections = new ArrayList<>();
    int periodCount = trackSelectionsByPeriodAndRenderer.length;
    for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) {
      allSelections.clear();
      int rendererCount = trackSelectionsByPeriodAndRenderer[periodIndex].length;
      for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) {
        allSelections.addAll(trackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]);
      }
      streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections));
    }
    return requestBuilder.setStreamKeys(streamKeys).build();
  }

  @RequiresNonNull({
    "trackGroupArrays",
    "trackSelectionsByPeriodAndRenderer",
    "mediaPreparer",
    "mediaPreparer.timeline"
  })
  private void addTrackSelectionInternal(
      int periodIndex, TrackSelectionParameters trackSelectionParameters)
      throws ExoPlaybackException {
    trackSelector.setParameters(trackSelectionParameters);
    runTrackSelection(periodIndex);
    // TrackSelectionParameters can contain multiple overrides for each track type. The track
    // selector will only use one of them (because it's designed for playback), but for downloads we
    // want to use all of them. Run selection again with each override being the only one of its
    // type, to ensure that all of the desired tracks are included.
    for (TrackSelectionOverride override : trackSelectionParameters.overrides.values()) {
      trackSelector.setParameters(
          trackSelectionParameters.buildUpon().setOverrideForType(override).build());
      runTrackSelection(periodIndex);
    }
  }

  @SuppressWarnings("unchecked") // Initialization of array of Lists.
  private void onMediaPrepared() throws ExoPlaybackException {
    checkNotNull(mediaPreparer);
    checkNotNull(mediaPreparer.mediaPeriods);
    checkNotNull(mediaPreparer.timeline);
    int periodCount = mediaPreparer.mediaPeriods.length;
    int rendererCount = rendererCapabilities.length;
    trackSelectionsByPeriodAndRenderer =
        (List<ExoTrackSelection>[][]) new List<?>[periodCount][rendererCount];
    immutableTrackSelectionsByPeriodAndRenderer =
        (List<ExoTrackSelection>[][]) new List<?>[periodCount][rendererCount];
    for (int i = 0; i < periodCount; i++) {
      for (int j = 0; j < rendererCount; j++) {
        trackSelectionsByPeriodAndRenderer[i][j] = new ArrayList<>();
        immutableTrackSelectionsByPeriodAndRenderer[i][j] =
            Collections.unmodifiableList(trackSelectionsByPeriodAndRenderer[i][j]);
      }
    }
    trackGroupArrays = new TrackGroupArray[periodCount];
    mappedTrackInfos = new MappedTrackInfo[periodCount];
    for (int i = 0; i < periodCount; i++) {
      trackGroupArrays[i] = mediaPreparer.mediaPeriods[i].getTrackGroups();
      TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i);
      trackSelector.onSelectionActivated(trackSelectorResult.info);
      mappedTrackInfos[i] = checkNotNull(trackSelector.getCurrentMappedTrackInfo());
    }
    setPreparedWithMedia();
    checkNotNull(callbackHandler).post(() -> checkNotNull(callback).onPrepared(this));
  }

  private void onMediaPreparationFailed(IOException error) {
    checkNotNull(callbackHandler).post(() -> checkNotNull(callback).onPrepareError(this, error));
  }

  @RequiresNonNull({
    "trackGroupArrays",
    "mappedTrackInfos",
    "trackSelectionsByPeriodAndRenderer",
    "immutableTrackSelectionsByPeriodAndRenderer",
    "mediaPreparer",
    "mediaPreparer.timeline",
    "mediaPreparer.mediaPeriods"
  })
  private void setPreparedWithMedia() {
    isPreparedWithMedia = true;
  }

  @EnsuresNonNull({
    "trackGroupArrays",
    "mappedTrackInfos",
    "trackSelectionsByPeriodAndRenderer",
    "immutableTrackSelectionsByPeriodAndRenderer",
    "mediaPreparer",
    "mediaPreparer.timeline",
    "mediaPreparer.mediaPeriods"
  })
  @SuppressWarnings("nullness:contracts.postcondition")
  private void assertPreparedWithMedia() {
    Assertions.checkState(isPreparedWithMedia);
  }

  /**
   * Runs the track selection for a given period index with the current parameters. The selected
   * tracks will be added to {@link #trackSelectionsByPeriodAndRenderer}.
   */
  @RequiresNonNull({
    "trackGroupArrays",
    "trackSelectionsByPeriodAndRenderer",
    "mediaPreparer",
    "mediaPreparer.timeline"
  })
  private TrackSelectorResult runTrackSelection(int periodIndex) throws ExoPlaybackException {
    TrackSelectorResult trackSelectorResult =
        trackSelector.selectTracks(
            rendererCapabilities,
            trackGroupArrays[periodIndex],
            new MediaPeriodId(mediaPreparer.timeline.getUidOfPeriod(periodIndex)),
            mediaPreparer.timeline);
    for (int i = 0; i < trackSelectorResult.length; i++) {
      @Nullable ExoTrackSelection newSelection = trackSelectorResult.selections[i];
      if (newSelection == null) {
        continue;
      }
      List<ExoTrackSelection> existingSelectionList =
          trackSelectionsByPeriodAndRenderer[periodIndex][i];
      boolean mergedWithExistingSelection = false;
      for (int j = 0; j < existingSelectionList.size(); j++) {
        ExoTrackSelection existingSelection = existingSelectionList.get(j);
        if (existingSelection.getTrackGroup().equals(newSelection.getTrackGroup())) {
          // Merge with existing selection.
          scratchSet.clear();
          for (int k = 0; k < existingSelection.length(); k++) {
            scratchSet.put(existingSelection.getIndexInTrackGroup(k), 0);
          }
          for (int k = 0; k < newSelection.length(); k++) {
            scratchSet.put(newSelection.getIndexInTrackGroup(k), 0);
          }
          int[] mergedTracks = new int[scratchSet.size()];
          for (int k = 0; k < scratchSet.size(); k++) {
            mergedTracks[k] = scratchSet.keyAt(k);
          }
          existingSelectionList.set(
              j, new DownloadTrackSelection(existingSelection.getTrackGroup(), mergedTracks));
          mergedWithExistingSelection = true;
          break;
        }
      }
      if (!mergedWithExistingSelection) {
        existingSelectionList.add(newSelection);
      }
    }
    return trackSelectorResult;
  }

  private static MediaSource createMediaSourceInternal(
      MediaItem mediaItem,
      DataSource.Factory dataSourceFactory,
      @Nullable DrmSessionManager drmSessionManager) {
    DefaultMediaSourceFactory mediaSourceFactory =
        new DefaultMediaSourceFactory(dataSourceFactory, ExtractorsFactory.EMPTY);
    if (drmSessionManager != null) {
      mediaSourceFactory.setDrmSessionManagerProvider(unusedMediaItem -> drmSessionManager);
    }
    return mediaSourceFactory.createMediaSource(mediaItem);
  }

  private static boolean isProgressive(MediaItem.LocalConfiguration localConfiguration) {
    return Util.inferContentTypeForUriAndMimeType(
            localConfiguration.uri, localConfiguration.mimeType)
        == C.CONTENT_TYPE_OTHER;
  }

  private static final class MediaPreparer
      implements MediaSourceCaller, MediaPeriod.Callback, Handler.Callback {

    private static final int MESSAGE_PREPARE_SOURCE = 0;
    private static final int MESSAGE_CHECK_FOR_FAILURE = 1;
    private static final int MESSAGE_CONTINUE_LOADING = 2;
    private static final int MESSAGE_RELEASE = 3;

    private static final int DOWNLOAD_HELPER_CALLBACK_MESSAGE_PREPARED = 0;
    private static final int DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED = 1;

    private final MediaSource mediaSource;
    private final DownloadHelper downloadHelper;
    private final Allocator allocator;
    private final ArrayList<MediaPeriod> pendingMediaPeriods;
    private final Handler downloadHelperHandler;
    private final HandlerThread mediaSourceThread;
    private final Handler mediaSourceHandler;

    public @MonotonicNonNull Timeline timeline;
    public MediaPeriod @MonotonicNonNull [] mediaPeriods;

    private boolean released;

    public MediaPreparer(MediaSource mediaSource, DownloadHelper downloadHelper) {
      this.mediaSource = mediaSource;
      this.downloadHelper = downloadHelper;
      allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE);
      pendingMediaPeriods = new ArrayList<>();
      @SuppressWarnings("nullness:methodref.receiver.bound")
      Handler downloadThreadHandler =
          Util.createHandlerForCurrentOrMainLooper(this::handleDownloadHelperCallbackMessage);
      this.downloadHelperHandler = downloadThreadHandler;
      mediaSourceThread = new HandlerThread("ExoPlayer:DownloadHelper");
      mediaSourceThread.start();
      mediaSourceHandler = Util.createHandler(mediaSourceThread.getLooper(), /* callback= */ this);
      mediaSourceHandler.sendEmptyMessage(MESSAGE_PREPARE_SOURCE);
    }

    public void release() {
      if (released) {
        return;
      }
      released = true;
      mediaSourceHandler.sendEmptyMessage(MESSAGE_RELEASE);
    }

    // Handler.Callback

    @Override
    public boolean handleMessage(Message msg) {
      switch (msg.what) {
        case MESSAGE_PREPARE_SOURCE:
          mediaSource.prepareSource(
              /* caller= */ this, /* mediaTransferListener= */ null, PlayerId.UNSET);
          mediaSourceHandler.sendEmptyMessage(MESSAGE_CHECK_FOR_FAILURE);
          return true;
        case MESSAGE_CHECK_FOR_FAILURE:
          try {
            if (mediaPeriods == null) {
              mediaSource.maybeThrowSourceInfoRefreshError();
            } else {
              for (int i = 0; i < pendingMediaPeriods.size(); i++) {
                pendingMediaPeriods.get(i).maybeThrowPrepareError();
              }
            }
            mediaSourceHandler.sendEmptyMessageDelayed(
                MESSAGE_CHECK_FOR_FAILURE, /* delayMillis= */ 100);
          } catch (IOException e) {
            downloadHelperHandler
                .obtainMessage(DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED, /* obj= */ e)
                .sendToTarget();
          }
          return true;
        case MESSAGE_CONTINUE_LOADING:
          MediaPeriod mediaPeriod = (MediaPeriod) msg.obj;
          if (pendingMediaPeriods.contains(mediaPeriod)) {
            mediaPeriod.continueLoading(/* positionUs= */ 0);
          }
          return true;
        case MESSAGE_RELEASE:
          if (mediaPeriods != null) {
            for (MediaPeriod period : mediaPeriods) {
              mediaSource.releasePeriod(period);
            }
          }
          mediaSource.releaseSource(this);
          mediaSourceHandler.removeCallbacksAndMessages(null);
          mediaSourceThread.quit();
          return true;
        default:
          return false;
      }
    }

    // MediaSource.MediaSourceCaller implementation.

    @Override
    public void onSourceInfoRefreshed(MediaSource source, Timeline timeline) {
      if (this.timeline != null) {
        // Ignore dynamic updates.
        return;
      }
      if (timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()).isLive()) {
        downloadHelperHandler
            .obtainMessage(
                DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED,
                /* obj= */ new LiveContentUnsupportedException())
            .sendToTarget();
        return;
      }
      this.timeline = timeline;
      mediaPeriods = new MediaPeriod[timeline.getPeriodCount()];
      for (int i = 0; i < mediaPeriods.length; i++) {
        MediaPeriod mediaPeriod =
            mediaSource.createPeriod(
                new MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ i)),
                allocator,
                /* startPositionUs= */ 0);
        mediaPeriods[i] = mediaPeriod;
        pendingMediaPeriods.add(mediaPeriod);
      }
      for (MediaPeriod mediaPeriod : mediaPeriods) {
        mediaPeriod.prepare(/* callback= */ this, /* positionUs= */ 0);
      }
    }

    // MediaPeriod.Callback implementation.

    @Override
    public void onPrepared(MediaPeriod mediaPeriod) {
      pendingMediaPeriods.remove(mediaPeriod);
      if (pendingMediaPeriods.isEmpty()) {
        mediaSourceHandler.removeMessages(MESSAGE_CHECK_FOR_FAILURE);
        downloadHelperHandler.sendEmptyMessage(DOWNLOAD_HELPER_CALLBACK_MESSAGE_PREPARED);
      }
    }

    @Override
    public void onContinueLoadingRequested(MediaPeriod mediaPeriod) {
      if (pendingMediaPeriods.contains(mediaPeriod)) {
        mediaSourceHandler.obtainMessage(MESSAGE_CONTINUE_LOADING, mediaPeriod).sendToTarget();
      }
    }

    private boolean handleDownloadHelperCallbackMessage(Message msg) {
      if (released) {
        // Stale message.
        return false;
      }
      switch (msg.what) {
        case DOWNLOAD_HELPER_CALLBACK_MESSAGE_PREPARED:
          try {
            downloadHelper.onMediaPrepared();
          } catch (ExoPlaybackException e) {
            downloadHelperHandler
                .obtainMessage(
                    DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED, /* obj= */ new IOException(e))
                .sendToTarget();
          }
          return true;
        case DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED:
          release();
          downloadHelper.onMediaPreparationFailed((IOException) castNonNull(msg.obj));
          return true;
        default:
          return false;
      }
    }
  }

  private static final class DownloadTrackSelection extends BaseTrackSelection {

    private static final class Factory implements ExoTrackSelection.Factory {

      @Override
      public @NullableType ExoTrackSelection[] createTrackSelections(
          @NullableType Definition[] definitions,
          BandwidthMeter bandwidthMeter,
          MediaPeriodId mediaPeriodId,
          Timeline timeline) {
        @NullableType ExoTrackSelection[] selections = new ExoTrackSelection[definitions.length];
        for (int i = 0; i < definitions.length; i++) {
          selections[i] =
              definitions[i] == null
                  ? null
                  : new DownloadTrackSelection(definitions[i].group, definitions[i].tracks);
        }
        return selections;
      }
    }

    public DownloadTrackSelection(TrackGroup trackGroup, int[] tracks) {
      super(trackGroup, tracks);
    }

    @Override
    public int getSelectedIndex() {
      return 0;
    }

    @Override
    public @C.SelectionReason int getSelectionReason() {
      return C.SELECTION_REASON_UNKNOWN;
    }

    @Override
    @Nullable
    public Object getSelectionData() {
      return null;
    }

    @Override
    public void updateSelectedTrack(
        long playbackPositionUs,
        long bufferedDurationUs,
        long availableDurationUs,
        List<? extends MediaChunk> queue,
        MediaChunkIterator[] mediaChunkIterators) {
      // Do nothing.
    }
  }

  private static final class FakeBandwidthMeter implements BandwidthMeter {

    @Override
    public long getBitrateEstimate() {
      return 0;
    }

    @Override
    @Nullable
    public TransferListener getTransferListener() {
      return null;
    }

    @Override
    public void addEventListener(Handler eventHandler, EventListener eventListener) {
      // Do nothing.
    }

    @Override
    public void removeEventListener(EventListener eventListener) {
      // Do nothing.
    }
  }
}
