package org.limewire.player.impl;

import static org.limewire.player.api.PlayerState.EOM;
import static org.limewire.player.api.PlayerState.GAIN;
import static org.limewire.player.api.PlayerState.NO_SOUND_DEVICE;
import static org.limewire.player.api.PlayerState.PAUSED;
import static org.limewire.player.api.PlayerState.PLAYING;
import static org.limewire.player.api.PlayerState.SEEKING;
import static org.limewire.player.api.PlayerState.SEEKING_PAUSED;
import static org.limewire.player.api.PlayerState.SEEKING_PLAY;
import static org.limewire.player.api.PlayerState.STOPPED;
import static org.limewire.player.api.PlayerState.UNKNOWN;

import java.awt.EventQueue;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;

import javax.media.GainChangeEvent;
import javax.media.GainChangeListener;
import javax.media.GainControl;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.UnsupportedAudioFileException;

import org.limewire.concurrent.ThreadExecutor;
import org.limewire.inject.LazySingleton;
import org.limewire.logging.Log;
import org.limewire.logging.LogFactory;
import org.limewire.player.api.AudioPlayer;
import org.limewire.player.api.AudioPlayerEvent;
import org.limewire.player.api.AudioPlayerListener;
import org.limewire.player.api.AudioSource;
import org.limewire.player.api.PlayerState;

/**
 *  An audio player to play compressed and uncompressed music.
 */
@LazySingleton
public class LimeWirePlayer implements Runnable, AudioPlayer {
   
    private static final Log LOG = LogFactory.getLog(LimeWirePlayer.class);
    
    /**
     * Sleep time is for when the song is loaded or paused but not playing.
     */
    private static final long SLEEP_NONPLAYING = 100;
    
    /**
     * Sleep time for when the song is playing but the sourceLine is full and 
     * no reading/writing occurs.
     */
    private static final long SLEEP_PLAYING = 100;

    /**
     *  Maximum size of the buffer to read/write from.
     */
    public static final int EXTERNAL_BUFFER_SIZE = 4096 * 8;

    /**
     * Our list of AudioPlayerListeners that are currently listening for events
     * from this player.
     */
    private List<AudioPlayerListener> listenerList = new CopyOnWriteArrayList<AudioPlayerListener>();
    
    /**
     * main thread that does the audio IO.
     */
    private Thread playerthread;

    /**
     * Synchronized holder for reading/writing the next song to be played.
     */
    private final LoadSongBuffer songBuffer;   
    
    /**
     * Used in playerThread to sleep when player is paused.
     */
    private final Object threadLock = new Object();
       
    /**
     * Current state of the player.
     */
    private volatile PlayerState playerState = UNKNOWN;

    private final Object seekLock = new Object();

    /**
     * byte location to skip to in file.
     */
    private long seekValue = -1;
    
    /**
     * true==the thread should close the current song and load the next song.
     */
    private volatile boolean loadSong = false;

    private final Object volumeLock = new Object();

    /**
     * true== the thread should update the volume on the sourceDataLine.
     */
    private boolean setVolume = false;
    
    /**
     * The current volume of the player.
     */
    private GainControl gainControl;

    /**
     * Contains the Input and Output streams for the IO
     * only <code>playerThread</code> should touch this.
     */
    private LimeAudioFormat currentAudioFormat;
    
    /**
     * The source that the thread is currently reading from.
     */
    private AudioSource currentSong;
    
    /**
     * Buffer for reading from input stream/ writing to the data line.
     */
    private final byte[] buffer = new byte[EXTERNAL_BUFFER_SIZE];
    
    /**
     * Bytes read from the input stream.
     */
    private int readBytes = 0;
    
    /**
     * Available bytes that can be written to the sourceDataLine.
     */
    private int avail;

    public LimeWirePlayer() {
        songBuffer = new LoadSongBuffer();
        gainControl = new JavaGainControl();
        gainControl.addGainChangeListener(new GainChangeListener(){
            @Override
            public void gainChange(GainChangeEvent event) {
                setVolume = true;
            }
        });
    }

    /**
     * Adds the specified AudioPlayer listener to the list.
     */
    public void addAudioPlayerListener(AudioPlayerListener listener) {
        listenerList.add(listener);
    }

    /**
     * Removes the specified AudioPlayer listener from the list.
     */
    public void removeAudioPlayerListener(AudioPlayerListener listener) {
        listenerList.remove(listener);
    }

    /**
     * Converts the playerstate from ints to PlayerState enums.
     */
    @Override
    public PlayerState getStatus() {
        return playerState;
    }
    
    /**
     * Loads a AudioSource into the player to play next.
     */
    @Override
    public void loadSong(AudioSource source) {
        if( source == null )
            throw new IllegalArgumentException();
        songBuffer.setSong(source);
    }

    @Override
    public void loadSong(File songFile) {
        if(LOG.isDebugEnabled())
            LOG.debug("loading " + songFile);
        loadSong(new AudioSourceImpl(songFile));        
    }

    @Override
    public void loadSong(InputStream songStream) {
        loadSong(new AudioSourceImpl(songStream));  
    }

    @Override
    public void loadSong(URL songUrl) {
        loadSong(new AudioSourceImpl(songUrl));  
    }

    /**
     * Begins playing a song.
     */
    public void playSong() {
        loadSong = true;
        playerState = PLAYING;
        if( (playerthread == null || !playerthread.isAlive()) ){ 
            playerthread = ThreadExecutor.newManagedThread(this, "LimewirePlayer");
            playerthread.setDaemon(true);
            playerthread.start();
        }
        notifyEvent(PLAYING, -1);
    }

    /**
     * Pausing the current song.
     */
    public void pause() {
        if( !(playerState == UNKNOWN || playerState == STOPPED)){
            playerState = PAUSED;
            notifyEvent(PAUSED, -1);
        }
    }

    /**
     * Unpauses the current song.
     */
    public void unpause() {
        if( !(playerState == UNKNOWN || playerState == STOPPED)){
            playerState = PLAYING;
            notifyEvent(PLAYING, -1);
        }
    }

    /**
     * Stops the current song.
     */
    public void stop() {
        if( !(playerState == UNKNOWN || playerState == STOPPED)) {
            playerState = STOPPED;
            notifyEvent(STOPPED, -1);
        }
    }

    /**
     * Seeks to a new location in the current song.
     */
    public long seekLocation(long value) {
        if( !(playerState == UNKNOWN || playerState == STOPPED) ) {
            if( playerState == PAUSED || playerState == SEEKING_PAUSED )
                playerState = SEEKING_PAUSED;
            else
                playerState = SEEKING_PLAY;
            synchronized (seekLock) {
                seekValue = value;
            }
            notifyEvent(SEEKING,value);
        }
        return value;
    }
    
    /**
     * Sets the gain(volume) for the outputline.
     * 
     * @param fGain [0.0 <-> 1.0]
     * @throws IOException thrown when the soundcard does not support this
     *         operation
     */
    public void setVolume(float fGain) {
        synchronized (volumeLock) {
            // volume = fGain;
            gainControl.setLevel(fGain);
            setVolume = true;
        }
    }
    
    public GainControl getGainControl() {
        return gainControl;
    }

    /**
     * Handles all the IO for reading and writing a song to the sound card.
     */
    public void run() {
        // while the thread is not stopped or unknown, keep alive
        while( playerState != UNKNOWN ) {

            // after stopping, wait a few milliseconds in case a new song is loaded to
            //  avoid recreating the thread
            if( playerState == STOPPED ){
                for(int i = 0; i < 3; i++) {
                    if( playerState == STOPPED )
                        try {
                            synchronized (threadLock) {
                                threadLock.wait(SLEEP_NONPLAYING);
                            }
                        } catch (InterruptedException e) { // dont catch
                        }
                }
                if( playerState == STOPPED )
                    playerState = UNKNOWN;
            }
            // update the volume if changed
            if (currentAudioFormat != null && setVolume) {
                try {
                    float vol = 0;
                    synchronized (volumeLock) {

                        vol = gainControl.getLevel();
                        setVolume = false;
                    }
                    currentAudioFormat.setGain(vol);
                    notifyEvent(GAIN, vol);

                } catch (IOException e) {
                }
            }
            // load a new song
            if( loadSong ){
                if( currentAudioFormat != null )
                    currentAudioFormat.closeStreams();    
                loadFromSongBuffer();
                if( currentSong != null ) 
                    loading();
                else
                    playerState = STOPPED;
            }
            // play the song
            if( playerState == PLAYING ){
                if(currentAudioFormat == null )
                    playerState = STOPPED;
                else 
                    playing();
            }
            // pause the song
            else if( playerState == PAUSED ){
                if( currentAudioFormat == null )
                    playerState = STOPPED;
                else
                    pausing();
            } 
            // seek to a new position in the song
            else if( playerState == SEEKING || playerState == SEEKING_PAUSED ||
                    playerState == SEEKING_PLAY){   
                seeking();
            }
        }
        if( currentAudioFormat != null)
            currentAudioFormat.closeStreams();
        currentAudioFormat = null;
        playerState = UNKNOWN;
    }
    
    /**
     * Attempts to remove the next song for playing from the songBuffer.
     * If there is no song waiting, the current song is placed back on
     * the buffer in case play() is pressed again prior to loading a new
     * song.
     */
    private void loadFromSongBuffer(){
        // if another song to play, load it and keep playing
        if( songBuffer.hasSong() ) { 
            currentSong = songBuffer.getSong();
            loadSong = false;
        }
        // if no other song to play
        else {
            loadSong = false;
            currentSong = null;
            playerState = STOPPED;
        }
    }
    
    /**
     *  Processes loading the current song.
     */
    private void loading(){          
        try {
            currentAudioFormat = new LimeAudioFormat(currentSong, 0);
            readBytes = 0;
            notifyOpened(currentAudioFormat.getProperties());
            playerState = PLAYING;
        } catch (IllegalArgumentException e ) {
            if(LOG.isDebugEnabled())
                LOG.debug("illegalArguement" + e);
            playerState = STOPPED;
            notifyEvent(NO_SOUND_DEVICE, -1);
        } catch (UnsupportedAudioFileException e) {
            if(LOG.isDebugEnabled())
                LOG.debug("unsupported audio file" + e);
            playerState = STOPPED;
            notifyEvent(EOM, -1);
        } catch (IOException e) {
            if(LOG.isDebugEnabled())
                LOG.debug("ioexception " + e);
            playerState = STOPPED;
            notifyEvent(EOM, -1);
        } catch (LineUnavailableException e) {
            if(LOG.isDebugEnabled())
                LOG.debug("line unavailable" + e);
            playerState = STOPPED;
            notifyEvent(EOM, -1);
        } catch (NullPointerException e) {
            if(LOG.isDebugEnabled())
                LOG.debug("null pointer " + e);
            playerState = STOPPED;
            notifyEvent(EOM, -1);
        }
    }
    
    /**
     * Processes playing the current song.
     */
    private void playing() {
        currentAudioFormat.startSourceDataLine();
        avail = currentAudioFormat.getSourceDataLine().available();

        // if we can write without blocking to the audio card, do so
        if (avail > 0) {
            try {
                readBytes = currentAudioFormat.getAudioInputStream().read(buffer, 0,
                        Math.min(avail, buffer.length));
            }
            // TODO: this is a hack for capturing problems in the JLayer decoder. The problems
            // should really fixed there but for the time being, this will hide the error from
            // the user and just proceed to the next song.
            catch (ArrayIndexOutOfBoundsException e) {
                playerState = STOPPED;
                notifyEvent(EOM, -1);
                loadSong = true;
            } catch (IOException e) {
                playerState = STOPPED;
            }
            // write whatever we were able to read
            if (readBytes > 0) {
                currentAudioFormat.getSourceDataLine().write(buffer, 0, readBytes);
                notifyProgress(currentAudioFormat.getEncodedStreamPosition());
            }
            // if the end of the song has been reached, see if there's a new
            // song
            // waiting to be played
            else if (readBytes == -1) {
                notifyEvent(EOM, -1);
                loadSong = true;
            }
        } else {
            try {
                synchronized (threadLock) {
                    threadLock.wait(SLEEP_PLAYING);
                }
            } catch (InterruptedException e) {
                // dont catch
            }
        }
    }
    
    /**
     * Processes pausing the current song.
     */
    private void pausing(){
        currentAudioFormat.stopSourceDataLine();
        try {
            synchronized (threadLock) {
                threadLock.wait(SLEEP_NONPLAYING);  
            }           
        } catch (InterruptedException e) {
            // dont catch
        }
    }
    
    /**
     * Processes a seek to a location in the song.
     */
    private void seeking(){
        try {
            
            long seekLocation = 0;
            synchronized (seekLock) {
                seekLocation = seekValue;
                seekValue = 0;
            }

            if (currentAudioFormat != null)
                currentAudioFormat.closeStreams();
            // open new file and seek to location
            currentAudioFormat = new LimeAudioFormat(currentSong, seekLocation);
            // TODO: the above should be replaced once the mp3 decoder is fixed

            // reload new volume
            synchronized (volumeLock) {
                setVolume = true;
            }
            if( playerState == SEEKING_PAUSED )
                playerState = PAUSED;
            else
                playerState = PLAYING;
        } catch (UnsupportedAudioFileException e) {
            playerState = STOPPED;
        } catch (IOException e) {
            playerState = STOPPED;
        } catch (LineUnavailableException e) {
            playerState = STOPPED;
        } catch (NullPointerException e) {
            playerState = STOPPED;
        } catch (IllegalArgumentException e) {
            playerState = STOPPED;
        }
    }
    
    /**
     * Notify listeners when a new audio source has been opened. 
     * 
     * @param properties any properties about the source that we extracted
     */
    protected void notifyOpened(final Map<String,Object> properties){
        EventQueue.invokeLater(new Runnable(){
            public void run(){
                fireOpened(properties);
            }
        });
    }

    /**
     * Notify listeners about an AudioPlayerEvent. This creates general state
     * modifications to the player such as the transition from opened to 
     * playing to paused to end of song.
     * 
     * @param value if the event was a modification such as a volume update,
     *        list the new value
     */
    protected void notifyEvent(final PlayerState state, final double value) {
        EventQueue.invokeLater(new Runnable() {
            public void run() {
                fireStateUpdated(new AudioPlayerEvent(state,value));
            }
        });
    }

    /**
     * fires a progress event off a new thread. This lets us safely fire events
     * off of the player thread while using a lock on the input stream.
     */
    protected void notifyProgress(final int bytesread) {
        EventQueue.invokeLater(new Runnable() {
            public void run() {
                fireProgress(bytesread);
            }
        });
    }

    /**
     * This is fired every time a new song is loaded and ready to play. The
     * properties map contains information about the type of song such as bit
     * rate, sample rate, media type(MPEG, Streaming,etc..), etc..
     */
    protected void fireOpened(Map<String, Object> properties) {
        for (AudioPlayerListener listener : listenerList)
            listener.songOpened(properties);
    }

    /**
     * Fired every time a byte stream is written to the sound card. This lets 
     * listeners be aware of what point in the entire file is song is currently
     * playing. This also returns a copy of the written byte[] so it can get
     * passed along to objects such as a FFT for visual feedback of the song.
     */
    protected void fireProgress(int bytesread) {
        for (AudioPlayerListener listener : listenerList)
            listener.progressChange(bytesread);
    }

    /**
     * Fired every time the state of the player changes. This allows a listener
     * to be aware of state transitions such as from OPENED -> PLAYING ->
     * STOPPED -> EOF.
     */
    protected void fireStateUpdated(AudioPlayerEvent event) {
        for (AudioPlayerListener listener : listenerList)
            listener.stateChange(event);
    }

    /**
     * Holds a reference to the next song to be played.
     */
    private static class LoadSongBuffer {
        
        private AudioSource nextItem;
        
        public synchronized void setSong(AudioSource song){ 
            nextItem = song;
        }
        
        /**
         * @return the next song to be played, returns null if no new song is 
         * awaiting play
         */
        public synchronized AudioSource getSong() {
            AudioSource next = nextItem;
            nextItem = null;
            return next;
        }

        public synchronized boolean hasSong() {
            return nextItem != null;
        }
    }

    public boolean isPlaying(File file) {
        return playerState == PLAYING && currentSong != null && file.equals(currentSong.getFile());
    }

    @Override
    public boolean isPaused(File file) {
        return playerState == PAUSED && currentSong != null && file.equals(currentSong.getFile());
    }

    @Override
    public AudioSource getCurrentSong() {
        return currentSong;
    }

    private class JavaGainControl implements GainControl {
        private static final float MAX = 1.0f;
        private static final float MIN = 0.0f;
        private float level = 0.0f;
        private boolean mute;
        private final List<GainChangeListener> gainListeners = new CopyOnWriteArrayList<GainChangeListener>();

        @Override
        public java.awt.Component getControlComponent() {
            return null;
        }

        @Override
        public void setMute(boolean mute) {
            this.mute = mute;
            notifyGainChangeListeners();
        }

        @Override
        public boolean getMute() {
            return mute;
        }

        @Override
        public float setDB(float gainInDb) {
            if (Float.isInfinite(gainInDb) && gainInDb < 0) {
                return setLevel(MIN);
            }

            return setLevel((float) Math.pow(10.0d, gainInDb / 20.0d));
        }

        @Override
        public float getDB() {
            if (mute || level <= MIN) {
                return Float.NEGATIVE_INFINITY;
            }

            return (float) (20.0d * Math.log10(level));
        }

        @Override
        public float getLevel() {
            return level;
        }

        @Override
        public float setLevel(final float level) {
            if(level > MAX)
                this.level = MAX;
            else if(level < MIN)
                this.level = MIN;
            else
                this.level = level;
            
            notifyGainChangeListeners();
            
            return this.level;
        }

        @Override
        public void addGainChangeListener(GainChangeListener listener) {
            gainListeners.add(listener);
        }

        @Override
        public void removeGainChangeListener(GainChangeListener listener) {
            gainListeners.remove(listener);
        }

        private void notifyGainChangeListeners() {
            GainChangeEvent event = new GainChangeEvent(this, mute, getDB(), level);
            for (GainChangeListener listener : gainListeners) {
                listener.gainChange(event);
            }
        }
    }
}
