package net.heartshield.sensors;

import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTimestamp;
import android.media.AudioTrack;
import android.media.MediaRecorder;
import android.media.MediaSyncEvent;
import android.os.Handler;
import android.os.Looper;
import android.os.Process;
import android.util.Log;

import net.heartshield.data.WavData;
import net.heartshield.signal.DVec;
import net.heartshield.signal.SVec;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import java.util.List;

/**
 * Audio time series signal capture.
 * AudioTimestamp needs API level 19.
 *
 * Events are delivered to the main thread, but data handling occurs in a separate thread.
 *
 * @author David Madl (git@abanbytes.eu)
 * @date 2016-05-16
 */
public class AudioSensorBase implements IAudioSensorBase {
    private static final String TAG = "AudioSensorBase";

    private static final int READ_TO_BUF_SIZE_RATIO = 2; // in code, always refer to mReadToBufSizeRatio which is testable
    private int mReadToBufSizeRatio; // for testing

    private AudioRecorder mRecorder;
    private State mState;
    private final Object mStateLock = new Object();

    private double mPrevTimestamp; // in nanoseconds
    private double mMinDataEventTime; // in seconds // TODO rename to *Sec
    private int mPrevOffset;

    private Handler mHandler;
    private Double mFps;
    private SensorDataPCM mDataPCM;
    private Listener mListener = null;
    private Exception mError = null;

    /**
     * @param maxDataEventRate maximum rate of onData() events emitted per second
     */
    public AudioSensorBase(double maxDataEventRate) {

        mMinDataEventTime = 1.0 / maxDataEventRate;
        mReadToBufSizeRatio = READ_TO_BUF_SIZE_RATIO;

        mHandler = new Handler(Looper.getMainLooper());
        mDataPCM = new SensorDataPCM();
        mState = State.STOPPED;

        initSamplingRate();
    }

    public State getState() { return mState; }

    /** for testing only! must be set before calling start() */
    @Override
    public void setReadToBufSizeRatio(int ratio) {
        mReadToBufSizeRatio = ratio;
    }

    private static final int AUDIO_SOURCE = MediaRecorder.AudioSource.MIC;
    private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO;
    private static final int CHANNEL_NUM = 1;
    private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT;
    private static final int AUDIO_FORMAT_BYTES = 2;
    private static final int STRIDE = CHANNEL_NUM * AUDIO_FORMAT_BYTES;
    private int mSamplingRate;
    private int mMinBufferSize;
    private int mBufferSize;
    private int mReadSize;
    private long mReadSpacingNanos; // usual time between two read() calls
    private long mReadSpacingMaxNanos; // maximum time elapsed between two read() calls before we'll start dropping frames

    public class AudioException extends RuntimeException {
        public AudioException(Throwable cause) {
            super(cause);
        }
        public AudioException(String message) {
            super(message);
        }
    }

    public class AudioLagException extends AudioException {
        public AudioLagException(String message) {
            super(message);
        }
    }

    private void initSamplingRate() {
        // see Android CDD (compatibility definition document) at http://source.android.com/compatibility/6.0/android-6.0-cdd.html
        // current version for Android 7.1 at https://static.googleusercontent.com/media/source.android.com/en//compatibility/android-cdd.pdf
        // "Device implementations that declare android.hardware.microphone MUST allow capture of raw audio content with [...] Sampling rates : 8000, 11025, 16000, 44100"
        // "SHOULD allow capture of raw audio content with [...] Sampling rates : 22050, 48000"
        final int[] RATES = new int[]{48000, 44100};
        for(int rate : RATES) {
            int bufferSize = AudioRecord.getMinBufferSize(rate, CHANNEL_CONFIG, AUDIO_FORMAT);
            // rate is supported if bufferSize is valid
            if(bufferSize > 0) {
                mSamplingRate = rate;
                mFps = (double) rate;
                mMinBufferSize = bufferSize;
                return;
            }
        }
        throw new IllegalStateException("could not find a valid audio sampling rate");
    }

    private void initBufferSize() {
        if(mMinBufferSize == 0)
            throw new IllegalStateException("call initSamplingRate() first");

        int bufSizePerSec = AUDIO_FORMAT_BYTES * mSamplingRate * CHANNEL_NUM;
        int idealBufferSize = (int) (bufSizePerSec * mMinDataEventTime * mReadToBufSizeRatio); // buf itself is bigger to accomodate several smaller reads

        // size buffer so one time interval between onData() events will fit.
        // since we have a background handler reading data, we can probably do with much less.
        mBufferSize = Math.max(mMinBufferSize, idealBufferSize);

        mReadSize = mBufferSize / mReadToBufSizeRatio;
        mReadSpacingNanos = (long) (1e9 * mReadSize / bufSizePerSec);
        mReadSpacingMaxNanos = (long) (1e9 * mBufferSize / bufSizePerSec * 1.2); // note: for laissez-faire audio handling

        Log.i(TAG, "buffer size per sec: " + bufSizePerSec);
        Log.i(TAG, "min buffer size: " + mMinBufferSize);
        Log.i(TAG, "chosen buffer size: " + mBufferSize);
        Log.i(TAG, "chosen read size: " + mReadSize);
        Log.i(TAG, "chosen read spacing: " + mReadSpacingNanos);
    }

    private static final int PLAYBACK_TIME_IN_MS = 200;
    //public static final int START_TIME_MS = PLAYBACK_TIME_IN_MS + 500;

    private class AudioRecorder extends Thread {
        boolean mStopped = false;

        // see Android CDD: "The device MUST allow playback of raw audio content with the following characteristics: [...] Sampling rates : 8000"
        final int PLAYBACK_SAMPLE_RATE = 8000;
        // TODO: rename 2x below to Secs
        private double mNanoTime; // recording time as far as samples have progressed
        private double mNanoTimeStep; // nanoseconds between each sample

        private AudioRecord mRecord;

        private AudioTrack createSilentTrack() {
            final int frameCount = PLAYBACK_TIME_IN_MS * PLAYBACK_SAMPLE_RATE / 1000;
            final int frameSize = 1;
            final int sampleCount = frameCount * 1; // * format.getChannelCount(); // getChannelCount() would need API level 23
            final int bufSize = frameSize * sampleCount;

            AudioTrack track = new AudioTrack(AudioManager.STREAM_NOTIFICATION, PLAYBACK_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_8BIT, bufSize, AudioTrack.MODE_STATIC);
            // AudioTrack(int streamType, int sampleRateInHz, int channelConfig, int audioFormat, int bufferSizeInBytes, int mode)
			/*
					new AudioTrack.Builder()
					.setAudioFormat(format)
					.setBufferSizeInBytes(frameCount * frameSize)
					.setTransferMode(AudioTrack.MODE_STATIC)
					.build();
			*/
            // create frame array and write it

            byte[] vab = new byte[bufSize];
            //Arrays.fill(vab, -128);
            for(int i = 0; i < vab.length; i++)
                vab[i] = -128;
            track.write(vab, 0 /* offsetInBytes */, vab.length); // copies audio data for later playback (static buffer mode)

            track.setNotificationMarkerPosition(frameCount-1);

            return track;
        }

        @Override
        public void run() {
            try {
                runRecording();
            } catch(Throwable t) {
                mError = new AudioException(t);
                Log.e(TAG, "error during recording", mError);
                emitOnState(AudioSensorBase.State.FAILED);
            } finally {
                if(mRecord != null)
                    mRecord.release();
                mRecord = null;
            }
        }

        private void runRecording() {
            Log.d(TAG, "entering AudioRecorder thread");
            Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO);

            initBufferSize();

            /* Play back a short, silent AudioTrack to use MediaSyncEvent to sync to it */

            AudioTrack track = createSilentTrack();
            final int trackSessionId = track.getAudioSessionId();

            ByteBuffer buffer = ByteBuffer.allocateDirect(mBufferSize);
            mRecord = new AudioRecord(AUDIO_SOURCE, mSamplingRate, CHANNEL_CONFIG, AUDIO_FORMAT, mBufferSize);

            // 3. create a MediaSyncEvent
            final int eventType = MediaSyncEvent.SYNC_EVENT_PRESENTATION_COMPLETE;
            MediaSyncEvent event = MediaSyncEvent.createEvent(eventType)
                    .setAudioSessionId(trackSessionId);

            // the marker for this is set with setNotificationMarkerPosition() in createSilentTrack()
            track.setPlaybackPositionUpdateListener(new AudioTrack.OnPlaybackPositionUpdateListener() {
                @Override
                public void onMarkerReached(AudioTrack track) {
                    Log.i(TAG, "onMarkerReached(), switching to RUNNING state");
                    emitOnState(AudioSensorBase.State.RUNNING);
                }

                @Override
                public void onPeriodicNotification(AudioTrack track) {}
            });

            // 4. now set the AudioTrack playing and start the recording synchronized
            track.play();

            // this returns immediately, but does not start recording yet
            Log.d(TAG, "startRecording(event), should return immediately");
            mRecord.startRecording(event);

            long beforeSleepNanoTime = System.nanoTime();
            Log.d(TAG, "startRecording(event) returned, beforeSleepNanoTime=" + beforeSleepNanoTime);

            try {
                Thread.sleep(PLAYBACK_TIME_IN_MS / 2); // ensure that audio is playing so we can poll for a timestamp.
            } catch(InterruptedException e) {
                throw new RuntimeException(e);
            }

            // best guess for when the recording actually starts in time
            AudioTimestamp ts = new AudioTimestamp();  // AudioTimestamp needs API level 19
            boolean startTimeGood = track.getTimestamp(ts);
            long recordStartNanoTime;
            if(startTimeGood) {
                // frameTimeNs should be roughly PLAYBACK_TIME_IN_MS / 2
                long frameTimeNs = (long) (ts.framePosition * 1e9) / PLAYBACK_SAMPLE_RATE;
                // recordStartNanoTime should be nanoTime at where the playback will end
                recordStartNanoTime = ts.nanoTime - frameTimeNs + (long) (PLAYBACK_TIME_IN_MS * 1e6);
                Log.i(TAG, "got a timestamp for framePosition=" + ts.framePosition + " at nanoTime=" + ts.nanoTime);
            } else {
                recordStartNanoTime = beforeSleepNanoTime + (long) (PLAYBACK_TIME_IN_MS * 1e6);
                Log.e(TAG, "AudioTrack.getTimestamp() not supported! Audio timestamps will be inaccurate."); //  (~ second accuracy?!)
            }
            Log.i(TAG, "recordStartNanoTime=" + recordStartNanoTime + " and System.nanoTime=" + System.nanoTime());

            mNanoTime = recordStartNanoTime / 1e9;
            mNanoTimeStep = 1e9 / mSamplingRate;

            short[] sbuf = new short[mReadSize / AUDIO_FORMAT_BYTES];

            mPrevTimeNanos = 0;
            while(!mStopped) {
                /*
                 * AudioRecord.read() blocks until the specified buffer size has been read in full.
                 *
                 * We can sanity-check that it has not been dropping frames through
                 *
                 * 1) checking the time at which buffers were returned.
                 *    Assuming huge CPU lags are the cause of dropping, delivery will not be on time.
                 *
                 * 2) From Android API level 24, using {@link AudioRecord#getTimestamp}
                 *
                 * Alternative approaches could be:
                 *
                 * 3) undocumented, unexposed Android JNI API AudioRecord::getInputFramesLost() -- the type has changed (unsigned int -> uint32_t)
                 *
                 * 4) registering a Listener with AudioRecord.setRecordPositionUpdateListener()
                 *    and AudioRecord.setPositionNotificationPeriod()
                 *    However, docs note that "It is possible for notifications to be lost if the period is too small."
                 */

                // note: pulled PCM "will be native endian" -- but we'll write it to WAV, which is little endian
                // see https://developer.android.com/reference/android/media/AudioRecord.html#read(java.nio.ByteBuffer, int)

                if(!ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN))
                    throw new IllegalStateException("implement handling of big endian");

                /*
                int nbytes = mRecord.read(buffer, mReadSize);
                // TODO: read shorts instead, using read(@NonNull short[] audioData, int offsetInShorts, int sizeInShorts)
                */

                int nsamples = mRecord.read(sbuf, 0, mReadSize / AUDIO_FORMAT_BYTES);
                int nbytes = 2*nsamples;
                final double[] buf = DVec.mul(SVec.toDouble(sbuf), 1.0 / 32768.0);

                mDataPCM.addData(short2pcmBuffer(sbuf), nbytes);

                mHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        mListener.onData(buf);
                    }
                });


                long time = System.nanoTime();
                if(mPrevTimeNanos != 0) {
                    long dt = time - mPrevTimeNanos;
                    if(dt > mReadSpacingMaxNanos)
                        throw new AudioLagException("audio lag: time between reads was " + dt + " nanos, which is larger than our buffer of " + mReadSpacingNanos + " nanos");
                    Log.d(TAG, "onAudioBuffer received nbytes=" + nbytes + " at t=" + time + ", dt=" + dt);
                } else {
                    Log.d(TAG, "onAudioBuffer received nbytes=" + nbytes + " at t=" + time + " first-time");
                }
                mPrevTimeNanos = time;

                if(nbytes < 0)
                    throw new RuntimeException("AudioRecord.read() returned error=" + nbytes);

                //onAudioBuffer(buffer, nbytes);
            }
            emitOnState(AudioSensorBase.State.STOPPED);
            Log.d(TAG, "exiting AudioRecorder thread");
        }

        private long mPrevTimeNanos = 0;

        /*
        private void onAudioBuffer(ByteBuffer buffer, int nbytes) {
            if(CHANNEL_NUM != 1 || AUDIO_FORMAT_BYTES != 2)
                throw new IllegalStateException("onAudioBuffer() was not written for this format");

            if(mDataPCM.length() == 0)
                mDataPCM.setStartTime(mNanoTime);

            mDataPCM.addData(buffer, nbytes);

            mNanoTime += nbytes/STRIDE * mNanoTimeStep;

            if((mNanoTime - mPrevTimestamp) > mMinDataEventTime) {
                final int offset = mPrevOffset;
                if(mListener != null) {
                    final int length = (mDataPCM.length() - offset);
                    Log.i(TAG, "posting onData(offset=" + offset + ") with rel length=" + length);
                    mHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            mListener.onData(offset, length);
                        }
                    });
                }
                mPrevTimestamp = mNanoTime;
                mPrevOffset = mDataPCM.length();
            }
        }
        */


        /*
        private void onAudioBuffer(ByteBuffer buffer, int nbytes) {
            if(CHANNEL_NUM != 1 || AUDIO_FORMAT_BYTES != 2)
                throw new IllegalStateException("onAudioBuffer() was not written for this format");

            int stride = CHANNEL_NUM * AUDIO_FORMAT_BYTES;
            //double[] buf = new double[2];
            final double[] buf = new double[nbytes/stride];
            final boolean le = ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN);

            int j = 0;
            for(int i = 0; i < nbytes; i += stride) {
                // PCM_16BIT is "native endian": https://developer.android.com/reference/android/media/AudioFormat.html#encoding
                short lsb = buffer.get(i);
                short msb = buffer.get(i+1);
                short sample = (short) (le ? ((msb << 8) | lsb) : ((lsb << 8) | msb));
                buf[j++] = sample / 32768.0;
                mNanoTime += mNanoTimeStep;
            }

            {
                if(mListener != null) {
                    //final int length = (mData.length() - offset);
                    //Log.i(TAG, "posting onData(offset=" + offset + ") with rel length=" + length);
                    mHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            mListener.onData(buf);
                        }
                    });
                }
                mPrevTimestamp = mNanoTime;
                //mPrevOffset = mData.length();
            }
        }
        */

        void quit() {
            mStopped = true;
        }
    }

    private static byte[] short2pcmBytes(short[] arr) {
        ByteBuffer bb = ByteBuffer.allocate(arr.length * 2);
        bb.order(ByteOrder.LITTLE_ENDIAN);
        bb.asShortBuffer().put(arr);
        return bb.array();
    }


    private static ByteBuffer short2pcmBuffer(short[] arr) {
        ByteBuffer bb = ByteBuffer.allocate(arr.length * 2);
        bb.order(ByteOrder.LITTLE_ENDIAN);
        bb.asShortBuffer().put(arr);
        return bb;
    }


    private void emitOnState(final State newState) {
        State oldState;
        synchronized(mStateLock) {
            oldState = mState;
            mState = newState;
        }
        if(mListener == null)
            return;

        if(!newState.equals(oldState)) {
            mHandler.post(new Runnable() {
                @Override
                public void run() {
                    mListener.onState(newState);
                }
            });
        }
    }

    /////

    /**
     * Nominal number of sample values per second.
     * Only valid after the first call to {@link ISensor#start}
     */
    @Override
    public double getFps() {
        if(mFps != null)
            return mFps;
        else
            return 0;
    }

    /** start measurement, resetting sensor data */
    @Override
    public void start() {
        // fail early (we currently assume AudioRecord.read() result is little endian)
        if(!ByteOrder.nativeOrder().equals(ByteOrder.LITTLE_ENDIAN))
            throw new IllegalStateException("implement handling of big endian");

        synchronized(mStateLock) {
            if(mState != State.STOPPED && mState != State.FAILED)
                throw new IllegalStateException("start() called in invalid state: " + mState.toString());
            mState = State.STARTING;
        }

        mDataPCM.clear();
        mPrevTimestamp = 0;
        mPrevOffset = 0;

        mRecorder = new AudioRecorder();
        mRecorder.start();
    }

    /** stop measurement, keeping sensor data */
    @Override
    public void stop() {
        if(mRecorder == null)
            return;
        mRecorder.quit();
        try {
            mRecorder.join();
            //emitOnState(State.STOPPED); // emitted in AudioRecorder thread upon exit
        } catch(InterruptedException e) {
            e.printStackTrace();
            emitOnState(State.STOPPED);
        }
        mRecorder = null;
    }

    public void stopAsync() {
        // stop() contains a blocking thread join, may take upto 1/(2*maxDataEventRate) seconds
        // this is a faster alternative.

        if(mRecorder == null)
            return;
        mRecorder.quit();
        mRecorder = null;

        synchronized(mStateLock) {
            mState = State.STOPPING;
        }
    }

    public List<double[]> getSamples() { throw new RuntimeException("not implemented"); }

    /**
     * returns a deep copy of the underlying WAV data.
     * Call might fail with OutOfMemoryError
     */
    public byte[] getPCM() {
        /*
         * since Google Volley requires a byte[] body,
         * we need to deep-copy and cannot return a ByteBuffer here,
         * even if we could build the WAV file in-place.
         */
        ByteBuffer buf = mDataPCM.getData();
        byte[] data = new byte[buf.remaining()];
        buf.get(data, 0, data.length);
        updateWavHeader(data, mSamplingRate, CHANNEL_NUM, AUDIO_FORMAT_BYTES * 8);
        return data;
    }

    /** update the leading WAV header bytes, so data will be a full WAV file */
    private void updateWavHeader(byte[] buffer, int sampleRate, int channels, int bitsPerSample) {
        WavData wavData = new WavData(mDataPCM.bodyLength(), sampleRate, channels, bitsPerSample);
        byte[] header = wavData.waveHeader();
        System.arraycopy(header, 0, buffer, 0, header.length); // write WAV header to buffer
    }

    /** start time in nanoTime timebase */
    public long getStartTimeNanos() { return (long) (mDataPCM.getStartTime() * 1e9); }

    /** @return number of samples */
    public int length() {
        return mDataPCM.length() / STRIDE;
    }

    /** events are delivered to the main thread, but pixel handling occurs in a separate thread */
    @Override
    public void setListener(Listener listener) { mListener = listener; }

    /** obtain details after FAILED state */
    public Exception getError() { return mError; }
}
