/*
 * Copyright (c) 2013, Slick2D
 * All rights reserved.
 * 
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 * - Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * - Neither the name of the Slick2D nor the names of its contributors may be
 *   used to endorse or promote products derived from this software without
 *   specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */

package org.newdawn.slick.openal;

import java.io.IOException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.IntBuffer;

import org.lwjgl.BufferUtils;
import org.lwjgl.Sys;
import org.lwjgl.openal.AL10;
import org.lwjgl.openal.AL11;
import org.lwjgl.openal.OpenALException;
import org.newdawn.slick.util.Log;
import org.newdawn.slick.util.ResourceLoader;

/**
 * A generic tool to work on a supplied stream, pulling out PCM data and buffered it to OpenAL
 * as required.
 * 
 * @author Kevin Glass
 * @author Nathan Sweet  {@literal <[email protected]>}
 * @author Rockstar play and setPosition cleanup 
 */
public class OpenALStreamPlayer {
	/** The number of buffers to maintain */
	public static final int BUFFER_COUNT = 20;  // 3
	/** The size of the sections to stream from the stream */
	private static final int sectionSize = 4096;  // 4096 * 20
	
	/** The buffer read from the data stream */
	private byte[] buffer = new byte[sectionSize];
	/** Holds the OpenAL buffer names */
	private IntBuffer bufferNames;
	/** The byte buffer passed to OpenAL containing the section */
	private ByteBuffer bufferData = BufferUtils.createByteBuffer(sectionSize);
	/** The buffer holding the names of the OpenAL buffer thats been fully played back */
	private IntBuffer unqueued = BufferUtils.createIntBuffer(1);
	/** The source we're playing back on */
    private int source;
	/** The number of buffers remaining */
    private int remainingBufferCount;
	/** True if we should loop the track */
	private boolean loop;
	/** True if we've completed streaming to buffer (but may not be done playing) */
	private boolean done = true;
	/** The stream we're currently reading from */
	private AudioInputStream audio;
	/** The source of the data */
	private String ref;
	/** The source of the data */
	private URL url;
	/** The pitch of the music */
	private float pitch;
	/** Position in seconds of the previously played buffers */
//	private float positionOffset;

	/** The stream position. */
	long streamPos = 0;

	/** The sample rate. */
	int sampleRate;

	/** The sample size. */
	int sampleSize;

	/** The play position. */
	long playedPos;

	/** The music length. */
	long musicLength = -1;

	/** The assumed time of when the music position would be 0. */
	long syncStartTime; 

	/** The last value that was returned for the music position. */
	float lastUpdatePosition = 0;

	/** The average difference between the sync time and the music position. */
	float avgDiff;

	/** The time when the music was paused. */
	long pauseTime;

	/**
	 * Create a new player to work on an audio stream
	 * 
	 * @param source The source on which we'll play the audio
	 * @param ref A reference to the audio file to stream
	 */
	public OpenALStreamPlayer(int source, String ref) {
		this.source = source;
		this.ref = ref;
		
		bufferNames = BufferUtils.createIntBuffer(BUFFER_COUNT);
		AL10.alGenBuffers(bufferNames);
	}

	/**
	 * Create a new player to work on an audio stream
	 * 
	 * @param source The source on which we'll play the audio
	 * @param url A reference to the audio file to stream
	 */
	public OpenALStreamPlayer(int source, URL url) {
		this.source = source;
		this.url = url;

		bufferNames = BufferUtils.createIntBuffer(BUFFER_COUNT);
		AL10.alGenBuffers(bufferNames);
	}
	
	/**
	 * Initialise our connection to the underlying resource
	 * 
	 * @throws IOException Indicates a failure to open the underling resource
	 */
	private void initStreams() throws IOException {
		if (audio != null) {
			audio.close();
		}

		AudioInputStream audio;

		if (url != null) {
			audio = new OggInputStream(url.openStream());
		} else {
			if (ref.toLowerCase().endsWith(".mp3")) {
				try {
					audio = new Mp3InputStream(ResourceLoader.getResourceAsStream(ref));
				} catch (IOException e) {
					// invalid MP3: check if file is actually OGG
					try {
						audio = new OggInputStream(ResourceLoader.getResourceAsStream(ref));
					} catch (IOException e1) {
						throw e;  // invalid OGG: re-throw original MP3 exception
					}
					if (audio.getRate() == 0 && audio.getChannels() == 0)
						throw e;  // likely not OGG: re-throw original MP3 exception
				}
			} else {
				audio = new OggInputStream(ResourceLoader.getResourceAsStream(ref));
				if (audio.getRate() == 0 && audio.getChannels() == 0) {
					// invalid OGG: check if file is actually MP3
					AudioInputStream audioOGG = audio;
					try {
						audio = new Mp3InputStream(ResourceLoader.getResourceAsStream(ref));
					} catch (IOException e) {
						audio = audioOGG;  // invalid MP3: keep OGG stream
					}
				}
			}
		}
		
		this.audio = audio;
		sampleRate = audio.getRate();
		if (audio.getChannels() > 1)
			sampleSize = 4; // AL10.AL_FORMAT_STEREO16
		else
			sampleSize = 2; // AL10.AL_FORMAT_MONO16
//		positionOffset = 0;
		streamPos = 0;
		playedPos = 0;
		
	}
	
	/**
	 * Get the source of this stream
	 * 
	 * @return The name of the source of string
	 */
	public String getSource() {
		return (url == null) ? ref : url.toString();
	}
	
	/**
	 * Clean up the buffers applied to the sound source
	 */
	private synchronized void removeBuffers() {
		AL10.alSourceStop(source);
		IntBuffer buffer = BufferUtils.createIntBuffer(1);

		while (AL10.alGetSourcei(source, AL10.AL_BUFFERS_QUEUED) > 0) {
			AL10.alSourceUnqueueBuffers(source, buffer);
			buffer.clear();
		}
	}
	
	/**
	 * Start this stream playing
	 * 
	 * @param loop True if the stream should loop 
	 * @throws IOException Indicates a failure to read from the stream
	 */
	public synchronized void play(boolean loop) throws IOException {
		this.loop = loop;
		initStreams();
		
		done = false;

		AL10.alSourceStop(source);
		
		startPlayback();
		syncStartTime = getTime();
	}
	
	/**
	 * Setup the playback properties
	 * 
	 * @param pitch The pitch to play back at
	 */
	public void setup(float pitch) {
		this.pitch = pitch;
		syncPosition();
	}
	
	/**
	 * Check if the playback is complete. Note this will never
	 * return true if we're looping
	 * 
	 * @return True if we're looping
	 */
	public boolean done() {
		return done;
	}
	
	/**
	 * Poll the bufferNames - check if we need to fill the bufferNames with another
	 * section. 
	 * 
	 * Most of the time this should be reasonably quick
	 */
	public synchronized void update() {
		if (done) {
			return;
		}

		int processed = AL10.alGetSourcei(source, AL10.AL_BUFFERS_PROCESSED);
		while (processed > 0) {
			unqueued.clear();
			AL10.alSourceUnqueueBuffers(source, unqueued);
			
			int bufferIndex = unqueued.get(0);

			int bufferLength = AL10.alGetBufferi(bufferIndex, AL10.AL_SIZE);

			playedPos += bufferLength;

			if (musicLength > 0 && playedPos > musicLength)
				playedPos -= musicLength;

			if (stream(bufferIndex)) {
				AL10.alSourceQueueBuffers(source, unqueued);
			} else {
				remainingBufferCount--;
				if (remainingBufferCount == 0) {
					done = true;
				}
			}
			processed--;
		}
		
		int state = AL10.alGetSourcei(source, AL10.AL_SOURCE_STATE);
		
		if (state != AL10.AL_PLAYING) {
			AL10.alSourcePlay(source);
		}
	}
	
	/**
	 * Stream some data from the audio stream to the buffer indicates by the ID
	 * 
	 * @param bufferId The ID of the buffer to fill
	 * @return True if another section was available
	 */
	public synchronized boolean stream(int bufferId) {
		try {
			int count = audio.read(buffer);
			if (count != -1) {
				streamPos += count;

				bufferData.clear();
				bufferData.put(buffer,0,count);
				bufferData.flip();

				int format = audio.getChannels() > 1 ? AL10.AL_FORMAT_STEREO16 : AL10.AL_FORMAT_MONO16;
				try {
					AL10.alBufferData(bufferId, format, bufferData, audio.getRate());
				} catch (OpenALException e) {
					Log.error("Failed to loop buffer: "+bufferId+" "+format+" "+count+" "+audio.getRate(), e);
					return false;
				}
			} else {
				if (loop) {
					musicLength = streamPos;
					initStreams();
					stream(bufferId);
				} else {
					done = true;
					return false;
				}
			}
			
			return true;
		} catch (IOException e) {
			Log.error(e);
			return false;
		}
	}

	/**
	 * Seeks to a position in the music.
	 * 
	 * @param position Position in seconds.
	 * @return True if the setting of the position was successful
	 */
	public synchronized boolean setPosition(float position) {
		try {
			long samplePos = (long) (position * sampleRate) * sampleSize;

			if (streamPos > samplePos)
				initStreams();

			long skipped = audio.skip(samplePos - streamPos);
			if (skipped >= 0)
				streamPos += skipped;
			else
				Log.warn("OpenALStreamPlayer: setPosition: failed to skip.");

			while (streamPos + buffer.length < samplePos) {
				int count = audio.read(buffer);
				if (count != -1) {
					streamPos += count;
				} else {
					if (loop) {
						initStreams();
					} else {
						done = true;
					}
					return false;
				}
			}

			playedPos = streamPos;
			syncStartTime = (long) (getTime() - (playedPos * 1000 / sampleSize / sampleRate) / pitch);

			startPlayback(); 

			return true;
		} catch (IOException e) {
			Log.error(e);
			return false;
		}
	}

	/**
	 * Starts the streaming.
	 */
	private void startPlayback() {
		removeBuffers();
		AL10.alSourcei(source, AL10.AL_LOOPING, AL10.AL_FALSE);
		AL10.alSourcef(source, AL10.AL_PITCH, pitch);

		remainingBufferCount = BUFFER_COUNT;

		for (int i = 0; i < BUFFER_COUNT; i++) {
			stream(bufferNames.get(i));
		}

		AL10.alSourceQueueBuffers(source, bufferNames);
		AL10.alSourcePlay(source);
	}

	/**
	 * Return the current playing position in the sound
	 * 
	 * @return The current position in seconds.
	 */
	public float getALPosition() {
		float playedTime = ((float) playedPos / (float) sampleSize) / sampleRate;
		float timePosition = playedTime + AL10.alGetSourcef(source, AL11.AL_SEC_OFFSET);
		return timePosition;
	}

	/**
	 * Return the current playing position in the sound
	 * 
	 * @return The current position in seconds.
	 */
	public float getPosition() {
		float thisPosition = getALPosition();
		long thisTime = getTime();
		float dxPosition = thisPosition - lastUpdatePosition;
		float dxTime = (thisTime - syncStartTime) * pitch;

		// hard reset
		if (Math.abs(thisPosition - dxTime / 1000f) > 1 / 2f) {
			syncPosition();
			dxTime = (thisTime - syncStartTime) * pitch;
			avgDiff = 0;
		}
		if ((int) (dxPosition * 1000) != 0) { // lastPosition != thisPosition
			float diff = thisPosition * 1000 - (dxTime);

			avgDiff = (diff + avgDiff * 9) / 10;
			if (Math.abs(avgDiff) >= 1) {
				syncStartTime -= (int) (avgDiff);
				avgDiff -= (int) (avgDiff);
				dxTime = (thisTime - syncStartTime) * pitch;
			}
			lastUpdatePosition = thisPosition;
		}

		return dxTime / 1000f;
	}

	/**
	 * Synchronizes the track position.
	 */
	private void syncPosition() {
		syncStartTime = getTime() - (long) (getALPosition() * 1000 / pitch);
		avgDiff = 0;
	}

	/**
	 * Processes a track pause.
	 */
	public void pausing() {
		pauseTime = getTime();
	}

	/**
	 * Processes a track resume.
	 */
	public void resuming() {
		syncStartTime += getTime() - pauseTime;
	}
	
	/**
	 * http://wiki.lwjgl.org/index.php?title=LWJGL_Basics_4_%28Timing%29
	 * Get the time in milliseconds
	 *
	 * @return The system time in milliseconds
	 */
	public long getTime() {
	    return (Sys.getTime() * 1000) / Sys.getTimerResolution();
	}

	/**
	 * Closes the stream.
	 */
	public void close() {
		if (audio != null) {
			try {
				audio.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
}