package org.schabi.newpipe.player.mediasource;

import android.util.Log;

import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.source.BaseMediaSource;
import com.google.android.exoplayer2.source.MediaPeriod;
import com.google.android.exoplayer2.source.SilenceMediaSource;
import com.google.android.exoplayer2.source.SinglePeriodTimeline;
import com.google.android.exoplayer2.upstream.Allocator;
import com.google.android.exoplayer2.upstream.TransferListener;

import org.schabi.newpipe.player.mediaitem.ExceptionTag;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;

import java.io.IOException;
import java.util.List;
import java.util.concurrent.TimeUnit;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSource {
    /**
     * Play 2 seconds of silenced audio when a stream fails to resolve due to a known issue,
     * such as {@link org.schabi.newpipe.extractor.exceptions.ExtractionException}.
     *
     * This silence duration allows user to react and have time to jump to a previous stream,
     * while still provide a smooth playback experience. A duration lower than 1 second is
     * not recommended, it may cause ExoPlayer to buffer for a while.
     * */
    public static final long SILENCE_DURATION_US = TimeUnit.SECONDS.toMicros(2);
    public static final MediaPeriod SILENT_MEDIA = makeSilentMediaPeriod(SILENCE_DURATION_US);

    private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode());
    private final PlayQueueItem playQueueItem;
    private final Exception error;
    private final long retryTimestamp;
    private final MediaItem mediaItem;
    /**
     * Fail the play queue item associated with this source, with potential future retries.
     *
     * The error will be propagated if the cause for load exception is unspecified.
     * This means the error might be caused by reasons outside of extraction (e.g. no network).
     * Otherwise, a silenced stream will play instead.
     *
     * @param playQueueItem  play queue item
     * @param error          exception that was the reason to fail
     * @param retryTimestamp epoch timestamp when this MediaSource can be refreshed
     */
    public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem,
                             @NonNull final Exception error,
                             final long retryTimestamp) {
        this.playQueueItem = playQueueItem;
        this.error = error;
        this.retryTimestamp = retryTimestamp;
        this.mediaItem = ExceptionTag.of(playQueueItem, List.of(error)).withExtras(this)
                .asMediaItem();
    }

    public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem,
                                       @NonNull final FailedMediaSourceException error) {
        return new FailedMediaSource(playQueueItem, error, Long.MAX_VALUE);
    }

    public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem,
                                       @NonNull final Exception error,
                                       final long retryWaitMillis) {
        return new FailedMediaSource(playQueueItem, error,
                System.currentTimeMillis() + retryWaitMillis);
    }

    public PlayQueueItem getStream() {
        return playQueueItem;
    }

    public Exception getError() {
        return error;
    }

    private boolean canRetry() {
        return System.currentTimeMillis() >= retryTimestamp;
    }

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

    /**
     * Prepares the source with {@link Timeline} info on the silence playback when the error
     * is classed as {@link FailedMediaSourceException}, for example, when the error is
     * {@link org.schabi.newpipe.extractor.exceptions.ExtractionException ExtractionException}.
     * These types of error are swallowed by {@link FailedMediaSource}, and the underlying
     * exception is carried to the {@link MediaItem} metadata during playback.
     * <br><br>
     * If the exception is not known, e.g. {@link java.net.UnknownHostException} or some
     * other network issue, then no source info is refreshed and
     * {@link #maybeThrowSourceInfoRefreshError()} be will triggered.
     * <br><br>
     * Note that this method is called only once until {@link #releaseSourceInternal()} is called,
     * so if no action is done in here, playback will stall unless
     * {@link #maybeThrowSourceInfoRefreshError()} is called.
     *
     * @param mediaTransferListener No data transfer listener needed, ignored here.
     */
    @Override
    protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
        Log.e(TAG, "Loading failed source: ", error);
        if (error instanceof FailedMediaSourceException) {
            refreshSourceInfo(makeSilentMediaTimeline(SILENCE_DURATION_US, mediaItem));
        }
    }

    /**
     * If the error is not known, e.g. network issue, then the exception is not swallowed here in
     * {@link FailedMediaSource}. The exception is then propagated to the player, which
     * {@link org.schabi.newpipe.player.Player Player} can react to inside
     * {@link com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException)}.
     *
     * @throws IOException An error which will always result in
     * {@link com.google.android.exoplayer2.PlaybackException#ERROR_CODE_IO_UNSPECIFIED}.
     */
    @Override
    public void maybeThrowSourceInfoRefreshError() throws IOException {
        if (!(error instanceof FailedMediaSourceException)) {
            throw new IOException(error);
        }
    }

    /**
     * This method is only called if {@link #prepareSourceInternal(TransferListener)}
     * refreshes the source info with no exception. All parameters are ignored as this
     * returns a static and reused piece of silent audio.
     *
     * @param id                The identifier of the period.
     * @param allocator         An {@link Allocator} from which to obtain media buffer allocations.
     * @param startPositionUs   The expected start position, in microseconds.
     * @return The common {@link MediaPeriod} holding the silence.
     */
    @Override
    public MediaPeriod createPeriod(final MediaPeriodId id,
                                    final Allocator allocator,
                                    final long startPositionUs) {
        return SILENT_MEDIA;
    }

    @Override
    public void releasePeriod(final MediaPeriod mediaPeriod) {
        /* Do Nothing (we want to keep re-using the Silent MediaPeriod) */
    }

    @Override
    protected void releaseSourceInternal() {
        /* Do Nothing, no clean-up for processing/extra thread is needed by this MediaSource */
    }

    @Override
    public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity,
                                        final boolean isInterruptable) {
        return newIdentity != playQueueItem || canRetry();
    }

    @Override
    public boolean isStreamEqual(@NonNull final PlayQueueItem stream) {
        return playQueueItem == stream;
    }

    public static class FailedMediaSourceException extends Exception {
        FailedMediaSourceException(final String message) {
            super(message);
        }

        FailedMediaSourceException(final Throwable cause) {
            super(cause);
        }
    }

    public static final class MediaSourceResolutionException extends FailedMediaSourceException {
        public MediaSourceResolutionException(final String message) {
            super(message);
        }
    }

    public static final class StreamInfoLoadException extends FailedMediaSourceException {
        public StreamInfoLoadException(final Throwable cause) {
            super(cause);
        }
    }

    private static Timeline makeSilentMediaTimeline(final long durationUs,
                                                    @NonNull final MediaItem mediaItem) {
        return new SinglePeriodTimeline(
                durationUs,
                /* isSeekable= */ true,
                /* isDynamic= */ false,
                /* useLiveConfiguration= */ false,
                /* manifest= */ null,
                mediaItem);
    }

    private static MediaPeriod makeSilentMediaPeriod(final long durationUs) {
        return new SilenceMediaSource.Factory()
                .setDurationUs(durationUs)
                .createMediaSource()
                .createPeriod(null, null, 0);
    }
}
