package net.sf.fmj.media.parser;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.media.BadHeaderException;
import javax.media.Buffer;
import javax.media.Duration;
import javax.media.Format;
import javax.media.IncompatibleSourceException;
import javax.media.ResourceUnavailableException;
import javax.media.Time;
import javax.media.Track;
import javax.media.protocol.ContentDescriptor;
import javax.media.protocol.DataSource;
import javax.media.protocol.FileTypeDescriptor;
import javax.media.protocol.PullDataSource;
import javax.media.protocol.PullSourceStream;
import javax.media.protocol.SourceCloneable;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.UnsupportedAudioFileException;

import net.sf.fmj.media.AbstractDemultiplexer;
import net.sf.fmj.media.AbstractTrack;
import net.sf.fmj.media.PullSourceStreamInputStream;
import net.sf.fmj.media.renderer.audio.JavaSoundUtils;
import net.sf.fmj.utility.LoggerSingleton;


/**
 * Demultiplexer (parser) which uses JavaSound.
 * @author Ken Larson
 *
 */
public class JavaSoundParser extends AbstractDemultiplexer 
{
	private static final Logger logger = LoggerSingleton.logger;

	/*
	 * If readFrame reads from pullSourceStreamInputStreamForReadFrame without audioInputStreamForReadFrame being opened,
	 * then the codec is getting the raw data WITH the header.  So it is able to create a converted AudioInputStream.
	 * But if we don't use a codec, then the renderer will get the header, which will come out as a short click or noise
	 * before the sound.
	 * 
	 * I see two options:
	 * 1. the header data gets passed using some special mechanism, like in a buffer header or in a buffer with a special flag set,
	 * so the codec knows to use it but a renderer will ignore it.
	 * 2. the codec reconstructs a header based on the format.
	 * 
	 * However, it is potentially worse than that, since WAV files are potentially stored as chunks, meaning there is more than
	 * just one header up front.  So I don't see that option 1 is very good.
	 * 
	 * Another possibility is we could change the reported output format to not be a standard AudioFormat, but to be a WAV audio format,
	 * then let there be a specific codec for that.
	 * 
	 * With #2, we could have some luck, because any internal headers will be stripped out by the audio input stream.
	 * So we don't have to have the codec put on the exact correct header, it just has to be one that
	 * allows getAudioInputStream to work.
	 * 
	 * Method 2 works.
	 * 
	 */
	
	private ContentDescriptor[] supportedInputContentDescriptors = new ContentDescriptor[] {
			new ContentDescriptor(FileTypeDescriptor.WAVE),	// .wav
			new ContentDescriptor(FileTypeDescriptor.BASIC_AUDIO),	// .au
			new ContentDescriptor(FileTypeDescriptor.AIFF),	// .aiff		
			new ContentDescriptor(FileTypeDescriptor.MPEG_AUDIO),	// .mp3 (requires mp3 SPI) - TODO: can we determine from JavaSound whether we have this SPI?
			new ContentDescriptor("audio.ogg"),	// .ogg (requires ogg SPI)	
			new ContentDescriptor("application.ogg"),	// this has been observed in the wild as well, as an alias for audio.ogg
			
	};
	
	// AudioSystem.getAudioFileTypes() does not return .mp3, even if the mp3 SPI is in the classpath.  So how can we
	// find out if it is present?

	// we need to open two streams, one is a clone of the other, because determining the format 
	// changes the stream position.
	private PullDataSource sourceForFormat;
	private PullDataSource sourceForReadFrame;
	
	private PullSourceStreamTrack[] tracks;
	//@Override
	@Override
	public ContentDescriptor[] getSupportedInputContentDescriptors()
	{
		return supportedInputContentDescriptors;
	}

	//@Override
	@Override
	public Track[] getTracks() throws IOException, BadHeaderException
	{
		return tracks;
	}

	// if we do the open in setSource, then we can identify an incompatible source
	// right away.  This is useful because the JMF/FMJ infrastructure (via the Manager) will
	// try a new Demuliplexer in this case.  If open fails, the Manager will not retry.
	// this is useful because in the case of .ogg, this parser will only handle audio, not video.
	// we need to use another Demultiplxer if there is video.
	private static final boolean OPEN_IN_SET_SOURCE = true;
	
	//@Override
	@Override
	public void setSource(DataSource source) throws IOException, IncompatibleSourceException
	{
		if (!(source instanceof PullDataSource))
			throw new IncompatibleSourceException();
       	if (!(source instanceof SourceCloneable))
    		throw new IncompatibleSourceException();
		
		this.sourceForFormat = (PullDataSource) source;
		
		if (OPEN_IN_SET_SOURCE)
		{
			try
			{
				doOpen();
			} catch (UnsupportedAudioFileException e)
			{
				logger.log(Level.INFO, "" + e);	// this happens with ogg files that have movies in it, so no stack trace needed
				throw new IncompatibleSourceException("" + e);
			} catch (IOException e)
			{
				logger.log(Level.WARNING, "" + e, e);
				throw e;
			}
			
		}
		
	}
	
	private void doOpen() throws IOException, UnsupportedAudioFileException
	{
		sourceForReadFrame = (PullDataSource) ((SourceCloneable) sourceForFormat).createClone();
		if (sourceForReadFrame == null)
			throw new IOException("Could not create clone");
		// no need to connect, as sourceForFormat was already connected, therefore clone will be connected.
		sourceForReadFrame.start();
		
		
		sourceForFormat.start();
		final PullSourceStream[] streamsForFormat = sourceForFormat.getStreams();
		final PullSourceStream[] streamsForReadFrame = sourceForReadFrame.getStreams();
		
		tracks = new PullSourceStreamTrack[streamsForFormat.length];
		for (int i = 0; i < streamsForFormat.length; ++i)
		{
			tracks[i] = new PullSourceStreamTrack(streamsForFormat[i], streamsForReadFrame[i]);
			
		}
	}
	
	
	
	//@Override
	@Override
	public void open() throws ResourceUnavailableException
	{
		if (!OPEN_IN_SET_SOURCE)
		{
			try
			{
				doOpen();
			} catch (UnsupportedAudioFileException e)
			{
				logger.log(Level.WARNING, "" + e, e);
				throw new ResourceUnavailableException("" + e);
			} catch (IOException e)
			{
				logger.log(Level.WARNING, "" + e, e);
				throw new ResourceUnavailableException("" + e);
			}

		}
	}

	//@Override
	@Override
	public void start() throws IOException
	{

	}
	
	// TODO: should we stop data source in stop?
//	// @Override
//	public void stop()
//	{
//		try 
//		{
//			source.stop();
//		} catch (IOException e) 
//		{
//			logger.log(Level.WARNING, "" + e, e);
//		}
//	}
	
	
	//@Override
	@Override
	public boolean isPositionable()
	{
		return true;
	}

	//@Override
	@Override
	public boolean isRandomAccess()
	{
		return super.isRandomAccess();	// TODO: can we determine this from the data source?
	}

	//@Override
	@Override
	public Time setPosition(Time where, int rounding)
	{
		// TODO: maybe we should just set a variable, and have the readFrame method handle the skip.
		// TODO: what do do about mp3/ogg?
		// TODO: what to do with rounding info?
		

		// if we can't skip based on nanos, then we can't seek.  This happens for mp3/ogg - compressed formats.
		for (int i = 0; i < tracks.length; ++i)
		{	if (!tracks[i].canSkipNanos())
				return super.setPosition(where, rounding);
		}
		
		if (where.getNanoseconds() == 0L)
		{
			boolean noBytesRead = true;
			for (int i = 0; i < tracks.length; ++i)
			{	if (tracks[i].getTotalBytesRead() != 0)
				{	noBytesRead = false;
					break;
				}
			}
			
			if (noBytesRead)
				return where; // no bytes read and we are seeking to time 0, nothing to do, we are already there.
		}
		
		try
		{
			logger.fine("JavaSoundParser: cloning, reconnecting, and restarting source");
			// just clone the data source again, start at zero, then skip to the position we want.
			sourceForReadFrame = (PullDataSource) ((SourceCloneable) sourceForFormat).createClone();
			if (sourceForReadFrame == null)
				throw new RuntimeException("Could not create clone");	// TODO: how to handle
			// no need to connect, as sourceForFormat was already connected, therefore clone will be connected.
			sourceForReadFrame.start();
			
			for (int i = 0; i < tracks.length; ++i)
			{
				tracks[i].setPssForReadFrame(sourceForReadFrame.getStreams()[i]);
				if (where.getNanoseconds() > 0)
					tracks[i].skipNanos(where.getNanoseconds());	// TODO: check result
			}
			
			return where;	// TODO: 
		}
		catch (IOException e)
		{	logger.log(Level.WARNING, "" + e, e);
			throw new RuntimeException(e);	// TODO: how to handle
		} catch (UnsupportedAudioFileException e)
		{
			logger.log(Level.WARNING, "" + e, e);
			throw new RuntimeException(e);	// TODO: how to handle
		}
	}


	private class PullSourceStreamTrack extends AbstractTrack
	{
		// TODO: track listener
		
		private final javax.media.format.AudioFormat format;
		private final javax.sound.sampled.AudioFormat javaSoundInputFormat;
		private final long frameLength;	// length of media in frames

		private PullSourceStream pssForReadFrame;
		private PullSourceStreamInputStream pssisForReadFrame;
		private AudioInputStream aisForReadFrame;
		
		private long totalBytesRead = 0L;	// keep track of total bytes so we can compute current timestamp

		public PullSourceStreamTrack(PullSourceStream pssForFormat, PullSourceStream pssForReadFrame) throws UnsupportedAudioFileException, IOException
		{
			super();
		
			// determine format and frame size.
			{
				final AudioInputStream aisForFormat;
				final PullSourceStreamInputStream pssisForFormat;
				
				pssisForFormat = new PullSourceStreamInputStream(pssForFormat);
				aisForFormat = AudioSystem.getAudioInputStream(markSupportedInputStream(pssisForFormat));
				this.javaSoundInputFormat = aisForFormat.getFormat();
				this.frameLength = aisForFormat.getFrameLength();
				this.format = JavaSoundUtils.convertFormat(javaSoundInputFormat);
				
				logger.fine("JavaSoundParser: java sound format: " + javaSoundInputFormat);
				logger.fine("JavaSoundParser: jmf format: " + format);
				logger.fine("JavaSoundParser: Frame length=" + frameLength);
				
				aisForFormat.close();
				pssisForFormat.close();
			}
			
			
			setPssForReadFrame(pssForReadFrame);
		

		}

		/**
		 * 
		 * @return nanos skipped, 0 if unable to skip.
		 * @throws IOException
		 */
		public long skipNanos(long nanos) throws IOException
		{
			final long bytes = nanosToBytes(nanos);
			if (bytes <= 0)
			{	logger.fine("JavaSoundParser: skipping nanos: " + 0);
				return 0;
			}
			final long bytesSkipped = aisForReadFrame.skip(bytes);
			totalBytesRead += bytesSkipped;
			if (bytesSkipped == bytes)
			{	logger.fine("JavaSoundParser: skipping nanos: " + nanos);
				return nanos;
			}
			else
			{	final long result = bytesToNanos(bytesSkipped);
				logger.fine("JavaSoundParser: skipping nanos: " + result);
				return result;
			}
			
		}
		
		public boolean canSkipNanos()
		{
			if (javaSoundInputFormat.getFrameSize() > 0 && javaSoundInputFormat.getFrameRate() > 0.f)
				return true;
			else
				return false;
		}

		// we can use this to set a new stream
		public void setPssForReadFrame(PullSourceStream pssForReadFrame) throws UnsupportedAudioFileException, IOException
		{
			this.pssForReadFrame = pssForReadFrame;
			this.pssisForReadFrame = new PullSourceStreamInputStream(pssForReadFrame);
			this.aisForReadFrame = AudioSystem.getAudioInputStream(markSupportedInputStream(pssisForReadFrame));
			this.totalBytesRead = 0;

		}
		
		@Override
		public Format getFormat()
		{
			return format;
		}

		
		/**
		 * 
		 * @return -1L if cannot convert, because frame size and frame rate are not known.
		 */
		private long bytesToNanos(long bytes)
		{
			if (javaSoundInputFormat.getFrameSize() > 0 && javaSoundInputFormat.getFrameRate() > 0.f)
			{
				final long frames = bytes / javaSoundInputFormat.getFrameSize();
				final double seconds = frames / javaSoundInputFormat.getFrameRate();
				final double nanos = secondsToNanos(seconds);
				return (long) nanos;
			}
			else
				return -1L;
		}
		
		/**
		 * 
		 * @return -1L if cannot convert, because frame size and frame rate are not known.
		 */
		private long nanosToBytes(long nanos)
		{
			if (javaSoundInputFormat.getFrameSize() > 0 && javaSoundInputFormat.getFrameRate() > 0.f)
			{
				// TODO: optimize this to avoid loss of precision.
				final double seconds = nanosToSeconds(nanos);
				final double frames = seconds * javaSoundInputFormat.getFrameRate();
				final double bytes = frames * javaSoundInputFormat.getFrameSize();
				return (long) bytes;
			}
			else
				return -1L;
		}
		
		
		
//		  TODO: from JAVADOC:
//		   This method might block if the data for a complete frame is not available. It might also block if the stream contains intervening data for a different interleaved Track. Once the other Track is read by a readFrame call from a different thread, this method can read the frame. If the intervening Track has been disabled, data for that Track is read and discarded.
//
//			Note: This scenario is necessary only if a PullDataSource Demultiplexer implementation wants to avoid buffering data locally and copying the data to the Buffer passed in as a parameter. Implementations might decide to buffer data and not block (if possible) and incur data copy overhead. 
		 
		@Override
		public void readFrame(Buffer buffer)
		{
			//format.getFrameSizeInBits() * format.getFrameRate();
			// TODO: make buffer size an integral number of frames.
			
			final int BUFFER_SIZE = 10000;	// TODO: how do we determine this size?  Is this what the BufferControl control is for?
			if (buffer.getData() == null)
				buffer.setData(new byte[BUFFER_SIZE]);
			final byte[] bytes = (byte[]) buffer.getData();
			
			// TODO: set other buffer fields, like format or sequenceNumber?
			try
			{
				final int result = aisForReadFrame.read(bytes, 0, bytes.length);
				if (result < 0)
				{	buffer.setEOM(true);
					buffer.setLength(0);
					return;
				}
				
				// calculate timestamp for this buffer, if we can.
				if (javaSoundInputFormat.getFrameSize() > 0 && javaSoundInputFormat.getFrameRate() > 0.f)
				{
					buffer.setTimeStamp(bytesToNanos(totalBytesRead));
					buffer.setDuration(bytesToNanos(result));
				}
				// TODO: otherwise, set the timestamp/duration to what? 0? -1?
				
				totalBytesRead += result;
				buffer.setLength(result);
				buffer.setOffset(0);
			} catch (IOException e)
			{
				buffer.setEOM(true);	// TODO
				buffer.setDiscard(true);	// TODO
				buffer.setLength(0);
				logger.log(Level.WARNING, "" + e, e);
			}
			
		}

		
		@Override
		public Time mapFrameToTime(int frameNumber)
		{
			return TIME_UNKNOWN;	// this is what audio tracks are supposed to return
		}

		@Override
		public int mapTimeToFrame(Time t)
		{	
			return FRAME_UNKNOWN;		// this is what audio tracks are supposed to return
		}
		
	
		
		@Override
		public Time getDuration()
		{
			// TODO: for ogg/mp3, we end up with -1 frame length and return unknown.  Maybe only the codec can figure this out.
			// or maybe we need to open our own converted stream to find the length.
			
			final long lengthInFrames = frameLength;
			if (lengthInFrames < 0)
			{	
				logger.fine("PullSourceStreamTrack: returning Duration.DURATION_UNKNOWN (1)");
				return Duration.DURATION_UNKNOWN;
			}
			final double lengthInSeconds = lengthInFrames / javaSoundInputFormat.getFrameRate();
			if (lengthInSeconds < 0.0)
			{	
				logger.fine("PullSourceStreamTrack: returning Duration.DURATION_UNKNOWN (2)");
				return Duration.DURATION_UNKNOWN;
			
			}
				
			final double lengthInNanos = secondsToNanos(lengthInSeconds);

			logger.fine("PullSourceStreamTrack: returning " + ((long) lengthInNanos));

			return new Time((long) lengthInNanos);
			
		}

		public long getTotalBytesRead()
		{
			return totalBytesRead;
		}
		
		

	}

	// AudioSystem.getAudioInputStream needs to use marks to determine the format.
	private static InputStream markSupportedInputStream(InputStream is)
	{
		if (is.markSupported())
			return is;
		else
			return new BufferedInputStream(is);
	}
	
	private static final double secondsToNanos(double secs)
	{	return secs * 1000000000.0;
	}

	private static final double nanosToSeconds(double nanos)
	{	return nanos / 1000000000.0;
	}

//	private static void dumpHeader()
//	{
//		// test code to find the header size
//		{
//			File file = new File(source.getLocator().getRemainder());
//			
//			RandomAccessFile raf = new RandomAccessFile(file, "r");
//			final long fileLength = raf.length();
//			logger.fine("file size: " + fileLength);
//			raf.close();
//			
//			InputStream fis = markSupportedInputStream(new FileInputStream(file));
//
//			
//			AudioSystem.getAudioInputStream(fis);
//			int fisLen = 0;
//			while (true)
//			{
//				if (fis.read() == -1)
//					break;
//				else
//					++fisLen;
//			}
//			
//			logger.fine("current pos: " + fisLen);
//			final long headerLen = fileLength - fisLen;
//			logger.fine("header len: " + headerLen);
//			
//			FileInputStream fis2 = new FileInputStream(file);
//			System.out.print("new byte[] {");
//			for (int i = 0; i < headerLen; ++i)
//			{
//				int b = fis2.read();
//				if (b > 127)
//					System.out.print("(byte) " + b + ", ");
//				else
//					System.out.print(" " + b + ", ");
//			}
//			logger.fine("};");
//		}
//	}
	
}
