package zero1hd.rhythmbullet.desktop.audio;

import static org.lwjgl.openal.AL10.alGetSourcef;

import java.math.RoundingMode;
import java.nio.ByteBuffer;
import java.nio.ShortBuffer;
import java.text.DecimalFormat;
import java.util.Observable;
import java.util.Observer;

import org.lwjgl.openal.AL;
import org.lwjgl.openal.AL11;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.backends.lwjgl.audio.OpenALMusic;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.TimeUtils;
import com.badlogic.gdx.utils.reflect.ClassReflection;
import com.badlogic.gdx.utils.reflect.Field;
import com.badlogic.gdx.utils.reflect.ReflectionException;

import edu.emory.mathcs.jtransforms.fft.FloatFFT_1D;
import zero1hd.rhythmbullet.audio.MusicController;
import zero1hd.rhythmbullet.audio.visualizer.PCMSystem;

public class PCMObtainer implements Observer, PCMSystem {
	private int windowSize = 1024;
	private int sampleRate;
	private long millisPerWindow;
	private boolean songChanged;
	private DecimalFormat df;
	private float[] PCM = new float[windowSize];
	private float[] frequencyBins = new float[windowSize / 2];
	private FloatFFT_1D fft = new FloatFFT_1D(windowSize);
	private ShortBuffer playingBuffer;
	private ShortBuffer intermediateBuffer;
	private ShortBuffer buffer;
	private volatile int sourceID;
	private int channelCount;
	private MusicController mc;
	private BufferStreamReadThread streamReadThread;
	private int windowsRead;
	private int currentPlaybackWindow;
	private volatile boolean updated;
	
	public PCMObtainer(MusicController musicController) {
		this.mc = musicController;
		mc.addObserver(this);
		try {
			Field bufferField = ClassReflection.getDeclaredField(OpenALMusic.class, "tempBuffer");
			bufferField.setAccessible(true);
			buffer = ((ByteBuffer) bufferField.get(null)).asShortBuffer().asReadOnlyBuffer();
			playingBuffer = ShortBuffer.allocate(buffer.capacity());
			intermediateBuffer = ShortBuffer.allocate(buffer.capacity());
		} catch (IllegalArgumentException | SecurityException | ReflectionException e) {
			Gdx.app.debug("Visualizer reflection", "Failed attempt at retrieving tempBuffer field.", e);
			Gdx.app.exit();
		}

		streamReadThread = new BufferStreamReadThread();
		
		df = new DecimalFormat("#.###");
		df.setRoundingMode(RoundingMode.HALF_EVEN);
	}

	private boolean calcPCMData() {
		synchronized (PCM) {
			boolean empty = true;
			short chanVal;
			for (int sid = 0; sid < PCM.length && sid < playingBuffer.remaining(); sid++) {
				PCM[sid] = 0;
				for (int channel = 0; channel < channelCount; channel++) {
					if (PCM[sid] < (chanVal = playingBuffer.get())) {
						PCM[sid] = chanVal;
					}
				}
				PCM[sid] /= Short.MAX_VALUE + 1f;
				
				if (PCM[sid] != 0) {
					empty = false;
				}
				
			}
			return empty;
		}
	}
	
	private void checkValidityOfPlaybackBuffer() {
		// Begin comparison
		buffer.rewind();
		if (intermediateBuffer.compareTo(buffer) != 0) {
			bufferChanged();

			// Begin copying current buffer to the comparison buffer
			intermediateBuffer.clear();
			intermediateBuffer.put(buffer);
			intermediateBuffer.flip();
		}
	}
	
	private void bufferChanged() {
		playingBuffer.rewind();
		playingBuffer.put(intermediateBuffer);
		playingBuffer.rewind();
		synchronizeBufferWithPlayback();
	}

	private int calculateBufferPosition() {
		int offset = (int) alGetSourcef(sourceID, AL11.AL_SAMPLE_OFFSET);
		offset = (offset / windowSize) * windowSize;
		return offset;
	}

	private boolean synchronizeBufferWithPlayback() {
		int bufferPos = calculateBufferPosition();
		synchronized (this) {
			if (bufferPos <= playingBuffer.limit() && bufferPos >= 0) {
				playingBuffer.position(bufferPos);
				windowsRead = (int) ((mc.getCurrentPosition() * sampleRate) / windowSize);
				return true;
			}
		}
		return false;
	}
	
	@Override
	public void loadMusic() {
		Gdx.app.debug("PCMObtainer", "music loaded.");
		sourceID = -1;

		channelCount = mc.getCurrentMusicHeader().getChannelCount();
		sampleRate = mc.getCurrentMusicHeader().getSampleRate();
		String millisPerWindowF = df.format(windowSize/(float) sampleRate);
		millisPerWindow = (long) (Float.valueOf(millisPerWindowF)*1000);
		songChanged = true;
		
		intermediateBuffer.clear();
		playingBuffer.clear();
		begin();
	}

	@Override
	public float[] getFrequencyBins() {
		if (mc.isPlaying()) {
			if (updated) {
				synchronized (PCM) {
					fft.realForward(PCM);
					System.arraycopy(PCM, 1, frequencyBins, 0, frequencyBins.length);
				}
				updated = false;
			}
		} else {
			for (int freqID = 0; freqID < frequencyBins.length; freqID++) {
				frequencyBins[freqID] = 0;
			}
		}
		return frequencyBins;
	}

	@Override
	public int getWindowSize() {
		return windowSize;
	}
	
	
	
	private class BufferStreamReadThread implements Runnable {
		private String name = "PCM-Audio-Processing";
		private Thread thread;
		private volatile boolean run = true;
		private long timeOfLastRead;
		private long waitTime;
		@Override
		public void run() {
			while (run) {
				if (mc.isPlaying()) {
					//record time of read
					timeOfLastRead = TimeUtils.millis();
					
					//calculate current pcm data and notify that there is new data
					checkValidityOfPlaybackBuffer();
					updated = !calcPCMData();
					windowsRead++;
					
					//contemplate synchronization
					currentPlaybackWindow = MathUtils.round((mc.getCurrentPosition() * sampleRate) / windowSize);
					if (windowsRead != currentPlaybackWindow) {
						synchronizeBufferWithPlayback();
					}
					//wait for a bit before reading again depending on the speed at which the system does playback.
					waitTime = Math.max(0, millisPerWindow - TimeUtils.timeSinceMillis(timeOfLastRead));
					try {
						Thread.sleep(waitTime);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				} else {

					synchronized (this) {
						try {
							wait();
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
					}
				}
			}
			Gdx.app.debug(thread.getName(), "stopped");
		}

		public void start() {
			if (thread == null) {
				thread = new Thread(this, name);
				thread.setDaemon(true);
				thread.start();
			} else {
				synchronized (this) {
					notify();
				}
			}
		}

		public void stop() {
			run = false;
		}
	}

	@Override
	public void update(Observable o, Object arg) {
		if (o == mc) {
			if (arg == mc.states.LOADED) {
				loadMusic();
			} else if (arg == mc.states.PLAYING) {
				begin();
			}
		}
	}
	
	private void begin() {
		if (mc.isPlaying()) {
			if (sourceID == -1) {
				try {
					Field sourceIDField = ClassReflection.getDeclaredField(OpenALMusic.class, "sourceID");
					sourceIDField.setAccessible(true);
					sourceID = (int) sourceIDField.get(mc.getCurrentMusic());
				} catch (ReflectionException e) {
					e.printStackTrace();
				}
			}
			streamReadThread.start();
		}
	}
	
	@Override
	public void dispose() {
		streamReadThread.stop();
	}
	
	@Override
	public boolean hasAudioChanged() {
		if (songChanged) {
			songChanged = false;
			return true;
		} else {
			return false;
		}
	}
}
