/*
 * Copyright (C) 2011 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.ex.variablespeed;

import com.google.common.base.Preconditions;

import android.content.Context;
import android.media.MediaPlayer;
import android.net.Uri;
import android.util.Log;

import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;

/**
 * This class behaves in a similar fashion to the MediaPlayer, but by using
 * native code it is able to use variable-speed playback.
 * <p>
 * This class is thread-safe. It's not yet perfect though, see the unit tests
 * for details - there is insufficient testing for the concurrent logic. You are
 * probably best advised to use thread confinment until the unit tests are more
 * complete with regards to threading.
 * <p>
 * The easiest way to ensure that calls to this class are not made concurrently
 * (besides only ever accessing it from one thread) is to wrap it in a
 * {@link SingleThreadedMediaPlayerProxy}, designed just for this purpose.
 */
@ThreadSafe
public class VariableSpeed implements MediaPlayerProxy {
    private static final String TAG = "VariableSpeed";

    private final Executor mExecutor;
    private final Object lock = new Object();
    @GuardedBy("lock") private MediaPlayerDataSource mDataSource;
    @GuardedBy("lock") private boolean mIsPrepared;
    @GuardedBy("lock") private boolean mHasDuration;
    @GuardedBy("lock") private boolean mHasStartedPlayback;
    @GuardedBy("lock") private CountDownLatch mEngineInitializedLatch;
    @GuardedBy("lock") private CountDownLatch mPlaybackFinishedLatch;
    @GuardedBy("lock") private boolean mHasBeenReleased = true;
    @GuardedBy("lock") private boolean mIsReadyToReUse = true;
    @GuardedBy("lock") private boolean mSkipCompletionReport;
    @GuardedBy("lock") private int mStartPosition;
    @GuardedBy("lock") private float mCurrentPlaybackRate = 1.0f;
    @GuardedBy("lock") private int mDuration;
    @GuardedBy("lock") private MediaPlayer.OnCompletionListener mCompletionListener;
    @GuardedBy("lock") private int mAudioStreamType;

    private VariableSpeed(Executor executor) throws UnsupportedOperationException {
        Preconditions.checkNotNull(executor);
        mExecutor = executor;
        try {
            VariableSpeedNative.loadLibrary();
        } catch (UnsatisfiedLinkError e) {
            throw new UnsupportedOperationException("could not load library", e);
        } catch (SecurityException e) {
            throw new UnsupportedOperationException("could not load library", e);
        }
        reset();
    }

    public static MediaPlayerProxy createVariableSpeed(Executor executor)
            throws UnsupportedOperationException {
        return new SingleThreadedMediaPlayerProxy(new VariableSpeed(executor));
    }

    @Override
    public void setOnCompletionListener(MediaPlayer.OnCompletionListener listener) {
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
            mCompletionListener = listener;
        }
    }

    @Override
    public void setOnErrorListener(MediaPlayer.OnErrorListener listener) {
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
            // TODO: I haven't actually added any error listener code.
        }
    }

    @Override
    public void release() {
        synchronized (lock) {
            if (mHasBeenReleased) {
                return;
            }
            mHasBeenReleased = true;
        }
        stopCurrentPlayback();
        boolean requiresShutdown = false;
        synchronized (lock) {
            requiresShutdown = hasEngineBeenInitialized();
        }
        if (requiresShutdown) {
            VariableSpeedNative.shutdownEngine();
        }
        synchronized (lock) {
            mIsReadyToReUse = true;
        }
    }

    private boolean hasEngineBeenInitialized() {
        return mEngineInitializedLatch.getCount() <= 0;
    }

    private boolean hasPlaybackFinished() {
        return mPlaybackFinishedLatch.getCount() <= 0;
    }

    /**
     * Stops the current playback, returns once it has stopped.
     */
    private void stopCurrentPlayback() {
        boolean isPlaying;
        CountDownLatch engineInitializedLatch;
        CountDownLatch playbackFinishedLatch;
        synchronized (lock) {
            isPlaying = mHasStartedPlayback && !hasPlaybackFinished();
            engineInitializedLatch = mEngineInitializedLatch;
            playbackFinishedLatch = mPlaybackFinishedLatch;
            if (isPlaying) {
                mSkipCompletionReport = true;
            }
        }
        if (isPlaying) {
            waitForLatch(engineInitializedLatch);
            VariableSpeedNative.stopPlayback();
            waitForLatch(playbackFinishedLatch);
        }
    }

    private void waitForLatch(CountDownLatch latch) {
        try {
            boolean success = latch.await(1, TimeUnit.SECONDS);
            if (!success) {
                reportException(new TimeoutException("waited too long"));
            }
        } catch (InterruptedException e) {
            // Preserve the interrupt status, though this is unexpected.
            Thread.currentThread().interrupt();
            reportException(e);
        }
    }

    @Override
    public void setDataSource(Context context, Uri intentUri) {
        checkNotNull(context, "context");
        checkNotNull(intentUri, "intentUri");
        innerSetDataSource(new MediaPlayerDataSource(context, intentUri));
    }

    @Override
    public void setDataSource(String path) {
        checkNotNull(path, "path");
        innerSetDataSource(new MediaPlayerDataSource(path));
    }

    private void innerSetDataSource(MediaPlayerDataSource source) {
        checkNotNull(source, "source");
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
            check(mDataSource == null, "cannot setDataSource more than once");
            mDataSource = source;
        }
    }

    @Override
    public void reset() {
        boolean requiresRelease;
        synchronized (lock) {
            requiresRelease = !mHasBeenReleased;
        }
        if (requiresRelease) {
            release();
        }
        synchronized (lock) {
            check(mHasBeenReleased && mIsReadyToReUse, "to re-use, must call reset after release");
            mDataSource = null;
            mIsPrepared = false;
            mHasDuration = false;
            mHasStartedPlayback = false;
            mEngineInitializedLatch = new CountDownLatch(1);
            mPlaybackFinishedLatch = new CountDownLatch(1);
            mHasBeenReleased = false;
            mIsReadyToReUse = false;
            mSkipCompletionReport = false;
            mStartPosition = 0;
            mDuration = 0;
        }
    }

    @Override
    public void prepare() throws IOException {
        MediaPlayerDataSource dataSource;
        int audioStreamType;
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
            check(mDataSource != null, "must setDataSource before you prepare");
            check(!mIsPrepared, "cannot prepare more than once");
            mIsPrepared = true;
            dataSource = mDataSource;
            audioStreamType = mAudioStreamType;
        }
        // NYI This should become another executable that we can wait on.
        MediaPlayer mediaPlayer = new MediaPlayer();
        mediaPlayer.setAudioStreamType(audioStreamType);
        dataSource.setAsSourceFor(mediaPlayer);
        mediaPlayer.prepare();
        synchronized (lock) {
            check(!mHasDuration, "can't have duration, this is impossible");
            mHasDuration = true;
            mDuration = mediaPlayer.getDuration();
        }
        mediaPlayer.release();
    }

    @Override
    public int getDuration() {
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
            check(mHasDuration, "you haven't called prepare, can't get the duration");
            return mDuration;
        }
    }

    @Override
    public void seekTo(int startPosition) {
        boolean currentlyPlaying;
        MediaPlayerDataSource dataSource;
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
            check(mHasDuration, "you can't seek until you have prepared");
            currentlyPlaying = mHasStartedPlayback && !hasPlaybackFinished();
            mStartPosition = Math.min(startPosition, mDuration);
            dataSource = mDataSource;
        }
        if (currentlyPlaying) {
            stopAndStartPlayingAgain(dataSource);
        }
    }

    private void stopAndStartPlayingAgain(MediaPlayerDataSource source) {
        stopCurrentPlayback();
        reset();
        innerSetDataSource(source);
        try {
            prepare();
        } catch (IOException e) {
            reportException(e);
            return;
        }
        start();
        return;
    }

    private void reportException(Exception e) {
        Log.e(TAG, "playback error:", e);
    }

    @Override
    public void start() {
        MediaPlayerDataSource restartWithThisDataSource = null;
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
            check(mIsPrepared, "must have prepared before you can start");
            if (!mHasStartedPlayback) {
                // Playback has not started. Start it.
                mHasStartedPlayback = true;
                EngineParameters engineParameters = new EngineParameters.Builder()
                        .initialRate(mCurrentPlaybackRate)
                        .startPositionMillis(mStartPosition)
                        .audioStreamType(mAudioStreamType)
                        .build();
                VariableSpeedNative.initializeEngine(engineParameters);
                VariableSpeedNative.startPlayback();
                mEngineInitializedLatch.countDown();
                mExecutor.execute(new PlaybackRunnable(mDataSource));
            } else {
                // Playback has already started. Restart it, without holding the
                // lock.
                restartWithThisDataSource = mDataSource;
            }
        }
        if (restartWithThisDataSource != null) {
            stopAndStartPlayingAgain(restartWithThisDataSource);
        }
    }

    /** A Runnable capable of driving the native audio playback methods. */
    private final class PlaybackRunnable implements Runnable {
        private final MediaPlayerDataSource mInnerSource;

        public PlaybackRunnable(MediaPlayerDataSource source) {
            mInnerSource = source;
        }

        @Override
        public void run() {
            try {
                mInnerSource.playNative();
            } catch (IOException e) {
                Log.e(TAG, "error playing audio", e);
            }
            MediaPlayer.OnCompletionListener completionListener;
            boolean skipThisCompletionReport;
            synchronized (lock) {
                completionListener = mCompletionListener;
                skipThisCompletionReport = mSkipCompletionReport;
                mPlaybackFinishedLatch.countDown();
            }
            if (!skipThisCompletionReport && completionListener != null) {
                completionListener.onCompletion(null);
            }
        }
    }

    @Override
    public boolean isPlaying() {
        synchronized (lock) {
            return mHasStartedPlayback && !hasPlaybackFinished();
        }
    }

    @Override
    public int getCurrentPosition() {
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
            if (!mHasStartedPlayback) {
                return 0;
            }
            if (!hasEngineBeenInitialized()) {
                return 0;
            }
            if (!hasPlaybackFinished()) {
                return VariableSpeedNative.getCurrentPosition();
            }
            return mDuration;
        }
    }

    @Override
    public void pause() {
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
        }
        stopCurrentPlayback();
    }

    public void setVariableSpeed(float rate) {
        // TODO: are there situations in which the engine has been destroyed, so
        // that this will segfault?
        synchronized (lock) {
            check(!mHasBeenReleased, "has been released, reset before use");
            // TODO: This too is wrong, once we've started preparing the variable speed set
            // will not be enough.
            if (mHasStartedPlayback) {
                VariableSpeedNative.setVariableSpeed(rate);
            }
            mCurrentPlaybackRate = rate;
        }
    }

    private void check(boolean condition, String exception) {
        if (!condition) {
            throw new IllegalStateException(exception);
        }
    }

    private void checkNotNull(Object argument, String argumentName) {
        if (argument == null) {
            throw new IllegalArgumentException(argumentName + " must not be null");
        }
    }

    @Override
    public void setAudioStreamType(int audioStreamType) {
        synchronized (lock) {
            mAudioStreamType = audioStreamType;
        }
    }
}
